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