From 6d54100bc9fafb227f5b99aba1c9df02bb311ecf Mon Sep 17 00:00:00 2001 From: rouggy Date: Fri, 31 Jul 2026 19:05:09 +0200 Subject: [PATCH] fix: a new playback raced the previous one for the audio device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported precisely: press play again and it stops the current message, but you then have to wait the whole length of the message before it will play — otherwise the radio is keyed and the PTT released at once. Play only SIGNALLED the previous playback to stop and started a new one straight away. The old goroutine still held the WASAPI render client for a moment, so the new client could not start; it returned immediately and the PTT tail was all that was left. The log now shows both halves of that: a play line, then a release 120 ms later. Stopping now waits for the goroutine to have returned — the point of stopping before starting again — bounded at 1.5 s so a wedged device cannot freeze the operator's click, and saying so in the log if it ever comes to that. --- changelog.json | 6 ++++-- internal/audio/manager.go | 37 ++++++++++++++++++++++++++++++++++--- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/changelog.json b/changelog.json index efd212e..0788ff7 100644 --- a/changelog.json +++ b/changelog.json @@ -4,11 +4,13 @@ "date": "", "en": [ "Cluster filters gain NEW POTA and NEW COUNTY, alongside the existing status chips.", - "A playback that fails to start now says so in the log instead of ending silently after a tenth of a second." + "A playback that fails to start now says so in the log instead of ending silently after a tenth of a second.", + "Replaying a recording before the previous one has finished now works: the new playback waits for the audio device to be free instead of keying the radio and releasing at once." ], "fr": [ "Les filtres du cluster gagnent NEW POTA et NEW COUNTY, à côté des pastilles de statut existantes.", - "Une lecture qui ne démarre pas le signale désormais dans le journal, au lieu de s'arrêter en silence au bout d'un dixième de seconde." + "Une lecture qui ne démarre pas le signale désormais dans le journal, au lieu de s'arrêter en silence au bout d'un dixième de seconde.", + "Relancer un enregistrement avant la fin du précédent fonctionne désormais : la nouvelle lecture attend que le périphérique audio soit libre, au lieu de passer en émission et de relâcher aussitôt." ] }, { diff --git a/internal/audio/manager.go b/internal/audio/manager.go index c0febe1..7bbe6da 100644 --- a/internal/audio/manager.go +++ b/internal/audio/manager.go @@ -5,6 +5,7 @@ package audio import ( "fmt" "sync" + "time" ) // Manager owns the DVK record/playback lifecycle: at most one recording and @@ -15,6 +16,10 @@ type Manager struct { recStop chan struct{} recDone chan recResult playStop chan struct{} + // playDone closes when the playback goroutine has returned and the audio + // device is free again. Without it, the next Play raced the old one for the + // device and lost. + playDone chan struct{} monStop chan struct{} // RX monitor passthrough (capture → render) monRing *pcmRing // live audio hand-off, also fed by the network stream txStop chan struct{} // TX audio passthrough (mic → rig) @@ -125,10 +130,20 @@ func (m *Manager) Play(deviceID, path string, gainPct int) error { pcm[i], pcm[i+1] = byte(uint16(v)), byte(uint16(v)>>8) } } + // Waits for any previous playback to have RELEASED THE DEVICE. + // + // It used to only signal the old one to stop and start a new one at once. + // The old goroutine still held the WASAPI render client for a moment, so the + // new client could not start: it returned immediately, the PTT was keyed and + // released a tenth of a second later, and nothing came out. On the air that + // looked like "press play again and you must wait the whole length of the + // message before it will play at all". m.StopPlayback() stop := make(chan struct{}) + done := make(chan struct{}) m.mu.Lock() m.playStop = stop + m.playDone = done m.mu.Unlock() go func() { // The error was discarded. A device that refuses to start returns here @@ -142,7 +157,11 @@ func (m *Manager) Play(deviceID, path string, gainPct int) error { if m.playStop == stop { m.playStop = nil } + if m.playDone == done { + m.playDone = nil + } m.mu.Unlock() + close(done) // the device is free from here m.notify() }() m.notify() @@ -153,12 +172,24 @@ func (m *Manager) Play(deviceID, path string, gainPct int) error { func (m *Manager) StopPlayback() { m.mu.Lock() stop := m.playStop + done := m.playDone m.playStop = nil m.mu.Unlock() - if stop != nil { - close(stop) - m.notify() + if stop == nil { + return } + close(stop) + // Wait for the goroutine to release the device — that is the whole point of + // stopping before starting again. Bounded: a wedged WASAPI call must not + // freeze the caller, which here is the operator clicking a button. + if done != nil { + select { + case <-done: + case <-time.After(1500 * time.Millisecond): + LogSink("audio: previous playback did not release the device within 1.5 s") + } + } + m.notify() } // ---- RX audio monitor (Phase 2: USB codec passthrough) --------------------