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
+34 -3
View File
@@ -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) --------------------