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

  1. 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.
  2. Opens a JACK client ({name}-send) and registers N mono input ports (in_1..in_N).
  3. 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.
  4. Sends REGISTER_TX to the mixer and waits for ACCEPT_TX. On rejection or timeout,
    retries with exponential backoff (1 → 30 s).
  5. Spawns four threads: heartbeat (PING every 1 s), rx-drain (discards incoming PONGs),
    watchdog (shutdown coordination), and optionally stats (--verbose).
  6. The main session loop reads per-channel rtrb rings (filled by the JACK RT callback),
    builds AUDIO_TX packets (S16LE interleaved), and sends them via UDP.
  7. 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.
  8. On graceful exit (Ctrl-C), sends BYE and unloads the null-sink.

Key flags

shiloh-broadcaster connect \
    --server stg-srv001:5005 \
    --name studio \
    --channels 2 \
    [--sink-name studio] \
    [--no-sink] \
    [--verbose]
  • --name defaults to the system hostname.
  • --sink-name defaults to --name. Separate it when you want a different JACK client
    name vs PipeWire sink name.
  • --no-sink skips 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

  1. Connects to the mixer (REGISTERACCEPT → stream of AUDIO packets).
  2. Spawns a heartbeat thread (PING every 2 s).
  3. An rx thread decodes S16LE packets and pushes into an rtrb ring.
  4. A cpal stream callback pops from the ring and writes to the output device.
  5. A watchdog in the main thread reconnects if no audio arrives for >3 s.
  6. 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]
  • --name defaults to the system hostname. Must match the name configured in the mixer’s
    relay assignment (and should be unique per relay client).
  • --device selects the cpal output device. Use shiloh-relay list-devices to enumerate.
  • --buffer-ms controls 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

  1. Registers three relay sessions with the mixer — one per feed (web-relay-main,
    web-relay-monitor, web-relay-cue).
  2. After ACCEPT, sends a set_relay_assignment JSON command to the mixer control plane
    on :19997 to pin each session to its named feed.
  3. Per feed: a blocking thread receives UDP AUDIO packets and pushes Vec<f32> frames
    into a Tokio mpsc channel.
  4. An async Tokio task reads from the channel and encodes to Opus (960 samples = 20 ms,
    48 kHz, stereo, 128 kbps VBR).
  5. webrtc-rs writes RTP to every PeerConnection attached to that feed’s track.
  6. An axum HTTP server on :8890 handles WHEP signalling: POST /whep/{feed} where
    feed is main, monitor, or cue.

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-ip is 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-types restricts ICE candidates. Use udp4 to 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.