Skip to content

Listening party can be streamed to either Icecast or Mux#1085

Merged
fusion2004 merged 17 commits into
mainfrom
srt-spike
May 21, 2026
Merged

Listening party can be streamed to either Icecast or Mux#1085
fusion2004 merged 17 commits into
mainfrom
srt-spike

Conversation

@fusion2004
Copy link
Copy Markdown
Owner

closes #1039

introduces swappable streaming xstate machines, extracting the existing icecast stream machine and introduce a mux/srt stream machine

fusion2004 and others added 14 commits May 9, 2026 19:56
Wires in the dependencies and Mux credential references used by the
SRT-to-Mux audio-only streaming spike on this branch. Also exposes a
yarn `spike:srt` script for running it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Standalone script that transcodes audio/intro01.mp3 to AAC-in-MPEG-TS
via ffmpeg-static, opens an SRT caller connection to Mux, and streams
the .ts file repeatedly with a Node-side packet rewriter that maintains
continuity-counter and PCR/PTS continuity across "file" boundaries.
Pacing reads PTS straight out of each packet to hold the writer to a
configurable lead ahead of wall-clock.

Standalone — not yet bot-integrated. Run with `yarn spike:srt`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces the foundation for a dual-backend streaming setup:

- src/lib/streaming/types.ts defines the StreamingMode and the input/output
  event protocol that both backend machines will speak.
- src/lib/streaming/format.ts centralises the encoding split: icecast keeps
  MP3 (libmp3lame 256k), mux uses AAC-in-MPEG-TS (160k) with .ts file
  extensions.
- src/lib/streaming/mpegts.ts ports the spike's MPEG-TS packet rewriter
  (CC / PCR / PTS / DTS) as pure functions, ready to drive the Mux machine.
- Song, RoundTranscoder, RoundAnnouncer, RoundExtraAnnouncer now require a
  StreamingMode and pick the right ffmpeg args + file extensions.
- partyService threads streamTo through PartyContext, the START event,
  setRoundContext, the partying entry, and reconcileSongs.

The user-facing /startparty option lands in a follow-up commit once the Mux
machinery exists; for now startparty hardcodes streamTo='icecast' so the bot
behaves exactly as before. All 136 tests still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Self-contained XState v5 child machine that owns the libshout connection
and the file-streaming loop, lifted with minimal restructuring from the
inline streaming substates that previously lived in src/lib/party.ts.

Protocol-level changes captured in streaming/types.ts:

- Adds an explicit CONNECT step so the child sits idle after spawn instead
  of opening the wire immediately. Matches today's behaviour of waiting
  for announcer generation to finish before holding open a libshout
  session.
- Drops firstTrack from PLAY_SONG; it was only relevant during announcer
  generation, not streaming.

Behavioural changes inside the machine:

- playCurrentSong is split into a `playingSong.announcer` substate and a
  `playingSong.songAudio` substate. The machine emits SONG_STARTED on
  entry to songAudio so the parent can fire its "now playing" Discord
  message at song-audio start instead of announcer-start.
- SKIP_SONG transitions out of any active stream straight back to ready
  and emits the corresponding *_DONE event, so the parent advances
  cleanly without proceeding through the rest of the current file.

party.ts still owns the existing inline streaming code and is unchanged in
this commit; the new machine is unused until partyService is rewired in a
follow-up. The bot keeps working as-is; no behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Self-contained XState v5 child machine that provisions a fresh Mux
audio-only livestream on CONNECT, opens an SRT caller, streams files
via the packet rewriter (CC + PCR + PTS continuity across boundaries),
and tears the livestream + recorded asset down on STOP.

Speaks the same parent protocol as icecastMachine. Differences from the
Icecast side:

- States layered as creatingLivestream → connectingSrt → ready, then
  the same playingIntro / playingSong{announcer, songAudio} / playingOutro
  shape. SONG_STARTED still fires on entry to songAudio so the parent
  message timing matches.
- After the outro finishes streaming, the machine sits in `holding` for
  ~30s before emitting OUTRO_DONE. Listeners are still hearing audio out
  of Mux's HLS buffer during that window, and we want OUTRO_DONE to mean
  "listeners have heard it," matching Icecast's semantics.
- Carries the spike's bug-arounds for @eyevinn/srt: IPv4 lookup before
  connect, sticky error tracking, Promise.race against the next 'error'
  event on every write, allocUnsafeSlow buffers so the worker's
  postMessage transfer doesn't reject pool-backed slices, dispose() on
  the AsyncSRT worker so the process can exit.
