# Export dialog

> The settings of an export: the file's format, its bit depth or bitrate, and its level, left as it is, brought to its peak or to a loudness target.

```tsx
"use client";

import { useCallback, useState } from "react";
import { Button } from "@/components/ui/button";
import { ExportDialog } from "@/components/audio-ui/export-dialog";
import { downloadFile } from "@/lib/audio-encode";
import {
  DEFAULT_EXPORT_SETTINGS,
  type ExportSettings,
  exportSound,
} from "@/lib/audio-export";

const RATE = 48_000;

/** Two seconds of an A minor chord, fading out, the same on both sides: the sound the demo exports. */
const CHORD = (() => {
  const channel = Float32Array.from({ length: RATE * 2 }, (_, frame) => {
    const time = frame / RATE;
    const tones = [220, 261.63, 329.63].reduce(
      (sum, hz) => sum + Math.sin(2 * Math.PI * hz * time),
      0
    );
    return 0.15 * tones * Math.exp(-1.5 * time);
  });
  return [channel, channel];
})();

/** The export dialog over a chord: pick a format and a level, and Export saves the file and says how loud it came out. */
export function ExportDialogDemo() {
  const [open, setOpen] = useState(false);
  const [settings, setSettings] = useState(DEFAULT_EXPORT_SETTINGS);
  const [status, setStatus] = useState("");
  const show = useCallback(() => setOpen(true), []);
  const exportChord = useCallback(async (picked: ExportSettings) => {
    const name = `chord.${picked.format}`;
    setStatus(`Exporting ${name}…`);
    const { file, loudness } = await exportSound(CHORD, RATE, picked);
    downloadFile(file, name);
    setStatus(
      `Exported ${name}: ${loudness.lufs.toFixed(1)} LUFS, ${loudness.truePeak.toFixed(1)} dBTP`
    );
  }, []);
  return (
    <div className="flex flex-col items-center gap-3">
      <Button onClick={show} variant="outline">
        Export…
      </Button>
      <ExportDialog
        onExport={exportChord}
        onOpenChange={setOpen}
        onSettingsChange={setSettings}
        open={open}
        range="Two seconds of a chord"
        settings={settings}
      />
      <p aria-live="polite" className="text-muted-foreground text-sm">
        {status}
      </p>
    </div>
  );
}
```

## Installation

**CLI**

```bash
npx shadcn@latest add @audio-ui/export-dialog
```

**Manual**

**1. Install the following dependencies:**

```bash
npm install @audio-ui/core @audio/encode @audio/loudness-lufs @audio/loudness-truepeak
```

**2. Copy and paste the following code into your project.**

