From 6a5bd7d4ad774ef738e305bc39eb09d971c37b8c Mon Sep 17 00:00:00 2001 From: Tom M0LTE Date: Thu, 4 Jun 2026 03:02:53 +0000 Subject: [PATCH] recording: composite stereo TX timeline WAV + API/UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 59 +++++ cmd/sim-router/main.go | 44 ++++ cmd/sim-web/composite_test.go | 151 ++++++++++++ cmd/sim-web/index.html | 115 +++++++++ cmd/sim-web/main.go | 192 ++++++++++++++- internal/audio/multiwav_test.go | 105 +++++++++ internal/audio/wav.go | 110 ++++++++- internal/router/composite.go | 375 ++++++++++++++++++++++++++++++ internal/router/composite_test.go | 255 ++++++++++++++++++++ internal/router/router.go | 12 + 10 files changed, 1401 insertions(+), 17 deletions(-) create mode 100644 cmd/sim-web/composite_test.go create mode 100644 internal/audio/multiwav_test.go create mode 100644 internal/router/composite.go create mode 100644 internal/router/composite_test.go diff --git a/README.md b/README.md index ddc4fa1..de9c5e0 100644 --- a/README.md +++ b/README.md @@ -322,6 +322,65 @@ but its data-chunk size will say zero — most players will refuse to play it. Stop the router cleanly (or click Record off) before pulling the plug. +### Composite recording — true on-air timeline + +The per-port `*.tx.wav` files above are written straight from each TNC +as it *bursts* its TX audio (samoyed can emit a 500 ms frame onto its +UDP socket in a few wall-clock ms), so they faithfully carry the +modulated waveform but are **not** a real-time timeline — you can't line +two of them up and trust the gaps. + +A **composite recording** fixes that. It captures a chosen set of +transmitters into a *single*, sample-aligned, multi-channel WAV — one +transmitter per channel — and paces every channel through one real-time +clock (the same mechanism that feeds audio into the receivers). For the +canonical two-station setup that's a **stereo** file with one station's +transmitted audio in each ear. The result is an accurate timeline of +what was on the air: 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 inspecting hidden-node collisions (put the two hidden +stations in left/right). + +The audio is the clean transmitter output (full-scale, before any +per-link path loss, noise, or the capture-effect mixer) — it's what each +station actually put on the air, not what any particular receiver heard. + +**`sim-router`** — pass `-composite a.vhf,b.vhf` together with `-record DIR` +(the base dir for the output). Recording starts as soon as the router +comes up and stops on a clean shutdown: + +``` +sim-router -config configs/hidden-node.yaml -record /tmp/sim-rec \ + -composite a.vhf,c.vhf +# /tmp/sim-rec/composite-20260604T120000.000Z.wav (stereo: a.vhf left, c.vhf right) +``` + +List more than two ports for a multi-track WAV (one channel each, in the +order given). + +**`sim-web`** — with `-record DIR` set, a **Composite recording** panel +appears on the control page: pick the Left-ear and Right-ear +transmitters, click **Start recording**, then **Stop recording** when +done, and **Download .wav**. + +#### HTTP API (for external software) + +Drive it from a test harness or any HTTP client. All endpoints live +under `/api/record/composite/` and need `sim-web` started with +`-record DIR` and the router running. + +| Method & path | Body | Effect | +|---|---|---| +| `POST /api/record/composite/start` | `{"ports":["a.vhf","c.vhf"]}` (optional) | Start a recording. Each port is one channel, in order (index 0 = left). Omit the body to default to the topology's first two ports. | +| `POST /api/record/composite/stop` | — | Finalise the file (patches the WAV header) and return its status. | +| `GET /api/record/composite/status` | — | Current state: `available`, `active`, `channels`, `path`, `duration_s`, and `download_url` once a file is complete. | +| `GET /api/record/composite/download` | — | Stream the most recently completed composite WAV as an attachment. | + +A typical external run: `POST .../start` → exercise your stations over +KISS → `POST .../stop` → `GET .../download`. The `composite` block is +also included in `GET /api/status`. + ## Why FM capture-effect mixing (and not linear sum) A linear-sum mixer is simpler and is what most "audio bus" libraries diff --git a/cmd/sim-router/main.go b/cmd/sim-router/main.go index a2a76de..2639083 100644 --- a/cmd/sim-router/main.go +++ b/cmd/sim-router/main.go @@ -17,6 +17,7 @@ import ( "os" "os/exec" "os/signal" + "strings" "syscall" "github.com/packethacking/net-sim/internal/config" @@ -30,6 +31,7 @@ func main() { direwolfPath := flag.String("direwolf", "", "path to direwolf (default: search $PATH)") workDir := flag.String("workdir", "", "scratch dir for per-port config files / FIFOs (default: a unique subdir of $TMPDIR)") recordDir := flag.String("record", "", "if set, record all per-port TX and RX audio to a timestamped subdirectory of this path") + composite := flag.String("composite", "", "comma-separated transmitter ports (e.g. a.vhf,b.vhf) to composite into one real-time, sample-aligned WAV (one TX per channel — stereo for two). Requires -record for the output dir") flag.Parse() if *cfgPath == "" { @@ -38,6 +40,16 @@ func main() { os.Exit(2) } + if *composite != "" && *recordDir == "" { + fmt.Fprintln(os.Stderr, "sim-router: -composite requires -record DIR (for the output directory)") + os.Exit(2) + } + compositePorts, err := parseCompositePorts(*composite) + if err != nil { + fmt.Fprintln(os.Stderr, "sim-router:", err) + os.Exit(2) + } + logLevel := slog.LevelInfo if *verbose { logLevel = slog.LevelDebug @@ -89,6 +101,16 @@ func main() { os.Exit(1) } + if len(compositePorts) > 0 { + path, err := r.StartCompositeRecording(compositePorts) + if err != nil { + logger.Error("start composite recording", "err", err) + _ = r.Stop() + os.Exit(1) + } + logger.Info("composite recording", "file", path, "channels", *composite) + } + // Run until SIGINT/SIGTERM or a child dies (router cancels its own ctx). sig := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) @@ -143,6 +165,28 @@ func resolveDirewolf(explicit string) (string, error) { return "", errors.New("direwolf not found in $PATH or common locations") } +// parseCompositePorts turns a comma-separated "a.vhf,b.vhf" list into +// PortRefs. Empty input yields no refs (composite disabled). Order is +// preserved: the first port is channel 0 (left ear in the stereo case). +func parseCompositePorts(s string) ([]config.PortRef, error) { + if strings.TrimSpace(s) == "" { + return nil, nil + } + var refs []config.PortRef + for _, part := range strings.Split(s, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + dot := strings.IndexByte(part, '.') + if dot <= 0 || dot >= len(part)-1 { + return nil, fmt.Errorf("invalid -composite port %q (want .)", part) + } + refs = append(refs, config.PortRef{NodeID: part[:dot], PortID: part[dot+1:]}) + } + return refs, nil +} + func resolveWorkDir(explicit string) (string, error) { if explicit != "" { return explicit, os.MkdirAll(explicit, 0o755) diff --git a/cmd/sim-web/composite_test.go b/cmd/sim-web/composite_test.go new file mode 100644 index 0000000..68abf03 --- /dev/null +++ b/cmd/sim-web/composite_test.go @@ -0,0 +1,151 @@ +package main + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func testApp(recordBase string) *app { + return &app{ + recordBase: recordBase, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } +} + +func decodeComposite(t *testing.T, body io.Reader) compositeStatus { + t.Helper() + var cs compositeStatus + if err := json.NewDecoder(body).Decode(&cs); err != nil { + t.Fatalf("decode composite status: %v", err) + } + return cs +} + +func TestCompositeStatusUnavailableWithoutRecordDir(t *testing.T) { + a := testApp("") + rec := httptest.NewRecorder() + a.handleCompositeStatus(rec, httptest.NewRequest(http.MethodGet, "/api/record/composite/status", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status code = %d, want 200", rec.Code) + } + cs := decodeComposite(t, rec.Body) + if cs.Available { + t.Error("Available should be false when no -record dir") + } + if cs.Active { + t.Error("Active should be false") + } +} + +func TestCompositeStatusAvailableWithRecordDir(t *testing.T) { + a := testApp(t.TempDir()) + rec := httptest.NewRecorder() + a.handleCompositeStatus(rec, httptest.NewRequest(http.MethodGet, "/api/record/composite/status", nil)) + cs := decodeComposite(t, rec.Body) + if !cs.Available { + t.Error("Available should be true when -record dir set") + } +} + +func TestCompositeStartRejectedWhenRecordingDisabled(t *testing.T) { + a := testApp("") + rec := httptest.NewRecorder() + a.handleCompositeStart(rec, httptest.NewRequest(http.MethodPost, "/api/record/composite/start", nil)) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status code = %d, want 400", rec.Code) + } +} + +func TestCompositeStartRejectedWhenRouterStopped(t *testing.T) { + a := testApp(t.TempDir()) + rec := httptest.NewRecorder() + a.handleCompositeStart(rec, httptest.NewRequest(http.MethodPost, "/api/record/composite/start", nil)) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status code = %d, want 400 (router not running)", rec.Code) + } + if !strings.Contains(rec.Body.String(), "not running") { + t.Errorf("body = %q, want mention of router not running", rec.Body.String()) + } +} + +func TestCompositeStartRejectsBadJSON(t *testing.T) { + a := testApp(t.TempDir()) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/record/composite/start", strings.NewReader("{not json")) + a.handleCompositeStart(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status code = %d, want 400 (bad json)", rec.Code) + } +} + +func TestCompositeStartRejectsBadPortRef(t *testing.T) { + a := testApp(t.TempDir()) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/record/composite/start", + strings.NewReader(`{"ports":["nodotsep"]}`)) + a.handleCompositeStart(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status code = %d, want 400 (bad port ref)", rec.Code) + } +} + +func TestCompositeStartWrongMethod(t *testing.T) { + a := testApp(t.TempDir()) + rec := httptest.NewRecorder() + a.handleCompositeStart(rec, httptest.NewRequest(http.MethodGet, "/api/record/composite/start", nil)) + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("status code = %d, want 405", rec.Code) + } +} + +func TestCompositeDownloadNotFoundWhenNoFile(t *testing.T) { + a := testApp(t.TempDir()) + rec := httptest.NewRecorder() + a.handleCompositeDownload(rec, httptest.NewRequest(http.MethodGet, "/api/record/composite/download", nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("status code = %d, want 404", rec.Code) + } +} + +func TestStatusIncludesCompositeBlock(t *testing.T) { + a := testApp(t.TempDir()) + rec := httptest.NewRecorder() + a.handleStatus(rec, httptest.NewRequest(http.MethodGet, "/api/status", nil)) + var resp map[string]any + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode status: %v", err) + } + comp, ok := resp["composite"].(map[string]any) + if !ok { + t.Fatalf("status JSON missing composite object; got %v", resp) + } + if comp["available"] != true { + t.Errorf("composite.available = %v, want true", comp["available"]) + } +} + +func TestParsePortRefs(t *testing.T) { + refs, err := parsePortRefs([]string{"a.vhf", " b.uhf-link ", ""}) + if err != nil { + t.Fatalf("parsePortRefs: %v", err) + } + if len(refs) != 2 { + t.Fatalf("got %d refs, want 2 (empty skipped)", len(refs)) + } + if refs[0].NodeID != "a" || refs[0].PortID != "vhf" { + t.Errorf("refs[0] = %+v, want a/vhf", refs[0]) + } + if refs[1].NodeID != "b" || refs[1].PortID != "uhf-link" { + t.Errorf("refs[1] = %+v, want b/uhf-link", refs[1]) + } + for _, bad := range []string{"nodot", ".leading", "trailing."} { + if _, err := parsePortRefs([]string{bad}); err == nil { + t.Errorf("parsePortRefs(%q) should error", bad) + } + } +} diff --git a/cmd/sim-web/index.html b/cmd/sim-web/index.html index 40cd824..f0f85fe 100644 --- a/cmd/sim-web/index.html +++ b/cmd/sim-web/index.html @@ -163,6 +163,22 @@

