Measured rather than guessed: the whole repository was cross-compiled for
linux/amd64 and the gaps closed one by one. There were fewer than expected.
Flex and TCI were never Windows-specific — they carried //go:build windows by
inheritance and import nothing but net and gorilla/websocket. Untagged, no code
change. The two backends a Linux operator is most likely to own were already
portable.
Audio was 560 lines, not 2287: only devices.go and engine.go touch WASAPI, while
manager.go, recorder.go, wav.go and mp3.go were pure Go wearing the tag by
association. The whole platform surface is seven functions, now implemented a
second time on PulseAudio through github.com/jfreymuth/pulse — pure Go over the
server socket, so the no-cgo rule survives, and PipeWire answers the same
protocol. The fixed 16 kHz mono format and the server-side resampling mirror
what AUTOCONVERTPCM does on Windows, for the same reason.
OmniRig is the only real loss, and its backend still EXISTS off Windows rather
than being compiled out of app.go: a settings database is portable, so an
operator moving a profile across keeps "omnirig" saved and must be told to pick
a native backend instead of meeting a nil one.
The parts where Linux is not Windows, and where a compile-only stub would have
been a silent bug:
- data dir: still beside the binary, but ~/.local/share/OpsLog/data when that
folder belongs to the system — decided by trying the write, because /opt and
/usr/local are writable on some stations and not others.
- single instance: an flock, not a pid file. The kernel drops it however the
process dies, so a crash leaves nothing to delete by hand. This is the guard
that stops two instances fighting over the rig frequency.
- update: simpler here. Unix renames over a running binary, so the deferred
swap the Windows path needs a detached helper for is unreachable.
- tasklist/taskkill become /proc and SIGTERM; the boot log moves out of /tmp,
which is wiped exactly when the evidence is wanted.
- serial ports sorted naturally: /dev/ttyUSB10 was landing between USB1 and
USB2, the same trap COM10 fell into.
release.ps1 now cross-builds and vets for linux before it builds the exe, and
refuses the release if that fails — a port rots one unguarded x/sys/windows call
at a time.
Nothing has been executed on Linux yet: Wails needs webkit2gtk and cgo there, so
the binary must be built on Linux. scripts/linux-setup.sh checks the machine and
does it; BUILDING-LINUX.md is the manual version.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
622 lines
20 KiB
Go
622 lines
20 KiB
Go
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()
|
|
|
|
// The radio side is either CAPTURED from a sound device or PUSHED in from
|
|
// somewhere else. Over the network there is no sound device at all: an
|
|
// IC-705 reached by LAN streams its receive audio over the Icom protocol,
|
|
// and the operator's audio settings can only offer the PC's own microphone
|
|
// and speakers. fromDev == PushedSource says "someone else will feed me",
|
|
// and PushRX is how they do it.
|
|
if fromDev == PushedSource {
|
|
LogSink("recorder: radio audio is pushed in (network), not captured from a device")
|
|
} else {
|
|
r.startRadioCapture(fromDev, stop)
|
|
}
|
|
if twoSrc {
|
|
r.startMicCapture(micDev, stop)
|
|
}
|
|
r.finishStart(fromDev, micDev, twoSrc, stop)
|
|
return nil
|
|
}
|
|
|
|
// PushedSource is the "device" name that means the radio audio arrives through
|
|
// PushRX instead of a capture stream.
|
|
const PushedSource = "\x00pushed"
|
|
|
|
// PushRX feeds one chunk of 16-bit mono PCM from a non-device source.
|
|
//
|
|
// Safe to call when nothing is recording — the samples are dropped, which is
|
|
// what should happen: the network stream runs whenever the rig is connected,
|
|
// and the recorder only wants it between BeginQSO and the save.
|
|
func (r *Recorder) PushRX(pcm []byte) {
|
|
r.mu.Lock()
|
|
running := r.running
|
|
r.mu.Unlock()
|
|
if !running || len(pcm) == 0 {
|
|
return
|
|
}
|
|
sm := bytesToInt16(pcm)
|
|
r.srcMu.Lock()
|
|
r.bufA = append(r.bufA, sm...)
|
|
r.lastA = time.Now()
|
|
r.srcMu.Unlock()
|
|
}
|
|
|
|
func (r *Recorder) startRadioCapture(fromDev string, stop chan struct{}) {
|
|
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)
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (r *Recorder) startMicCapture(micDev string, stop chan struct{}) {
|
|
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)
|
|
}
|
|
}()
|
|
}
|
|
|
|
// finishStart logs what is being recorded and arms the silence watchdog.
|
|
func (r *Recorder) finishStart(fromDev, micDev string, twoSrc bool, stop chan struct{}) {
|
|
// Name the devices being recorded FROM, once, at the start.
|
|
//
|
|
// A station with two radios has two sets of endpoints, and the recorder will
|
|
// happily capture the one that is not being listened to: the result is a
|
|
// file full of hiss with no relation to what the operator hears, and nothing
|
|
// anywhere said which receiver it came from.
|
|
if twoSrc {
|
|
LogSink("recorder: capturing %q + %q", DeviceName(fromDev), DeviceName(micDev))
|
|
} else {
|
|
LogSink("recorder: capturing %q", DeviceName(fromDev))
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// 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: two sound cards run on their own clocks, so drop the
|
|
// excess to keep them within a second of each other.
|
|
//
|
|
// ONLY while both are actually running. A starved source is not drift: it
|
|
// made this guard throw away the radio audio a second at a time during
|
|
// the grace period, so the opening of every recording was lost even
|
|
// though it had been captured. Waiting costs nothing now — whatever is
|
|
// buffered is written whole the moment the silent source is written off.
|
|
if len(r.bufA) > 0 && len(r.bufB) > 0 {
|
|
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
|
|
}
|