diff --git a/README.md b/README.md
index d9811cbb..6e194684 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@ cargo run --release -p moq-studio --features capture,openh264 -- \
a second, so viewers and recordings on the relay see the broadcast end
rather than cut off.
- `capture` adds camera, display, window, microphone, and system-audio
- sources, and listening to a mix. On Linux it links ALSA; add `pipewire` for
+ sources, and listening to an audio scene. On Linux it links ALSA; add `pipewire` for
PipeWire cameras and the [virtual camera](#virtual-camera)'s PipeWire node.
`openh264` is the software H.264 fallback for a machine without a hardware
encoder.
@@ -97,7 +97,7 @@ Every panel is a tab you can drag, split, or stack.
| Panel | Use it to |
| --- | --- |
| Preview, Program | See the scene you are editing, at its own size, and the one that is live, and drag items in the preview to place them. A preview no tab shows is not drawn. **Cut** puts the preview on program at once; **Transition** takes it there by the transition picked beside it, over the time set there, or for a stinger by the media source and cut point set there (see [Transitions](#transitions)). **Interact** passes the mouse over the preview to the browser source under it instead, and the keyboard to the one last clicked. |
-| Scenes | Pick the scene in preview, and add, rename (double-click), reorder, remove, or tick **Publish** on one (see [Published scenes](#published-scenes)). A scene on program, or still leaving it in a transition, cannot be removed, even by an undo, until program cuts away from it. |
+| Scenes | Pick the scene in preview, and add, rename (double-click), reorder, remove, or tick **Publish** on one and pick the audio scene it carries (see [Published scenes](#published-scenes)). A scene on program, or still leaving it in a transition, cannot be removed, even by an undo, until program cuts away from it. Add, rename, reorder, and remove audio scenes too (see [Audio scenes](#audio-scenes)). |
| History | See every edit, and undo or redo back to any of them. |
| Items | Place, show, hide, and order what the preview scene or the top layer draws, back to front. The top layer is edited over a scene of the program's size. Select an item to filter or copy it. |
| Sources | Add a source by hand, see whether each and its own track are live and where it is shown, clone one, and remove one. Select a source to filter it. A camera or broadcast without a new picture, or a microphone or system audio without new sound, for a second reads as stalled. A browser source shows whether its page is copied on the GPU or, as a fallback, uploaded from memory (CPU, saying why on hover), in place of a track, and **Reload** restarts its page. A media file reads as ended once it plays to its end without looping, **Play** plays it from its start, **Stop** stops it at once, blank and silent, until played again, and **Reload** reads the file again. |
@@ -105,8 +105,8 @@ Every panel is a tab you can drag, split, or stack.
| Guests | Invite a guest, show their join link as a QR code, see who joined, and take a guest out of every scene. |
| Filters | Remove the selected source's background, crop the selected source or item, add, order, and set its filters, set an item's blend mode, and copy or paste filters. |
| Keyframes | Animate the selected item: add, time, ease, and remove its keyframes, and set how far each moves, resizes, crops, and filters it (see [Keyframes](#keyframes)). **Play** plays the preview scene's keyframes again. |
-| Audio | Add, rename (double-click), and remove mixes, pick the one on program and the one to listen to and on which output, and set each audio source's gain, mute, delay, and the mixes it feeds, with a meter on every mix and source. |
-| Output | Start and stop recording, pick the picture and the mix it records, set the canvas size and frame rate and each scene's size, see the broadcast path, whether the program is encoding, the encoder sessions open, and the encoder work of the program, the recording, and each published scene, and turn the virtual camera on. |
+| Audio | Pick the audio scene on program and the one to listen to and on which output, set each sound's gain, mute, and delay, and send each sound and audio scene into audio scenes, with a meter on every one. |
+| Output | Start and stop recording, pick the picture and the audio scene it records, set the canvas size and frame rate and each scene's size, see the broadcast path, whether the program is encoding, the encoder sessions open, and the encoder work of the program, the recording, and each published scene, and turn the virtual camera on. |
| Health | See how the stream reaches viewers, relay by relay and, for the program and each published scene, track by track, and the cause of each problem, and which recordings a crash left unfinished were repaired at launch. |
| Replay | Replay the last moments of the program or a source in a replay source, slowed down if you like, or save them as a clip. |
| Hotkeys | Bind keys to cut, transition, preview a scene, replay, save a clip, and start or stop recording, and clear them (see [Hotkeys](#hotkeys)). |
@@ -132,9 +132,10 @@ published scene is always live and never switched: it shows what its items
show, whatever is on program. For a portrait stream that follows the program,
publish a portrait scene drawing the program through a program source (see
[Scene sources](#scene-sources)), with any overlay of its own placed beside
-it; the top layer goes over the program alone. Its catalog lists the
-program's audio rather than encoding it again, so every published scene
-carries the program's mix for now. Unticking it, or removing the scene, ends
+it; the top layer goes over the program alone. It carries the audio scene
+on program, or the one picked beside its **Publish** box, encoded as audio of
+its own, so picking another never changes the track its viewers play.
+Unticking it, or removing the scene, ends
its broadcast. A published scene's name is one segment of its broadcast path,
so it holds no `/`, and a `\` only before another `\`. A scene named `viewers`, where viewers report back,
does not publish while live.
@@ -270,38 +271,50 @@ after a stall skips ahead to the delay. Without audio, the
picture plays 100 ms behind. Sources shows how far behind each broadcast
plays.
-### Audio mixes
-
-Audio from every source is mixed whether or not its scene is live. A
-collection has one or more **mixes**, each the sum of the sources it is fed:
-for example, a stream mix that leaves out music you may not broadcast, and a
-monitor mix for your headphones. A source feeds every mix, a new one included,
-until you untick that mix in its row of the Audio panel; mute a source to take
-it out of every mix. **On program** picks the mix the broadcast carries, and
-switching it, or editing a mix, never drops or repeats audio. A recording
-holds the mix on program unless the Output panel picks another, such as one
-keeping music the stream leaves out; that mix never reaches the relay, and
-switching it while recording never drops or repeats audio either. The mix on
-program cannot be removed; removing the one a recording picks returns the
-recording to the mix on program.
-
-**Listen** plays a mix to an audio output, from the `capture` feature's
-build: the system default, or the one picked under **Listen on**, which the
-studio remembers for you rather than in the scene file. Use headphones: a
-microphone that hears the speakers feeds the mix back into itself.
-
-A source's **delay** holds it back in every mix, up to 2 s, so a microphone
+### Audio scenes
+
+Audio from every source is mixed whether or not its scene is live, and cuts
+never change it. A collection has one or more **audio scenes**, each the sum
+of the sounds it holds and of the audio scenes it holds: for example a "Mics"
+audio scene holding every microphone, which both a "Stream" audio scene (Mics,
+without music you may not broadcast) and a "Headphones" one (Mics and the
+music) hold. A sound an audio scene reaches more than one way plays in it
+once, and an audio scene never holds itself, even through others. Add,
+rename, reorder, and remove audio scenes in the Scenes panel, where they are
+tagged `audio` and listed after the scenes.
+
+The Audio panel is a mixer. Each sound has a strip, with its gain, mute,
+delay, and meter, which apply in every audio scene holding it, and a send to
+each audio scene; each audio scene has a strip with its meter and a send to
+each other audio scene. A source added from the Sources or Devices panel, or
+pasted, is sent to the audio scene on program, so it is heard at once.
+**On program** picks the audio scene the broadcast carries, and switching
+it, or editing an audio scene, never drops or repeats audio. A recording holds
+the audio scene on program unless the Output panel picks another, such as one
+keeping music the stream leaves out, and each published scene carries the one
+on program unless the Scenes panel picks another beside its **Publish** box;
+switching either while it runs never drops or repeats audio either. The audio
+scene on program cannot be removed; removing one a recording or published
+scene picks returns it to the one on program.
+
+**Listen** plays an audio scene to an audio output, from the `capture`
+feature's build: the system default, or the one picked under **Listen on**,
+which the studio remembers for you rather than in the scene file. Use
+headphones: a microphone that hears the speakers feeds its audio scene back
+into itself.
+
+A source's **delay** holds it back in every audio scene, up to 2 s, so a microphone
lines up with a camera that lags it. A broadcast's picture already follows its
sound, so its delay holds the sound back from its own picture: use it only for
a publisher whose sound leads their picture.
-Each mix is advanced on the program clock, and each output reads its mix on
-its own, so an output that falls behind skips ahead in its own audio rather
-than holding up the mixes or another output.
+Each audio scene is mixed on the program clock, and each output reads its
+audio scene on its own, so an output that falls behind skips ahead in its own
+audio rather than holding up the mixing or another output.
A browser source renders a web page, such as an overlay, alert box, or chat
widget, at the canvas frame rate (CEF caps it at 60) over a transparent
-background, and its sound joins the mixes. Every page shares one
+background, and its sound joins the audio scenes that hold it. Every page shares one
browser profile, kept in `$XDG_DATA_HOME/moq-studio/browser`
(`~/.local/share/moq-studio/browser` by default) rather than beside the scene
file, so cookies and logins, such as an alert service's, survive a reload and
@@ -376,7 +389,7 @@ program.
### Media files
A `media` source plays a video or audio file, such as an intro clip or a music
-bed, on the program clock, and its sound joins the mixes like any other
+bed, on the program clock, and its sound joins the audio scenes like any other
source's. It reads fragmented MP4, MKV or WebM, MPEG-TS, and FLV, as moq's
importers do, and fails to start on a codec this machine cannot decode. A
regular MP4 fails to start with a remux to fragmented MP4 that makes it readable:
@@ -398,7 +411,8 @@ loop = true # start over at the end
```
Without `loop`, it plays once, then holds its last picture, silent, and reads
-as ended in Sources and, while the program shows or plays it, in Health.
+as ended in Sources and, while the program or a published scene shows or
+plays it, in Health.
**Play** in Sources plays it from its start again, as does going on program
with `restart`, in a scene on air or the top layer, when it was not on program
before. Either clears its picture at once, so the end of the last play never
@@ -409,7 +423,7 @@ The source a [stinger](#transitions) plays waits, blank and silent, for the
first stinger instead of playing as it opens. Its pictures and its sound each have a track of their own (see [Source
tracks](#source-tracks)), its pictures on one timeline across plays. Each is
used apart, as a camera's and a microphone's are: the pictures while a scene on
-air shows it, the sound while the mix on program plays it, and either while
+air shows it, the sound while the audio scene on program plays it, and either while
read. So a music bed that no scene shows encodes its sound alone. A file with
no sound has no strip in the Audio panel.
@@ -478,8 +492,9 @@ and read only their own renewals, their return feed, and the program.
Treat the link like a password: anyone holding it can join as that guest
before it expires, and is then renewed for as long as they stay, until you
remove them. A guest's broadcasts become sources named after them (`alice`,
-`alice screen`) when they first appear, muted and in no scene: place them from
-the Items panel and unmute them in Audio to put them on air. That includes a
+`alice screen`) when they first appear, in no scene or audio scene: place
+them from the Items panel and send them to the audio scene on program in Audio
+to put them on air. That includes a
guest invited before a restart whose link still works. A guest's source is
saved to the scene file but is not your edit: it stays out of History, so undo
never removes it and a guest joining never loses what you could redo. A guest
@@ -497,7 +512,7 @@ renewal; **New link** lets them back in and renews them again, under a new
generation, so a link from before the removal cannot read their renewals.
While a guest is in the room, the join page plays their return feed: the
-program's video, and the mix on program without that guest's microphone (a
+program's video, and the audio scene on program without that guest's microphone (a
mix-minus), so they follow the show without hearing themselves back. Ask
guests to wear headphones, so the show does not reach their microphone. The
studio publishes it over its session in the room, never with its own
@@ -567,13 +582,13 @@ html = '''
Ada Lovelace
'''
-[mixes]
-names = ["Stream", "Headphones"]
-program = "Stream" # the mix the broadcast carries
+[sources.music]
+kind = "media"
+path = "music.webm"
+loop = true
[audio.guest]
gain = 0.5
-exclude = ["Headphones"] # feeds every other mix
delay = "80ms"
[sources.camera-faded]
@@ -632,6 +647,21 @@ items = [
{ source = "logo", rect = { x = 780, y = 40, width = 280, height = 120 } },
]
+[[audio_scenes]]
+name = "Voices"
+sounds = ["guest"]
+
+[[audio_scenes]]
+name = "Stream"
+program = true # the audio scene the broadcast carries
+scenes = ["Voices"]
+
+[[audio_scenes]]
+name = "Headphones"
+recorded = true # recordings hold it rather than the one on program
+scenes = ["Voices"]
+sounds = ["music"]
+
[[top]]
source = "logo"
rect = { x = 1600, y = 40, width = 280, height = 120 }
@@ -654,11 +684,16 @@ the program's. `published = true` puts it live as its own broadcast (see
`recorded = true` has recordings hold it rather than the program, on one scene
at most.
-`[mixes]` names every mix, in the order the Audio panel lists them, the one
-on `program`, and the one a `recording` holds, the mix on program without it;
-without `[mixes]`, the collection has one mix, `Program`.
-`[audio.]` sets a source's linear `gain`, `muted`, the mixes it is
-left out of (`exclude`), and its `delay`.
+A scene's `audio` names the audio scene it carries while published, the
+one on program without it.
+
+`[[audio_scenes]]` lists the audio scenes, in the order the Scenes panel lists
+them, each with its `name`, the sources whose `sounds` it holds, and the other
+audio `scenes` it holds. Exactly one is on `program = true`, and
+`recorded = true` has recordings hold one rather than the one on program.
+Without `[[audio_scenes]]`, the collection has one audio scene, `Program`, on
+program and holding nothing. `[audio.]` sets a source's linear `gain`,
+`muted`, and `delay`, in every audio scene holding it.
`[video.]` sets a source's `background`, `crop`, and `filters`,
wherever it is shown, and an item's `crop`, `filters`, and `blend` its own.
@@ -733,11 +768,11 @@ before sharing.
## Output
The program publishes as two H.264 renditions, at the canvas size and at half
-of it, at the canvas frame rate, with the mix on program as Opus stereo audio,
+of it, at the canvas frame rate, with the audio scene on program as Opus stereo audio,
under the `--broadcast` path. A canvas under 360 lines has no half size. Each
published scene publishes the same way at `<--broadcast>/`, at
its own size and half of it, its tracks sharing the connection's bandwidth
-with the program's.
+with the program's, and its own encode of the audio scene it carries.
Timestamps come from the studio's own clock, so a source that stalls repeats
its last picture instead of holding up the stream.
@@ -796,10 +831,10 @@ removes it too. A browser source or an overlay has no track of its own, and a
broadcast source is already its publisher's.
A microphone, system audio, or media file's sound encodes its track as Opus,
-from the same sound the mixes hear, only while it is used: while the mix on program plays it
-(unmuted and not left out of it), so a replay of it has its recent past, or
+from the same sound the audio scenes hear, only while it is used: while the audio scene on program plays it
+(unmuted and held by it), so a replay of it has its recent past, or
while a recording reads its track. The track carries the sound as captured,
-before its gain, mute, and delay, which stay the mixes'. A buffer the capture
+before its gain, mute, and delay, which stay the audio scenes'. A buffer the capture
lost, or a stretch the source was out of use, is a gap on its track. Opus
encodes on the CPU, about 1% of one Apple M4 core for a stereo source and
half that for mono, and holds no encode session.
@@ -860,8 +895,8 @@ early, and a track that goes on into the next file, such as the program
audio, repeats up to 2 s of the last. The
program and local sources are read from the studio's own broadcasts, never what
the relay serves, so a recording starts and stops without touching the live
-stream. The mix it holds is
-encoded a second time for it while recording: the mix on program, so the file
+stream. The audio scene it holds is
+encoded a second time for it while recording: the one on program, so the file
holds what viewers hear, or the one the Output panel picks, which viewers never
can. Without `--connect`, only the
recording's encoder encodes the program.
@@ -965,8 +1000,8 @@ it is written, shortly after the click.
Nothing records in the background for this. The program's tracks keep their
last 30 seconds for late viewers anyway, and each source's track while it is on
-program (a picture shown in a scene on air, or a sound played by the mix on
-program) or recorded, and a replay copies from those, so a replay
+program (a picture shown in a scene on air, or a sound played by the audio
+scene on program) or recorded, and a replay copies from those, so a replay
goes back at most that far. A replay of the program needs `--connect`; a local
source keeps its track without a relay. With `--on-demand`, the program has
no past from before someone watched it, and a source has none from before it
@@ -984,14 +1019,16 @@ The Health panel grades the stream every second on the thresholds moq's
`hang::health` shares with the rest of the stack, and lists each problem
with its cause in plain words: a relay is unreachable, the uplink is
congested or losing packets, the relay is far away, the encoder is behind or
-stuck, a source stalled (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), a source the program shows or plays failed or, as a media file
-that does not loop, played to its end, a browser source the program shows is
-uploaded from memory rather than copied on the GPU, or viewers are late,
-stalling, or cannot decode a track. A relay lost while the other serves is
+stuck, a source stalled (a camera or broadcast the program or a published
+scene draws, or a microphone or system audio that the audio scene on program
+or one a published scene carries plays: unmuted and held by it), a source
+one of them shows or plays failed or, as a media file that does not loop,
+played to its end, a browser source one of them shows is uploaded from memory
+rather than copied on the GPU, or viewers are late, stalling, or cannot decode
+a track. A relay lost while the other serves is
degraded, since viewers have nothing left to fall back to; with none serving
-it is unhealthy. A problem with a published scene's broadcast names the scene.
+it is unhealthy. A problem with a published scene's broadcast, or a source
+only it shows or plays, names the scene.
Below that it shows:
- **Relays**: each relay's state, and the protocol its session negotiated.
diff --git a/quest/m0/README.md b/quest/m0/README.md
index 79eed49d..a14677d3 100644
--- a/quest/m0/README.md
+++ b/quest/m0/README.md
@@ -94,6 +94,7 @@ repository.
- [Wayland display and window sources](/quest/m0/wayland-sources.md) - portal-picked sources that reopen without the picker
- [Destinations](/quest/m0/destinations.md) - RTMP, RTMPS, and WHIP push with per-destination codecs and shared encoders, on a UI-free egress library a hosted service can reuse
- [Composable scenes](/quest/m0/scenes/README.md) - scenes nest scenes and the program, any scene publishes at its own size, and mixes become audio scenes
+- [Pan each sound](/quest/m0/audio-pan.md) - the Audio panel and `[audio.]` pan each sound wherever it plays
- [Clap-to-measure sync delay](/quest/m0/sync-clap.md) - a clap sets a microphone's delay against its camera
- [Audio filters](/quest/m0/audio-filters.md) - per-source noise suppression, gate, compressor, limiter, and plugins, configured from the studio
- [Browser sources on macOS and Windows](/quest/m0/browser-source.md) - the out-of-process CEF pages Linux has, with shared GPU textures on the other desktops
@@ -111,6 +112,7 @@ repository.
- [moq-video's browser backends are pinned](/quest/m0/pin-browser-video.md) - the web studio's video slice and browser composition
- [moq-audio's browser backends are pinned](/quest/m0/pin-browser-audio.md) - the web studio's audio slice
- [moq-audio's source tap is pinned](/quest/m0/pin-audio-tap.md) - the samples clap-to-measure sync times a clap from
+- [moq-audio's strip pan is pinned](/quest/m0/pin-audio-pan.md) - the bus strip pan the studio's pan sets
## Related
diff --git a/quest/m0/audio-pan.md b/quest/m0/audio-pan.md
new file mode 100644
index 00000000..1d648475
--- /dev/null
+++ b/quest/m0/audio-pan.md
@@ -0,0 +1,29 @@
+# [S] Pan each sound
+
+## Goal
+
+Each sound's strip in the Audio panel has a pan beside its fader, saved as
+`pan` in `[audio.]` and applied wherever the sound plays, in every
+audio scene holding it, as gain, mute, and delay are. `README.md` documents
+it.
+
+## Plan
+
+Decided 2026-11-07 with the maintainer, on
+[moq-dev/studio#77](https://github.com/moq-dev/studio/pull/77), which landed
+the rest of audio scenes:
+
+- ✅ Pan is a property of the source's bus strip, like its gain, set in
+ moq-audio; the studio only sets it and saves it. Rejected: a pan per audio
+ scene a sound is sent to.
+- ✅ Settled moq-side in
+ [Pan a bus source](https://github.com/moq-dev/moq/blob/main/quest/m3/audio-strip-pan.md):
+ a constant-power law, -3 dB at center; a stereo source is balanced, the
+ far side attenuated and each channel kept where it is; a bus that is not
+ stereo refuses a pan other than center.
+- Changing pan never drops or repeats audio, ramped across a block as gain
+ is.
+
+## Required
+
+- [moq-audio's strip pan is pinned](/quest/m0/pin-audio-pan.md) - the `Strip` pan the studio sets
diff --git a/quest/m0/pin-audio-pan.md b/quest/m0/pin-audio-pan.md
new file mode 100644
index 00000000..ea948fa6
--- /dev/null
+++ b/quest/m0/pin-audio-pan.md
@@ -0,0 +1,20 @@
+# [XS] moq-audio's strip pan is pinned
+
+## Goal
+
+The `moq` submodule includes a pan on moq-audio's bus `Strip`, from
+[Pan a bus source](https://github.com/moq-dev/moq/blob/main/quest/m3/audio-strip-pan.md).
+
+## Plan
+
+Once the wait clears, advance it with `just upgrade-moq` in its own PR,
+fixing whatever the new API breaks here.
+
+## Waiting
+
+- moq-dev/moq: the upstream quest above lands. Check: that quest's PR has
+ merged on moq's `main`, deleting its file, and `moq_audio::bus::Strip`
+ there has a pan. A quest file that moved, or went away without its work
+ landing, does not count. The quest itself is planned in
+ [moq-dev/moq#8133](https://github.com/moq-dev/moq/pull/8133), not merged
+ yet.
diff --git a/quest/m0/scenes/README.md b/quest/m0/scenes/README.md
index 69487c52..3fba3224 100644
--- a/quest/m0/scenes/README.md
+++ b/quest/m0/scenes/README.md
@@ -1,4 +1,4 @@
-# Composable scenes
+# [M] Composable scenes
## Goal
@@ -49,6 +49,7 @@ picks a picture scene and an audio scene; a scene no panel shows and no output
reads skips drawing. Per-scene cost grows only with what that scene draws;
`compositor::bench` measures it.
-## Required
-
-- [Audio scenes](/quest/m0/scenes/audio.md) - mixes become audio-only scenes that nest
+Changed 2026-11-07 with the maintainer, on
+[moq-dev/studio#77](https://github.com/moq-dev/studio/pull/77): pan left
+this epic for [its own quest](/quest/m0/audio-pan.md), since it waits on
+moq-audio, so it no longer holds up this end-to-end test.
diff --git a/quest/m0/scenes/audio.md b/quest/m0/scenes/audio.md
deleted file mode 100644
index 4120aac4..00000000
--- a/quest/m0/scenes/audio.md
+++ /dev/null
@@ -1,25 +0,0 @@
-# [M] Audio scenes
-
-## Goal
-
-The audio mixes become audio-only scenes. An audio scene holds sounds
-(sources' audio, with gain, pan, mute, and delay) and other audio scenes, so a
-"Mics" scene can feed both "Stream" and "Headphones". **On program** picks the
-audio scene the broadcast carries; each published scene and each recording
-picks one. Audio stays independent of cuts.
-
-## Plan
-
-Decided 2026-11-05 with the maintainer (/quest-plan); see the
-[epic](/quest/m0/scenes/README.md).
-
-- Replaces `[mixes]` and `[audio.]` in the scene file with no
- migration; nothing is released.
-- An audio scene cannot contain itself, directly or through others.
-- The Audio panel stays a mixer: a strip per sound with fader, pan, meter,
- and a send to each audio scene. Listen and On program work as today.
-- The guest return feed (a mix-minus) and the sync delay keep working; a
- guest hears the audio scene on program without their own sound.
-- Switching or editing an audio scene never drops or repeats audio, as with
- mixes today.
-- `README.md` documents audio scenes.
diff --git a/quest/m2/publish-independently.md b/quest/m2/publish-independently.md
index 1dff0be9..fe9ea201 100644
--- a/quest/m2/publish-independently.md
+++ b/quest/m2/publish-independently.md
@@ -18,7 +18,7 @@ rendition, as every source track does, while the program keeps its
two sizes. It counts as used, so it encodes for as long as it is published
(decided 2026-11-04, maintainer): a source encodes while it is on program
(shown in a scene on air, or, for a microphone or system audio, played by the
-mix on program) or while something reads its track, and naming a published
+audio scene on program) or while something reads its track, and naming a published
source among those on program (`Publish::set_on_program` in
`rs/moq-scene/src/publish.rs`) keeps it encoding.
diff --git a/rs/moq-scene/src/clip.rs b/rs/moq-scene/src/clip.rs
index 9e3f96cd..c5c4be16 100644
--- a/rs/moq-scene/src/clip.rs
+++ b/rs/moq-scene/src/clip.rs
@@ -7,9 +7,7 @@ use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::Error;
-use crate::collection::{
- Audio, Collection, Filter, Item, Layer, Mixes, Scene, Source, Video, validate_filters, versioned,
-};
+use crate::collection::{Audio, Collection, Filter, Item, Layer, Scene, Source, Video, validate_filters, versioned};
use crate::edit::Draft;
/// What a copy holds: items, or a filter chain.
@@ -101,13 +99,9 @@ impl Clip {
let clip = Self::deserialize(table).map_err(|err| Error::Format(err.to_string()))?;
match &clip {
// The rules a collection holding just the clip would break, such as
- // an item showing a source the clip lacks. It has whatever mixes the
- // clip's sources are left out of, and an empty scene for each its
- // scene sources draw, which pasting checks.
+ // an item showing a source the clip lacks. It has an empty scene for
+ // each its scene sources draw, which pasting checks.
Self::Items(items) => {
- let excluded = items.audio.values().flat_map(|audio| &audio.exclude);
- let mut names: BTreeSet = excluded.cloned().collect();
- names.insert(Mixes::DEFAULT.into());
let drawn: BTreeSet<&str> = items
.sources
.values()
@@ -124,11 +118,6 @@ impl Clip {
let drawn = drawn.into_iter().map(Scene::new);
Collection {
sources: items.sources.clone(),
- mixes: Mixes {
- names: names.into_iter().collect(),
- program: Mixes::DEFAULT.into(),
- recording: None,
- },
audio: items.audio.clone(),
video: items.video.clone(),
// In a scene, where keyframes play; pasting into the top
@@ -172,8 +161,8 @@ impl Draft {
/// one shown, so items pasted within a collection are linked to the ones
/// copied. A source whose name is taken by a different one is added under
/// a new name, which clones and filters naming it follow, and an added
- /// source brings its audio and video settings, left out of only the mixes
- /// both collections have. An image or LUT under `dir`
+ /// source brings its audio and video settings, its sound joining the
+ /// audio scene on program. An image or LUT under `dir`
/// is named relative to it, as the scene file would.
pub fn paste(&mut self, layer: &Layer, items: &Items, dir: &Path) -> Result<(), Error> {
let mut names = BTreeMap::new();
@@ -225,11 +214,10 @@ impl Draft {
};
for (name, renamed) in added {
if let Some(audio) = items.audio.get(name) {
- // Left out only of the mixes this collection has too.
- let mut audio = audio.clone();
- let mixes = &self.collection().mixes.names;
- audio.exclude.retain(|mix| mixes.contains(mix));
- self.set_audio(&renamed, Some(audio))?;
+ self.set_audio(&renamed, Some(audio.clone()))?;
+ }
+ if items.sources[name].has_audio() {
+ self.hear(&renamed)?;
}
if let Some(video) = items.video.get(name) {
let filters = rename(&video.filters);
@@ -391,12 +379,12 @@ mod tests {
assert_eq!(pasted.scenes[0].items, source.scenes[0].items);
}
- /// A pasted source keeps its sound's settings, left out of only the mixes
- /// the collection it lands in has.
+ /// A pasted source keeps its sound's settings, and is heard on program
+ /// wherever it lands.
#[test]
- fn pastes_audio_into_the_mixes_there() {
+ fn pastes_audio_into_the_audio_scene_on_program() {
let mut source = Collection::new();
- source.mixes.names.push("Stream".into());
+ source.audio_scenes[0].name = "Stream".into();
source.sources.insert(
"guest".into(),
Source::Broadcast {
@@ -405,7 +393,6 @@ mod tests {
);
let audio = crate::Audio {
gain: 0.5,
- exclude: ["Program".to_string(), "Stream".to_string()].into(),
..Default::default()
};
source.audio.insert("guest".into(), audio.clone());
@@ -418,11 +405,8 @@ mod tests {
.unwrap();
let pasted = paste(&Collection::new(), &Layer::top(), &text, DIR);
- let expected = crate::Audio {
- exclude: ["Program".to_string()].into(),
- ..audio
- };
- assert_eq!(pasted.audio["guest"], expected);
+ assert_eq!(pasted.audio["guest"], audio);
+ assert_eq!(pasted.heard(crate::AudioScene::DEFAULT), ["guest"].into());
}
/// A relative image path names the same file wherever the clip is pasted,
diff --git a/rs/moq-scene/src/collection.rs b/rs/moq-scene/src/collection.rs
index e20a8063..62e37d6c 100644
--- a/rs/moq-scene/src/collection.rs
+++ b/rs/moq-scene/src/collection.rs
@@ -31,11 +31,9 @@ pub struct Collection {
/// so one camera can appear in many scenes while being opened once.
#[serde(default)]
pub sources: BTreeMap,
- /// The audio mixes, and which ones the program and recordings carry.
- #[serde(default, skip_serializing_if = "Mixes::is_default")]
- pub mixes: Mixes,
- /// Gain, mute, routing, and sync delay of each audio-carrying source, by
- /// source name. A source without an entry plays at unity in every mix.
+ /// The gain, mute, and sync delay of each source with sound, by source
+ /// name, in every audio scene that holds it. A source without an entry
+ /// plays at unity.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub audio: BTreeMap,
/// The filters on each picture-carrying source, by source name, applied
@@ -45,6 +43,11 @@ pub struct Collection {
/// The scenes, in the order the scene list shows them.
#[serde(default)]
pub scenes: Vec,
+ /// The audio scenes, in the order the scene list shows them after the
+ /// scenes: one on program, and any others for recordings, published
+ /// scenes, headphones, or to nest. Independent of cuts.
+ #[serde(default = "AudioScene::defaults", skip_serializing_if = "AudioScene::are_default")]
+ pub audio_scenes: Vec,
/// The top layer: items drawn over whichever scene is on program, and in
/// preview over one of the program's size, back to front, so they stay on
/// across scene switches (a downstream keyer). They do not fade with a
@@ -66,10 +69,10 @@ impl Collection {
canvas: Canvas::default(),
transition: Transition::default(),
sources: BTreeMap::new(),
- mixes: Mixes::default(),
audio: BTreeMap::new(),
video: BTreeMap::new(),
scenes: vec![Scene::new("Scene")],
+ audio_scenes: AudioScene::defaults(),
top: Vec::new(),
}
}
@@ -139,6 +142,67 @@ impl Collection {
recorded.map(|scene| scene.name.as_str())
}
+ /// The audio scene called `name`.
+ pub fn audio_scene(&self, name: &str) -> Option<&AudioScene> {
+ self.audio_scenes.iter().find(|scene| scene.name == name)
+ }
+
+ /// The audio scene the program broadcast carries. Only an invalid
+ /// collection, such as one an edit is part way through, has none.
+ pub fn program_audio(&self) -> Option<&str> {
+ let program = self.audio_scenes.iter().find(|scene| scene.program);
+ program.map(|scene| scene.name.as_str())
+ }
+
+ /// The audio scene recordings hold: the one picked for them, or the one
+ /// on program.
+ pub fn recorded_audio(&self) -> Option<&str> {
+ let recorded = self.audio_scenes.iter().find(|scene| scene.recorded);
+ recorded
+ .map(|scene| scene.name.as_str())
+ .or_else(|| self.program_audio())
+ }
+
+ /// The audio scene the scene called `scene` carries while published: the
+ /// one it picks, or the one on program.
+ pub fn published_audio(&self, scene: &str) -> Option<&str> {
+ let picked = self.scene(scene).and_then(|scene| scene.audio.as_deref());
+ picked.or_else(|| self.program_audio())
+ }
+
+ /// The sources whose sound the audio scene called `scene` plays: those it
+ /// holds, and those of the audio scenes it holds, and so on, each once.
+ pub fn heard(&self, scene: &str) -> BTreeSet<&str> {
+ let mut heard = BTreeSet::new();
+ for scene in self.reached_audio(scene) {
+ heard.extend(scene.sounds.iter().map(String::as_str));
+ }
+ heard
+ }
+
+ /// Whether the audio scene called `scene` holds the one called `nested`,
+ /// directly or through others.
+ pub fn nests(&self, scene: &str, nested: &str) -> bool {
+ let mut reached = self.reached_audio(scene).into_iter();
+ reached.any(|scene| scene.scenes.contains(nested))
+ }
+
+ /// The audio scene called `scene` and every one it holds, directly or
+ /// through others, each once.
+ fn reached_audio(&self, scene: &str) -> Vec<&AudioScene> {
+ let mut scenes = vec![scene];
+ let mut seen = BTreeSet::new();
+ let mut reached = Vec::new();
+ while let Some(scene) = scenes.pop() {
+ let Some(scene) = self.audio_scene(scene).filter(|scene| seen.insert(&scene.name)) else {
+ continue;
+ };
+ scenes.extend(scene.scenes.iter().map(String::as_str));
+ reached.push(scene);
+ }
+ reached
+ }
+
/// Whether the scene called `scene` can go on program: it is the
/// program's size, and draws no program source, which it would draw into
/// itself. Refused with the reason otherwise, or for a scene that does
@@ -440,7 +504,7 @@ impl Collection {
)));
}
- self.mixes.validate()?;
+ self.validate_audio_scenes()?;
for (name, audio) in &self.audio {
if !self.sources.contains_key(name) {
return Err(Error::Invalid(format!(
@@ -450,11 +514,6 @@ impl Collection {
if !audio.gain.is_finite() || audio.gain < 0.0 {
return Err(Error::Invalid(format!("`{name}` has gain {}", audio.gain)));
}
- if let Some(mix) = audio.exclude.iter().find(|mix| !self.mixes.names.contains(mix)) {
- return Err(Error::Invalid(format!(
- "`{name}` is left out of `{mix}`, which is not a mix"
- )));
- }
if audio.delay > moq_audio::bus::MAX_DELAY {
return Err(Error::Invalid(format!(
"`{name}` is held back {:?}, past the longest, {:?}",
@@ -467,6 +526,68 @@ impl Collection {
Ok(())
}
+ /// Check the audio scenes: each named once, one on program and one at
+ /// most recorded, each holding sources with sound and other audio scenes
+ /// but never itself, and each scene's pick naming one.
+ fn validate_audio_scenes(&self) -> Result<(), Error> {
+ let mut names = BTreeSet::new();
+ for scene in &self.audio_scenes {
+ if scene.name.is_empty() {
+ return Err(Error::Invalid("an audio scene has an empty name".into()));
+ }
+ if !names.insert(scene.name.as_str()) {
+ return Err(Error::Invalid(format!("two audio scenes are named `{}`", scene.name)));
+ }
+ }
+ let flagged = |flag: fn(&AudioScene) -> bool| -> Vec {
+ let scenes = self.audio_scenes.iter().filter(|scene| flag(scene));
+ scenes.map(|scene| format!("`{}`", scene.name)).collect()
+ };
+ let program = flagged(|scene| scene.program);
+ if program.len() != 1 {
+ let found = match program.is_empty() {
+ true => "none".to_string(),
+ false => program.join(" and "),
+ };
+ return Err(Error::Invalid(format!("one audio scene is on program, not {found}")));
+ }
+ let recorded = flagged(|scene| scene.recorded);
+ if recorded.len() > 1 {
+ return Err(Error::Invalid(format!(
+ "audio scenes {} are recorded, but recordings hold one",
+ recorded.join(" and ")
+ )));
+ }
+ for scene in &self.audio_scenes {
+ let on = &scene.name;
+ let silent = |sound: &&String| !self.sources.get(*sound).is_some_and(Source::has_audio);
+ if let Some(sound) = scene.sounds.iter().find(silent) {
+ return Err(Error::Invalid(format!(
+ "audio scene `{on}` holds `{sound}`, which is not a source with sound"
+ )));
+ }
+ if let Some(nested) = scene.scenes.iter().find(|nested| !names.contains(nested.as_str())) {
+ return Err(Error::Invalid(format!(
+ "audio scene `{on}` holds `{nested}`, which is not an audio scene"
+ )));
+ }
+ if self.nests(on, on) {
+ return Err(Error::Invalid(format!("audio scene `{on}` holds itself")));
+ }
+ }
+ for scene in &self.scenes {
+ if let Some(audio) = &scene.audio
+ && !names.contains(audio.as_str())
+ {
+ return Err(Error::Invalid(format!(
+ "scene `{}` carries `{audio}`, which is not an audio scene",
+ scene.name
+ )));
+ }
+ }
+ Ok(())
+ }
+
/// Check `filters` are in range and name sources of the kind they read.
fn validate_chain(&self, filters: &[Filter], on: &str) -> Result<(), Error> {
validate_filters(filters, on)?;
@@ -754,19 +875,25 @@ pub struct Scene {
/// is recorded.
#[serde(default, skip_serializing_if = "is_false")]
pub recorded: bool,
+ /// The audio scene its broadcast carries while published, or `None` for
+ /// the one on program.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub audio: Option,
/// What the scene draws, back to front.
#[serde(default)]
pub items: Vec,
}
impl Scene {
- /// An empty scene of the program's size, neither published nor recorded.
+ /// An empty scene of the program's size, neither published nor recorded,
+ /// carrying the audio scene on program.
pub fn new(name: impl Into) -> Self {
Self {
name: name.into(),
size: None,
published: false,
recorded: false,
+ audio: None,
items: Vec::new(),
}
}
@@ -1717,84 +1844,70 @@ impl Source {
}
}
-/// The audio mixes every source with sound feeds, and which ones the program
-/// and recordings carry.
+/// An audio-only scene: the sum of the sounds it holds, as their sources
+/// play them, and of the audio scenes it holds. A sound it reaches more than
+/// one way plays in it once.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
-pub struct Mixes {
- /// Each mix's unique name, in the order the studio lists them.
- pub names: Vec,
- /// The mix the program broadcast carries.
- pub program: String,
- /// The mix recordings hold, when not the one on program. It never
- /// reaches a relay.
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub recording: Option,
+pub struct AudioScene {
+ /// Unique among the collection's audio scenes.
+ pub name: String,
+ /// Whether the program broadcast carries it, as exactly one does.
+ #[serde(default, skip_serializing_if = "is_false")]
+ pub program: bool,
+ /// Whether recordings hold it rather than the one on program. One at
+ /// most is recorded.
+ #[serde(default, skip_serializing_if = "is_false")]
+ pub recorded: bool,
+ /// The sources whose sound it holds, by name.
+ #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
+ pub sounds: BTreeSet,
+ /// The other audio scenes it holds, by name, never itself, even through
+ /// others.
+ #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
+ pub scenes: BTreeSet,
}
-impl Mixes {
- /// The one mix a new collection has, which the program carries.
+impl AudioScene {
+ /// The audio scene a new collection has on program.
pub const DEFAULT: &str = "Program";
- fn is_default(&self) -> bool {
- *self == Self::default()
- }
-
- /// The mix recordings hold.
- pub fn recorded(&self) -> &str {
- self.recording.as_deref().unwrap_or(&self.program)
+ /// An audio scene holding nothing, neither on program nor recorded.
+ pub fn new(name: impl Into) -> Self {
+ Self {
+ name: name.into(),
+ program: false,
+ recorded: false,
+ sounds: BTreeSet::new(),
+ scenes: BTreeSet::new(),
+ }
}
- fn validate(&self) -> Result<(), Error> {
- let mut names = BTreeSet::new();
- for name in &self.names {
- if name.is_empty() {
- return Err(Error::Invalid("a mix has an empty name".into()));
- }
- if !names.insert(name.as_str()) {
- return Err(Error::Invalid(format!("two mixes are named `{name}`")));
- }
- }
- if !names.contains(self.program.as_str()) {
- return Err(Error::Invalid(format!(
- "the program carries `{}`, which is not a mix",
- self.program
- )));
- }
- if !names.contains(self.recorded()) {
- return Err(Error::Invalid(format!(
- "recordings hold `{}`, which is not a mix",
- self.recorded()
- )));
- }
- Ok(())
+ /// What a collection has without audio scenes of its own: one on
+ /// program, holding nothing.
+ pub fn defaults() -> Vec {
+ vec![Self {
+ program: true,
+ ..Self::new(Self::DEFAULT)
+ }]
}
-}
-impl Default for Mixes {
- fn default() -> Self {
- Self {
- names: vec![Self::DEFAULT.into()],
- program: Self::DEFAULT.into(),
- recording: None,
- }
+ fn are_default(scenes: &Vec) -> bool {
+ *scenes == Self::defaults()
}
}
-/// The gain, mute, routing, and sync delay of one source with sound.
+/// The gain, mute, and sync delay of one source with sound, in every audio
+/// scene that holds it.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Audio {
/// Linear gain: 1.0 is unity.
pub gain: f32,
- /// Whether the source is silenced in every mix, keeping its gain.
+ /// Whether the source is silenced in every audio scene, keeping its gain.
#[serde(default)]
pub muted: bool,
- /// The mixes the source is left out of. It feeds every other one,
- /// including a mix added later.
- #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
- pub exclude: BTreeSet,
- /// How long the source is held back in every mix, so a microphone lines up
+ /// How long the source is held back in every audio scene, so a microphone lines up
/// with a camera that lags it. At most the bus's
/// [`MAX_DELAY`](moq_audio::bus::MAX_DELAY).
#[serde(default, with = "humantime_serde", skip_serializing_if = "Duration::is_zero")]
@@ -1806,7 +1919,6 @@ impl Default for Audio {
Self {
gain: 1.0,
muted: false,
- exclude: BTreeSet::new(),
delay: Duration::ZERO,
}
}
@@ -1904,17 +2016,24 @@ mod tests {
},
);
}
- collection.mixes = Mixes {
- names: vec!["Stream".into(), "Recording".into()],
- program: "Stream".into(),
- recording: Some("Recording".into()),
- };
+ collection.audio_scenes = vec![
+ AudioScene {
+ program: true,
+ sounds: ["mic".to_string()].into(),
+ ..AudioScene::new("Stream")
+ },
+ AudioScene {
+ recorded: true,
+ sounds: ["intro".to_string()].into(),
+ scenes: ["Stream".to_string()].into(),
+ ..AudioScene::new("Recording")
+ },
+ ];
collection.audio.insert(
"mic".into(),
Audio {
gain: 0.5,
muted: true,
- exclude: ["Recording".to_string()].into(),
delay: Duration::from_millis(80),
},
);
@@ -2001,14 +2120,8 @@ kind = "camera"
kind = "color"
color = "#ff000080"
-[mixes]
-names = ["Stream", "Recording"]
-program = "Stream"
-recording = "Recording"
-
[audio.cam2]
gain = 2.0
-exclude = ["Stream"]
delay = "120ms"
[sources.cam2]
@@ -2048,8 +2161,23 @@ name = "Portrait"
size = { width = 720, height = 1280 }
published = true
recorded = true
+audio = "Recording"
items = [{ source = "cam", rect = { x = 0, y = 0, width = 720, height = 1280 } }]
+[[audio_scenes]]
+name = "Guests"
+sounds = ["cam2"]
+
+[[audio_scenes]]
+name = "Stream"
+program = true
+scenes = ["Guests"]
+
+[[audio_scenes]]
+name = "Recording"
+recorded = true
+scenes = ["Stream", "Guests"]
+
[[top]]
source = "bg"
rect = { x = 0, y = 680, width = 1280, height = 40 }
@@ -2075,12 +2203,15 @@ rect = { x = 0, y = 680, width = 1280, height = 40 }
Audio {
gain: 2.0,
muted: false,
- exclude: ["Stream".to_string()].into(),
delay: Duration::from_millis(120),
}
);
- assert_eq!(collection.mixes.program, "Stream");
- assert_eq!(collection.mixes.recorded(), "Recording");
+ assert_eq!(collection.program_audio(), Some("Stream"));
+ assert_eq!(collection.recorded_audio(), Some("Recording"));
+ assert_eq!(collection.published_audio("Portrait"), Some("Recording"));
+ assert_eq!(collection.published_audio("Main"), Some("Stream"));
+ // Reached two ways, played once.
+ assert_eq!(collection.heard("Recording"), ["cam2"].into());
let portrait = collection.scene("Portrait").unwrap();
assert!(portrait.published);
assert_eq!(collection.recorded(), Some("Portrait"));
@@ -2217,23 +2348,49 @@ path = "sting.webm"
assert!(matches!(collection.validate(), Err(Error::Invalid(_))));
}
- /// The program carries one of the mixes, each named once, and a source is
- /// left out of mixes that exist, held back no longer than the bus can.
+ /// One audio scene is on program and one at most recorded, each named
+ /// once, holding sources with sound and audio scenes that exist, never
+ /// itself; and a source is held back no longer than the bus can.
#[test]
- fn refuses_a_mix_that_does_not_add_up() {
+ fn refuses_audio_scenes_that_do_not_add_up() {
let refused = |edit: fn(&mut Collection)| {
let mut collection = example();
edit(&mut collection);
matches!(collection.validate(), Err(Error::Invalid(_)))
};
- assert!(refused(|collection| collection.mixes.program = "Monitor".into()));
- assert!(refused(|collection| collection.mixes.recording = Some("Monitor".into())));
- assert!(refused(|collection| collection.mixes.names.push("Stream".into())));
- assert!(refused(|collection| collection.mixes.names.push(String::new())));
+ fn scene<'a>(collection: &'a mut Collection, name: &str) -> &'a mut AudioScene {
+ let mut scenes = collection.audio_scenes.iter_mut();
+ scenes.find(|scene| scene.name == name).unwrap()
+ }
+ assert!(!refused(|_| {}));
+ assert!(refused(|collection| scene(collection, "Stream").program = false));
+ assert!(refused(|collection| scene(collection, "Recording").program = true));
+ assert!(refused(|collection| scene(collection, "Stream").recorded = true));
+ assert!(refused(|collection| collection
+ .audio_scenes
+ .push(AudioScene::new("Stream"))));
+ assert!(refused(|collection| collection.audio_scenes.push(AudioScene::new(""))));
assert!(refused(|collection| {
- let mic = collection.audio.get_mut("mic").unwrap();
- mic.exclude.insert("Monitor".into());
+ scene(collection, "Stream").sounds.insert("nobody".into());
+ }));
+ // A picture without sound.
+ assert!(refused(|collection| {
+ scene(collection, "Stream").sounds.insert("camera".into());
+ }));
+ assert!(refused(|collection| {
+ scene(collection, "Stream").scenes.insert("Monitor".into());
+ }));
+ assert!(refused(|collection| {
+ scene(collection, "Stream").scenes.insert("Stream".into());
+ }));
+ // Through `Recording`, which holds `Stream`.
+ assert!(refused(|collection| {
+ scene(collection, "Stream").scenes.insert("Recording".into());
}));
+ assert!(refused(|collection| collection.scenes[0].audio = Some("Monitor".into())));
+ assert!(!refused(
+ |collection| collection.scenes[0].audio = Some("Recording".into())
+ ));
assert!(refused(|collection| {
let mic = collection.audio.get_mut("mic").unwrap();
mic.delay = moq_audio::bus::MAX_DELAY + Duration::from_millis(1);
diff --git a/rs/moq-scene/src/compositor.rs b/rs/moq-scene/src/compositor.rs
index e6b33a2a..88f33402 100644
--- a/rs/moq-scene/src/compositor.rs
+++ b/rs/moq-scene/src/compositor.rs
@@ -2630,6 +2630,7 @@ mod tests {
restart: false,
};
collection.sources.insert("sting".into(), media);
+ collection.audio_scenes[0].sounds.insert("sting".into());
collection.transition = Transition::Stinger {
source: "sting".into(),
cut: Duration::from_millis(300),
@@ -2785,7 +2786,7 @@ mod tests {
async fn a_stinger_stops_its_clip() {
local(async {
let mut studio = stinging(crate::media::tests::CLIP).await;
- let program = studio.sources.mix(crate::Mixes::DEFAULT).unwrap().clone();
+ let program = studio.sources.mix(crate::AudioScene::DEFAULT).unwrap().clone();
let mut at = Instant::now();
studio.mixer.advance(480);
let transition = studio.collection.transition.clone();
diff --git a/rs/moq-scene/src/edit.rs b/rs/moq-scene/src/edit.rs
index 3fa41b78..333224bb 100644
--- a/rs/moq-scene/src/edit.rs
+++ b/rs/moq-scene/src/edit.rs
@@ -14,7 +14,7 @@ use std::collections::{BTreeMap, VecDeque};
use serde::{Deserialize, Serialize};
use crate::Error;
-use crate::collection::{Audio, Canvas, Collection, Filter, Item, Layer, Mixes, Scene, Source, Transition, Video};
+use crate::collection::{Audio, AudioScene, Canvas, Collection, Filter, Item, Layer, Scene, Source, Transition, Video};
/// One part of a collection, before and after an edit.
///
@@ -70,8 +70,11 @@ pub enum Change {
Transition { before: Transition, after: Transition },
/// [`Collection::canvas`].
Canvas { before: Canvas, after: Canvas },
- /// [`Collection::mixes`].
- Mixes { before: Mixes, after: Mixes },
+ /// [`Collection::audio_scenes`], whole.
+ AudioScenes {
+ before: Vec,
+ after: Vec,
+ },
}
impl Change {
@@ -117,7 +120,7 @@ impl Change {
before: after,
after: before,
},
- Self::Mixes { before, after } => Self::Mixes {
+ Self::AudioScenes { before, after } => Self::AudioScenes {
before: after,
after: before,
},
@@ -132,7 +135,7 @@ impl Change {
| Self::Item { .. }
| Self::Transition { .. }
| Self::Canvas { .. }
- | Self::Mixes { .. } => None,
+ | Self::AudioScenes { .. } => None,
}
}
@@ -146,7 +149,7 @@ impl Change {
Self::Item { before, after, .. } => before == after,
Self::Transition { before, after } => before == after,
Self::Canvas { before, after } => before == after,
- Self::Mixes { before, after } => before == after,
+ Self::AudioScenes { before, after } => before == after,
}
}
@@ -235,10 +238,12 @@ impl Change {
(Self::Canvas { before, after }, Self::Canvas { before: b, after: a }) if after == *b => {
Self::Canvas { before, after: *a }
}
- (Self::Mixes { before, after }, Self::Mixes { before: b, after: a }) if after == *b => Self::Mixes {
- before,
- after: a.clone(),
- },
+ (Self::AudioScenes { before, after }, Self::AudioScenes { before: b, after: a }) if after == *b => {
+ Self::AudioScenes {
+ before,
+ after: a.clone(),
+ }
+ }
_ => return None,
};
Some(merged)
@@ -278,11 +283,11 @@ impl Change {
collection.canvas = *after;
Ok(())
}
- Self::Mixes { before, after } => {
- if collection.mixes != *before {
- return Err(conflict("the mixes changed".into()));
+ Self::AudioScenes { before, after } => {
+ if collection.audio_scenes != *before {
+ return Err(conflict("the audio scenes changed".into()));
}
- collection.mixes = after.clone();
+ collection.audio_scenes = after.clone();
Ok(())
}
}
@@ -528,8 +533,7 @@ impl Draft {
})
}
- /// Set or clear the gain, mute, routing, and delay of the source called
- /// `name`.
+ /// Set or clear the gain, mute, and delay of the source called `name`.
pub fn set_audio(&mut self, name: &str, audio: Option