Listening party can be streamed to either Icecast or Mux#1085
Merged
Conversation
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>
fusion2004
commented
May 21, 2026
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
closes #1039
introduces swappable streaming xstate machines, extracting the existing icecast stream machine and introduce a mux/srt stream machine