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
14 changes: 13 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ FROM golang:1.25-bookworm AS builder
# `main` (or that commit). Pinned to a fixed SHA so release images are
# reproducible rather than tracking a floating branch.
ARG SAMOYED_REPO=https://github.com/M0LTE/samoyed.git
ARG SAMOYED_REF=6b4f5c7aef633041cb2e55ab063f29ff0bacbefa
ARG SAMOYED_REF=7fdd617fe4acbb67d746b756086db49c716def84

RUN apt-get update && apt-get install -y --no-install-recommends \
git make pkg-config gcc libc6-dev \
Expand Down Expand Up @@ -71,6 +71,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libbsd0 libgps28 libasound2 libjack-jackd2-0 libpulse0 \
direwolf \
ca-certificates \
libcap2-bin \
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/* /var/log/*

# binaries
Expand All @@ -87,6 +88,17 @@ EXPOSE 8080 8001 8002

# A non-root user for the running process.
RUN useradd --system --no-create-home --shell /usr/sbin/nologin sim

# -rt-priority renices the router and its TNC children, which needs
# CAP_SYS_NICE — but the container runs as the non-root `sim` user, and
# docker's --cap-add only populates the *bounding* set (a non-root process
# doesn't inherit it). File capabilities on the binaries grant it to the
# process directly; still inert unless the container ALSO gets
# --cap-add SYS_NICE (file caps can't exceed the bounding set), and the
# binaries only use it when -rt-priority is passed.
RUN setcap cap_sys_nice+ep /usr/local/bin/sim-web \
&& setcap cap_sys_nice+ep /usr/local/bin/sim-router

USER sim

ENTRYPOINT ["/usr/local/bin/sim-web", "-addr", ":8080", "-config", "/etc/sim/network.yaml"]
Expand Down
93 changes: 88 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,30 @@ expected steady state right after startup. Override (`docker run ...
ghcr.io/.../net-sim:main` with extra args) if you'd rather drive
Start/Stop manually from your test harness via `POST /api/start`.

### Smoother audio under host load (`-rt-priority`)

The router paces audio with 10 ms tickers and every TNC child runs a
software demodulator; on a busy shared host, scheduler jitter glitches
both (lost ticks → choppy RX audio → decode failures that look like RF
problems). `sim-router -rt-priority` renices the router *and* each
spawned TNC child to `-10`. Deliberately plain niceness, not
`SCHED_FIFO` — a real-time policy could starve the host; niceness is
enough to keep the tickers honest. It's best-effort: without
`CAP_SYS_NICE` you get a one-line warning and the simulation carries on
at normal priority.

Granting the capability in Docker (`--cap-add SYS_NICE`), or in compose:

```yaml
services:
net-sim:
image: ghcr.io/packethacking/net-sim:main
cap_add: [SYS_NICE]
```


> **Nested-container hosts:** on a host that is itself an unprivileged container (e.g. Docker inside an unprivileged Proxmox LXC), the kernel checks CAP_SYS_NICE against the *init* user namespace, so negative nice is unavailable to anything inside — even container root. The flag then logs its one-line warning and the sim runs at normal priority; everything else is unaffected.

## Quick install (curl | sudo bash)

On a fresh Debian 12 / Ubuntu 24.04+ host (LXC, VM, bare metal — anywhere
Expand Down Expand Up @@ -206,7 +230,8 @@ Each demo prints its KISS port assignments at startup.
```yaml
mixer_mode: fm_capture # fm_capture (default) | linear_sum (stub)
capture_db: 6.0 # FM capture ratio
collision_mode: silence # silence (default) | sum (stub) | noise (stub)
collision_mode: silence # silence (default) | noise (FM garble) | sum (stub)
time_scale: 1.0 # run N x faster than wall clock (>= 1.0; see below)

nodes:
- id: a
Expand All @@ -226,12 +251,54 @@ nodes:
links:
# Directional. Both endpoints must use compatible modem configs.
- { from: a.vhf, to: b.vhf, loss_db: 0 }
- { from: b.vhf, to: a.vhf, loss_db: 0 }
- { from: b.vhf, to: a.vhf, loss_db: 0, squelch_open_ms: 50 }
```

Per-link `squelch_open_ms` (optional, `0..500`, default `0`) models the
receiving radio's squelch / carrier-detect opening delay: the first N ms
of every transmission heard via that link are delivered as silence (the
carrier is still on the air for capture/collision purposes — only the
audio is muted while the squelch opens). Real FM receivers take tens of
milliseconds to open squelch and settle the discriminator, which is
exactly why KISS TXDELAY exists; with the default `0` a tiny TXDELAY
looks fine in simulation when it wouldn't be on air.

Strict parsing: any unknown key inside a `modem:` block (e.g. `baud_rate`
when you meant `baud`) is an error at startup, not a silent default.

### time_scale — faster-than-real-time simulation

> **TNC pacing does not scale (measured):** samoyed paces its transmissions in
> wall-clock time (the real-airtime sleep before PTT release), so at
> `time_scale > 1` the TNC transmits in real time while the channel runs N×
> faster — TX throughput stays wall-clock-bound and ACKMODE echoes arrive N×
> "late" relative to a host whose protocol timers are scaled to match. In
> practice `time_scale` is currently only sound for receive-path/mixer
> experiments; ACKMODE pacing or throughput measurements need `time_scale: 1`
> until the TNC grows a matching speed factor (tracked upstream).

`time_scale: N` (or the `-time-scale N` flag on `sim-router`, which
overrides the config) runs the whole simulation N× faster than wall
clock: the router divides every pacing interval by N — the 10 ms
per-block RX ticker, the composite recorder's ticker, and the TX
watchdog's tick and silence window (silence detection has to scale with
the audio rate or `tx_end` events would fire mid-transmission). A 60 s
exchange completes in 60/N wall-clock seconds; recordings still come out
as normal 44.1 kHz files whose time axis is *sim* time.

**Fidelity caveat — read before trusting numbers from a scaled run.**
Only the router's clocks scale. The TNC child processes (samoyed /
direwolf) still run their own wall-clock behaviours — CSMA persist and
slottime waits, DCD hang times, any internal timeouts — which means at
`time_scale: 4` a TNC's 100 ms slottime is effectively 400 ms of sim
time. `time_scale > 1` is therefore an **accelerated-testing mode** (get
through a long soak/protocol exchange quickly), *not* a calibrated CSMA
/ channel-access simulation; for timing-sensitive contention studies run
at `1.0`. Hosts driving the KISS ports must also scale their own
protocol timers (T1/T2 etc.) by N, or their retries will fire N× too
early in sim time. Large factors are also bounded by CPU: every TNC
demodulator must keep up with N× real-time audio.

### TNC backend per port

Each port chooses which TNC implementation runs the modem:
Expand Down Expand Up @@ -404,12 +471,27 @@ for each rx block per receiving port:
if len(active) >= 2:
sort by RX level; margin = strongest − next
if margin >= capture_db: output strongest, attenuated (capture)
else: collision garbage (silence in v1)
else: collision garbage (per collision_mode)
```

This is what makes the `hidden-node` and `hidden-node-capture` demos do
qualitatively different things from the same code.

What "collision garbage" sounds like is selectable via `collision_mode`:

- `silence` (default) — clean digital silence. Simple and backwards
compatible, but unrealistically *clean*: it hands receiving modems a
perfectly quiet channel at exactly the moment a real one would be full
of noise.
- `noise` — gaussian garble at an RMS matching the strongest colliding
signal's post-loss level. A real FM discriminator outputs loud garble
(the heterodyne beat between the carriers plus wideband noise) when
two comparable carriers collide, at signal-comparable amplitude — so a
hot collision is loud garble, a weak distant one quiet garble. Use
this to exercise modem false-sync / DCD behaviour that the silence
model can't.
- `sum` — accepted but still a stub (behaves as silence).

## Known limitations (samoyed-side, expected to be fixed upstream)

This is a gap in the current samoyed build that affects what you can
Expand Down Expand Up @@ -450,8 +532,9 @@ round-trip test lives in `internal/tnc/ackmode_test.go`.
- BER / FER reporting beyond the basic frame counters demonstrable from
KISS sniffing.
- SSB modelling, AGC, pre/de-emphasis, multipath, Doppler, fading.
- `linear_sum` / `sum` / `noise` mixer modes — accepted in the YAML, only
`fm_capture` + `silence` are functional in v1.
- `linear_sum` / `sum` mixer modes — accepted in the YAML, only
`fm_capture` is functional (`collision_mode: silence` and `noise` both
work; `sum` is a stub).
- Modem modes beyond what samoyed currently supports.

## Layout
Expand Down
16 changes: 16 additions & 0 deletions cmd/sim-router/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ func main() {
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")
timeScale := flag.Float64("time-scale", 0, "run the simulation N x faster than wall clock (>= 1.0; overrides the config's time_scale; see README for the fidelity caveat)")
rtPriority := flag.Bool("rt-priority", false, "renice sim-router and every TNC child to -10 for smoother audio pacing under host load (best-effort; needs CAP_SYS_NICE)")
flag.Parse()

if *cfgPath == "" {
Expand Down Expand Up @@ -62,6 +64,19 @@ func main() {
logger.Error("load config", "path", *cfgPath, "err", err)
os.Exit(1)
}
if *timeScale != 0 {
if *timeScale < 1 {
logger.Error("-time-scale must be >= 1.0 (slower-than-real-time is not supported)", "got", *timeScale)
os.Exit(2)
}
cfg.TimeScale = *timeScale
}
if cfg.TimeScale > 1 {
// Accelerated-testing mode, not a calibrated CSMA simulation: the
// TNC children's wall-clock waits (persist/slottime, timeouts) do
// NOT scale. See README "time_scale".
logger.Warn("time_scale active — TNC CSMA timing does not scale; hosts must scale their own protocol timers", "time_scale", cfg.TimeScale)
}

samoyedBin, samoyedErr := resolveSamoyed(*samoyedPath)
direwolfBin, direwolfErr := resolveDirewolf(*direwolfPath)
Expand Down Expand Up @@ -95,6 +110,7 @@ func main() {
Logger: logger,
RecordDir: *recordDir,
RecordOnStart: *recordDir != "",
RTPriority: *rtPriority,
})
if err != nil {
logger.Error("start router", "err", err)
Expand Down
4 changes: 4 additions & 0 deletions cmd/sim-web/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ func main() {
workDir := flag.String("workdir", "", "scratch dir for per-port config files / FIFOs (default: temp)")
autostart := flag.Bool("autostart", false, "start the router immediately on launch")
recordDir := flag.String("record", "", "if set, enables the Record toggle and Composite recording panel in the UI; recordings land under this path")
rtPriority := flag.Bool("rt-priority", false, "renice the router and its TNC children for jitter-free pacing (needs CAP_SYS_NICE)")
flag.Parse()

logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
Expand All @@ -97,6 +98,7 @@ func main() {
direwolfBin: direwolfBin,
workDir: *workDir,
recordBase: *recordDir,
rtPriority: *rtPriority,
logger: logger,
eventBus: events.NewBus(),
audioTap: audio.NewTap(),
Expand Down Expand Up @@ -179,6 +181,7 @@ type app struct {
direwolfBin string
workDir string
recordBase string // -record DIR; "" means feature disabled
rtPriority bool // -rt-priority: renice router + TNC children at start
logger *slog.Logger
tmpl *template.Template
mapTmpl *template.Template
Expand Down Expand Up @@ -790,6 +793,7 @@ func (a *app) start() error {
EventBus: a.eventBus,
AudioTap: a.audioTap,
Observer: a.observer,
RTPriority: a.rtPriority,
})
if err != nil {
cancel()
Expand Down
17 changes: 17 additions & 0 deletions internal/audio/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,23 @@ func (b Block) IsSilence() bool {
return true
}

// RMS returns the root-mean-square sample amplitude of the block
// (0..32768). Used by the collision "noise" model to size the garble to
// the colliding signals' level.
func (b Block) RMS() float64 {
var sum float64
n := 0
for i := 0; i+1 < len(b); i += 2 {
s := int16(uint16(b[i]) | uint16(b[i+1])<<8)
sum += float64(s) * float64(s)
n++
}
if n == 0 {
return 0
}
return math.Sqrt(sum / float64(n))
}

// PeakAbs returns the peak absolute amplitude in the block (0..32768).
// Used as a rough RX-level estimator for the capture-effect mixer.
func (b Block) PeakAbs() int {
Expand Down
42 changes: 38 additions & 4 deletions internal/audio/mixer.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,16 +101,50 @@ func (m *Mixer) Mix(active []ActiveTX) (Block, MixDecision) {
return m.collision(sorted), MixCollision
}

func (m *Mixer) collision(_ []ActiveTX) Block {
// collision renders the receiver's output when 2+ signals land inside the
// capture margin and none of them takes the demodulator. sorted is the
// active set ordered strongest-first (by receiver-side Level).
func (m *Mixer) collision(sorted []ActiveTX) Block {
switch m.CollisionMode {
case "sum", "noise":
// stubs only — silence is the simplest defensible model and the
// only branch v1 needs working
case "noise":
// FM capture below threshold does NOT yield silence: with two
// comparable carriers on channel, the discriminator output is
// loud garble — the heterodyne beat between the carriers plus
// wideband noise — at an amplitude comparable to the signals
// themselves. Pure digital silence is unrealistically clean: it
// gives receiving modems a perfectly quiet channel exactly when
// a real one would be hammering their DCD and false-sync
// behaviour. Model the garble as gaussian noise with RMS equal
// to the strongest signal's post-loss level, so a hot collision
// is loud garble and a weak distant one is quiet garble.
return m.noiseBlock(sorted[0].Block.RMS() * AmplitudeFromDB(sorted[0].LossDB))
case "sum":
// stub — behaves as silence until the SSB path needs it
return Silence()
}
// "silence" (the default): the v1 model, kept for backwards
// compatibility.
return Silence()
}

// noiseBlock returns a fresh block of gaussian noise with the given RMS
// amplitude (sigma, in sample units, 0..32767). Routed through AddNoise so
// the RNG locking and int16 clamping live in one place.
func (m *Mixer) noiseBlock(sigma float64) Block {
b := Silence()
if sigma <= 0 {
return b
}
// AddNoise expresses level as positive dB below full-scale; convert,
// clamping at "full-scale garble" for sigma at/above full-scale.
noiseDB := 20 * math.Log10(float64(math.MaxInt16)/sigma)
if noiseDB <= 0 {
noiseDB = 0.01
}
m.AddNoise(b, noiseDB)
return b
}

func (m *Mixer) linearSum(active []ActiveTX) Block {
if len(active) == 0 {
return Silence()
Expand Down
Loading
Loading