//go:build windows package audio import ( "encoding/binary" "fmt" "runtime/debug" "strings" "sync" "time" ) // LogSink receives audio-subsystem diagnostics (set to applog.Printf at startup). // Defaults to a no-op so the package is usable without wiring. var LogSink = func(string, ...any) {} // AlertSink receives the few audio problems an operator must see WHILE they are // operating, not afterwards in a log file — a capture device that opens but // never streams being the one that matters: the recording is silently empty and // nothing says so until the QSO is logged and gone. // // Set to a toast emitter at startup; a no-op keeps the package standalone. var AlertSink = func(string, ...any) {} // recoverGoroutine turns a panic in a long-running audio goroutine into a logged // event with a stack trace instead of a silent process-killing crash. (It can't // catch a hard Windows access violation from the WASAPI layer — those are fatal // — but it catches any Go-level panic in capture/mix.) func recoverGoroutine(what string) { if r := recover(); r != nil { LogSink("audio: PANIC in %s: %v\n%s", what, r, debug.Stack()) } } // Recorder continuously captures audio into a rolling pre-roll buffer so a QSO // recording can begin a few seconds BEFORE the operator entered the callsign. // It optionally mixes two sources (the rig RX "From Radio" + your mic) into a // single mono track, so both sides of the contact are captured. // // Lifecycle: Start() runs capture+mix in the background. BeginQSO() snapshots // the pre-roll and starts accumulating; SaveQSO() writes the WAV; DiscardQSO() // drops it. Stop() tears down capture. type Recorder struct { mu sync.Mutex stopCh chan struct{} wg sync.WaitGroup running bool prerollSamples int // Per-source sample queues (guarded by srcMu), drained by the mixer. srcMu sync.Mutex bufA []int16 // From Radio bufB []int16 // mic // When each source last delivered samples. A configured device that never // produces anything is not an error anywhere — the capture call just sits // there — so the only way to notice is to watch the clock. lastA, lastB time.Time // startedAt is when capture began — the reference for a source that has not // delivered anything at all yet. startedAt time.Time // deadB (deadA) latches once a source has been declared silent, so the // warning is logged once rather than 25 times a second. deadA, deadB bool twoSrc bool gainA float64 // From Radio gain (1.0 = unity), guarded by srcMu gainB float64 // mic gain // Mixed output state (guarded by mu). ring []int16 // last prerollSamples of mixed audio active bool // paused freezes an ACTIVE take: nothing more is accumulated, but everything // captured so far is kept and the take still ends normally when the QSO is // logged. It is what lets an operator stop, play the recording back on the // air to the station they are working, and still have it saved with the QSO. paused bool acc []int16 // active QSO accumulation (seeded from ring on BeginQSO) } func NewRecorder() *Recorder { return &Recorder{gainA: 1, gainB: 1} } // SetGains sets the per-source mix levels (1.0 = unity). Use this to balance a // hot mic against quieter rig RX audio. Values ≤0 fall back to unity. func (r *Recorder) SetGains(fromGain, micGain float64) { if fromGain <= 0 { fromGain = 1 } if micGain <= 0 { micGain = 1 } r.srcMu.Lock() r.gainA, r.gainB = fromGain, micGain r.srcMu.Unlock() } // scaleSample applies gain to a sample with clamping. func scaleSample(s int16, g float64) int16 { if g == 1 { return s } v := float64(s) * g if v > 32767 { return 32767 } if v < -32768 { return -32768 } return int16(v) } func (r *Recorder) Running() bool { r.mu.Lock() defer r.mu.Unlock() return r.running } func (r *Recorder) Active() bool { r.mu.Lock() defer r.mu.Unlock() return r.active } // Start begins continuous capture from fromDev (required) mixed with micDev // (optional — "" or same as fromDev → single source). prerollSec is how much // audio to retain ahead of BeginQSO. func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error { r.mu.Lock() if r.running { r.mu.Unlock() return nil } if prerollSec < 0 { prerollSec = 0 } // Does the configured endpoint still EXIST? // // Endpoint ids are stored, and a DAX channel that is reconfigured, disabled // or removed comes back with a different id. The old one then opens without // complaint on some drivers and simply never streams — which is // indistinguishable from a quiet band until the recording turns out empty. // Checking the list takes milliseconds and answers it outright. if devs, derr := ListInputDevices(); derr == nil { known := func(id string) bool { for _, d := range devs { if d.ID == id { return true } } return false } if fromDev != "" && !known(fromDev) { LogSink("recorder: the configured radio input no longer exists (%s) — re-select it in Settings → Audio", fromDev) AlertSink("The configured radio audio input no longer exists — re-select it in Settings") } if micDev != "" && micDev != fromDev && !known(micDev) { LogSink("recorder: the configured microphone no longer exists (%s) — re-select it in Settings → Audio", micDev) } } r.prerollSamples = prerollSec * sampleRate r.twoSrc = micDev != "" && micDev != fromDev r.stopCh = make(chan struct{}) r.running = true r.startedAt = time.Now() r.ring, r.acc, r.active, r.paused, r.bufA, r.bufB = nil, nil, false, false, nil, nil r.lastA, r.lastB, r.deadA, r.deadB = time.Time{}, time.Time{}, false, false stop := r.stopCh twoSrc := r.twoSrc r.mu.Unlock() // Capture goroutine(s) feed the per-source queues. r.wg.Add(1) go func() { defer r.wg.Done() defer recoverGoroutine("recorder capture (radio)") // The error was discarded here. A device that cannot be opened — renamed, // unplugged, held by another program — then looked exactly like a device // that is merely quiet, and the recording came out empty with nothing in // the log to say why. if err := captureStream(fromDev, stop, func(chunk []byte) { s := bytesToInt16(chunk) r.srcMu.Lock() r.bufA = append(r.bufA, s...) r.lastA = time.Now() r.srcMu.Unlock() }); err != nil { LogSink("recorder: capture from %q failed: %v", DeviceName(fromDev), err) } }() if twoSrc { r.wg.Add(1) go func() { defer r.wg.Done() defer recoverGoroutine("recorder capture (mic)") if err := captureStream(micDev, stop, func(chunk []byte) { s := bytesToInt16(chunk) r.srcMu.Lock() r.bufB = append(r.bufB, s...) r.lastB = time.Now() r.srcMu.Unlock() }); err != nil { LogSink("recorder: capture from %q failed: %v", DeviceName(micDev), err) } }() } // Watchdog. A WASAPI device can open cleanly and then produce nothing at // all — a DAX channel with no stream behind it does exactly that, and it is // indistinguishable from silence until the recording turns out to be empty // at the end of a QSO. Say it once, three seconds in, while there is still // time to fix the setup. r.wg.Add(1) go func() { defer r.wg.Done() defer recoverGoroutine("recorder watchdog") select { case <-stop: return case <-time.After(3 * time.Second): } r.srcMu.Lock() aQuiet, bQuiet := r.lastA.IsZero(), r.lastB.IsZero() r.srcMu.Unlock() if aQuiet { LogSink("recorder: no audio at all from %q after 3 s — the device opened but nothing is streaming", DeviceName(fromDev)) AlertSink("No audio from %s — nothing is being recorded", DeviceName(fromDev)) } // The mic is only worth a warning when the RADIO is silent too. On CW the // mic channel legitimately delivers nothing, and the mixer has already // said "recording the radio alone" — repeating it as an alarm made the log // read as if something were broken while the recording was going fine. if twoSrc && bQuiet && aQuiet { LogSink("recorder: no audio at all from %q either", DeviceName(micDev)) } }() // Mixer goroutine. r.wg.Add(1) go func() { defer r.wg.Done() defer recoverGoroutine("recorder mixer") t := time.NewTicker(40 * time.Millisecond) defer t.Stop() for { select { case <-stop: return case <-t.C: r.mixTick() } } }() return nil } // mixTick drains the source queues, mixes what's available, and appends to the // ring + active accumulation. // deadSourceAfter is how long a source may deliver nothing before the recorder // carries on without it. Long enough not to trip on a scheduling hiccup, short // enough that almost nothing is lost from the source that IS working. const deadSourceAfter = 1500 * time.Millisecond func (r *Recorder) mixTick() { r.srcMu.Lock() var mixed []int16 if r.twoSrc { // A source that has been silent for a while is treated as ABSENT and the // other one is recorded alone. // // Two sources used to mean min(len(A), len(B)) samples: if one device // delivered nothing, NOTHING was recorded, and the drift guard below // then threw the live source away a second at a time. That is exactly // what happened on a Flex in CW — DAX Mic delivers nothing when the mic // path is not running — and the operator got "recording was empty" after // a whole QSO. Half a recording is worth having; silence is not. now := time.Now() // A source is dry when it has been quiet for too long — and a source that // has NEVER delivered is measured from when capture started, because it // has no last-delivery time of its own. // // The first version required a source to have spoken at least once. That // covers a device that stops, but not the one that actually happens: a // DAX Mic channel that is simply switched off never delivers a single // sample, so it stayed "not yet dry" forever and took the whole recording // down with it. Three empty CW recordings, with the radio audio streaming // perfectly the entire time. dry := func(last time.Time) bool { if last.IsZero() { return now.Sub(r.startedAt) > deadSourceAfter } return now.Sub(last) > deadSourceAfter } aDry, bDry := dry(r.lastA), dry(r.lastB) if bDry && !aDry && len(r.bufA) > 0 { if !r.deadB { r.deadB = true LogSink("recorder: the second audio source is silent — recording the radio alone") } mixed = make([]int16, len(r.bufA)) for i, v := range r.bufA { mixed[i] = scaleSample(v, r.gainA) } r.bufA = r.bufA[:0] r.bufB = r.bufB[:0] r.srcMu.Unlock() r.store(mixed) return } if aDry && !bDry && len(r.bufB) > 0 { if !r.deadA { r.deadA = true LogSink("recorder: the radio audio source is silent — recording the microphone alone") } mixed = make([]int16, len(r.bufB)) for i, v := range r.bufB { mixed[i] = scaleSample(v, r.gainB) } r.bufA = r.bufA[:0] r.bufB = r.bufB[:0] r.srcMu.Unlock() r.store(mixed) return } n := len(r.bufA) if len(r.bufB) < n { n = len(r.bufB) } if n > 0 { // Both alive again after one was written off. r.deadA, r.deadB = false, false mixed = make([]int16, n) for i := 0; i < n; i++ { mixed[i] = clampSum(scaleSample(r.bufA[i], r.gainA), scaleSample(r.bufB[i], r.gainB)) } r.bufA = append(r.bufA[:0], r.bufA[n:]...) r.bufB = append(r.bufB[:0], r.bufB[n:]...) } // Drift guard: if the clocks diverge, drop the excess so the two // sources stay roughly aligned (≤1 s skew). if d := len(r.bufA) - len(r.bufB); d > sampleRate { r.bufA = append(r.bufA[:0], r.bufA[d:]...) } else if d < -sampleRate { r.bufB = append(r.bufB[:0], r.bufB[-d:]...) } } else if len(r.bufA) > 0 { mixed = make([]int16, len(r.bufA)) for i, s := range r.bufA { mixed[i] = scaleSample(s, r.gainA) } r.bufA = r.bufA[:0] } r.srcMu.Unlock() r.store(mixed) } // store appends mixed samples to the pre-roll ring and, when a take is running, // to the take itself. func (r *Recorder) store(mixed []int16) { if len(mixed) == 0 { return } r.mu.Lock() r.ring = append(r.ring, mixed...) if len(r.ring) > r.prerollSamples { r.ring = append(r.ring[:0], r.ring[len(r.ring)-r.prerollSamples:]...) } if r.active && !r.paused { r.acc = append(r.acc, mixed...) } r.mu.Unlock() } // BeginQSO starts accumulating a recording, seeded with the current pre-roll. // No-op if already accumulating or not running. func (r *Recorder) BeginQSO() { r.mu.Lock() defer r.mu.Unlock() if !r.running || r.active { return } r.acc = append([]int16(nil), r.ring...) r.active, r.paused = true, false } // RestartQSO begins a fresh accumulation even if one is already active — // re-seeding from the pre-roll ring. Used when the target QSO changes (a new // call+freq from a clicked spot or an external app) so the previous take is // dropped and a new one starts from the pre-roll, rather than continuing to // accumulate the old contact. func (r *Recorder) RestartQSO() { r.mu.Lock() defer r.mu.Unlock() if !r.running { return } r.acc = append([]int16(nil), r.ring...) r.active, r.paused = true, false } // ResetQSOClock restarts the active accumulation from ZERO — discarding // everything captured so far INCLUDING the pre-roll. Unlike RestartQSO (which // re-seeds from the pre-roll ring), this keeps nothing: the saved file will // contain only audio from this moment onward. Used when the contact you entered // was already in a long QSO and you want to record just your own exchange. // No-op if not running; if no take is active it begins one (empty). func (r *Recorder) ResetQSOClock() { r.mu.Lock() defer r.mu.Unlock() if !r.running { return } r.acc = nil r.active = true } // TakeQSO snapshots the accumulated recording as raw 16 kHz mono PCM bytes and // stops accumulating — fast, no encoding. The next BeginQSO can safely start a // new take immediately. Pair with WritePCM to encode/write off the hot path so // a long recording doesn't delay logging. func (r *Recorder) TakeQSO() ([]byte, error) { r.mu.Lock() if !r.active { r.mu.Unlock() return nil, fmt.Errorf("no active recording") } samples := r.acc r.acc, r.active, r.paused = nil, false, false r.mu.Unlock() if len(samples) == 0 { return nil, fmt.Errorf("recording was empty") } return int16sToBytes(samples), nil } // WritePCM encodes raw 16 kHz mono PCM to path (WAV, or MP3 when path ends in // .mp3). Slow for MP3 — call off the logging path. func WritePCM(path string, data []byte) error { if strings.HasSuffix(strings.ToLower(path), ".mp3") { return writeMP3(path, data) } return writeWAV(path, data) } // SaveQSO snapshots and writes the recording in one call (synchronous). func (r *Recorder) SaveQSO(path string) error { data, err := r.TakeQSO() if err != nil { return err } return WritePCM(path, data) } // DiscardQSO drops the active accumulation without saving (callsign cleared). func (r *Recorder) DiscardQSO() { r.mu.Lock() r.acc, r.active, r.paused = nil, false, false r.mu.Unlock() } // Stop tears down capture+mix. func (r *Recorder) Stop() { r.mu.Lock() if !r.running { r.mu.Unlock() return } r.running = false stop := r.stopCh r.stopCh = nil r.mu.Unlock() close(stop) r.wg.Wait() r.mu.Lock() r.ring, r.acc, r.active, r.paused = nil, nil, false, false r.mu.Unlock() r.srcMu.Lock() r.bufA, r.bufB = nil, nil r.srcMu.Unlock() } func clampSum(a, b int16) int16 { v := int32(a) + int32(b) if v > 32767 { return 32767 } if v < -32768 { return -32768 } return int16(v) } func bytesToInt16(b []byte) []int16 { out := make([]int16, len(b)/2) for i := range out { out[i] = int16(binary.LittleEndian.Uint16(b[i*2:])) } return out } func int16sToBytes(s []int16) []byte { b := make([]byte, len(s)*2) for i, v := range s { binary.LittleEndian.PutUint16(b[i*2:], uint16(v)) } return b } // PauseQSO freezes the active take without ending it: nothing more is recorded, // nothing is thrown away, and logging the QSO still writes the file. // // This exists for one situation, which is common enough on the bands to be // worth the state: you are working a station, you have been recording, and they // ask to hear it. You stop, you play it back to them on the air, and the // recording is still saved with the QSO afterwards. func (r *Recorder) PauseQSO() bool { r.mu.Lock() defer r.mu.Unlock() if !r.active { return false } r.paused = true return true } // ResumeQSO continues an interrupted take, appending to what is already there. func (r *Recorder) ResumeQSO() bool { r.mu.Lock() defer r.mu.Unlock() if !r.active { return false } r.paused = false return true } // Paused reports whether the active take is frozen. func (r *Recorder) Paused() bool { r.mu.Lock() defer r.mu.Unlock() return r.active && r.paused } // PeekQSO returns what has been captured so far WITHOUT ending the take. // // Unlike TakeQSO this keeps the audio, because the take is going to be played // back and then still saved with the QSO. The copy is deliberate: the caller // gets bytes it can hold while the recorder keeps appending to its own slice. func (r *Recorder) PeekQSO() ([]byte, error) { r.mu.Lock() defer r.mu.Unlock() if !r.active { return nil, fmt.Errorf("no active recording") } if len(r.acc) == 0 { return nil, fmt.Errorf("recording is empty") } return int16sToBytes(append([]int16(nil), r.acc...)), nil }