```tsx title="components/audio-ui/export-dialog.tsx"
"use client";

import {
  type ComponentProps,
  type ReactNode,
  type SubmitEvent,
  useCallback,
  useId,
} from "react";
import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import type { AudioFileFormat } from "@/lib/audio-encode";
import type {
  ExportNormalize,
  ExportSettings,
} from "@/lib/audio-export";
import { LOUDNESS_TARGETS } from "@/lib/loudness";

interface SettingItem {
  label: string;
  value: string;
}

const FORMATS: SettingItem[] = [
  { label: "WAV", value: "wav" },
  { label: "FLAC", value: "flac" },
  { label: "MP3", value: "mp3" },
];

const BIT_DEPTHS: Record<"flac" | "wav", SettingItem[]> = {
  flac: [
    { label: "16-bit", value: "16" },
    { label: "24-bit", value: "24" },
  ],
  wav: [
    { label: "16-bit", value: "16" },
    { label: "24-bit", value: "24" },
    { label: "32-bit float", value: "32" },
  ],
};

const BITRATES: SettingItem[] = [128, 192, 256, 320].map((kbps) => ({
  label: `${kbps} kbps`,
  value: String(kbps),
}));

const NORMALIZE: SettingItem[] = [
  { label: "Off", value: "off" },
  { label: "Peak, 0 dBFS", value: "peak" },
  {
    label: `Streaming, ${LOUDNESS_TARGETS.streaming} LUFS`,
    value: String(LOUDNESS_TARGETS.streaming),
  },
  {
    label: `Podcast, ${LOUDNESS_TARGETS.podcast} LUFS`,
    value: String(LOUDNESS_TARGETS.podcast),
  },
  {
    label: `Broadcast, ${LOUDNESS_TARGETS.broadcast} LUFS`,
    value: String(LOUDNESS_TARGETS.broadcast),
  },
];

const toNormalize = (value: string): ExportNormalize =>
  value === "off" || value === "peak" ? value : Number(value);

/** A label and the select it names, on one row. */
function SettingSelect({
  items,
  label,
  onValueChange,
  value,
}: {
  items: SettingItem[];
  label: string;
  onValueChange: (value: string) => void;
  value: string;
}) {
  const id = useId();
  const change = useCallback(
    (next: string | null) => {
      if (next !== null) {
        onValueChange(next);
      }
    },
    [onValueChange]
  );
  return (
    <div className="grid grid-cols-[5.5rem_1fr] items-center gap-2">
      <span className="text-muted-foreground text-sm" id={id}>
        {label}
      </span>
      <Select items={items} onValueChange={change} value={value}>
        <SelectTrigger aria-labelledby={id} className="w-full">
          <SelectValue />
        </SelectTrigger>
        <SelectContent>
          <SelectGroup>
            {items.map((item) => (
              <SelectItem key={item.value} value={item.value}>
                {item.label}
              </SelectItem>
            ))}
          </SelectGroup>
        </SelectContent>
      </Select>
    </div>
  );
}

interface ExportDialogProps
  extends Omit<ComponentProps<typeof Dialog>, "children"> {
  /** Fired by Export with the settings, as the dialog closes: render, then pass the channels to `exportSound` from `@/lib/audio-export`. */
  onExport: (settings: ExportSettings) => void;
  /** Fired with the settings as one of them changes. */
  onSettingsChange: (settings: ExportSettings) => void;
  /** What the export renders, as the reader counts it, such as "Bars 1 to 9". */
  range?: ReactNode;
  /** The settings shown, and what Export sends. */
  settings: ExportSettings;
}

/**
 * The settings of an export: the file's format, its bit depth (WAV, FLAC) or
 * its bitrate (MP3), and its level, left as it is, brought to its peak, or
 * to a loudness target. Controlled: it renders and writes nothing itself.
 */
function ExportDialog({
  onExport,
  onSettingsChange,
  range,
  settings,
  ...props
}: ExportDialogProps) {
  const pickFormat = useCallback(
    (value: string) => {
      const format = value as AudioFileFormat;
      onSettingsChange({
        ...settings,
        // A FLAC holds whole numbers only: a WAV's 32-bit floats become 24 bits.
        bitDepth:
          format === "flac" && settings.bitDepth === 32
            ? 24
            : settings.bitDepth,
        format,
      });
    },
    [onSettingsChange, settings]
  );
  const pickBitDepth = useCallback(
    (value: string) =>
      onSettingsChange({
        ...settings,
        bitDepth: Number(value) as ExportSettings["bitDepth"],
      }),
    [onSettingsChange, settings]
  );
  const pickBitrate = useCallback(
    (value: string) =>
      onSettingsChange({ ...settings, bitrate: Number(value) }),
    [onSettingsChange, settings]
  );
  const pickNormalize = useCallback(
    (value: string) =>
      onSettingsChange({ ...settings, normalize: toNormalize(value) }),
    [onSettingsChange, settings]
  );
  const submit = useCallback(
    (event: SubmitEvent<HTMLFormElement>) => {
      event.preventDefault();
      onExport(settings);
    },
    [onExport, settings]
  );
  return (
    <Dialog {...props}>
      <DialogContent className="sm:max-w-sm">
        <form className="flex flex-col gap-6" onSubmit={submit}>
          <DialogHeader>
            <DialogTitle>Export</DialogTitle>
            <DialogDescription>
              {range ?? "Renders the sound to a file."}
            </DialogDescription>
          </DialogHeader>
          <div className="flex flex-col gap-3">
            <SettingSelect
              items={FORMATS}
              label="Format"
              onValueChange={pickFormat}
              value={settings.format}
            />
            {settings.format === "mp3" ? (
              <SettingSelect
                items={BITRATES}
                label="Bitrate"
                onValueChange={pickBitrate}
                value={String(settings.bitrate)}
              />
            ) : (
              <SettingSelect
                items={BIT_DEPTHS[settings.format]}
                label="Bit depth"
                onValueChange={pickBitDepth}
                value={String(settings.bitDepth)}
              />
            )}
            <SettingSelect
              items={NORMALIZE}
              label="Normalize"
              onValueChange={pickNormalize}
              value={String(settings.normalize)}
            />
          </div>
          <DialogFooter>
            <DialogClose render={<Button variant="outline" />}>
              Cancel
            </DialogClose>
            <DialogClose render={<Button type="submit" />}>Export</DialogClose>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  );
}

export { ExportDialog, type ExportDialogProps };
```

