---
title: Audio
description: 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.

```tsx title="videos/launch/video.tsx" lineNumbers
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:

```ts title="odori.config.ts"
export default defineConfig({
  audioDir: "public/audio",
  assets: [{reference: "score", url: "/audio/product-cinematic.m4a"}],
});
```

```tsx
<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:

```ts title="videos/layout.tsx"
export const productBrand = defineBrand({
  audio: {
    cues: {"bed.main": "score", "ui.confirm": "confirm"},
    targetLufs: -14,
  },
});
```

```tsx
<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.

```bash
odori inspect launch
```

```text
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.

```ts title="videos/layout.tsx"
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.

:::note
Only project-local audio can be encoded today. A remote URL previews in Studio
and is hashed into the manifest, but the encoder needs the file on disk.
:::
