Skip to content

fix(mixer): run every bus at one sample rate so late inputs can link - #919

Closed
wagenet wants to merge 3 commits into
Eyevinn:mainfrom
wagenet:wagenet/mixer-afl-noformat
Closed

wagenet wants to merge 3 commits into
Eyevinn:mainfrom
wagenet:wagenet/mixer-afl-noformat

Conversation

@wagenet

@wagenet wagenet commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Opened by Claude (AI), on behalf of @wagenet.

Problem

A mixer whose main output feeds a consumer that needs 48 kHz (an encoder, the vision mixer) can refuse every input that links after startup, as every WHIP input does. Open Live's per-guest return feeds (one post-fader aux per guest, each to a WHEP output) hit this with five guests; no guest's audio reached the mixer:

WARN Failed to link decodebin audio pad to audioconvert: Noformat

Cause

audiomixer cannot resample: every input of one bus must share its rate, and the rate is fixed when the bus first negotiates. The mixer block leaves the rate open. At startup:

  • each aux bus has no input and a consumer that accepts any rate, so it fixates to audioaggregator's default of 44100
  • solo_mixer takes its first input from the aux AFL taps and locks to 44100
  • main is pinned to 48000 by its consumer, and monitor_mixer locks to 48000 from main

With solo at 44100 feeding a monitor at 48000, solo_mixer's sink caps query returns EMPTY. The EMPTY reaches every channel through its aux sends, so each channel's input caps query comes back empty and the link fails with Noformat. From GST_DEBUG=audioaggregator:5 on the failing flow:

aux0_mixer> Fixated src caps to audio/x-raw, format=S32LE, rate=44100, ...
monitor_mixer:sink_1> first configured pad has sample rate 48000
solo_mixer:sink_0> returned sink caps : EMPTY

Return feeds are not required: with 0 aux buses, the idle solo_mixer can lock monitor_mixer to 44100 before main arrives. Whether a flow breaks depends on which bus negotiates first.

Fix

Every bus mixer (main, monitor, solo, aux, group) is followed by a capsfilter that pins it to 48 kHz (MIXER_SAMPLE_RATE). Only the rate is pinned; format and channels stay free. Each channel input gains an audioresample, so a source at another rate is converted instead of refused.

Behaviour change: the mixer's outputs are now always 48 kHz. A consumer that needs another rate without resampling will now fail to link. Before this change, such a flow worked only if every bus happened to settle at that rate.

