Component Roles
shiloh-broadcaster
Purpose: Captures audio from a PipeWire null-sink and sends it to the mixer ingest.
Source: crates/shiloh-broadcaster/
How it works
- Loads a PipeWire null-sink module via
pactl load-module(RAII — always unloaded on exit,
even on panic or signal). The sink persists across reconnect attempts so apps routed to it
don’t need to be re-routed when the network drops. - Opens a JACK client (
{name}-send) and registers N mono input ports (in_1..in_N). - A background autoconnect thread wires
{sink_name}:monitor_AUX{i}→{name}-send:in_{i+1}.
Retries for up to 30 s to handle PipeWire port-naming delays. - Sends
REGISTER_TXto the mixer and waits forACCEPT_TX. On rejection or timeout,
retries with exponential backoff (1 → 30 s). - Spawns four threads: heartbeat (PING every 1 s), rx-drain (discards incoming PONGs),
watchdog (shutdown coordination), and optionally stats (--verbose). - The main session loop reads per-channel
rtrbrings (filled by the JACK RT callback),
buildsAUDIO_TXpackets (S16LE interleaved), and sends them via UDP. - On mixer restart, the TX thread detects that its session ID is stale and triggers an
immediate re-register without waiting for the full backoff window. - On graceful exit (Ctrl-C), sends
BYEand unloads the null-sink.
Key flags
shiloh-broadcaster connect \
--server stg-srv001:5005 \
--name studio \
--channels 2 \
[--sink-name studio] \
[--no-sink] \
[--verbose]
--namedefaults to the system hostname.--sink-namedefaults to--name. Separate it when you want a different JACK client
name vs PipeWire sink name.--no-sinkskips creating the null-sink (useful when routing from an existing source).
shiloh-mixer
Purpose: Central JACK mix bus. Receives audio from broadcaster senders, mixes all
channels, writes to JACK output ports, and fans out relay feeds to registered clients.
Source: crates/shiloh-mixer/
Modules
| Module | Role |
|---|---|
jack_io |
Opens the JACK client, registers all channel and bus ports, runs the RT process callback, spawns all threads |
mix |
Per-channel mix math: add_channel() accumulates into main/monitor/cue bus buffers |
ingest |
Handles REGISTER_TX / AUDIO_TX / BYE from broadcaster senders; owns per-slot rtrb producers |
broadcast |
UDP relay fan-out to shiloh-relay / shiloh-web-relay clients; session lifecycle |
control |
UDP control plane on :19997: receives JSON fader/mute/scene commands from mixer_web |
meters |
Polls peak values from the RT thread at ~10 Hz, writes meters.json |
scenes |
Load/save named snapshots of all channel faders and mutes |
midi |
UDP MIDI listener on :19999; modal state machine driving fader/mute/Ardour OSC |
config |
TOML config loader and validator |
Ingest (broadcaster → mixer)
Senders are registered in shiloh-mixer.toml as an allow-list. Each sender is assigned a
contiguous range of slots in a flat ring buffer bank. The JACK RT thread pops from these
rings for any channel with ingest_slot set, instead of reading a JACK input port.
Stale sessions are evicted after 3 s of no audio packets.
Mix math (per channel, per JACK callback)
main_l += ch_L × main_gain × (1 − main_muted)
main_r += ch_R × main_gain × (1 − main_muted)
monitor_l += ch_L × monitor_gain × (1 − monitor_muted)
monitor_r += ch_R × monitor_gain × (1 − monitor_muted)
cue_l += ch_L × cue_gain × (1 − cue_muted)
cue_r += ch_R × cue_gain × (1 − cue_muted)
Mono channels pass the same slice for L and R, so both peaks are equal and the UI renders
a single VU bar.
Config file
Default path: ~/mixer/shiloh-mixer.toml. Key sections:
jack_name = "shiloh-mixer"
control_port = 19997 # mixer_web → mixer
control_bind = "127.0.0.1"
broadcast_port = 5005 # relay clients + broadcaster ingest
broadcast_frames = 128 # frames per UDP audio packet (deployed; code default is 160)
[[channels]]
id = "bcast1"
label = "Studio"
kind = "stereo"
main_gain = 1.0
ingest_slot = 0 # reads from ingest ring slots 0+1
[ingest]
slot_count = 6
[[ingest.sender]]
name = "studio" # must match --name on broadcaster
channels = 2
start_slot = 0
shiloh-relay
Purpose: Lightweight audio receiver. Subscribes to one mixer bus feed and plays it
to a local audio output device via cpal/ALSA.
Source: crates/shiloh-relay/
How it works
- Connects to the mixer (
REGISTER→ACCEPT→ stream ofAUDIOpackets). - Spawns a heartbeat thread (PING every 2 s).
- An rx thread decodes S16LE packets and pushes into an rtrb ring.
- A cpal stream callback pops from the ring and writes to the output device.
- A watchdog in the main thread reconnects if no audio arrives for >3 s.
- On exit, sends
BYE.
The relay client subscribes to the feed assigned to it by name in relay-assignments.json.
Feed assignment is controlled from mixer_web or the control plane.
Key flags
shiloh-relay connect \
--server stg-srv001:5005 \
--name pi-relay \
[--device "hw:0,0"] \
[--buffer-ms 10] \
[--verbose]
--namedefaults to the system hostname. Must match the name configured in the mixer’s
relay assignment (and should be unique per relay client).--deviceselects the cpal output device. Useshiloh-relay list-devicesto enumerate.--buffer-mscontrols the local audio ring depth (default 10 ms).
Reconnect behaviour
Backoff: 1 → 2 → 4 → … → 30 s, capped. Resets to 1 s after a session that lasted ≥10 s.
shiloh-web-relay
Purpose: WebRTC/WHEP egress. Subscribes to all three mixer feeds, encodes each to
Opus, and serves browser listeners via the WHEP signalling protocol.
Source: crates/shiloh-web-relay/
How it works
- Registers three relay sessions with the mixer — one per feed (
web-relay-main,
web-relay-monitor,web-relay-cue). - After ACCEPT, sends a
set_relay_assignmentJSON command to the mixer control plane
on:19997to pin each session to its named feed. - Per feed: a blocking thread receives UDP
AUDIOpackets and pushesVec<f32>frames
into a Tokiompscchannel. - An async Tokio task reads from the channel and encodes to Opus (960 samples = 20 ms,
48 kHz, stereo, 128 kbps VBR). webrtc-rswrites RTP to everyPeerConnectionattached to that feed’s track.- An axum HTTP server on
:8890handles WHEP signalling:POST /whep/{feed}where
feed ismain,monitor, orcue.
Key flags
shiloh-web-relay \
--mixer 127.0.0.1:5005 \
--control 127.0.0.1:19997 \
--http 127.0.0.1:8890 \
--name-prefix web-relay \
[--announce-ip 203.0.113.1] \
[--ice-network-types udp4]
--announce-ipis required when running behind NAT. Pass the public IPv4 so that the
ICE Host candidate in the WHEP SDP answer is reachable by the browser.--ice-network-typesrestricts ICE candidates. Useudp4to suppress IPv6 and TCP
candidates on simple setups.
mixer_web
Purpose: Phoenix LiveView web UI. Displays fader strips, VU meters, session list,
scene controls, MIDI panel, and Ardour transport. All controls write back to the mixer
via UDP control commands.
Language: Elixir / Phoenix LiveView
Default port: :8889
What it reads (polling)
~/mixer-state/meters.json— per-channel peaks, ~10 Hz, drives VU meter animation~/mixer-state/sessions.json— active UDP relay + ingest sessions list~/mixer-state/gains.json— current fader positions on page load~/mixer-state/scenes.json— saved scene names~/mixer-state/midi-state.json— current MIDI modal state for the debug panel
What it writes (UDP → mixer :19997)
All fader/mute/scene operations send a JSON datagram to the mixer control plane. The
mixer applies the change on the RT thread via the command rtrb queue and writes the new
state back to gains.json.
shiloh-midi-sender
Purpose: Reads MIDI note-on events from a USB MIDI device and forwards them as raw
UDP datagrams to the mixer’s MIDI listener on :19999.
Source: crates/shiloh-midi-sender/
shiloh-midi-sender \
--device "USB MIDI" \
--server stg-srv001.bq.shilohbv.com:19999
--device matches a substring of the MIDI input port name (case-insensitive). The mixer
interprets note-ons through a vim-style modal state machine. See the MIDI control guide
for key mappings.