Skip to content
Esc
navigateopen⌘Jpreview
On this page

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.

Was this page helpful?