Composite stereo TX recording — true on-air timeline (WAV + API/UI) - #13
Merged
Conversation
Add a "composite recording": capture a chosen set of transmitters into a single, sample-aligned, multi-channel WAV — one transmitter per channel — paced through one real-time clock. For the canonical two-station setup that's a stereo file with one station's transmitted audio in each ear.
Why this is new rather than reusing the per-port `*.tx.wav` files: those are written straight from each TNC as it *bursts* TX audio (samoyed can emit a 500 ms frame onto its UDP socket in a few wall-clock ms), so they carry the waveform faithfully but are not a real-time timeline — you can't line two of them up and trust the gaps. The composite recorder instead drains each transmitter's blocks through a single ticker at the block rate (the same mechanism rxFeeder uses to pace audio into the receivers), writing one interleaved frame per tick. Because every channel is advanced together by one goroutine they share one start time and stay sample-aligned, and idle channels emit silence so gaps are preserved. The result is an accurate timeline of what was on the air: overlapping transmissions overlap in time. The audio is the clean transmitter output (full-scale, before per-link loss / noise / the capture mixer).
Implementation:
- internal/audio: generalise the WAV header writer to N channels, add MultiWAVWriter and an Interleave helper.
- internal/router: compositeRecorder with per-channel queues fed from the txReader hot path (atomic pointer, like the existing recorder) and a real-time pacer goroutine; Router.StartCompositeRecording / StopCompositeRecording / CompositeStatus; graceful drain + WAV-header patch on stop and on shutdown.
- cmd/sim-web: /api/record/composite/{start,stop,status,download} for external software, a composite block in /api/status, and a Left/Right-ear picker + Start/Stop/Download panel on the control page (map UI untouched). The -record DIR flag enables it.
- cmd/sim-router: -composite a.vhf,b.vhf flag (requires -record) to capture on startup.
Tests: multichannel WAV header + interleave round-trip; recorder start/stop/validation/defaults/real-time pacing; a concurrent-feed test that passes under -race; sim-web handler tests via httptest. Verified end-to-end inside the Docker image with live samoyed children — a 0.7 s wall-clock recording produced 0.70 s of stereo audio.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds a composite recording: capture a chosen set of transmitters into a single, sample-aligned, multi-channel WAV — one transmitter per channel, paced through one real-time clock. For the canonical two-station setup that's a stereo file with one station's transmitted audio in each ear.
This is the feature requested: a WAV that is an accurate timeline of what actually happened on the channel — overlapping transmissions overlap in time, inter-burst gaps are real silence, and both channels share one start time. Drop it into Audacity and you can see at a glance who was keying when (ideal for hidden-node collisions — put the two hidden stations in left/right).
Why it's new, not the existing recorder
The per-port
*.tx.wavfiles are written straight from each TNC as it bursts TX audio (samoyed can dump a 500 ms frame onto its UDP socket in a few wall-clock ms), so they carry the waveform faithfully but are not a real-time timeline. The composite recorder drains each transmitter's blocks through a single ticker at the block rate — the same mechanismrxFeederalready uses to pace audio into the receivers — writing one interleaved frame per tick. Every channel is advanced together by one goroutine, so they stay sample-aligned and share one start time; idle channels emit silence so gaps are preserved. The captured audio is the clean transmitter output (full-scale, before per-link loss / noise / the capture-effect mixer) — what each station put on the air, not what any one receiver heard.Changes
internal/audio— generalise the WAV header writer to N channels; addMultiWAVWriterand anInterleavehelper.internal/router—compositeRecorderwith per-channel queues fed from thetxReaderhot path (atomic pointer, mirroring the existing recorder) and a real-time pacer goroutine;Router.StartCompositeRecording/StopCompositeRecording/CompositeStatus; graceful drain + WAV-header patch on stop and on shutdown.cmd/sim-web—POST/GET /api/record/composite/{start,stop,status,download}for external software; acompositeblock in/api/status; a Left/Right-ear picker with Start/Stop/Download on the control page. Map UI untouched. Gated on-record DIR.cmd/sim-router—-composite a.vhf,b.vhf(requires-record) to capture on startup.API (for external software)
POST /api/record/composite/startwith optional{"ports":["a.vhf","c.vhf"]}(omit → first two ports) → transmit over KISS →POST .../stop→GET .../download.Testing
httptest.-race.dockerworkflow rebuilds the image to validate it; merge tomainpublishes:main.)