Skip to content

feat(returns): build the low-latency fast return feed - #386

Draft
wagenet wants to merge 2 commits into
Eyevinn:mainfrom
wagenet:wagenet/fast-return-feed
Draft

wagenet wants to merge 2 commits into
Eyevinn:mainfrom
wagenet:wagenet/fast-return-feed

Conversation

@wagenet

@wagenet wagenet commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Opened by Claude (AI) on Peter Wagenet's behalf.

Depends on Eyevinn/strom#844 (audio bridge blocks) and Eyevinn/strom#930 (mixer direct outputs). Do not merge until both are merged and deployed. The program flow uses both, so on a Strom without them a production with a lowLatency guest fails to start. Productions without one are unaffected.

What

returnFeed.lowLatency: true now builds the fast feed the guest-calling spec describes under "Low-latency mode": an audio-only mix-minus that reaches the guest about 500 ms before the picture feed's audio.

  • Program flow: the audio mixer is built with direct_outs: true, and each channel's direct_out_N feeds a builtin.audio_bridge_output. A direct out carries what the channel sends to Main: after the fader, the mute and audio-follow-video (ch{N}_to_main), before the Main sum. Crew changes therefore reach the fast feed inside Strom, with the same fades as program.

  • Conversation flow (a second Strom flow): one builtin.audio_bridge_input per channel feeds a builtin.liveaudiorouter. The router is a fixed mix-minus set at activation: each guest's output sums every channel except their own. Each output goes to an audio-only builtin.whep_output (whep-fast-<N>-<suffix>). Router settings are 15/10/5 ms, the values measured with five live seats. The bridge target is Strom's default (40 ms), or values.fast_return_latency_ms if set.

  • Limit: Strom's router takes at most 8 inputs. A production with more than 8 audio channels, or with an audio channel that has no source, gets no fast feed and a warning; its guests keep the picture feed's audio.

  • API: returnFeed.lowLatency: true is accepted on POST /productions/:id/sources (main accepts only false). The fast feed is served wherever the picture feed is, through the same proxy helpers:

    • crew: POST/DELETE /api/v1/productions/:id/returns/:mixerInput/fast/whep
    • guest: POST/DELETE /api/v1/guests/:inviteId/returns/fast/whep, the alias the OSC ingress gate lets through

    A guest's fast session is bound to its own fastWhepSessionId, so neither feed's session id tears down the other. Join, the crew return view and GET /guests/:inviteId/session/return list the fast feed (video: false) and the low-latency-minus mode only when the guest has one. Join hands out the guest-alias URL. low-latency-minus is still refused by the mode PUTs: it is a feed choice for the client, not a mix.

  • Lifecycle: the conversation flow is tagged conv:<programFlowId> and is removed with the program flow. Activation and startup also remove any conversation flow whose program flow is gone.

Why a second flow, and why direct outs

Strom runs every sink in a flow at one latency. On a TRL-shaped production that is 360 ms, set by the mixer's monitor bus (Eyevinn/strom#920 cuts it). It sits on top of the 400 ms WHIP jitterbuffer offset. A return inside the program flow cannot be released earlier than that. The bridge restamps each buffer on arrival, so neither cost reaches the conversation flow.

Tapping each source before the mixer would need every crew change copied into the router's gains, and that copy cannot be kept correct from outside the mixer: writes to the mixer and to the router can be answered out of order or refused separately, and reading the mixer back returns live element values, which are 0 for a muted fader and stale for a stalled source. Fed from the direct outs, the router never changes after activation.

A direct out is not an aggregator output. It taps the channel strip before the Main sum, so one guest's bad link is not passed to the other guests through the mixer.

A running program flow is never edited

Per strom#930, a direct-out consumer that ends (EOS) or is torn down (flushing) while program runs silently stops that channel on program. open-live builds every audio_bridge_output into the program flow at activation and never edits a running flow: flows.update is unused, and the program flow is only stopped and deleted whole. The bridge sink is unsynchronised and returns OK whether or not anyone reads, so stopping the conversation flow does not reach program.

Measured

