Scaling & capacity planning (channel density)
This page covers how many channels one broadcaster can run, what hardware you need, and how to keep the constant per-segment write churn off your disk. If you are going past a handful of channels, read this first.
TL;DR — At scale the wall is disk IOPS, not space, once you're off a real disk (see the scale profile below). Past that, CPU is the real wall for copy channels, not RAM. Measured with live (flaky) IPTV sources on a 4 vCPU / 8 GiB box: ~0.04 core/channel (~1 % of the box each) and ~20–40 MB RAM/channel — CPU runs out long before RAM does. At the recommended ≤80 % average-CPU sizing policy, a 4 vCPU box's ceiling is ≈80 copy channels, while its RAM alone could hold several times that. Transcoding channels are still CPU-bound, just far more so (~0.5–1 core each). Measure your own box with
node tools/scale-bench.mjs— but see the real-world measurement further down, since a quiet synthetic bench undercounts the watchdog churn that live sources cause.Before you put real viewers on it: channel density is an ingest cost. Viewer fan-out is a different limit, and on stock Linux it is capped by the kernel's socket buffers rather than by anything in this page. Install
deploy/sysctl/99-aliran.confand read Network tuning. An undersized buffer drops packets inside the kernel, so it looks like stalling playback, not like a resource limit.
Why this is different from a normal HLS server
nginx, Nimble, and Flussonic are HTTP HLS origins: they write the rolling
window to tmpfs and serve .ts files over HTTP, so storage equals the
window and every viewer pulls from the origin/CDN.
Aliran has no central media server — viewers re-seed each other. The segments aren't served over HTTP; they're written into an encrypted Hypercore and replicated over the DHT. That append-only, verifiable log is exactly what makes P2P re-sharing possible, and it is why the storage/IO story is its own thing (see feed buffer & tuning). What matters for scale is that every segment is written more than once (ffmpeg → scratch dir → the Hypercore, plus the tree/oplog/metadata), so at high channel counts you become IOPS-bound long before you run out of space or bandwidth — the same reason traditional streaming uses a ramdisk.
The scale profile
Two independent write paths hit disk by default. Move both to RAM:
| Lever | What it moves to RAM | How |
|---|---|---|
HLS_WORK_DIR on a tmpfs |
ffmpeg's live-window scratch (seg*.ts, index.m3u8) |
mount a tmpfs, point HLS_WORK_DIR at it |
FEED_BUFFER=ram |
the Hypercore feed store (data + tree + metadata) | env var (or per-channel buffer:"ram") |
Together these give you zero segment IOPS on disk. In
docker-compose.yml (already stubbed, commented):
broadcaster:
environment:
HLS_WORK_DIR: /hlstmp
FEED_BUFFER: ram
tmpfs:
- /hlstmp:size=512m # ~ (HLS_LIST_SIZE + 2) x segment_size x channels
# (14 segments/channel at the 12-segment window,
# ~14 MB at ~1 MB per 2 s SD segment → 512m ≈ ~35
# SD copy channels)
Bare-metal: HLS_WORK_DIR=/dev/shm/aliran (Linux /dev/shm is already
tmpfs) plus FEED_BUFFER=ram.
The trade (see feed-buffer.md for the full
explanation): ram mints a fresh feedKey on every restart, so
returning viewers pay a cold DHT rejoin (~40–55 s) — but the player
auto-follows the new key via the catalog, so no re-login is needed. On
a big multi-channel box that reset is also your natural way to clear the
slow append-only metadata creep. If you instead want stable feedKeys (a
warm ~10 s reconnect) and can spare the disk, keep FEED_BUFFER=disk and
just move HLS_WORK_DIR to tmpfs — that alone removes the heavier of the
two write paths.
The four walls (per-channel cost)
Measured on: a dev box (i5-8259U, 8 core) and the live 1 vCPU / 1 GB VPS, both running copy passthrough channels (the common case: pull a source, re-mux, no encode).
| Resource | Per channel (copy) | Notes |
|---|---|---|
| RAM | ~40 MB | ~3 MB feed engine (Corestore+Hyperdrive+Hyperswarm) + ~38 MB marginal ffmpeg (VmRSS ~70 MB but libs are shared). Base runtime ~70–90 MB. Measured at the 8 × 2 s window; the recommended 12-segment window adds ~50 % of the window bytes (~+3 MB at SD bitrates). |
| CPU | ~0.04 core (~1% of a 4-vCPU box) | Mux + mirror, plus watchdog/demuxer churn from real (flaky) sources — measured at 69-channel scale on a 4 vCPU box (see the real-world callout below). A quiet/synthetic source undercounts this. |
| Disk IOPS | ~2.5–5 sync ops/s | 1 segment / 2 s × several fsync'd writes (core append, oplog, metadata B-tree). This is the wall — put it on tmpfs. |
| Disk space | ~9 MB window (12 × 2 s at ~3 Mbps) + FEED_ROTATE_TREE_MB cap |
disk mode only. Segment window bounded by reclaim; the append-only merkle tree is now bounded by feed rotation (FEED_ROTATE_TREE_MB — feed-buffer.md), so store ≈ cap × channels, no unbounded creep. ram mode = 0 disk. |
| DHT | 1 swarm + topic | Socket fan-out grows with peers × channels; the practical network wall on very large deployments. |
Transcoding changes the CPU line entirely: x264 SD is about 0.5–1
core/channel, and 1080p costs more. If you transcode, CPU is your wall,
not RAM — a 4-core box does about 4–6 x264 SD channels, not 60. Use copy
wherever the source codec is already deliverable.
Capacity formula
Size the CPU term to a ≤80 % average-CPU policy, not 100 % — headroom
absorbs watchdog respawn bursts from flaky live sources (see the
real-world callout below). Loadavg is not a substitute here: dozens of
ffmpeg processes queuing in D-state on a flaky source can push load1
well past core count while CPU idle still has room to spare.
max_channels ≈ min(
(RAM_MB − 90) / 40, # RAM, copy (÷ more for transcode's bigger ffmpeg)
(cores × 0.8) / cpu_per_channel, # copy ≈ 0.04 core/ch (measured, live sources) ; x264 SD ≈ 0.5–1 core/ch
disk_iops / 4, # ONLY if not on tmpfs — the scale profile removes this term
dht_socket_budget # very large deployments; shard across nodes
)
Starter table — copy channels, scale profile on (first wall = min of RAM and CPU)
CPU is sized to the ≤80 % policy (cores × 0.8 / 0.04), so it stops being
"free" once a box has more RAM than roughly 20 × cores MB. Most
small/RAM-tight boxes are still RAM-bound, but a RAM-rich, core-light box
(a Pi 5 with 8 GB, for instance) flips to CPU-bound.
The rows assume the 8-segment window. The recommended 12-segment window
holds ~7 MB more RAM per channel under the scale profile (a larger tmpfs
scratch, ~+4 MB, plus a larger ram feed window, ~+3 MB at SD bitrates) —
trim the RAM-bound rows by a few channels (the 2 GB Pi drops from ~30 to
~27).
| Box | RAM | Cores | ~Max copy channels | First wall |
|---|---|---|---|---|
| Raspberry Pi 4 | 2 GB | 4 | ~30 | RAM |
| Raspberry Pi 4/5 | 4 GB | 4 | ~60 | RAM |
| Raspberry Pi 5 | 8 GB | 4 | ~80 | CPU |
| Small VPS (the Aliran box) | 1 GB | 1 | ~14 | RAM |
| VPS | 2 GB | 2 | ~30 | RAM |
| VPS | 4 GB | 4 | ~60 | RAM |
| VPS 4 vCPU / 8 GiB (this doc's live-measured box — see below) | 8 GB | 4 | ~80 (measured) | CPU |
| Server | 8 GB | 8 | ~125 | RAM |
For transcoding deployments, recompute the CPU term and it dominates: the 8-core server that does ~125 copy channels does only ~8–12 x264 SD channels.
Measured on the reference box (1 vCPU / 1 GB / 2 GB swap)
A real load test with live pull sources (copy passthrough, disk
mode) found 15 channels ran cleanly (all live, ~377 MB
broadcaster, load ~1.9), and ~18–20 tipped into swap-thrash (load
climbed past 8, the box went sluggish — no OOM kill, but unusably
slow). So the table's ~14 is a safe number — the real ceiling was a
bit higher because ffmpeg's shared libraries make the marginal RAM
cost ~25–30 MB/channel, not the ~40 MB a single process suggests. On a
RAM-bound box the wall is RAM → swap-thrash, not CPU or IOPS (disk
writes stayed ~modest).
Short-term ceiling ≠ sustained ceiling (7-hour duration test)
Running those 15 channels for ~7 hours told a two-part story. Disk passed: segment data stayed bounded (it oscillated ~55–225 MB tracking the live channel count, no runaway; the metadata tree crept only ~110 MB/day for the whole set — far below a naive per-channel extrapolation), confirming the reclaim fix holds at scale over time. But compute drifted: the broadcaster's RSS crept over the hours until the box began swapping (swap 0.6 → 1.1 GB), and it slid into a thrash + ffmpeg-respawn spiral — the live ffmpeg count churned 9–15 (the watchdog kills a CPU-starved stalled edge, then respawns it) and load ran a sustained 7–19 on the one core. There was no OOM, but 15 channels is not sustainable 24/7 on this box. Treat the "runs cleanly" figure as a short-term ceiling, and size a long-running deployment a few channels below it (≈8–10 here): multi-hour RSS growth, not the fresh-start footprint, sets the real limit.
Measured on a 4 vCPU / 8 GiB / 100 GiB NVMe (1300 IOPS) box — CPU is the wall, not IOPS
A scale-bench.mjs sweep (copy channels, disk buffer) at 4→16→32→48
channels went all live in under 4 s. Marginal node RSS fell 4→1.6
MB/ch; the tool's summed ffmpeg RSS (~59 MB/ch) triple-counts shared
libraries — the real footprint from free -m at 48 channels was
~900 MB total (~19 MB/ch). Watch out for that when reading the RAM
extrapolation.
The surprises at density:
- CPU is the binding wall, ~80 copy channels. At 48 channels the
box ran 59 % of 4 cores, 0 % iowait — so
copyis not free at scale: 48 ffmpegs (demux→remux→HLS-mux→write) plus the mirror use ~2.4 cores, ~5 %/ch total. Size sustained 24/7 at ~65–70. - Disk IOPS is a non-issue — 48 channels drove only ~62 write
IOPS / 13 MB/s to the device (reads ≈ 0; all cached). A big page
cache absorbs the write-then-delete churn, so more RAM indirectly
buys IOPS headroom. That's ~5 % of this disk's 1300-IOPS cap; even a
500-IOPS disk would be fine here. tmpfs (
HLS_WORK_DIR) is unnecessary at this IOPS budget — and on a RAM-tight box it would steal the RAM you actually need. - Ordering of the walls (copy): CPU (~80) < RAM (~370) < IOPS (~1000) < space (~350 at a 250 MB rotation cap). So 50 copy channels is comfortable here (~60 % CPU, ~1 GB RAM, 62 IOPS). Transcoding flips it entirely — CPU dominates immediately (~4–6 x264 SD on 4 cores); shard or transcode upstream.
On some VPS providers, IOPS scales with disk size
The reference box's 20 GiB tier capped at 500 IOPS; its 100 GiB tier gives 1300. So buying a bigger disk buys space and IOPS together, and N cheap boxes give N × the IOPS — sharding across budget nodes beats one big node when IOPS is the constraint.
Real-world confirmation: live (flaky) IPTV sources, same 4 vCPU / 8 GiB / 100 GB NVMe box
The sweep above uses quiet, well-behaved test sources — no live-source
flakiness, no watchdog churn. Real IPTV/live pull sources aren't so
well-behaved: they drop, stall, and reconnect, and every hiccup fires
the watchdog to restart ffmpeg. Two sustained real-deployment runs on
this same box class, both true CPU averages over the whole window
(/proc/stat deltas, not vmstat spot samples — a 1-second spot
sample is a poor estimator of a 10-minute average and can miss the
true figure by double digits, especially if it lands during a
channel-count change):
| Channels | Window | CPU (us+sy) mean | Watchdog respawns |
|---|---|---|---|
| 43 (copy, mixed h264/HEVC, 720p–1080p) | 17.4 h sustained | 45.5 % | ~1.45/channel/hour |
| 69 (copy, mixed h264/HEVC, 720p–1080p) | 10 h confirmation run | 69.6 % (max 72.5 %, min 64.9 %, 0/61 samples over 80 %, drift 69.1 %→70.5 %) | ~1.8/channel/hour (1,265 total) |
That is a linear marginal cost of ~0.93 % CPU/channel (~0.037 core/channel) between the two points — close to the simple ~1.0 % CPU/channel (~0.04 core/channel) average at 69. Either way, ~0.04 core/channel is the number to size against. RAM was a non-issue throughout (2.5–2.9 GB of 8 GB, swap peaked at 22 MB — noise, not pressure). At the ≤80 % average-CPU sizing policy, that puts this box's ceiling at ≈80 copy channels; 69 ran with real headroom, not at the edge, and every channel stayed up (two transient watchdog backoffs during the run, both self-recovered — not a capacity symptom).
Why this exceeds what the quiet synthetic sweep alone would suggest
sizing against: the two per-channel figures are actually close —
what the synthetic sweep doesn't model is the churn: over a thousand
ffmpeg watchdog restarts across the fleet from flaky upstreams over
just 10 hours. That churn is also why loadavg is not a usable
saturation proxy here — load1 ran 7.8 mean / 11.0 max at 69
channels while CPU idle still had ~28–30 % to spare, because dozens of
ffmpeg processes were queued in D-state waiting on flaky sources, not
because the CPU was out of headroom. Size real deployments off
measured CPU%, not load, and off this live-source row rather than the
quiet synthetic sweep — flaky sources are the common case in
production.
ffmpeg itself creeps on some upstreams — bounded by FFMPEG_MAX_RSS_MB
The node processes aren't the only slow growers. On certain live-HLS
pulls (typically SSAI/ad-inserted feeds, whose TS PID churn makes
libavformat allocate streams it never frees), a long-running
ffmpeg accumulates demuxer state: observed on the reference box,
one pull ffmpeg reached ~100+ MB (much of it in swap) after days,
versus the 13–30 MB RSS a fresh one uses — a steady ~20 MB/h swap creep
while both node processes stayed flat. No hls demuxer input flag
bounds this (-live_start_index, -http_persistent,
-m3u8_hold_counters control start position, connection reuse and
reload budgets — not state retention), so the watchdog reads each pull
ffmpeg's VmRSS+VmSwap from /proc on its tick, and past
FFMPEG_MAX_RSS_MB (default 150) recycles it like a stalled edge —
same backoff, no feed rotation, the usual sub-window blip. Watch it
with the per-process swap survey on the box —
for p in /proc/[0-9]*/status; do awk '/^Name|^VmRSS|^VmSwap/{printf "%s ", $2} END{print ""}' $p; done | sort -k3 -n
— alongside the long-run soak log (/root/aliran-mem-soak.log on the
reference box); status.watchdog.memMb / memRecycles expose the same
numbers per channel over the control API.
Counter-intuitive but important: on a RAM-constrained box,
FEED_BUFFER=ramis the wrong lever — it moves the whole feed store into RAM, so you hit the RAM wall sooner. Inrammode the whole segment window is resident in RAM per channel, so the move from 8 to 12 segments adds ~50 % of the window bytes to each channel's RAM cost. The scale profile (ram + tmpfs) is for boxes that are IOPS-bound with RAM to spare (spinning disk / SD card, many channels). On a small RAM-bound VPS, keepFEED_BUFFER=disk.Past ~one box's worth, scale horizontally. 200 channels isn't a single-node story in any storage mode — it's also 200 ffmpeg processes and 200 DHT topics. Shard channels across N broadcaster nodes (each registers with the same panel); the panel catalog already aggregates them. RAM+tmpfs is the right per-node engine; sharding is how you reach the hundreds.
The panel's own disk (control plane)
The four walls above are the broadcaster's. The panel has a different growth law, and it is the one that bites on a long-running box: its signed Hyperbee is append-only, single-writer, and never compacted. That is deliberate — an append-only verifiable log is what lets every client replicate and verify the catalog — but it means panel disk tracks how many writes you have ever made, not how many records you currently have. A 300-channel catalog is a few hundred KiB of records; the store is however large the write history made it.
So the thing to watch on the panel is write rate, not record count. What writes:
| Writer | Cost | Notes |
|---|---|---|
Broadcaster register |
0 when nothing changed | The 5-min heartbeat re-asserts every running stream. An unchanged re-assert is compared and skipped, so steady-state heartbeat traffic is free. |
register with a real change |
1 block (~490 B) | feedKey rotation, isLive/status flip, new origin. Rotation is the one that recurs — see FEED_ROTATE_TREE_MB/FEED_ROTATE_HOURS in feed-buffer.md: rotating harder bounds broadcaster disk but adds panel writes. |
Viewer session |
1 block per session | Rewrites the whole user record, including its wrapped grant map — so this scales with grants per user, not with channel count alone. |
| Admin edits / grants | 1 block each | grant re-puts the full user record per channel, so granting n channels one at a time is O(n²) in bytes. Bulk/auto-grant paths matter at 300 channels. |
| Source sync | 0 for unchanged entries | sources.js compares before it puts; a 304 or an unchanged feed appends nothing. |
EPG service setEpgKey |
~1 block per epoch rotation (≈ monthly) | The guide itself NEVER touches the bee. The EPG service writes schedules into its own epoch-rotated Hyperdrive and only the drive pointer (meta/epgKey) lands here — compared first, so a boot re-assert appends nothing. |
Measured on the register path, before the idempotent-register fix: 43 channels × 288 heartbeats/day = 12,384 redundant appends/day ≈ 5.8 MiB/day, forever, growing linearly with the lineup. That is small per day — but it is monotonic, with no compaction to ever give it back, which is what makes it a planning item rather than a rounding error.
The EPG service's growth law is different — and bounded. A program
guide legitimately changes every day, which is exactly the write pattern
the bee must never carry. So the guide lives in the EPG service's own
Hyperdrive, and that drive is epoch-rotated: every EPOCH_DAYS
(default 30) a fresh drive is minted, the current window copied in, the
panel pointer flipped (the one bee block above), and the old drive purged
after GRACE_HOURS. Steady state for a ~90-channel, 7-day guide is a few
MiB of records and up to ~100 MiB of write history at the end of an
epoch — then it resets. Day files are byte-compared before every put, so
an ingest pass that finds nothing new appends nothing.
Sizing the panel volume is not the same question as sizing the broadcaster's. The panel's
DATA_DIRalso holds the assets Hyperdrive (posters/backdrops/logos) in the same corestore, and art is itself append-only — a re-uploaded poster does not reclaim the old one. If a panel volume is far larger than the write rates above explain, measure the pieces before assuming it is the catalog:# biggest cores first. Use du, NOT `find -printf %s`/`ls` — blob `data` # files are SPARSE, so apparent size reports TB-scale nonsense. du -sh $DATA_DIR/cores/*/*/* | sort -h | tail -20
Steady state, the panel's corestore holds exactly three cores: the
signed Hyperbee, and the assets drive's metadata and blobs cores. Anything
else under cores/ is a leftover — see below.
Fixed: the blobsKey enricher used to leak a core per feed it probed.
To publish a feed's blobs-core key, the panel opens that feed's drive on
its own Corestore (panel/src/blobs-key.js). Those cores are keyed,
so corestore files them under cores/<discovery-key>/ regardless of the
blobs-probe: namespace, and drive.close() only ended the session — the
directories stayed behind with nothing to collect them. The cost tracked
distinct feedKeys ever probed, not channel count: ~85 KB per reachable
feed (metadata + blobs core) and ~8 KB per feed that never answered.
Harmless while feedKeys were stable — but
feed rotation (FEED_ROTATE_TREE_MB /
FEED_ROTATE_HOURS) mints a fresh feedKey per rotation, which
re-enqueues the enricher, so the panel grew with rotations × channels,
without bound. Each probe now purges the cores it opened, and
test:register asserts the panel's core set stays unchanged across
repeated rotations.
Upgrading an existing panel — the stranded cores are reclaimed for you
Purging probes as they run only stops new growth; cores an older
build already left behind show up in the du breakdown above as many
small cores/** entries matching no key the panel owns. Never
hand-delete them — the panel's Hyperbee is the single-writer origin
of truth for accounts and the catalog, so deleting the wrong core
directory is unrecoverable (there is no peer to re-replicate it from).
You don't have to. Restarting the panel reclaims them:
openStore() sweeps every core directory it cannot account for, and
logs what it took:
[gc] reclaimed 412 stray core dir(s), 34.15 MB freed (blobsKey probe cores stranded by earlier builds)
It is deliberately timid, because this is the one delete in the panel that could cost you the control plane:
- it runs inside
openStore(), before the enricher exists — it cannot race a probe; - it refuses to delete anything unless all three of the panel's own cores resolve, so a half-open store leaks (retried next start) instead of deleting;
- anything the Corestore currently holds open is kept regardless of that list;
- only directories named like a full discovery key are considered —
files,
primary-keyand the rest ofDATA_DIRare never touched.
The sweep is the same one the broadcaster uses to drop retired feed
generations (@aliran/core/store-gc.js). Nothing to enable, and no
downtime beyond the restart itself; a second start reports nothing
left to do.
Confirmed in production: panel bee growth is now flat
On the live 69-channel deployment, a 10-hour post-restart measurement window (true interval disk-growth sampling) showed panel-bee growth: +0 MB over the window (steady at ~20 MB, matching the three-core steady state) — versus the pre-fix leak, which had been adding roughly 100 MB/hour. The one-time restart reclaim recovered about 2.2 GB on this box; ongoing growth is now negligible.
Measure your own hardware
Absolute numbers are hardware-specific. Run the bench on the target box — it uses a local DHT testnet, never the public network, and production-representative copy channels:
node tools/scale-bench.mjs --channels 8 --seconds 60 # disk mode
node tools/scale-bench.mjs --channels 8 --buffer ram --workdir /dev/shm/ab # scale profile
It reports per-channel node RAM, ffmpeg RAM (Linux), CPU, the allocated
on-disk footprint (sparse-aware — meaningful on Linux/ext4), and a
RAM-bound ceiling for several budgets. Run it at a couple of --channels
values and read the marginal cost between them.
Recovering an overloaded broadcaster
If you push past the wall and the box goes into swap-thrash (load ≫
cores, everything crawls, SSH sluggish), don't try to fix it
channel-by-channel through the API — under thrash each stop waits on the
panel flush and takes seconds. Shed load decisively:
# 1. force the broadcaster down (SIGKILL after 5 s) — instant RAM/CPU relief
docker stop -t 5 aliran-broadcaster-1
# 2. keep only the channels you want; drop the rest from the registry
V=/var/lib/docker/volumes/aliran_broadcaster-data/_data
python3 -c "import json;d=json.load(open('$V/channels.json'));\
json.dump({i:v for i,v in d.items() if i in ('ch1','ch2')},open('$V/channels.json','w'),indent=2)"
# 3. bring it back — only the kept channels auto-resume
docker start aliran-broadcaster-1
Auto-resume can turn one OOM into a boot loop
Every started channel persists desiredRunning:true. If the box
hard-OOMs, Docker restarts the broadcaster, and it auto-resumes them
all at once — and simultaneous starts spike higher than steady
state, so a box that was stable running N channels can fail to
boot them. Stay a couple of channels below the measured ceiling, and
shed the registry (step 2) before restarting a box that's already at
the edge. A MAX_CHANNELS start guardrail on the broadcaster is a
sensible future addition.
Raspberry Pi broadcaster (experimental)
A Pi is a good low-power edge broadcaster for copy channels. The scale profile isn't optional here — it's what saves the SD card:
- 64-bit OS is mandatory. The crypto (
sodium-native) and disk-reclaim (fs-native-extensions) native modules shiplinux-arm64prebuilds only — no 32-bitlinux-arm. On 32-bit Raspberry Pi OS the broadcaster won't even start. Use arm64 Raspberry Pi OS / Ubuntu (Pi 4 or 5). - tmpfs is essential. An SD card has terrible random IOPS and wears
out from constant small writes — exactly what an HLS window is. Always
run the scale profile (
HLS_WORK_DIRon tmpfs +FEED_BUFFER=ram) so the churn never touches the card. (This also sidesteps the fact that disk-mode hole-punch reclaim depends on the card's filesystem.) - Copy, not transcode. The Pi's CPU won't transcode many streams; its
hardware encoder (
h264_v4l2m2m) is an advanced, per-source setup. Pull already-H.264 sources andcopy. - Build: the image is multi-arch (base
node:24-bookworm-slim). Build for the Pi with:or justdocker buildx build --platform linux/arm64 -f broadcaster/Dockerfile -t aliran-broadcaster:arm64 .docker compose buildnatively on the Pi. Debian bookworm's ffmpeg (arm64) has the demuxers/protocols for HTTP/RTSP/SRT pulls out of the box.
Sizing follows the table above (Pi 4 2 GB ≈ 27 copy channels at the
recommended 12-segment window), with tmpfs sized into the RAM budget.
Treat it as experimental until you load-test it with scale-bench.mjs on
the specific board.