Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions cmd/sim-router/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"os"
"os/exec"
"os/signal"
"strings"
"syscall"

"github.com/packethacking/net-sim/internal/config"
Expand All @@ -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 == "" {
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 <node>.<port>)", 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)
Expand Down
151 changes: 151 additions & 0 deletions cmd/sim-web/composite_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading
Loading