Rig: a fresh Strom per trial (Strom main with #844, plus #930), activated through this branch's activateStromFlow. Five WHIP seats (Opus + H.264), loopback. Delay is from tone ticks into seats 1–4 to the same ticks out of seat 0's feeds, all on one clock, through a GStreamer WHEP consumer. Over 4 trials at load average 6–8, the fast return was 188 ms (trial means 175–200 ms) and the picture return 688–703 ms. Worst dropout was 0.17 %.

Crew changes, with open-live not running: a mute on one channel, to_main off on another, and a fader at 0.1 on a third, each sent straight to Strom's mixer for 3 s. Each took that seat's ticks in seat 0's fast feed from 0.40–0.55 down to 0.02–0.08, and each came back when undone. Seat 0's own voice stayed at the noise floor throughout. Two runs, same result.

One bad link: one seat had 0–200 ms jitter, 0.4–1.2 s stalls and 14 % burst loss. A tone from another seat lost 0.14–0.17 % in seat 0's fast feed, with no change in delay. Three alternated control runs lost 0.13–0.21 %. Leave and rejoin: a guest left, then published again on the same slot 4 s later. They were back in the fast feed and on program by the next tick. Two runs, and the other seats were undisturbed.

These runs predate the rebase onto current main. The fast-feed flow code is unchanged since; the program flow around it now has main's shared viewer encode (#413) and compact vision-mixer pads (#463). The fast feed is keyed by audio channel, and its endpoint ids use the stored video_in_N, as the picture feed's do, so #463's pad map does not reach it.

Not in this PR

  • Clients. feat(guests): play the fast return feed on the guest page #393 (stacked on this) plays the fast feed on the guest page. open-live-studio does not play it yet. A client must keep the fast feed and the picture on separate PeerConnections, or Chrome will lip-sync the fast audio back to the picture.
  • The fast feed is not synced to the picture by design.
  • A conversation flow that stops on its own while the program runs is not detected; its feeds stay advertised until the production stops.
  • A channel routed to a group but not to Main reaches program through the group, but is silent on its direct out and so in the fast feed (strom#930, "Where the tap sits").
  • The fast feed carries the whole channel strip, so pan and a fader above unity reach it, and the router sums with no limiter (feat(liveaudiorouter): give an output bus headroom and a ceiling strom#795).
  • fastFeedRouter is stored on the production but nothing reads it yet.

How to review

Two commits, each builds and passes on its own:

  1. feat(returns): build the low-latency fast return feed: src/lib/fast-returns.ts (the plan) and src/lib/flow-generator.ts (wiring it into activation and teardown), plus storage in productions.ts and the orphan sweep in reconcile.ts. The API still accepts only lowLatency: false here.
  2. feat(returns): serve the fast return feed and accept lowLatency: true: src/routes/returns.ts turns main's picture-only proxy helpers and routes into ones that take a feed; guests.ts adds the fast feed to join; server.ts and guest-scope.ts widen the guest-token path patterns from picture to picture|fast.

Tests

  • flow-generator-returns.test.ts: the mixer gets direct_outs: true only when fast returns are built; each bridge links from direct_out_<ch+1> and nothing else; the router matrix is the fixed mix-minus; no fast feed past 8 channels; the program keeps running if the conversation flow fails; teardown removes only this program's conversation flow.
  • returns-guest-auth.test.ts: crew and guest-alias fast routes, separate session binding for the two feeds, a 409 retry on the binding, and the feed and mode listings.
  • activation.test.ts, production-lifecycle.test.ts, guest-slot-declaration.test.ts: storage, the startup sweep and lowLatency: true on the sources route. The activation test's controller mock now stubs reinitConnectedControllers, which main's activation calls once the flow plays.
  • pnpm typecheck and pnpm test pass on each commit: 1056 tests after the first, 1065 after the second (105 files).

Not run since the rebase: the Strom rig, a real remote guest, a browser.

🤖 Generated with Claude Code

@birme

birme commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

This PR looks like the base of a multi-PR dependency stack for the low-latency fast return feed, together with #393 (play the fast return feed on the guest page, which depends on this PR). Holding off on automated review/merge here — this needs a human (or the relevant dev agent) to assess the whole stack together, not a per-PR pass. Nothing further is needed from the author; this comment exists so the dependency is recorded on the PR rather than in a run log.

wagenet added a commit to wagenet/open-live that referenced this pull request Oct 1, 2026
wagenet added a commit to wagenet/open-live that referenced this pull request Oct 1, 2026
Conflicts from 6ad0d3f (fast feed follows a mixer read) resolved to Eyevinn#386's
side: Eyevinn#386's direct-outs refactor replaced that approach and removed
fast-feed-state.
wagenet added a commit to wagenet/open-live that referenced this pull request Oct 1, 2026
The fixture repeated makeActivationResult's field list. A branch that adds
a field to makeActivationResult (as Eyevinn#386 does) then merged into whichever
copy git matched, and left the other without it. Spreading the base keeps
one list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@wagenet
wagenet force-pushed the wagenet/fast-return-feed branch from bc1a39f to 2618709 Compare October 3, 2026 00:24
wagenet added a commit to wagenet/open-live that referenced this pull request Oct 6, 2026
The fixture repeated makeActivationResult's field list. A branch that adds
a field to makeActivationResult (as Eyevinn#386 does) then merged into whichever
copy git matched, and left the other without it. Spreading the base keeps
one list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
wagenet added a commit to wagenet/open-live that referenced this pull request Oct 7, 2026
The fixture repeated makeActivationResult's field list. A branch that adds
a field to makeActivationResult (as Eyevinn#386 does) then merged into whichever
copy git matched, and left the other without it. Spreading the base keeps
one list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
wagenet added a commit to wagenet/open-live that referenced this pull request Oct 7, 2026
The fixture repeated makeActivationResult's field list. A branch that adds
a field to makeActivationResult (as Eyevinn#386 does) then merged into whichever
copy git matched, and left the other without it. Spreading the base keeps
one list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
wagenet and others added 2 commits October 7, 2026 07:01
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>
A guest slot can now be declared with `returnFeed.lowLatency: true`, and
its fast feed is served like the picture feed.

- `POST/DELETE /api/v1/productions/:id/returns/:mixerInput/fast/whep`
  (crew, and guest token via the existing guest scope) and
  `/api/v1/guests/:inviteId/returns/fast/whep` (guest alias, reachable
  through the OSC ingress gate) proxy the fast feed through the same
  helpers as the picture feed.
- A guest's fast-feed WHEP session is bound to its own
  `fastWhepSessionId`, so neither feed's session id tears down the other.
  The binding re-reads the guest session and retries a 409, since a
  client opens both feeds at once.
- Join, the crew return view and `GET .../session/return` list the
  `fast` feed (`video: false`) and the `low-latency-minus` mode when the
  guest has a fast feed. Join hands out the guest-alias URL.
  `PUT .../session/return` still 400s `low-latency-minus`: it is a
  client-side feed choice, not a mix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@wagenet
wagenet force-pushed the wagenet/fast-return-feed branch from 2618709 to 3b92db6 Compare October 7, 2026 14:03
@wagenet

wagenet commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

Claude (Claude Code, for @wagenet): rebased onto main (af7bca1) to clear the conflicts. All resolutions are unions with main's changes: mixerInputMap (#463/#475) sits next to fastWhepEntries/fastFeedRouter on the activation result, the join handler keeps main's tile label (#466) ahead of the fast feed URLs, and the guest-token path pattern keeps slot and accepts returns/(picture|fast)/whep. #463's compact pad map does not reach the fast feed: it routes by audio channel, and its endpoint ids use the stored video_in_N, as the picture feed's do. pnpm typecheck and pnpm test pass on both commits (1056 and 1065 tests). The rig was not run.

wagenet added a commit to wagenet/open-live that referenced this pull request Oct 9, 2026
@birme

birme commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

This PR is part of the low-latency return-feed feature on open-live. Holding off on automated review/merge — needs a human (or the relevant dev agent) to assess together.

Members:

Nothing further is needed from the author; this records the dependency on the PRs themselves.

birme added a commit that referenced this pull request Oct 17, 2026
* fix(recording): link PGM video to the recorder's video_in_0 pad

Strom's builtin.recorder numbers its inputs from zero (video_in_0,
audio_in_0). The generator linked PGM to `video_in`, which the recorder
does not have. Strom drops that link as stale when the flow is created,
so the recorder was built with its video track unconnected.

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

* fix(recording): set the recorder's filename_prefix, not prefix

The recorder's property is `filename_prefix`. Strom ignores the unknown
`prefix`, so segments were named recording_00001.mp4 instead of
<production id>_00001.mp4. Output dir and upload key already carry the
production id, so nothing else changes.

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

* fix(recording): AAC-encode the main mix before the recorder

The recorder refuses raw audio ("Recorder needs AAC, MP3, AC-3, DTS or
Opus audio, but its audio input is raw"), and the audio mixer's main_out
is raw, so the recording had no audio track. Route main_out through a
builtin.audioenc (AAC) into the recorder's audio_in_0.

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

* docs(recording): match the recorder's filename template in comments

Strom writes {filename_prefix}_{timestamp}_%05d.{ext}. Update the three
comments that describe the segment filename.

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

* fix(recording): record picture only when Strom has no audio encoder

builtin.audioenc first shipped in Strom 0.6.9. An older Strom refuses to
start any flow that references it, so a production with a recording
output could not go on air. Activation now checks Strom's block list
and leaves the encoder out when the block is missing. The recording then
has picture but no sound, and activation saves a warning on the
production (activationWarnings) and logs it. If the block list can't be
read, activation keeps the encoder.

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

* feat(recording): show activation warnings as a toast in the controller

Studio shows WS ERROR frames as a toast, so activation warnings (such as
a recording with no sound) now go out that way: broadcast to connected
controllers when the production activates, and sent again to each
controller that connects while the production is live.

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

* fix(recording): clear activation warnings when activation fails

A production that never went on air was left inactive with the warning
from the failed attempt still set.

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

* fix(recording): send activation warnings once the production is live

The warning toast is broadcast when the production goes active, and
not at all if activation was aborted. The connect-time frame needs
status 'active' as well as a stromFlowId, so an activating production
or one left with a stale flow id sends nothing. controller-websocket.md
documents the ERROR frames that carry activation warnings.

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

* test(recording): build the warning fixture from makeActivationResult

The fixture repeated makeActivationResult's field list. A branch that adds
a field to makeActivationResult (as #386 does) then merged into whichever
copy git matched, and left the other without it. Spreading the base keeps
one list.

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

* test(recording): update main's tests for warnings and video_in_0

activation-failure-relay-stop's makeActivationResult now returns
warnings: [], which runActivationFlow reads. flow-generator-encoders
looks for the recorder feed on video_in_0. activation.test's
makeActivationResult keeps main's mixerInputMap.

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

* test(strom-public-url): add warnings to the activation mock

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Jonas Birmé <jonas.birme@eyevinn.se>
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