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) --------------------