- Reads MUX_TOKEN_ID / MUX_TOKEN_SECRET via fetchEnv, matching the rest
  of the codebase's secret-loading pattern.

audio/intro01.ts is the AAC-in-MPEG-TS transcode of audio/intro01.mp3
generated with the same args round-transcoder uses for Mux mode (-c:a
aac -b:a 160k -ar 44100 -ac 2 -f mpegts). Checked in alongside the
source mp3 so the bot doesn't have to transcode it on every startup.

Neither child machine is wired into partyService yet; that lands in a
follow-up. Bot still uses Icecast as before. No behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
partyService no longer owns any libshout-specific code. Instead:

- partying.entry spawns the appropriate streaming child machine based on
  context.streamTo (icecastMachine for icecast, muxMachine for mux). The
  child sits idle until the processing pipeline raises START_STREAM, at
  which point we sendTo('streamer', { type: 'CONNECT' }) and the child
  opens the wire.
- The streaming parallel substate becomes thin: connecting →
  playingIntro → pickNextSong ↔ playingSong → playingOutro → done.
  Each transition is driven by STREAM_* events emitted up from the
  child via sendParent. SKIP_SONG is forwarded down via sendTo.
- The "now playing" Discord embed moves from playingSong.entry to a
  STREAM_SONG_STARTED handler. Listeners hear the announcer first, then
  the song; the embed lands at song-audio start on both backends now.
- The Discord stream URL embedded in startIntroMessage and
  playCurrentSongMessage comes from context.streamUrl, populated when the
  child emits STREAM_READY. The bare streamUrl() env reader is gone.
- partying.exit sends STOP to the streamer so each child runs its own
  teardown (libshout free + shoutShutdown for icecast; SRT close +
  AsyncSRT dispose + Mux livestream/asset deletion for mux).

Streaming protocol output events are prefixed with STREAM_
(STREAM_READY, STREAM_INTRO_DONE, STREAM_SONG_STARTED, STREAM_SONG_DONE,
STREAM_OUTRO_DONE, STREAM_ERROR) so they don't collide with parent-side
event names and are obviously child-emitted at the parent's on-handlers.

Removed from party.ts: STREAM constants, streamUrl(), checkShout(),
streamFile(), playIntro(), playCurrentSong(), playOutro(), the cleanup
action, the initNodeshout actor, the local ExtraAnnouncer interface
(now imported from streaming/types), and PartyContext fields _shout +
abortController. Shrinks the file by ~200 lines.

The bot still uses Icecast in practice because /startparty hardcodes
streamTo='icecast' — the user-facing option lands in a follow-up. All
136 tests still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a required string-choice option (icecast | mux) to the slash
command. The value is read with getString('stream_to', true), narrowed
to StreamingMode, and forwarded on the START event so partyService
spawns the matching streaming child machine.

The reply embed now includes the chosen backend so admins get a quick
confirmation of which path they're on:
  "Starting listening party for OHC123 (streaming to mux)..."

Tests:
- All existing test interactions now provide stream_to: 'icecast' so
  Sapphire's option resolver finds the value when chatInputRun reads it.
- New test asserts stream_to='mux' propagates through to the START event
  unchanged.

This is the last user-facing piece of the dual-backend rollout. /startparty
now lets admins explicitly choose Icecast or Mux per party; both backends
are wired end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single-line import after the StreamingInputEvent removal — oxfmt fits
the three remaining named imports on one line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
41 new tests across three files. Combined with the existing 137, the
suite is now at 178 green.

test/lib/streaming/mpegts.test.ts (24 tests)
- Round-trip and fixture-based tests on readTimestamp33/writeTimestamp33
  and readPCRBase/writePCRBase, including marker-bit/prefix preservation
  and round-trip at non-zero offsets.
- processPacket: throws on missing sync byte; rewrites continuity counter
  per-PID with wrap; doesn't advance CC on AF-only packets; shifts PCR
  base by offset; shifts PTS in PTS-only headers; shifts both PTS and
  DTS in PTS+DTS headers; skips PES stream IDs in PES_NO_PTS_STREAM_IDS;
  skips non-PUSI packets; keeps latestStreamTimeSec monotonic.

test/lib/streaming/icecast.test.ts (8 tests)
- Spawn the machine inside a tiny wrapper parent that captures STREAM_*
  events so we can assert the protocol surface, not just internal state.
