Skip to content

feat(studio): audio mixes, routing, meters, listening, and sync delay - #32

Merged
kixelated merged 14 commits into
mainfrom
quest/m0/mixes
Nov 4, 2026
Merged

kixelated merged 14 commits into
mainfrom
quest/m0/mixes

Conversation

@kixelated

@kixelated kixelated commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Audio mixes: the studio had one program mix with every source in it. A creator could not keep music off the stream while still hearing it, could not listen to a mix, and could not line a microphone up with a camera that lags it. #27 pinned moq-audio's multi-mix router (bus::Mix, routing, mix-minus, sync delay); the studio used one Mix of it.

Approach

  • Scene file (moq-scene Collection): [mixes] names every mix and the one on program (default: one mix, Program). [audio.<source>] gains exclude, the mixes the source is left out of (it feeds every other one, a mix added later included), and delay, at most moq-audio's bus::MAX_DELAY (2 s). New Change::Mixes, Draft::{set_mixes, rename_mix, remove_mix}; rename and remove carry every source's exclusions with them, and the mix on program cannot be removed. A pasted source keeps only exclusions from mixes the target has.
  • Routing (moq_scene::source::Sources): Sources owns one bus::Mix per mix name and syncs routing, gain, mute, and delay on every edit; Context::program is gone. A removed or edited source leaves every mix during the sync, not when its driver stops, so its replacement never plays over it.
  • Program audio (engine.rs, new mix.rs): the audio thread only advances the bus. A new audio-encode thread reads the mix on program through mix::Follow, which switches mixes on a block boundary (no drop, no repeat), and reanchors the Opus producer if its reader fell behind and skipped, so a slow encoder loses its own audio and never stalls the bus or shifts later audio early. Stopping drops the bus driver, so every output reads its mix to the end and the track finishes as before.
  • Listen (mix::Monitor): plays a mix to an output with moq-audio's playback, now pulled in by the capture feature. Listen on picks the output from playback::devices (or the system default); a new pick moves the running stream with playback::Engine::switch, which closes the old stream before opening the new. The pick is saved per user through eframe's persistence storage. Picking a mix marks the state stale, so the next frame's sync resolves it by name; a removed mix hands over to the one on program, and a renamed one is followed.
  • Audio panel: a row per mix (meter, On program, Listen, Rename, Remove, Add mix) and a grid per source: mute, gain, delay in ms, a tick per mix, and the source's meter.
  • Quests: mixes.md is done and deleted. What it left is split, recorded as quests: record-mix.md (a recording's own pick, which needs feat(studio)!: a two-size program that follows the bandwidth grant, and local source tracks #21's local broadcast to keep the track off the relay) and sync-clap.md (clap-to-measure, which needs a moq-audio onset or sample tap). guest-return.md no longer waits on mixes and names Mix::minus on the program's mix; broadcast-sync.md points at the delay.

Impact

  • Scene file: new [mixes] (names, program); [audio.<source>] gains exclude and delay. Additive: an existing file loads unchanged, with one mix, Program.
  • History file: a new target = "mixes" change.
  • Audio panel: mixes with meters, On program, Listen and the output it plays on, rename, remove, add; per-source delay and mix ticks.
  • Per-user state: eframe now keeps the Listen output, the window's size and place, and egui's memory in the platform's app-data directory.
  • The broadcast's audio is the mix on program; switching it is live.
  • Health reports a stalled microphone or system audio only when the mix on program plays it.
  • --features capture now also builds moq-audio playback (cpal, libasound on Linux, as capture already links). eframe gains persistence, which adds ron.
  • Public API (moq-scene): Collection::mixes, Mixes, Audio::{exclude, delay} (and Audio is no longer Copy), Change::Mixes, Draft::{set_mixes, rename_mix, remove_mix}, Sources::{mix, plays}; source::Context::program removed. No wire change: the catalog and tracks are as before.
  • README: Panels, a new Audio mixes section, the scene file, the capture feature, and Output.

