Skip to content

fix(whip): keep a slot's audio format stable across sessions - #758

Draft
wagenet wants to merge 2 commits into
Eyevinn:mainfrom
wagenet:wagenet/whip-slot-caps-reuse
Draft

fix(whip): keep a slot's audio format stable across sessions#758
wagenet wants to merge 2 commits into
Eyevinn:mainfrom
wagenet:wagenet/whip-slot-caps-reuse

Conversation

@wagenet

@wagenet wagenet commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

The problem

A WHIP Input slot is built once, at flow build time, and stays in the running pipeline while sessions come and go. Caps travel with every sample the session bridge pushes into appsrc_audio_<slot>, so a second publisher taking a freed slot can hand a running chain a different audio format than the first one used.

Everything past the slot's tee has already committed to that first format — the seat's recorder has an AAC encoder and a muxer, and a muxer will not renegotiate mid-file. It answers with not-negotiated, which travels back up and stops the appsrc's streaming thread. The seat's audio is then dead for good.

It does not stay contained to the audio branch. Video keeps arriving, splitmuxsink blocks waiting on the stopped track, the recorder's video queue backs up, and tee backpressure stalls the seat's other branches including its vision-mixer feed. If that seat is the only live source, the program output freezes. This was hit by a real participant reconnecting over a tunnel.

The fix

A capsfilter after each slot's audioconvert ! audioresample, so downstream sees one audio format for the life of the flow and a publisher's format change is absorbed on the slot's side of the tee, where audioconvert can deal with it.

The capsfilter deliberately does not name a sample rate. This is the part worth reviewing, because pinning one is the obvious thing to do and it is wrong:

  • The rate belongs to the seat's downstream graph, not to the slot. Consumers are shared — every seat feeds one audio mixer — and that graph settles on a rate of its own (44.1 kHz on the rig I tested).
  • Pinning rate=48000 at build time makes the caps query back through audioresample/audioconvert intersect to nothing once downstream has settled on something else. decodebin's audio pad then fails to link at all with Noformat, the framework auto-tees the orphaned pad, and the seat gets no audio whatsoever — in either session. I shipped that version first and caught it only on the real rig; the unit test passed happily, because the test's own downstream accepted 48 kHz.
  • So the rate is locked at runtime instead, on the first caps event, to the value downstream actually negotiated. A value downstream chose cannot conflict with downstream, which is the whole point of doing it there rather than in the caps builder.

format/layout/channels are safe to pin at build time because audioconvert sits directly upstream and can always produce them from anything. The Mixer block already pins its own boundary format this way, and for the same reason omits rate.

Verification

Regression testbackend/tests/whip_slot_caps_reuse_test.rs, two cases (channel count, and sample rate) run through the real build_whipserversrc slot chain into the recorder's shape (avenc_aacmp4mux).

It asserts the symptom, not the mechanism: how much of the second session's audio actually reaches the muxer. It fails if the fix is reverted — verified by removing the capsfilter and re-running:

the slot did not survive being reused in a different audio format:
only 2 of the second session's buffers reached the muxer
(54 of them reached the slot boundary, so the slot itself was fed)

With the fix, 99 reach the muxer. Both cases fail on revert, both pass with it.

The test needs no gst-plugins-rs — the slot chain is plain core GStreamer, so whipserversrc is not required. Every element it does need is in the CI package list, and CI sets STROM_REQUIRE_GST_PLUGINS=1, so a missing element fails rather than skipping green.

End to end, headless, on the 5-seat meeting rig: publish to a seat with mono audio, SIGINT the publisher, publish to the same seat with stereo. Negotiated caps at the slot boundary and at the recorder, sampled live via /api/flows/{id}/pad-caps:

before after
session 1 (mono) rate=44100 ch=1 rate=44100 ch=2
session 2 (stereo) rate=44100 ch=2 rate=44100 ch=2

Before, the format changes under a committed consumer; after, it does not. Media confirmed flowing in both sessions via Pad video_0 / Pad audio_0, and the runtime lock logs its choice:

WHIP Input: slot 0 audio format locked to audio/x-raw, rate=(int)44100,
format=(string)S16LE, channels=(int)2, layout=(string)interleaved

Tests run: the full backend suite with STROM_REQUIRE_GST_PLUGINS=1 (544 unit tests plus all integration tests, including pipeline_lifecycle_test) — all pass. cargo clippy --all-targets --features efp,nvidia -D warnings and cargo fmt --check clean. Nothing skipped.

Scope

Audio only, and only the slot boundary. Two things I deliberately did not touch:

  • Video has the same class of problem — a second publisher at a different resolution would change the format under the seat's committed video consumers. Fixing it the same way means choosing a resolution to pin every publisher to, which is a quality decision rather than a correctness one, so it wants its own change.
  • splitmuxsink blocking when one input track stops and WHIP slot-takeover liveness are being handled separately. I saw the queue_video backlog in my runs, but its first warning lands during the first session, before any format change, so it is not attributable to this bug and I make no claim about it here. recorder.rs and whip_session_manager.rs are untouched.

🤖 Generated with Claude Code

wagenet and others added 2 commits September 3, 2026 17:32
A WHIP Input slot is built once and stays in the running pipeline while
sessions come and go, and caps travel with every sample pushed into
appsrc_audio_<slot>. A second publisher on a free slot could therefore
hand a running chain a different channel count than the first. Consumers
past the slot's tee have already committed: the seat's recorder answers
the change with not-negotiated, which travels back up, stops the appsrc's
streaming thread and kills the seat's audio. Video keeps arriving, so
splitmuxsink blocks on the stopped track and tee backpressure stalls the
seat's other branches, the vision-mixer feed included.

Pin the format at the slot boundary with a capsfilter after
audioconvert/audioresample, so downstream sees one format for the life of
the flow and the change is absorbed on the slot's side of the tee.

The capsfilter does not name a rate. The sample rate belongs to the seat's
downstream graph rather than the slot -- consumers are shared, and the
audio mixer settles the whole graph on 44.1 kHz. A build-time rate makes
the caps query through audioconvert/audioresample intersect to nothing
whenever downstream chose a different one, and then decodebin's audio pad
cannot link at all and the seat gets no audio whatsoever. Instead the rate
is locked at runtime to the value downstream actually negotiated, on the
first caps event, which cannot conflict with downstream by construction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ot idle

A slot's decodebin may be built with its state locked so an idle slot cannot
hold the pipeline out of PLAYING, and claiming the slot is what releases it.
Without that step the chain under test sits in NULL and never sees a buffer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant