fix: a new playback raced the previous one for the audio device

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.
This commit is contained in:
2026-07-31 19:05:09 +02:00
parent 4fe5811fd2
commit 6d54100bc9
2 changed files with 38 additions and 5 deletions
+4 -2
View File
@@ -4,11 +4,13 @@
"date": "", "date": "",
"en": [ "en": [
"Cluster filters gain NEW POTA and NEW COUNTY, alongside the existing status chips.", "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": [ "fr": [
"Les filtres du cluster gagnent NEW POTA et NEW COUNTY, à côté des pastilles de statut existantes.", "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."
] ]
}, },
{ {
+34 -3
View File
@@ -5,6 +5,7 @@ package audio
import ( import (
"fmt" "fmt"
"sync" "sync"
"time"
) )
// Manager owns the DVK record/playback lifecycle: at most one recording and // Manager owns the DVK record/playback lifecycle: at most one recording and
@@ -15,6 +16,10 @@ type Manager struct {
recStop chan struct{} recStop chan struct{}
recDone chan recResult recDone chan recResult
playStop chan struct{} 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) monStop chan struct{} // RX monitor passthrough (capture → render)
monRing *pcmRing // live audio hand-off, also fed by the network stream monRing *pcmRing // live audio hand-off, also fed by the network stream
txStop chan struct{} // TX audio passthrough (mic → rig) 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) 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() m.StopPlayback()
stop := make(chan struct{}) stop := make(chan struct{})
done := make(chan struct{})
m.mu.Lock() m.mu.Lock()
m.playStop = stop m.playStop = stop
m.playDone = done
m.mu.Unlock() m.mu.Unlock()
go func() { go func() {
// The error was discarded. A device that refuses to start returns here // 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 { if m.playStop == stop {
m.playStop = nil m.playStop = nil
} }
if m.playDone == done {
m.playDone = nil
}
m.mu.Unlock() m.mu.Unlock()
close(done) // the device is free from here
m.notify() m.notify()
}() }()
m.notify() m.notify()
@@ -153,12 +172,24 @@ func (m *Manager) Play(deviceID, path string, gainPct int) error {
func (m *Manager) StopPlayback() { func (m *Manager) StopPlayback() {
m.mu.Lock() m.mu.Lock()
stop := m.playStop stop := m.playStop
done := m.playDone
m.playStop = nil m.playStop = nil
m.mu.Unlock() m.mu.Unlock()
if stop != nil { if stop == nil {
close(stop) return
m.notify()
} }
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) -------------------- // ---- RX audio monitor (Phase 2: USB codec passthrough) --------------------