More

+ +
@@ -198,6 +214,11 @@

More

let topology = null; // the JSON view of /etc/sim/network.yaml let activeTab = "form"; +// Composite-recording UI state (persists across the 2s status poll so the +// dropdown selection isn't clobbered while the user is choosing). +let compSel = { left: null, right: null }; +let compActive = false; + function setMsg(text, ok) { const m = $("msg"); m.className = ok ? "ok" : "err"; @@ -262,6 +283,98 @@

More

await act(want ? "/api/record/start" : "/api/record/stop"); } +// ---- composite (true on-air timeline) recording ------------------------- + +async function apiCompositeToggle() { + if (compActive) { + await act("/api/record/composite/stop"); + } else { + const ports = [compSel.left, compSel.right].filter(Boolean); + if (ports.length < 1) { setMsg("choose at least one transmitter", false); return; } + await actJSON("/api/record/composite/start", { ports }); + } + refreshStatus(); +} + +function apiCompositeDownload() { window.location = "/api/record/composite/download"; } + +async function actJSON(path, body) { + try { + const r = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!r.ok) throw new Error(await r.text()); + setMsg(path + " ok", true); + } catch (e) { + setMsg(path + ": " + e.message, false); + } +} + +// renderComposite reflects the composite-recorder status into the panel. +// Only shown when sim-web was started with -record DIR. +function renderComposite(s) { + const c = s.composite || {}; + const panel = $("composite"); + if (!c.available) { panel.hidden = true; return; } + panel.hidden = false; + + const ports = (s.ports || []).map(p => p.node + "." + p.port); + const running = !!s.running; + compActive = !!c.active; + + const leftSel = $("comp-left"), rightSel = $("comp-right"); + if (compSel.left === null && ports.length) compSel.left = ports[0]; + if (compSel.right === null && ports.length) compSel.right = ports[Math.min(1, ports.length - 1)]; + fillSel(leftSel, ports, compSel.left); + fillSel(rightSel, ports, compSel.right); + compSel.left = leftSel.value || null; + compSel.right = rightSel.value || null; + + const canPick = running && !compActive; + leftSel.disabled = !canPick; + rightSel.disabled = !canPick; + + const btn = $("comp-btn"), dl = $("comp-dl"), state = $("comp-state"), hint = $("comp-hint"); + + if (compActive) { + btn.textContent = "Stop recording"; + btn.className = "danger"; + btn.disabled = false; + const dur = (c.duration_s || 0).toFixed(1); + const chans = (c.channels && c.channels.length) ? " [" + c.channels.join(" | ") + "]" : ""; + state.textContent = "● REC " + dur + "s" + chans; + state.style.color = "#ffb3b3"; + hint.textContent = c.path ? ("writing " + c.path.split("/").pop()) : ""; + } else { + btn.textContent = "Start recording"; + btn.className = "primary"; + btn.disabled = !running; + state.textContent = ""; + state.style.color = ""; + hint.textContent = running + ? "Captures each chosen transmitter into one channel of a single, sample-aligned WAV paced in real time — overlapping transmissions overlap in time, gaps are real silence. Two stations → stereo, one per ear. Load it in Audacity to see who was on the air when." + : "Start the simulator, then choose two transmitters."; + } + dl.disabled = !c.download_url; +} + +// fillSel (re)builds a port