- Exercises the full lifecycle: idle → opening → ready, intro flow,
  the announcer→songAudio split with STREAM_SONG_STARTED firing between
  them, SKIP_SONG fast-path, outro flow, STOP from any state, and the
  STREAM_ERROR path on libshout open failure. Mocks
  @fusion2004/nodeshout-koffi and provides a no-op `cleanup` action.

test/lib/streaming/mux.test.ts (9 tests)
- Same wrapper-parent pattern. Mocks @mux/mux-node, @eyevinn/srt, and
  fetch-env so importing muxMachine doesn't try to spawn an SRT worker
  or read MUX_TOKEN_*. Provides fast-resolving stubs for all five
  invoked actors (createLivestream, connectSrt, streamFile, tailHold,
  teardown). Asserts the player-URL-bearing STREAM_READY, the
  announcer→songAudio split, SKIP_SONG, the playingOutro → holding →
  stopping → stopped cascade with STREAM_OUTRO_DONE only firing after
  the (mocked-instant) tail-hold, plus STREAM_ERROR on Mux create or
  SRT connect failure.

The plan also called for a parent-machine test asserting that
STREAM_SONG_STARTED triggers the "now playing" embed. The icecast and
mux tests already verify the child fires STREAM_SONG_STARTED at the
right point, and party.ts's behaviour on receiving it will be
validated end-to-end in the next task. Spinning up partyService in
isolation (it's not exported as a machine) would be a lot of
infrastructure for marginal value, so I'm leaving it out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- The "what this is" line now mentions Mux/SRT alongside Icecast and
  notes that the choice happens per-party via the stream_to option on
  /startparty.
- Stack line picks up @eyevinn/srt + @mux/mux-node.
- partyService description corrected: streaming is no longer an inline
  parallel substate, it spawns a streaming child machine and forwards
  events. Line count updated.
- Audio Pipeline section now describes the two transcode formats
  (icecast=MP3, mux=AAC-in-MPEG-TS) and points at streaming/format.ts
  as the single source of truth for backend-specific FFmpeg args + file
  extensions.
- New "Streaming Backends" section covers icecast.ts / mux.ts, the
  parent↔child protocol, and the small pile of Mux SRT bug-arounds we
  carry from the spike (IPv4 lookup, write race against 'error',
  allocUnsafeSlow, 30s tail-hold).
- Ambient shim note: only prism-media these days; both libshout-koffi
  and @eyevinn/srt ship their own types now.

mise.development.toml gets a comment pointing future devs at fnox.toml
for MUX_TOKEN_ID/MUX_TOKEN_SECRET so they don't waste time looking for
dev defaults that aren't there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
music-metadata bails with "Guessed MIME-type not supported: video/mp2t"
on Mux parties because transcodeFinal is an MPEG-TS container in mux
mode. Switching parseMetadata to read downloadFinal (always MP3) makes
it backend-agnostic. The metadata is only used for the "Now Playing"
embed's duration string, and the source MP3's duration is what users
expect to see anyway.

Leaves a TODO pointing at ffprobe as the eventual fix if we want to
read metadata off whatever container the streaming backend produced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two bugs surfaced when /skipsong was used during a Mux party:

(1) "Connection was broken" — concurrent SRT writes.

The old SKIP_SONG handler aborted the abort-controller AND immediately
transitioned playingSong → ready, which let the parent kick off the next
song's PLAY_SONG before the in-flight streamFile promise had drained
its current asyncSrt.write. Two streamFile actors then wrote to the same
socket concurrently, packets interleaved on the wire, and Mux dropped us.

Fix: drain-before-transition. SKIP_SONG sets a `skipping: true` flag in
context and aborts the abort-controller, but does NOT change state. The
in-flight streamFile's loop exits on its next abort check, the promise
resolves, and the announcer/songAudio invoke's onDone (and onError, in
case the final write failed mid-abort) branches on `skipping` to land in
ready and emit STREAM_SONG_DONE. `skipping` is reset on entry to each
new playingSong. No second streamFile starts until the current one is
fully done.

Applies to both machines for behavioural symmetry, even though libshout's
synchronous writes don't have the concurrent-write hazard.

(2) "Cannot read properties of null (reading 'terminate')" — double
teardown.

When STREAM_ERROR escalated to STOP at the parent, partying.exit re-fired
sendTo('streamer', { type: 'STOP' }), which the muxMachine's top-level
`on: { STOP: { target: '.stopping' } }` then re-entered. Stopping's
`teardown` actor invoked AsyncSRT#dispose() a second time; dispose() reads
`this._worker.terminate()` after the first call already nulled
`this._worker`.

