Files
OpsLog/internal/audio/manager.go
T
rouggy 67a03c3ddc feat: a level control for the voice keyer, and name every CAT backend in its PTT list
The messages went to the radio exactly as recorded. Nothing in OpsLog could
raise them, so a microphone captured quietly drove the rig quietly and the only
remedies were the radio's own USB input menu or the Windows mixer — which is
where the operator was heading. There is now a level from 10 to 400 %, applied
with clamping (a wrap would turn loud speech into noise on the air), and Play
previews at that same level so the adjustment is made against what will actually
be transmitted.

The PTT method list also named only OmniRig, Flex, Icom and TCI, so choosing a
native Yaesu left "CAT" with no backend beside it — which reads as "there is no
CAT PTT for my radio" and sends the operator to RTS on a COM port that has
nothing to do with the rig. Same list-needing-every-member shape as three
earlier bugs in this feature. The TestPTT log line had the same rot: it said
"CAT via OmniRig" whatever backend was running.
2026-07-29 14:29:47 +02:00

297 lines
8.5 KiB
Go

//go:build windows
package audio
import (
"fmt"
"sync"
)
// 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
playStop 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)
}
}
m.StopPlayback()
stop := make(chan struct{})
m.mu.Lock()
m.playStop = stop
m.mu.Unlock()
go func() {
_ = playPCM(deviceID, pcm, rate, ch, bits, stop)
m.mu.Lock()
if m.playStop == stop {
m.playStop = nil
}
m.mu.Unlock()
m.notify()
}()
m.notify()
return nil
}
// StopPlayback halts any in-progress playback.
func (m *Manager) StopPlayback() {
m.mu.Lock()
stop := m.playStop
m.playStop = nil
m.mu.Unlock()
if stop != nil {
close(stop)
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.
go func() {
_ = captureStream(inputDev, stop, func(chunk []byte) { ring.Push(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
}
// 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
}
// 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)
}