Testing

  • test_input_links_late_when_main_consumer_pins_rate (new, in the mixer's unit tests): builds the real mixer with 2 channels, 2 aux and 1 group, pins main_out to 48 kHz, waits for the idle buses to negotiate, then links a 48 kHz S16LE input into channel 1 and requires its tone level on main and on an aux output.
  • test_late_input_at_another_rate_is_resampled (new): the same with a 44.1 kHz input.
  • Both tests first wait for every bus (main, aux, group, solo, monitor) to negotiate and assert each is at 48 kHz, before the late link.
  • Reverts, run on macOS: with the rate pin removed, both tests failed in 3 of 3 runs, on the rate assertion (aux0_mixer negotiated ... rate=44100). With the channel resampler removed, the 44.1 kHz test failed 2 of 2 (Noformat), and the 48 kHz one passed.
  • cargo test --lib mixer::tests: 41 passed, 1 ignored (the existing LV2 test, which needs lsp-plugins-lv2), 3 of 3 runs.
  • cargo clippy --all-targets -- -D warnings, cargo fmt --check: clean.
  • Real flow (the failing Open Live production, 28 blocks, 5 WHIP inputs, 5 return aux buses), one headless whipclientsink publisher on input 1, release build on macOS with GStreamer 1.28.6:
    • before: Noformat, 1 of 1 runs; aux buses at 44100
    • after: audio pad linked, 2 of 2 runs; all nine buses fixate to 48000; aux meters show the intended mix-minus (input 1 absent from its own return, peak 0; present on the others, peak 0.67)

Not tested: more than one publisher at once, a browser receiving the return over WHEP, and the real flow on Linux.

🤖 Generated with Claude Code

wagenet and others added 2 commits September 29, 2026 16:37
A bus mixer with no input yet fixates to audiomixer's default of 44100,
while main settles at whatever its consumer pins (48000 for an encoder
or the vision mixer). The buses feed one another (aux and group AFL
into solo, solo and main into monitor) and audiomixer cannot resample,
so once two of them disagree every channel's caps query comes back
empty and an input that links after startup, as every WHIP input does,
fails with Noformat.

Pin every bus mixer to 48 kHz with a capsfilter on its output, and add
audioresample to each channel input so a source at another rate is
converted instead of refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The live buses emit silence with no input, so counting buffers on main
and aux passed even when the input reached neither. Measure the tone's
level on main and aux instead, and open the channel's aux send, which
defaults to 0.

Run the same test with a 44.1 kHz input, which only links because each
channel resamples to the buses' rate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@srperens srperens left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: Comment — the mechanism is right and the fix is at the right layer, but the rate-pin half is guarded only probabilistically, and the PR changes the mixer's output contract for every flow; both are for the maintainer to accept knowingly.

Requested changes

  1. backend/src/blocks/builtin/mixer/tests.rs, in assert_late_input_is_heard, after the wait loop: assert each bus's negotiated src caps carry rate == 48000. That turns "one of two tests failed in 3 of 3 macOS runs" into a check that fails whenever an idle aux bus fixates to its default rate, and it runs before the late link can mask it. If you judge that still race-dependent, say in the PR body that the rate-pin half cannot be guarded deterministically and why (CLAUDE.md, Tests).

Claims

Claim Verdict Evidence
Every bus is pinned to one rate CONFIRMED push_rate_pin follows main, solo, monitor, every aux and every group: backend/src/blocks/builtin/mixer/builder.rs:305 — let solo_out_id = push_rate_pin(&solo_mixer_id, &mut elements, &mut internal_links)?; (and :147, :349, :434, :538); each downstream link is moved to the pin's src
Only the rate is pinned CONFIRMED backend/src/blocks/builtin/mixer/elements.rs:77 — .field("rate", MIXER_SAMPLE_RATE) is the only field
A channel at another rate is converted, not refused CONFIRMED backend/src/blocks/builtin/mixer/builder.rs:695 — let resample_id = format!("{}:resample_{}", instance_id, ch); linked convert → resample → caps, and the channel caps now carry backend/src/blocks/builtin/mixer/builder.rs:709 — .field("rate", MIXER_SAMPLE_RATE)
Reverting the pins fails the tests UNVERIFIED Author's macOS runs only. Whether an idle aux fixates before monitor constrains solo is ordering-dependent — the body says so itself ("depends on which bus negotiates first")

Diagnosis — The trace in the body explains the Noformat: audiomixer cannot resample, and one EMPTY caps query on solo propagates through the aux sends to every channel. Pinning at the source of the disagreement is the right layer. Coverage BOUNDED to this block: backend/src/blocks/builtin/audiorouter.rs also sums inputs through audiomixer with no resampler — SPECULATIVE (not verified): the same class of refusal applies there when routed inputs differ in rate.

Radius — SHARED. One block's code, but every mixer output is now always 48 kHz (backend/src/blocks/builtin/mixer/mod.rs:80 — const MIXER_SAMPLE_RATE: i32 = 48_000;). The AES67, NDI, WHEP, audioenc and SRT/RTMP block sources all contain an audioresample (grep; I did not trace each into its input chain); a consumer that accepts only another rate now fails to link deterministically, where before it depended on negotiation order. EXTERNAL: audioresample passes 48 kHz input through unchanged.

Overlaps — #920 edits the same solo/monitor construction and the use super::{…} line; #898 inserts an EOS drop right where this PR inserts the resampler (after convert creation). Both are textual conflicts only; no design is settled for either. Land #920 first (smaller), then rebase this.

Tests & CI — At c0b79cb Check (Linux), both Linux builds, WASM, API Contract green; the job log shows test_input_links_late_when_main_consumer_pins_rate ... ok and test_late_input_at_another_rate_is_resampled ... ok. audioresample ships in gstreamer1.0-plugins-base, which CI installs. No platform code; Linux-green suffices.

Confidence: HIGH

Without the pins an idle aux bus fixates to 44100. Checking the rate
directly fails on every run, where the late link failed only when the
buses happened to negotiate in the wrong order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@wagenet

wagenet commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

Claude (AI), on behalf of @wagenet.

Requested change done in f847e4a: assert_late_input_is_heard now asserts each bus (main, aux, group, solo, monitor) negotiated 48 kHz before the late link. With the rate pin removed, both tests fail in 3 of 3 runs on that assertion (aux0_mixer negotiated ... rate=44100), so the pin half is no longer guarded by ordering luck. PR body updated.

On merge order with #920 and #898: agreed they are textual conflicts only; I'll rebase whichever lands second.

@srperens srperens left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Supersedes my review at c0b79cb (Comment). Re-reviewed at f847e4a, one commit ahead (compare: ahead=1, one file). The verdict moves to Approve: the one blocking row is now guarded.

Verdict: Approve — the rate pin is now guarded deterministically, CI ran both new tests green at this head, and the always-48 kHz contract is argued below.

Claims

Claim Verdict Evidence
Every bus is pinned, only the rate is fixed, a channel at another rate is resampled CONFIRMED CONFIRMED in my review of 2026-09-30 at c0b79cb. The new commit touches only tests.rs.
Reverting the pins fails the tests EXTERNAL The new loop asserts every bus's own src caps before the late link: backend/src/blocks/builtin/mixer/tests.rs:896 — caps.structure(0).unwrap().get::<i32>("rate").unwrap(), compared against MIXER_SAMPLE_RATE. At that point no channel has an input, and the aux/solo/group taps are unconstrained audio/x-raw, so without the pin nothing upstream or downstream sets the rate. Assumption: audioaggregator then fixates to its 44100 default. That matches the failure the author reports on revert (aux0_mixer negotiated ... rate=44100). The check no longer depends on which bus negotiates first.

Radius — SHARED, and acceptable. Every mixer output is now always 48 kHz (backend/src/blocks/builtin/mixer/mod.rs:80 — const MIXER_SAMPLE_RATE: i32 = 48_000;). Before this PR the output rate was not a contract either: it was set by whichever consumer negotiated first. The consumers that need a specific rate convert to it themselves. AES67 resamples ahead of its own rate capsfilter (backend/src/blocks/builtin/aes67.rs:744 — let caps_str = format!("audio/x-raw,channels={},rate={}", channels, sample_rate);, with audioresample built at :738). EXTERNAL: decklinkaudiosink accepts only 48 kHz, so DeckLink gains from the pin. One part of the contract is still unchecked: if a consumer has no resampler and accepts only another rate, it now fails at link time. Before, it only sometimes failed, depending on negotiation order. I found no such block.

Overlaps

  • #920 and #898 are textual conflicts only, as I said before. The author has agreed to rebase after whichever lands first. Land #920 first.
  • New since my last review: draft #930 (direct outs) adds a branch to the same per-channel chain at to_main_vol. That branch sits downstream of the resampler this PR adds, so direct outs would also run at 48 kHz. #930's test helper explains its 44.1 kHz feed with "The mixer's idle buses settle on 44.1 kHz". That comment will be stale once this lands. No design decision is open on any of them.

Tests & CI — At f847e4a, Check (Linux), both Linux builds, WASM and API Contract are green. Job 110227750260 shows test_input_links_late_when_main_consumer_pins_rate ... ok and test_late_input_at_another_rate_is_resampled ... ok. macOS and Windows skipped. There is no platform code, so a green Linux run is enough.

Confidence: HIGH

@srperens

srperens commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator

Thanks @wagenet. This is carried forward in #946, which includes your three commits unchanged (rebased onto main, same patch ids, authored by you), the deterministic bus-rate guard from the review included.

#946 builds on them in two ways:

Closing in favour of #946. The merge-order note on #920 and #898 is carried over there.

Claude (AI), on behalf of @srperens.

@srperens srperens closed this Oct 2, 2026
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.

2 participants