Repository navigation
Conversation
Each audio_bridge_output now links from the audio mixer's direct_out_N (Eyevinn/strom#930), which carries the channel after its fader, mute and to-Main switch. The mixer is built with direct_outs: true only when fast returns are planned. Crew changes reach the fast feed inside Strom, so the router is a fixed mix-minus set at activation, and the mirroring of crew state into its crosspoints is removed. The channel-count check treats a gap in the mixer's input links as a missing channel (Array.every skips holes in a sparse array, so the check now runs over Array.from of it). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Noted as a draft, so I am holding off on a full review until it is marked ready for review. If you want one before then, say so here or request me as a reviewer. |
506bbd2 to
c3dc0f7
Compare
|
Claude (AI), on behalf of @wagenet. Rebased onto main. Conflicts were with #946 (one sample rate for every mixer bus):
Ran: |
A guest's picture feed plays its mix-minus about 0.8 s after the voices it carries were spoken: the WHIP jitterbuffer (400 ms) plus the program flow's latency, because Strom holds every sink in a flow to one latency. That is too slow to talk over. For a guest slot with `returnFeed.lowLatency: true`, activation now also builds the spec's fast feed. The audio mixer is built with `direct_outs` (Eyevinn/strom#930), and each channel's direct out (after its fader, mute and to-Main) feeds a Strom audio bridge in the program flow. A second, conversation flow takes the bridges into a liveaudiorouter, a fixed mix-minus that leaves out each guest's own channel, and feeds an audio-only WHEP output per guest. The bridge restamps on arrival and holds a small target latency, so neither the jitterbuffer nor the program's latency reaches the fast feed. Crew changes reach it inside Strom; open-live never writes the router after activation. - Activation stores `fastWhepUrls` and `fastFeedRouter`; deactivate clears them, and an activation without a fast feed overwrites what an idle or reconcile stop left behind. - The conversation flow is tagged `conv:<programFlowId>`. `deactivateStromFlow` removes it with its program flow; activation and startup reconcile remove one whose program flow is gone. - If the conversation flow cannot start, the production still activates, without fast feeds. More than 8 audio channels (the router's limit), or a channel with no source link, builds no fast feed. - `values.fast_return_latency_ms` sets the bridge target; Strom's default otherwise. The API still accepts only `lowLatency: false`; the next commit serves the feed and accepts `true`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
srperens
left a comment
There was a problem hiding this comment.
Verdict: Comment. I found no defect: the code, the tests and the queue override all hold up. What remains is a decision only a maintainer can make. The body asks for a view on the name and the tap point, and both become flow contract on merge: direct_outs and direct_out_N get persisted in saved flows and links.
For the maintainer to choose
- Name / tap point. As built, the tap is post-route: after to-Main, so a group-only channel is silent on it. That is the right tap for this PR's purpose, a mix-minus that follows audio-follow-video. A console engineer reading "direct out" will expect a pre/post-fader tap that ignores routing. Either keep the name and later add a
direct_out_tapenum (post_routedefault), which is additive, or rename now (e.g.main_sends/main_send_N) while nothing persists it. Renaming after merge is a breaking change to saved flows. - Consumer EOS (#933). With direct outs on, a consumer that ends silences its channel on Main at once, where today's aux outs take ~1 s. Disclosed; #933 owns the fix. Landing this first makes the failure faster on an opt-in path. Waiting for #933 is a legitimate alternative.
Claims
| Claim | Verdict | Evidence |
|---|---|---|
| Off by default; with it off the graph is unchanged | CONFIRMED |
pads only under backend/src/blocks/builtin/mixer/builder.rs:101 — if get_bool_prop(properties, "direct_outs", false) {. The else arm keeps the original to_main_vol → to_main_queue link |
| The tap is after to-Main, so Main and the direct out share one ramp | CONFIRMED |
backend/src/blocks/builtin/mixer/builder.rs:1264 — ElementPadRef::pad(&direct_tee_id, "sink"), linked from to_main_vol's src |
| The queue override is justified | CONFIRMED |
CLAUDE.md "GStreamer Queues" allows overrides with a documented latency requirement. The comment above backend/src/blocks/builtin/mixer/builder.rs:1237 — .property("max-size-time", 3 * gst::ClockTime::SECOND.nseconds()) documents one, and test_direct_out_feeds_a_synced_consumer_at_high_latency guards it |
| The editor save fix covers every build-time key it does not edit | CONFIRMED |
frontend/src/mixer/state.rs:5 — const BUILD_CONFIG_KEYS: [&str; 7] = [, merged into both save paths (lines 375 and 638). Of the live: false keys in definition.rs, the rest are num_* (written by the editor) and per-channel label/aux-pre/HPF (loaded and written by the editor) |
Diagnosis: Tapping inside the mixer is the only way to get "what this channel sends to Main" without mirroring, and the body shows why mirroring cannot converge. The leaky queue makes the loop OPEN on the healthy path: an unlinked or stalled branch drops, so Main costs nothing extra. The empty-channel case reports PAUSED for outputs that wait for their first audio. It is documented, and #934 removes it for NDI, DeckLink audio and TAMS.
Radius: LOCAL. The feature is opt-in in one block. The editor save fix changes every mixer save, but it only stops resetting keys that were previously dropped silently, and its test fails when the fix is removed.
Overlaps: #933 (above). #934 removes the empty-channel stall for three outputs. #898 drops input EOS at convert_N, upstream of this tap. A local trial merge of main + this + #898 is clean (not built). Once both land, a direct out on a force-live mixer never sees an ended input's EOS, which is consistent with that block not ending.
Tests & CI: Check (Linux) at b21fc6a ran all five test_direct_out* tests and saves_keep_build_config_the_editor_does_not_edit, all ok. It predates #971; the trial merge above includes it. The author saw the ramp/stall tests fail 2/5 at load above 20, so watch for flakes on CI. macOS/Windows skipped: nothing here is platform-specific.
Confidence: HIGH
A guest's picture feed plays its mix-minus about 0.8 s after the voices it carries were spoken: the WHIP jitterbuffer (400 ms) plus the program flow's latency, because Strom holds every sink in a flow to one latency. That is too slow to talk over. For a guest slot with `returnFeed.lowLatency: true`, activation now also builds the spec's fast feed. The audio mixer is built with `direct_outs` (Eyevinn/strom#930), and each channel's direct out (after its fader, mute and to-Main) feeds a Strom audio bridge in the program flow. A second, conversation flow takes the bridges into a liveaudiorouter, a fixed mix-minus that leaves out each guest's own channel, and feeds an audio-only WHEP output per guest. The bridge restamps on arrival and holds a small target latency, so neither the jitterbuffer nor the program's latency reaches the fast feed. Crew changes reach it inside Strom; open-live never writes the router after activation. - Activation stores `fastWhepUrls` and `fastFeedRouter`; deactivate clears them, and an activation without a fast feed overwrites what an idle or reconcile stop left behind. - The conversation flow is tagged `conv:<programFlowId>`. `deactivateStromFlow` removes it with its program flow; activation and startup reconcile remove one whose program flow is gone. - If the conversation flow cannot start, the production still activates, without fast feeds. More than 8 audio channels (the router's limit), or a channel with no source link, builds no fast feed. - `values.fast_return_latency_ms` sets the bridge target; Strom's default otherwise. The API still accepts only `lowLatency: false`; the next commit serves the feed and accepts `true`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An opt-in `direct_outs` property gives each channel an output pad, `direct_out_N`, carrying what the channel sends to Main: after its fader, mute and to-Main switch, before the Main sum. A consumer that needs each channel as the operator has set it, such as a guest mix-minus built in another flow, can tap it instead of mirroring the operator's changes. The direct branch has a leaky queue, so a consumer that stops pulling drops direct-out audio instead of blocking the channel on Main. The fullscreen mixer editor replaces the block's properties on save, which dropped construction-time settings it has no controls for (dsp_backend, force_live, latency, min_upstream_latency). It now carries those, and direct_outs, through saves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A consumer that syncs to the clock, such as the Inter Output block, holds each buffer for the flow's latency. With the queue's default limits (1 s, 200 buffers) the leaky direct-out queue overflowed in a flow above about 1 s of latency, or above about 200 ms with 1 ms buffers, and dropped nearly all of the direct-out audio. The queue now holds 3 s with no buffer or byte limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A direct out carries nothing until its channel has input, while the other mixer outputs carry silence. An output that waits for its first audio before starting (NDI, DeckLink audio, a raw sink with async on) therefore holds the whole flow until that channel has input. Say so in the property description and the operator guide. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An output that waits for its first audio gets nothing and keeps the flow from reporting PLAYING, but Main and the other outputs keep playing: live sources take every other element to PLAYING. TAMS Output waits too (its splitmuxsink keeps async on). The other outputs carry silence only with force_live on, the default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fullscreen mixer editor has no control for internal_bus_latency, so a save reset it to its default and Monitor gaps it had fixed came back. Carry it through saves with the other construction-time settings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Claude (AI), on behalf of @wagenet. Rebased onto main (dcccdd7) to clear the merge conflict. No behaviour change.
Both fixes are folded into the commits that introduced them, so each commit builds on its own (clippy Ran: The open name and tap-point questions are untouched and still need a maintainer decision. |
b21fc6a to
61dd167
Compare
srperens
left a comment
There was a problem hiding this comment.
Verdict: Comment. This is a re-review at the new head, 61dd167. My review of 2026-10-05 at b21fc6a stands unchanged. The push only rebases the branch, and the maintainer decision that review asked for is still open.
What moved. git range-diff of the old and new branches shows the five commits unchanged apart from three things:
- the test helper
feedis renamed tofeed_wave, because main added its ownfeed; - the capture probe uses
as_chunks::<4>()for the new clippy lint; - the operator guide's table context is updated.
No product code changed. The gate is still where it was: backend/src/blocks/builtin/mixer/builder.rs:101 — if get_bool_prop(properties, "direct_outs", false) {. So every claim row in my earlier review carries over without re-verification.
Still for the maintainer. Both choices from the earlier review are unanswered: the direct_outs name and its tap point, which become persisted flow contract on merge, and whether to wait for #933, which is still open.
Overlaps. #933 (open, owns the consumer-EOS behaviour), #934 and #898 are unchanged since the last review. The branch merges cleanly with current main (checked with git merge-tree).
Tests & CI. At 61dd167, Check (Linux) passes. All five direct-out tests are ok in that job's log, test_stalled_direct_out_consumer_does_not_starve_main among them, and so is saves_keep_build_config_the_editor_does_not_edit. The two ignored mixer tests are not part of this diff. Both Linux builds, WASM and API Contract pass. macOS and Windows were skipped, and nothing here is platform-specific.
Confidence: HIGH
Opened by Claude (AI) on Peter Wagenet's behalf.
The design here is flexible, and we're open to other approaches and to any feedback. The points we'd most like a view on are listed under "Open for discussion".
Problem
The mixer has no per-channel output. Anything outside it that needs a channel as it is actually mixed can only tap the channel's source, before the mixer, and copy the operator's fader, mute and audio-follow-video (
ch{N}_to_main) changes onto its own gain. That copying cannot be made correct from outside the mixer:volumeto 0. A channel whose source has stalled reads a stale value, because a controlled property only updates while buffers flow.The case that prompted this is a guest return: a mix-minus (each guest hears everyone but themselves) built in a separate, low-latency flow, because every sink in the program flow runs at the program flow's latency. Eyevinn/open-live#386 builds it today by tapping each channel's source into a cross-flow audio sink and summing those in a
liveaudiorouter, then mirroring every mixer change into the router's gains. The cross-flow hop can beinteraudiosinkor the adaptive bridge proposed in #844. This PR does not depend on #844, and the direct outs work with either.Change
New property and pads: an opt-in
direct_outsproperty onbuiltin.mixer(Bool, default false, construction-time). When on, each channel gets an output paddirect_out_N(labelDN). It carries exactly what that channel sends to Main: after its fader, mute and to-Main switch, before the Main sum and the Main processing.No change for existing flows: with the property off, the mixer builds exactly what it builds today.
Nothing to mirror: the same
volumeelements shape Main and the direct out, so a mute or to-Main change fades identically on both. The router becomes a fixed mix-minus matrix.Consumers cannot block Main: the direct branch has a leaky queue holding 3 s. A consumer that stops pulling drops direct-out audio instead of blocking the channel and silencing it on Main. An unlinked direct out is ignored, like the other outputs.
Mixer editor save fix: the fullscreen mixer editor replaces the block's properties wholesale on save, so it dropped construction-time settings it has no controls for. That includes
dsp_backend,force_live,latency,internal_bus_latency,min_upstream_latencyandsample_ratetoday, and would includedirect_outs. A save reset each to its default: a mixer set to 44.1 kHz came back at 48 kHz. Strom then prunes links to pads that no longer exist, so a save would also quietly remove every direct-out link. The editor now carries those keys through saves.Docs: a section in the operator guide.
Open for discussion
direct_outs/direct_out_Nfollows console vocabulary. But this tap is not a hardware console's direct out (next point), so a different name may read better.direct_out_tapenum (post_routedefault,post_fader,pre_fader) could be added later without breaking anything.force_liveon, the default). An output that waits for its first audio before starting gets nothing until a source reaches that channel, and the flow reports PAUSED rather than PLAYING until then. Main and the other outputs keep playing: withinteraudiosinkon an empty direct out, an AES67 output on Main sent 2,021 packets in 2 s while the flow reported PAUSED, and the flow reached PLAYING about 65 ms after a source joined. Inter, AES67, Recorder, SRT, RTMP, WHEP and WHIP outputs do not wait. The NDI, DeckLink audio and TAMS outputs do (draft fix(outputs): start NDI, DeckLink audio and TAMS outputs without waiting for data #934 changes them), as does any raw sink left withasyncon. The property description and the operator guide say so. The alternative fix is to have the mixer send silence on an empty direct out. The simple way to do that adds a live mixer per channel, which adds delay to the return this PR is for.Latency
Tapping the direct outs adds nothing measurable to the fast return. Main's 360 ms pipeline latency does not reach it.
rustDSP backend, nothing in the channel strip holds audio. Of the lsp-rs elements only the limiter has lookahead, and it sits on the Main bus. The tap is upstream of the Main audiomixer.Tests
New tests in
mixer/tests.rs, each with its own block id:test_direct_outs_exist_only_when_enabledtest_direct_out_carries_only_its_channeldirect_out_1, silence ondirect_out_2, resolved through the external pad mappingtest_direct_out_follows_mute_and_to_main_rampsVolumeRampManager(as the property API does). Each fades on the direct out and matches Main bucket by buckettest_stalled_direct_out_consumer_does_not_starve_maintest_direct_out_feeds_a_synced_consumer_at_high_latencyIn
frontend/src/mixer/state.rs,saves_keep_build_config_the_editor_does_not_editfails when the carry-through is removed, or whensample_rateorinternal_bus_latencyis left out of it.The ramp and stall tests judge by the longest contiguous run, not single 10 ms buckets. On a loaded host, Main's aggregator drops a bucket here and there with the feature off too.
Ran (macOS, GStreamer 1.28,
STROM_REQUIRE_GST_PLUGINS=1so a missing element fails instead of skipping):cargo fmt --allandcargo clippy --workspace --all-targets -- -D warningsat the head.cargo test --workspaceat the head: 1,241 passed, 0 failed, 6 ignored, includingopenapi_test.taskpolicy -b(background priority, which mimics a slow CI runner), feeding 48 kHz against 44.1 kHz, alternated 8 times each: 16 of 16 passed, no difference between the two. In one earlier run at load average above 20, the ramp, stall and synced-consumer tests failed in 2 of 5 iterations; that did not recur in 22 runs at lower load.Not run:
🤖 Generated with Claude Code