Measurements

moq-audio's bus bench on the pinned moq (cargo bench -p moq-audio --bench bus, release, Apple Silicon, other agents loading the host), time per 10 ms block of 48 kHz stereo (a block's budget is 10,000 µs):

Sources / mixes 1 4 16 64
N sources, one mix of all (48 kHz) 2.1 µs 6.3 µs 23.7 µs 95.3 µs
N sources, one mix of all (44.1 kHz, resampled) 4.0 µs 14.3 µs 85.8 µs 404 µs
16 sources, N mixes, each of every source 25.1 µs 42.2 µs 83.0 µs 148 µs
16 sources, N mixes, one source each (sparse) 33.2 µs* 25.3 µs 34.6 µs 75.9 µs
N sources, a mix-minus per source 2.9 µs 9.7 µs 57.1 µs 230 µs

* noise; the 4-mix sparse case costs the same as the 16 sources alone. A sparse mix costs what is routed into it plus about 0.8 µs to publish its block, never a scan of every source. The worst case here, 64 resampled sources, is 4% of a block on one core.

The studio adds nothing per block that scales with sources: each output reads one mix, and routing changes only on an edit (Sources::sync, one route check per source and mix).

Alternatives

  • One Bus per mix in the studio: rejected by the maintainer's 2026-11-02 decision for moq-audio's router.
  • Per-source inclusion lists (mixes = [...]) instead of exclusions: two spellings of "every mix" and a new mix would start empty. Per-mix exclusion tables: removing a source would have to edit every mix.
  • Recording a non-program mix now, as a track in the program broadcast: that track would be fetchable from the relay, which is what leaving music off the stream is for. Split to record-mix.md instead.
  • Encoding on the audio thread, as before: one slow encode would delay advance and every other output.

Decisions

Settled by the maintainer (2026-11-04, relayed by the coordinator):

  1. ✅ Scene-file shape: keep [mixes] names = [...] / program = "..." plus [audio.<source>] exclude = [...] and delay.
  2. ✅ Scope split: a recording's own mix (record-mix.md, after feat(studio)!: a two-size program that follows the bandwidth grant, and local source tracks #21) and clap-to-measure (sync-clap.md, after a moq-audio tap) are their own quests.
  3. ✅ Listen output picker: added, on moq-audio's playback::devices listing (in the pinned moq, so no moq change). Where the pick is kept was left to the author: per user, through eframe's persistence storage, not the shared scene file.

Choices made without asking

  • The output pick is saved with eframe's persistence feature, which also brings eframe's own per-user state: the window's size and place and egui's memory are remembered across launches. Listen itself still starts off at each launch.

  • A saved output that is gone shows as "(not found)" in the picker, and listening on it fails with the device's error until another is picked.

  • The default mix is named Program, and a file without [mixes] gets it.

  • A source muted with Mute is silent in every mix; per-mix gain is not offered, since moq-audio's strip gain applies to every mix.

  • Listen falls back to the mix on program when its mix is removed.

  • The delay is edited in whole milliseconds.

Follow-ups

  • record-mix.md and sync-clap.md, created here.
  • Deferred moq work: sync-clap needs moq-audio to expose a strip's samples, or a detected onset, with its bus position (not filed; moq is untouched here).

Validation

Review

Reviewer: Codex, gpt-6.1-sol, medium effort (quest review --base origin/main). Round 9 reviewed head f6713a726ede45da7141a07c65f9624dde003719 and reported no findings.

(Written by Claude Opus 5.5)

🤖 Generated with Claude Code

kixelated and others added 4 commits November 4, 2026 03:08
Quest-Claim: c91694ccbe2b9904
A collection holds named mixes; each source with sound feeds every mix it is
not excluded from, with a delay. The program audio follows the mix on program,
switching on a block boundary, and encodes on its own thread so a slow encoder
skips only its own audio. Listen plays a mix to the default output.

