Audio
Place sound on the timeline declaratively, preview it against the frame clock, and mix it into the export.
Audio is declarative like a scene. <Audio> registers a cue rather than
starting playback, so the player, the manifest, and the encoder all read the
same track.
import {Audio, Scene, Video, defineVideoMetadata} from "odori";
export default function LaunchVideo() {
return (
<Video>
{/* One bed under the whole cut, ducked so scene sounds stay audible. */}
<Audio src="score" gain={0.75} fadeIn="1s" fadeOut="1.5s" duckUnder />
<Scene id="opening" duration="4s">
<TitleReveal title="Author the story." />
</Scene>
<Scene id="resolution" duration="3s">
<Audio src="confirm" from="0.2s" duration="1s" gain={0.6} />
<EndCard title="Available today." />
</Scene>
</Video>
);
}
Where audio lives
Files live under public/, because that is the one directory the dev server
and the render worker both serve, so a URL means the same thing in Studio, in a
still, and in an export. audioDir names the library Studio lists, and
defaults to public/audio.
A cue takes either a path or a reference:
export default defineConfig({
audioDir: "public/audio",
assets: [{reference: "score", url: "/audio/product-cinematic.m4a"}],
});
<Audio src="score" /> // declared once, swapped in one place
<Audio src="/audio/product-cinematic.m4a" /> // still valid
A reference resolves through the same registry useAssets() reads, so a cue
and a component name the same file the same way. An undeclared reference throws
instead of playing silence. A file found outside public/ is reported during
export, because it would hash into the manifest but never be served.
Naming sounds in the brand
A brand can name cues symbolically, so a video says what a sound means and the brand decides which file that is:
export const productBrand = defineBrand({
audio: {
cues: {"bed.main": "score", "ui.confirm": "confirm"},
targetLufs: -14,
},
});
<Audio src="bed.main" duckUnder />
Re-scoring a library is then one brand edit rather than a change in every
video.tsx. A source resolves in three steps: a path is used as written, a
name the brand knows becomes whatever the brand points at, and anything else is
an asset reference.
Placement
A cue declared inside a scene is offset by that scene’s start, so moving a
scene moves its sound with it. A cue declared at the top level of <Video>
starts at frame zero and runs for the whole composition unless you give it a
duration.
| Prop | Meaning |
|---|---|
src |
A path under public/, or a reference declared in odori.config.ts |
from |
Offset from the enclosing scene, or from the video at the top level |
duration |
Cue length. Defaults to the enclosing scene, or the video |
gain |
Linear gain. 1 is unchanged |
fadeIn / fadeOut |
Fade lengths, applied identically in preview and export |
trimStart |
Seconds skipped at the head of the source file |
loop |
Repeat the source to fill the cue |
duckUnder |
Attenuate while another cue plays over it |
Collection without seeking
Scenes only mount while they are on screen, so Odori runs a hidden collection
pass that mounts every scene at once and gathers its cues. That is how
odori inspect can print the whole track, and how the encoder knows about a
sound in the last scene without rendering the first one.
odori inspect launch
Audio
/audio/product-cinematic.m4a 0 to 359 gain 0.75 (ducked)
sha256-LDDbqQD4gCPt/iV+/hz5y42SCfX1NuKnH8d/Emv1/vM=
/audio/ui-confirm.m4a 276 to 305 gain 0.6
Preview
Studio plays the track against the frame clock. Dragging the playhead auditions
under the cursor; clicking a frame only moves the playhead, so picking a frame
while paused stays silent. Either way playback is left in the state it was
found in, so a seek while playing keeps playing. Clicking a cue solos it; clicking it again
returns to the whole mix. Press m to mute.
Browsers refuse to start audio until a page has been interacted with, so a freshly loaded Studio plays silently until you click or press a key. Rather than failing quietly, the transport says so and offers an Enable sound button; the click itself is the gesture the browser is waiting for.
The Assets view lists the audio library with a play button, a waveform, and a length per file, so a sound can be auditioned, and recognized as a short quiet one, before it is placed on a timeline.
Preview audio follows the frame clock as closely as the browser allows. The exported mix is built separately from the same cues, so the file itself is frame accurate.
Export
The encoder trims, delays, fades, gains, and mixes every cue, then normalizes the result to the brand’s target loudness before muxing it with the video.
Ducking is a window, not a constant. A ducked cue drops to 0.35 only while a non-ducked cue overlaps it, with a six frame ramp either side, so a one second confirmation does not hold a music bed down for the length of the video. The player evaluates that envelope and the encoder compiles the same points into its volume filter, so what you hear while mixing is what the file carries.
export const odoriBrand = defineBrand({
name: "odori",
audio: {cues: {"bed.main": "score"}, targetLufs: -14},
});
Studio measures the mix it is playing against that target. The track is rendered offline through the same envelopes the player uses and measured to ITU-R BS.1770, so the Audio panel reports real integrated loudness beside the brand’s target rather than only the goal.
The resulting file carries stereo AAC at 48 kHz, normalized to that target with a true peak ceiling of -1.5 dBTP. Cues whose source cannot be resolved locally are skipped with a warning rather than silently dropped.