The talk button learns the network road the voice keyer already took: when To-radio is the radio itself, the microphone is captured and re-framed into the rig's 320-sample packets, the capture callback serving as the clock — the mic delivers in real time, so no ring and no pacer. Counters and the frame remainder live on the audio stream, so a talk session spans calls. PTT keys before and releases after, through the same code as the USB path. With this, a network Icom is a complete remote station over three UDP ports: RX audio to the headset, live voice and recorded messages back, CW through the rig's keyer, CAT for everything else.
413 lines
13 KiB
Go
413 lines
13 KiB
Go
//go:build windows
|
|
|
|
package audio
|
|
|
|
import (
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Manager owns the DVK record/playback lifecycle: at most one recording and
|
|
// one playback at a time. Device ids are passed per call so the host can route
|
|
// recording to the mic and playback to the rig (or the preview speakers).
|
|
type Manager struct {
|
|
mu sync.Mutex
|
|
recStop chan struct{}
|
|
recDone chan recResult
|
|
// monGainPct scales what the RX monitor plays, 100 = as captured.
|
|
//
|
|
// The "From radio" slider used to reach only the QSO recorder, so an
|
|
// operator listening through OpsLog heard no difference between 10% and
|
|
// 150% — the setting looked broken because it was, for the thing they were
|
|
// listening to.
|
|
monGainPct int
|
|
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)
|
|
onChange func() // fired on any record/playback state transition
|
|
}
|
|
|
|
type recResult struct {
|
|
pcm []byte
|
|
err error
|
|
}
|
|
|
|
// NewManager creates a DVK manager. onChange (optional) is called whenever the
|
|
// recording/playback state changes, so the host can push an audio:status event.
|
|
func NewManager(onChange func()) *Manager { return &Manager{onChange: onChange} }
|
|
|
|
func (m *Manager) notify() {
|
|
if m.onChange != nil {
|
|
m.onChange()
|
|
}
|
|
}
|
|
|
|
// StartRecording begins capturing from deviceID into memory. Finish with
|
|
// StopRecording (which writes the WAV) or CancelRecording (which discards it).
|
|
func (m *Manager) StartRecording(deviceID string) error {
|
|
m.mu.Lock()
|
|
if m.recStop != nil {
|
|
m.mu.Unlock()
|
|
return fmt.Errorf("already recording")
|
|
}
|
|
stop := make(chan struct{})
|
|
done := make(chan recResult, 1)
|
|
m.recStop, m.recDone = stop, done
|
|
m.mu.Unlock() // release BEFORE notify — onChange re-enters via IsRecording()
|
|
go func() {
|
|
pcm, err := recordPCM(deviceID, stop)
|
|
done <- recResult{pcm, err}
|
|
}()
|
|
m.notify()
|
|
return nil
|
|
}
|
|
|
|
// StopRecording ends the capture and writes it to path as a WAV file.
|
|
func (m *Manager) StopRecording(path string) error {
|
|
m.mu.Lock()
|
|
stop, done := m.recStop, m.recDone
|
|
m.recStop, m.recDone = nil, nil
|
|
m.mu.Unlock()
|
|
if stop == nil {
|
|
return fmt.Errorf("not recording")
|
|
}
|
|
close(stop)
|
|
res := <-done
|
|
m.notify()
|
|
if res.err != nil {
|
|
return res.err
|
|
}
|
|
if len(res.pcm) == 0 {
|
|
return fmt.Errorf("captured no audio (check the recording device)")
|
|
}
|
|
return writeWAV(path, res.pcm)
|
|
}
|
|
|
|
// CancelRecording aborts a recording without saving.
|
|
func (m *Manager) CancelRecording() {
|
|
m.mu.Lock()
|
|
stop, done := m.recStop, m.recDone
|
|
m.recStop, m.recDone = nil, nil
|
|
m.mu.Unlock()
|
|
if stop != nil {
|
|
close(stop)
|
|
<-done
|
|
m.notify()
|
|
}
|
|
}
|
|
|
|
func (m *Manager) IsRecording() bool {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.recStop != nil
|
|
}
|
|
|
|
func (m *Manager) IsPlaying() bool {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.playStop != nil
|
|
}
|
|
|
|
// Play renders a WAV file to deviceID. Any current playback is stopped first.
|
|
// Returns immediately; playback runs in the background.
|
|
// Play sends a recorded message to a device, amplified by gainPct (100 = as
|
|
// recorded).
|
|
//
|
|
// The gain exists because nothing else could raise the level: the message went
|
|
// out exactly as captured, so a mic recorded quietly drove the rig quietly and
|
|
// the operator had no control anywhere in OpsLog — only the radio's own USB
|
|
// input level, buried in its menus, and the Windows mixer.
|
|
func (m *Manager) Play(deviceID, path string, gainPct int) error {
|
|
pcm, rate, ch, bits, err := readWAV(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if gainPct > 0 && gainPct != 100 && bits == 16 {
|
|
g := float64(gainPct) / 100
|
|
// In place: the buffer is this call's own copy of the file.
|
|
for i := 0; i+1 < len(pcm); i += 2 {
|
|
v := int16(uint16(pcm[i]) | uint16(pcm[i+1])<<8)
|
|
v = scalePCM(v, g)
|
|
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
|
|
// instantly, the PTT is released 120 ms later, and NOTHING says why —
|
|
// which is exactly what a station heard as "it plays once, then never
|
|
// again": the call succeeded, the sound did not.
|
|
play := func() error { return playPCM(deviceID, pcm, rate, ch, bits, stop) }
|
|
if deviceID == NetworkDeviceID {
|
|
// Straight to the radio over its own link. Decided HERE rather than
|
|
// inside playPCM because there is no Windows endpoint to open: asked
|
|
// for one, the system complains about a missing device instead of
|
|
// saying the true thing, which is that no radio is connected.
|
|
fn := networkPlayer()
|
|
play = func() error {
|
|
if fn == nil {
|
|
return errNoNetworkRadio
|
|
}
|
|
return fn(pcm, rate, ch, bits, stop)
|
|
}
|
|
}
|
|
if err := play(); err != nil {
|
|
LogSink("audio: playback on %q failed: %v", DeviceName(deviceID), err)
|
|
}
|
|
m.mu.Lock()
|
|
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()
|
|
return nil
|
|
}
|
|
|
|
// StopPlayback halts any in-progress playback.
|
|
func (m *Manager) StopPlayback() {
|
|
m.mu.Lock()
|
|
stop := m.playStop
|
|
done := m.playDone
|
|
m.playStop = nil
|
|
m.mu.Unlock()
|
|
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) --------------------
|
|
//
|
|
// StartMonitor pipes live RX audio from inputDev (e.g. the rig's "USB Audio
|
|
// CODEC" capture endpoint) to outputDev (your speakers/headset) through a
|
|
// latency-bounded ring, so you HEAR the radio inside OpsLog. The very same ring
|
|
// is later fed by the network 50003 stream instead of a USB capture — the render
|
|
// half is transport-agnostic. inputDev "" = system default capture.
|
|
func (m *Manager) StartMonitor(inputDev, outputDev string) error {
|
|
return m.startMonitor(inputDev, outputDev, true)
|
|
}
|
|
|
|
// StartMonitorSink starts ONLY the render side (no USB capture) so an external
|
|
// producer — the network 50003 stream — can feed decoded RX PCM via
|
|
// PushMonitorAudio. Same output path as StartMonitor, minus the capture goroutine.
|
|
func (m *Manager) StartMonitorSink(outputDev string) error {
|
|
return m.startMonitor("", outputDev, false)
|
|
}
|
|
|
|
// startMonitor wires the RX monitor: always a render loop pulling from monRing;
|
|
// when capture is true it also captures inputDev into that ring (USB monitor).
|
|
// When false the ring is fed only by PushMonitorAudio (network audio).
|
|
func (m *Manager) startMonitor(inputDev, outputDev string, capture bool) error {
|
|
m.mu.Lock()
|
|
if m.monStop != nil {
|
|
m.mu.Unlock()
|
|
return fmt.Errorf("monitor already running")
|
|
}
|
|
stop := make(chan struct{})
|
|
ring := newPCMRing(bytesPerSec / 2) // ~500 ms cap — low latency for live monitor
|
|
m.monStop, m.monRing = stop, ring
|
|
m.mu.Unlock()
|
|
|
|
if capture {
|
|
// Producer: capture the rig's USB audio into the ring, at the level the
|
|
// operator set. Applied HERE rather than on the render side so the
|
|
// network-fed path (PushMonitorAudio) keeps its own untouched levels —
|
|
// that stream is already scaled by the radio.
|
|
go func() {
|
|
_ = captureStream(inputDev, stop, func(chunk []byte) { ring.Push(m.scaleMonitor(chunk)) })
|
|
}()
|
|
}
|
|
// Consumer: render the ring to the output device at the internal 16 kHz mono.
|
|
go func() {
|
|
_ = renderStream(outputDev, sampleRate, channels, bitsPerSample, stop, ring)
|
|
}()
|
|
m.notify()
|
|
return nil
|
|
}
|
|
|
|
// SetMonitorGain sets the RX monitor level in percent (100 = as captured).
|
|
// Takes effect on the next captured chunk — no need to restart the monitor.
|
|
func (m *Manager) SetMonitorGain(pct int) {
|
|
if pct <= 0 {
|
|
pct = 100
|
|
}
|
|
m.mu.Lock()
|
|
m.monGainPct = pct
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
// scaleMonitor applies the monitor level to one captured chunk, returning a
|
|
// buffer the ring may keep. At unity it hands the chunk straight back: the
|
|
// common case must not pay for a copy 30 times a second.
|
|
func (m *Manager) scaleMonitor(chunk []byte) []byte {
|
|
m.mu.Lock()
|
|
pct := m.monGainPct
|
|
m.mu.Unlock()
|
|
if pct == 0 || pct == 100 {
|
|
return chunk
|
|
}
|
|
g := float64(pct) / 100
|
|
out := make([]byte, len(chunk))
|
|
copy(out, chunk)
|
|
for i := 0; i+1 < len(out); i += 2 {
|
|
v := int16(uint16(out[i]) | uint16(out[i+1])<<8)
|
|
v = scalePCM(v, g)
|
|
out[i], out[i+1] = byte(uint16(v)), byte(uint16(v)>>8)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// StopMonitor stops the RX monitor passthrough.
|
|
func (m *Manager) StopMonitor() {
|
|
m.mu.Lock()
|
|
stop := m.monStop
|
|
m.monStop, m.monRing = nil, nil
|
|
m.mu.Unlock()
|
|
if stop != nil {
|
|
close(stop)
|
|
m.notify()
|
|
}
|
|
}
|
|
|
|
// MonitorActive reports whether the RX monitor passthrough is running.
|
|
func (m *Manager) MonitorActive() bool {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.monStop != nil
|
|
}
|
|
|
|
// PushMonitorAudio feeds externally-sourced PCM (16 kHz mono 16-bit) into the
|
|
// active monitor's output — the hook the network 50003 audio stream uses to play
|
|
// decoded RX through the very same output path a USB capture feeds. No-op when no
|
|
// monitor is running. Keeps the unexported ring inside the package.
|
|
func (m *Manager) PushMonitorAudio(pcm []byte) {
|
|
m.mu.Lock()
|
|
ring := m.monRing
|
|
m.mu.Unlock()
|
|
if ring != nil {
|
|
ring.Push(pcm)
|
|
}
|
|
}
|
|
|
|
// ---- TX audio passthrough (Phase 3: live mic → rig over USB) --------------
|
|
//
|
|
// StartTXAudio pipes your live microphone (micDev) into the rig's audio input
|
|
// (toRadioDev — for a USB-connected rig, its "USB Audio CODEC" render endpoint),
|
|
// so you talk through the PC. It is the mirror of StartMonitor (same ring +
|
|
// capture + render primitives, source/sink swapped). PTT keying is the caller's
|
|
// job (the app layer keys PTT before this and unkeys after) so this stays a pure
|
|
// audio route. The captured 16 kHz mono stream is also the exact shape the future
|
|
// network 50003 TX will encode and send — so Phase 5 reuses this capture side.
|
|
func (m *Manager) StartTXAudio(micDev, toRadioDev string) error {
|
|
m.mu.Lock()
|
|
if m.txStop != nil {
|
|
m.mu.Unlock()
|
|
return fmt.Errorf("TX audio already running")
|
|
}
|
|
stop := make(chan struct{})
|
|
ring := newPCMRing(bytesPerSec / 4) // ~250 ms — tighter for live TX latency
|
|
m.txStop = stop
|
|
m.mu.Unlock()
|
|
|
|
go func() {
|
|
_ = captureStream(micDev, stop, func(chunk []byte) { ring.Push(chunk) })
|
|
}()
|
|
go func() {
|
|
_ = renderStream(toRadioDev, sampleRate, channels, bitsPerSample, stop, ring)
|
|
}()
|
|
m.notify()
|
|
return nil
|
|
}
|
|
|
|
// StartTXAudioNetwork pipes the live microphone into a SEND function instead
|
|
// of a render device — the talk button when the radio is reached over its own
|
|
// link. No ring and no pacing goroutine: the microphone delivers in real time,
|
|
// and the sender re-frames to the rig's cadence, so the capture callback IS
|
|
// the clock.
|
|
func (m *Manager) StartTXAudioNetwork(micDev string, send func([]byte) error) error {
|
|
m.mu.Lock()
|
|
if m.txStop != nil {
|
|
m.mu.Unlock()
|
|
return fmt.Errorf("TX audio already running")
|
|
}
|
|
stop := make(chan struct{})
|
|
m.txStop = stop
|
|
m.mu.Unlock()
|
|
go func() {
|
|
if err := captureStream(micDev, stop, func(chunk []byte) { _ = send(chunk) }); err != nil {
|
|
LogSink("audio: network TX capture from %q failed: %v", DeviceName(micDev), err)
|
|
}
|
|
}()
|
|
m.notify()
|
|
return nil
|
|
}
|
|
|
|
// StopTXAudio stops the TX mic→rig passthrough.
|
|
func (m *Manager) StopTXAudio() {
|
|
m.mu.Lock()
|
|
stop := m.txStop
|
|
m.txStop = nil
|
|
m.mu.Unlock()
|
|
if stop != nil {
|
|
close(stop)
|
|
m.notify()
|
|
}
|
|
}
|
|
|
|
// TXAudioActive reports whether the TX mic→rig passthrough is running.
|
|
func (m *Manager) TXAudioActive() bool {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.txStop != nil
|
|
}
|
|
|
|
// scalePCM applies a gain to one sample, clamping rather than wrapping — an
|
|
// overflow that wraps turns loud speech into a burst of noise on the air.
|
|
func scalePCM(s int16, g float64) int16 {
|
|
v := float64(s) * g
|
|
if v > 32767 {
|
|
return 32767
|
|
}
|
|
if v < -32768 {
|
|
return -32768
|
|
}
|
|
return int16(v)
|
|
}
|