//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. if err := playPCM(deviceID, pcm, rate, ch, bits, stop); 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 } // 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) }