**3. Update the import paths to match your project setup.**

**Agent**

Give your agent the skill, and it picks the component, installs it and wires it to your audio:

```bash
npx skills add ouestlabs/audio-ui
```

Then ask it:

```text
Add the export dialog from audio/ui to my project and wire it to my audio code.
```

To let the agent read the docs live, connect the [MCP server](/docs/mcp). More in [Agent Skill](/docs/agents).

## Usage

```tsx
import { useState } from "react";
import { ExportDialog } from "@/components/audio-ui/export-dialog";
import { downloadFile } from "@/lib/audio-encode";
import { DEFAULT_EXPORT_SETTINGS, type ExportSettings, exportSound } from "@/lib/audio-export";

const [open, setOpen] = useState(false);
const [settings, setSettings] = useState(DEFAULT_EXPORT_SETTINGS);

async function exportMix(picked: ExportSettings) {
  const rendered = await renderMix(); // an AudioBuffer, from your engine
  const channels = [rendered.getChannelData(0), rendered.getChannelData(1)];
  const { file, loudness } = await exportSound(channels, rendered.sampleRate, picked, showProgress);
  downloadFile(file, `mix.${picked.format}`);
}

<ExportDialog
  onExport={exportMix}
  onOpenChange={setOpen}
  onSettingsChange={setSettings}
  open={open}
  range="Bars 1 to 9"
  settings={settings}
/>;
```

The dialog picks the settings and renders nothing itself: Export closes it and hands `onExport` the settings. Render the sound your own way (an `OfflineAudioContext`, [`renderArrangement`](/docs/engine/playback#playing-an-arrangement), an engine of your own), then `exportSound` from `audio-export` turns the channels into the file. `settings` and `onSettingsChange` keep the choice wherever you want it remembered.

- **Format**: WAV or FLAC, at 16 or 24 bits (a WAV also at 32-bit float, which keeps what goes past full scale), or MP3, at 128 to 320 kbps.
- **Normalize**: off, the file holds the render as it is; peak, the loudest sample reaches 0 dBFS; or a loudness target, -14 LUFS for streaming, -16 for podcasts, -23 for broadcast, reached as far as the true peak stays at or under -1 dBTP. A sound that would pass the ceiling lands under its target, and nothing is limited.

`exportSound` says how far it is through its last argument, `(stage, done)`: `"measuring"`, then `"encoding"` from 0 to 1. Measuring reads every sample at once, and the page waits about half a second a minute of stereo, after it has painted the stage, so show it. The result's `loudness` is how loud the file came out, in LUFS and dBTP, and `gain` the dB it was brought by. The [loudness](/docs/engine/analysis#loudness) and [file](/docs/engine/playback#to-a-file) libs it is built on work on their own too.

## API reference

### ExportDialog

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `settings` | `ExportSettings` |  | The settings shown, and what Export sends. |
| `onSettingsChange` | `(settings: ExportSettings) => void` |  | Fired with the settings as one of them changes. Switching a 32-bit WAV to FLAC makes it 24 bits, which a FLAC holds. |
| `onExport` | `(settings: ExportSettings) => void` |  | Fired by Export with the settings, as the dialog closes. |
| `range` | `ReactNode` |  | What the export renders, as the reader counts it, such as "Bars 1 to 9". Without it, the dialog says it renders the sound to a file. |
| `open, onOpenChange` | `boolean, (open: boolean) => void` |  | The dialog's: whether it is shown, and the change Cancel, Export, Escape and a click outside ask for. |

### exportSound

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `channels` | `readonly Float32Array[]` |  | The rendered sound, a channel each, from -1 to 1. |
| `sampleRate` | `number` |  | Its sample rate. An MP3 comes out at the nearest rate MP3 has: 48 kHz for 96 kHz. |
| `settings` | `ExportSettings` |  | `format`, `bitDepth`, `bitrate` and `normalize` (`"off"`, `"peak"` or a target in LUFS). |
| `onProgress` | `(stage: ExportStage, done: number) => void` |  | How far it is: `"measuring"`, then `"encoding"` from 0 to 1. |

It resolves to `{ file, gain, loudness }`: the file as a `Blob`, the gain in dB, and how loud the file is (`lufs`, `peak`, `truePeak`).