Fix: add `on: { STOP: {} }` to stopping/stopped on both machines so a
second STOP arriving while we're already tearing down is a no-op.

Tests updated: SKIP_SONG tests now use an `abortable: true` streamFile
stub that mimics streamPacketFile's drain-on-abort, asserts the machine
reaches `ready` only after the abort fires, and verifies that
STREAM_SONG_STARTED is never emitted when songAudio gets skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The skip-then-disconnect symptom that survived drain-before-transition
turned out to be Mux's HLS segmenter stalling on a truncated audio PES:
streamPacketFile exited on signal.aborted between iterations, which
typically meant we'd sent a PUSI=1 start-of-PES packet plus a couple of
continuation packets but never finished the frame. The audio PID's
in-progress PES sat half-written in Mux's input buffer, and a few seconds
later Mux gave up on us — "Connection was broken" on a subsequent write.

Fix: PES-boundary-aware drain. Once signal.aborted fires, set
`drainingToBoundary` and keep reading + sending packets until we hit one
with PUSI=1 on the audio PID (the start of the *next* PES). Stop just
before sending that packet. Side-effect: every PES we begin sending we
finish sending. Worst-case overshoot is ~one audio frame (~23ms at
44.1kHz/1024 AAC).

The audio PID is discovered on the fly — the first PUSI=1 packet whose
payload starts with the PES start-code prefix (0x00 0x00 0x01) locks
`pesPid`. PSI tables (PAT/PMT) also use PUSI=1 but their payloads start
with a table_id byte, so they don't get falsely identified. Until pesPid
is set we fall back to "stop at any PUSI=1," which is safe — we're still
in the PSI preamble and no audio frames have been on the wire yet.

The flush block at the end no longer skips on abort: any packets still in
chunkBuf when we hit the boundary belong to the PES we just committed to
finishing, so they go out before we close the file.

icecastMachine doesn't need this — libshout streams MP3, not MPEG-TS,
and its writes are synchronous.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the at-end-of-stream Mux teardown with a start-of-stream sweep.
The old behaviour deleted the livestream + recorded asset as soon as
streaming finished, which cut any listener still hearing the natural HLS
playback tail off mid-segment. Now we let each stream live out its
playback tail and clean up later.

How it works:

- Every Mux livestream we create is tagged `meta.title = chorus-<ulid>`,
  and its `new_asset_settings.meta.title` is set to the same value so the
  recorded asset inherits the tag. ULIDs come from the new `ulid` dep
  (https://github.com/ulid/javascript) — sortable, unique, 26 chars.
- At the start of every Mux-backed party, before creating the new
  livestream, we iterate every livestream and asset in the account and
  delete any whose `meta.title` starts with `chorus-`. Each deletion is
  logged with the id + title so operations can verify what was swept.
  The new livestream's title doesn't collide because we sweep *before*
  creating it.
- The teardown actor still closes the SRT socket and disposes the
  AsyncSRT worker, but no longer calls the Mux delete APIs. The
  `stopping` lifecycle comment in the header is updated to match.

This relies on us being the only writer of chorus-* tagged entries in
the Mux account. Anything tagged with that prefix is fair game for the
sweep. If a future party were to start while a previous one's playback
is still live, the in-progress listener would lose the connection —
matches the user-accepted trade-off (long-tail playback survives until
the next party kicks off).

All 178 tests still green; the existing mux.test.ts already stubs
createLivestream + teardown, so the new cleanup path is exercised only
in production. A future test could mock the list+delete paginated APIs
if desired.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread src/srt-spike.ts Fixed
Comment thread mise.development.toml Outdated
@socket-security
Copy link
Copy Markdown

socket-security Bot commented May 21, 2026

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​eyevinn/​srt@​0.8.36910010082100
Addedulid@​3.0.210010010083100
Added@​mux/​mux-node@​14.1.010010010094100

View full report

The module reads HUBOT_STREAM_* at import time, so the test fails to
load on CI where those env vars aren't set. mux.test.ts already mocks
fetchEnv for the same reason — mirror that here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@fusion2004 fusion2004 merged commit 487be14 into main May 21, 2026
6 checks passed
@fusion2004 fusion2004 deleted the srt-spike branch May 21, 2026 03:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Spike: SRT streaming

2 participants