Clap-to-measure and a recording's own mix pick are split into quests.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated kixelated changed the title Audio mixes feat(studio): audio mixes, routing, meters, listening, and sync delay Nov 4, 2026
kixelated and others added 2 commits November 4, 2026 06:02
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated
kixelated marked this pull request as ready for review November 4, 2026 14:10
@coderabbitai

coderabbitai Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 15 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: b1dd2c0b-14d6-46cd-9f00-07483203532f
Commits

Reviewing files that changed from the base of the PR and between a3946ed and bd5a052.

Files ignored due to path filters: 1
  • Cargo.lock is excluded by !**/*.lock
Files selected for processing: 19
  • README.md
  • quest/m0/README.md
  • quest/m0/broadcast-sync.md
  • quest/m0/guest-return.md
  • quest/m0/mixes.md
  • quest/m0/record-mix.md
  • quest/m0/sync-clap.md
  • rs/moq-scene/src/clip.rs
  • rs/moq-scene/src/collection.rs
  • rs/moq-scene/src/edit.rs
  • rs/moq-scene/src/lib.rs
  • rs/moq-scene/src/source.rs
  • rs/moq-studio/Cargo.toml
  • rs/moq-studio/src/engine.rs
  • rs/moq-studio/src/guests.rs
  • rs/moq-studio/src/main.rs
  • rs/moq-studio/src/mix.rs
  • rs/moq-studio/src/ui.rs
  • rs/moq-studio/tests/browser.rs
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@kixelated kixelated left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Automated review by review (OpenAI)

Reviewed head de8d47b against base dba63e5. No actionable correctness findings; the overall direction looks sound.

Checked mix validation and defaults, rename/remove/undo and paste exclusions, source routing and delay, independent audio encoding, block-boundary switching, monitor output changes, and saved output selection. The two fixes described in the PR are present: Follow skips already-returned positions, and an active monitor switches devices through the existing playback engine. Reanchoring after skipped bus blocks matches the pinned encoder's timestamp semantics.

Verification: GitHub-only static review of the changed code, tests, and pinned bus/playback/encoder APIs. I did not run tests or exercise real audio devices. The head's Check workflow was still in progress when inspected; live Listen/output-switch behavior remains unverified here.

(Written by review (OpenAI))

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ℹ️ No critical issues — one minor doc inconsistency inline.

Reviewed changes

  • Mix model and validation — Mixes and Audio gain exclude and delay, and Collection::validate now rejects empty or duplicate mix names, a program that is not a mix, excludes naming a missing mix, and delays past bus::MAX_DELAY.
  • Routing and lifecycle — Sources::sync creates and retires mixes, routes each strip per exclusion (including into a newly added mix), applies gain/mute/delay without restarting the source, and unroutes removed or edited strips at once; the old Driver::Drop unroute is gone.
  • Editing and undo — Change::Mixes plus set_mixes/rename_mix/remove_mix, with retarget_mix moving the name in place, the program pick, and every source's exclusion together, reversibly.
  • Clips — an Items clip validates against a synthetic collection covering its sources' excluded mixes, and paste keeps only the excludes the destination also has.
  • Mix playback — Pick/Follow switch mixes on block boundaries without dropping or repeating a block, and Monitor plays one to a chosen output behind the capture/moq-audio/playback feature.
  • Program encode — program_audio follows the mix on program while encode_audio runs on its own thread, reanchoring after skipped positions and finishing when the bus ends.
  • Audio panel — mix add/rename/remove, On program, Listen with a remembered output device, per-source gain/delay/routing with meters, all as undoable edits.
  • Docs — README documents mixes, [mixes], exclude, delay, and Listen; the completed mixes.md quest splits into record-mix.md and sync-clap.md.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using big-pickle (free) | 𝕏

Comment thread quest/m0/guest-return.md Resolved

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

  • Quest wording — quest/m0/guest-return.md drops the stale "so this waits on audio mixes" clause, so the 2026-11-02 decision note reads as history rather than a live dependency. This is the only change since the last review, and it resolves the prior inline nit; no code or behavior changed.

Pullfrog  | View workflow run | Using big-pickle (free) | 𝕏

@kixelated kixelated left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Automated review by review (OpenAI)

Reviewed head: 04ef1ae (base c9d4185)

  • [P2] Only grade sound stalls routed to the program mix (rs/moq-studio/src/engine.rs:729–735). The merged audio-stall check includes every unmuted strip, but this branch lets a source be excluded from the program while remaining audible in a listen-only mix. If that microphone or system-audio source stops delivering, Health marks the stream Unhealthy and claims the program plays silence for it even though it never reaches the program. Check that the strip is routed to the current program mix as well as unmuted, and cover excluded and on-program stalled sources in a regression test.

This is an integration issue between the new mixes and the merged audio-stall feature; the previously reviewed mix behavior is otherwise unchanged in this update.

Verification limits: GitHub-only static review of the merge, routing, and health paths; I did not run tests or exercise audio devices.

(Written by review (OpenAI))

…'s mix plays

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

Copy link
Copy Markdown
Contributor Author

Re the OpenAI review of 04ef1ae, [P2] "Only grade sound stalls routed to the program mix" (rs/moq-studio/src/engine.rs:729-735): fixed in 1adab8a. moq_scene::source::Sources::plays(mix, name) says whether a mix plays a source (it has sound, is not muted, and is routed into that mix), and Engine::local now reports a sound stall only when the mix on program plays the source. A source left out of the program's mix but heard in another, such as a listen-only mix, no longer marks the stream unhealthy. The new source::tests::a_mix_plays_only_what_is_routed_into_it covers a source on program, one excluded from the program mix but routed into another, a muted one, a silent one, and unknown names. That was the review's only finding. just ci and a local Codex round on 1adab8a both pass with no findings.

(Written by Claude Opus 5.5)

@kixelated kixelated left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Automated review by review (OpenAI)

Reviewed head 1adab8a against base c9d4185.

The prior program-health finding is addressed: the sound-stall check now requires the source's unmuted strip to be routed into the current program mix. The new test covers sources excluded from program, muted, or absent. No new actionable finding in this follow-up.

Verification limits: GitHub-only static review; I did not run tests or exercise audio devices. GitHub reported the PR as not mergeable when checked.

(Written by review (OpenAI))

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ℹ️ No critical issues — the new program-mix gate is sound; one README line still describes the pre-mix Health rule.

Reviewed changes

  • Sources::plays — added a predicate that reports whether a mix actually carries a source (has sound, unmuted, and routed into it), with a test covering the program mix, a second mix, exclusions, and mute.
  • Health sound gating — Engine::local now grades a Stall::Sound only when the program's mix plays the source, so a microphone excluded from program but heard in a listen-only mix no longer marks the stream Unhealthy.
  • Merge of main (#31) — kept the new stall detection and the mixes changes together across source.rs, engine.rs, health.rs, and ui.rs.

ℹ️ README Health still says any unmuted microphone is graded

README.md:554-555 still reads "a source stalled (a camera or broadcast the program draws, or an unmuted microphone or system audio)", but this commit now grades sound only when the program's mix plays it. A microphone left out of the program's mix but ticked into a listen-only mix is unmuted and correctly not graded, so the prose no longer matches. AGENTS.md asks a user-facing behavior change to update the README in the same PR, and the PR's Impact already states the new rule.

Technical details
# README Health prose lags the program-mix gate

## Affected sites
- `README.md:554-555` — "a source stalled (a camera or broadcast the program draws, or an unmuted microphone or system audio)" predates `mixes.program`.

## Required outcome
- Say sound is graded only when the program's mix plays it, matching `Engine::local`'s `Sources::plays(&state.collection.mixes.program, name)`.

## Note
- The Sources panel row is already correct: it reads `Feed::stalled` directly and is intentionally not gated on routing.

Pullfrog  | Fix it ➔ | View workflow run | Using big-pickle (free) | 𝕏

kixelated and others added 2 commits November 4, 2026 09:09
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@kixelated kixelated left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Automated review by review (OpenAI)

Reviewed head 73f0e8af53aa5f050a77bea93e605c813960866d against base 501fbb585c3873cf0da768327084ce71d39a178c. The reload integration now removes the old source's strip from every mix synchronously before the replacement is routed; the new test checks both mixes and the replacement. The earlier program-health routing fix remains intact. No new actionable finding in the follow-up.

Verification: GitHub-only static review of the merge, source lifecycle, regression test and pinned mixer API. I did not run tests or exercise a browser or audio device.

(Written by review (OpenAI))

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ℹ️ No new issues in the delta — the reload/routing fix is sound; one README line from the prior review still lags the program-mix Health rule.

Reviewed changes

  • Merge of main — brought #29's persistent browser profile and Reload and #34's HTML/CSS overlays into the branch. The README conflict kept both sides (browser-profile/overlay prose plus the mixes wording), the scene-file example carries the overlay source alongside [mixes], and the quest/m0 index/scene docs lost no links.
  • A restarted source leaves every mix — Sources::restart now calls the same leave helper that sync uses to unroute a stopped feed's strip from every mix, so a Reload no longer leaves a stale route behind. The a_mix_plays_only_what_is_routed_into_it test restarts a source and checks both mixes drop the old strip before the new one plays.

ℹ️ README Health still says any unmuted microphone is graded

README.md:605-606 still reads "an unmuted microphone or system audio", but Engine::local grades a sound stall only when the program's mix plays the source (Sources::plays). A microphone excluded from program but ticked into a listen-only mix is unmuted and correctly not graded, so the prose no longer matches. This was raised in the previous review and is untouched by these commits; AGENTS.md asks a user-facing behavior change to update the README in the same PR.

Technical details
# README Health prose lags the program-mix gate

## Affected sites
- `README.md:605-606` — "a source stalled (a camera or broadcast the program draws, or an unmuted microphone or system audio)" predates `mixes.program`.

## Required outcome
- Say sound is graded only when the program's mix plays it, matching `Engine::local`'s `Sources::plays(&state.collection.mixes.program, name)`.

## Note
- The Sources panel row is already correct: it reads `Feed::stalled` directly and is intentionally not gated on routing.

Pullfrog  | Fix it ➔ | View workflow run | Using big-pickle (free) | 𝕏

# Conflicts:
#	rs/moq-scene/src/lib.rs
#	rs/moq-studio/src/engine.rs
@kixelated

Copy link
Copy Markdown
Contributor Author

Re the pullfrog reviews of 1adab8a and 73f0e8a ("README Health still says any unmuted microphone is graded"): fixed in f6713a7. README.md's Health paragraph now says a source stalled if it is "a camera or broadcast the program draws, or a microphone or system audio the mix on program plays: unmuted and not left out of it", matching Engine::local's Sources::plays(&state.collection.mixes.program, name). As noted, the Sources panel row still reads Feed::stalled directly, unchanged. The same head merges main (#33, transitions); just ci and a local Codex round on f6713a7 pass with no findings.

(Written by Claude Opus 5.5)

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

  • Health README updated — README.md now states Health grades a stalled sound source only when the program's mix plays it (unmuted and not left out of it), matching Engine::local's Sources::plays gate. This resolves the README note from the previous review.
  • Merge of main — merged origin/main into the branch (conflicts already resolved in earlier commits). No new functional changes in this delta.

Pullfrog  | View workflow run | Using big-pickle (free) | 𝕏

@kixelated
kixelated merged commit acd4cd7 into main Nov 4, 2026
4 checks passed
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