# Audio

Software mixer + platform device I/O, selected like every other driver. Game code admits requests and posts commands; a dedicated worker owns decode and buffers; the device callback owns playback state and DSP.

Audio is optional at session startup. System allocation, driver initialization, or worker startup failure logs a warning and leaves the session running muted, after releasing any partially initialized audio resources.

## Architecture

```text
Game / session             Audio worker                  Device callback
admission + commands ────► decode PCM / fill rings
                           prepared commands ──────────► apply ≤64 commands
                           buffer ownership              voices / transport / DSP
                           ◄──── detachment receipts ─── mix → device
queries ◄────────────────────── immutable snapshots ───── publish
```

| Layer | Path | Role |
|-------|------|------|
| Device driver | `src/hikari/src/backend/audio.zig` | Type-erased output device (`AudioDriver`) |
| Native stream | `src/hikari/src/native/{macOS,Windows}/…/AudioDevice.*` | Core Audio (AudioQueue) / WASAPI shared mode |
| Platform glue | `src/hikari/src/platform/{macOS,Windows}/audio.zig` | Thin Zig wrappers |
| Command queue + system | `src/hikari/src/audio/audio.zig` façade + `audio/system/` | SPSC ring, worker thread, public façade, decode cache (domains: types/lifecycle/client/worker/streams/decode) |
| Admission / handoff | `src/hikari/src/audio/admission.zig`, `realtime.zig` | Game-side budget, bounded callback commands, snapshots and reclamation receipts |
| Mixer | `src/hikari/src/audio/mixer.zig` facade + `mixer/` domains | Callback-owned 64-voice pool, buses, spatial gains and DSP |
| Scene | `SceneAudioDesc` / `AudioSourceComponent` | Authored sources on builtin `_audio` |

**Contract:** shared code never imports platform audio types. Selection is only through `backend.Registry` (`drivers.audio` in `DriverRecipe`). Default names: `native` (macOS/Windows), `noop` (Linux scaffolding).

**Link modes** — audio is a first-class peer of window/input/render/physics/scripting/ui:

| Piece | Path |
|-------|------|
| Type-erased contract | `backend/audio.zig` (`AudioDriver` / `AudioFactory`) |
| Dynamic module root | `driver_module_audio.zig` → `backend/modules/audio.zig` |
| Static (monolithic) | `backend/native.zig` `createAudio` |
| Registry | `Registry.create(.audio, …)` + default module lists (7 peers) |
| Zig build artifact | `hikari-backend-audio-coreaudio` / `-wasapi` / `-noop` (`.linkage = .dynamic`) |
| Kaji packaging | copies every `hikari-backend-*` shared library Zig installed (`HikariGameConductor.Package.cs`, `BackendPattern`); no list is kept in Kaji |
| Recipe | `drivers.audio` (`native` / `noop` / null = host default) |

| `--type=` | Audio |
|-----------|--------|
| `dynamic` | Load `libhikari-backend-audio-coreaudio.dylib` / `hikari-backend-audio-wasapi.dll` next to the executable (with other `libhikari-backend-*`) |
| `monolithic` | No audio dylib; factory compiled into the product via `native.createAudio` |

Bump `backend/module.zig` `engine_contract_version` when the audio factory/vtable layout changes.

## Threading model

Three roles, strict ownership:

| Role | Thread | Owns | Does not |
|------|--------|------|----------|
| **Client** | Game / session | Admission metadata, command posting, immutable transport queries | Voice buffers, decode cache, mixer mutation, freeing rings/streamers |
| **Worker** | `audio.worker` (engine-spawned) | Cue decode cache / stream fill, prepared command submission, acknowledged reclamation | Live voice/cursor/fade/DSP mutation, device I/O |
| **Device** | OS audio callback (AudioQueue / WASAPI) | Bounded command application, voices/cursors/fades/DSP, mixing, snapshot/event publication | Alloc, decode, free streamers, file I/O, waiting for controllers |

Commands and samples cross different boundaries. Only the worker decodes; the
device callback reads prepared audio and never waits for it:

```mermaid
flowchart TD
    client["Game / session"] -->|Commands| queue["SPSC ring · 256 slots"]
    queue --> worker["Audio worker"]
    worker -->|Prepared commands| renderq["SPSC ring · 256 slots"]
    renderq -->|At most 64 per callback| mix["Apply + mix active voices"]
    device["OS device callback"] --> mix
    worker -->|PCM / stream rings| mix
    mix -->|Snapshots + detachment receipts| worker
    mix --> output["Device output"]
    class output accent
```

**Stop / steal lifetime (detach-then-free):**

```text
worker: enqueue stop / replace / invalidate with resource identity
device: detach old samples/ring → finish rendering → publish receipt
worker: observe receipt → free old Streamer/ring or invalidated PCM
```

Client never frees rings. Worker owns decode cache + streamers. The callback borrows prepared resources until detachment.

There is no shared mixer mutex or contention-silence/catch-up path. The callback captures one finite queue batch, applies at most **64 commands**, then renders normally. A stalled worker or full command queue cannot freeze resident PCM or reset reverb. Streaming still depends on refill and may underrun; late PCM is discarded.

Readback uses three preallocated immutable slots. Game and worker readers pin a slot; the callback makes bounded publication attempts and skips an update if readers retain the other slots. Slow readers cannot block audio. Snapshots contain compact voice metadata, not live DSP state; sample pointers are identity-only and must never be dereferenced by readers. Normal PCM reclamation requires a snapshot at least as new as its latest queued start and no remaining voice reference. Explicit detach acknowledgements publish independently of snapshots, so pinned readers cannot hold up stream retirement or invalidation.

Before device start and after device stop has joined callbacks, an atomic ownership handoff permits the worker to drain commands with zero output frames. It never borrows playback state while the device owns it. Device format is fixed at binding before worker startup. The headless `noop` backend pulls and discards samples on its own paced thread, keeping transport and receipts live without opening a hardware device.

### Command queue

- Fixed **256-slot SPSC** ring (game produces, worker consumes). Lock-free atomics; the worker parks on a condition variable, and a post takes the wake mutex only while the worker is parked (seq-cst handshake between the post and the worker's idle re-check).
- Commands are fixed-size PODs (path inlined, max 240 bytes). No heap on the post path.
- Client APIs (`playCue`, `stopVoice`, `stopAllVoices`, `updateVoice`, `setListener` / `trySetListener`, `setMasterMute`, bus gain/mute, `invalidateCue`) post commands. One-shot posts backpressure the client; `playCue` returns `error.QueueFull` and rolls back admission without changing the previously playing voice. Last-wins per-tick state (`trySetListener`, `updateVoice`) drops instead of spinning — the caller must not record the value as posted so the next tick re-arms.
- Worker processes in order — play then same-frame update is well-defined. Commands that need no worker resources (pause, fade-out, update, listener, mix knobs) carry the callback command and are forwarded unchanged; only play, crossfade, stop, seek, stop-all and invalidate do worker work.
- A second **256-slot SPSC** ring carries prepared worker-to-callback commands. Its backpressure waits only on the worker. Queue-slot reuse is not a reclamation receipt: acknowledgement occurs after the callback finishes its render.

### Play lifecycle

1. **Client** `playCue` → game-owned admission → post `play` with path + opts → return `VoiceHandle` immediately. Preparing requests count toward aggregate and bus caps.
2. **Worker** decodes (or hits cache), prepares a voice and queues its start. Preparation failures become callback commands that publish `decode_failed`.
3. **`isPlaying`** is true for a current preparing reservation or a matching live snapshot. Replacing a reservation invalidates the previous client handle immediately.
4. **Device** installs the prepared voice and mixes it. An old voice being replaced may continue until that command arrives; game admission never edits it.

Preparing requests are not steal candidates: cue-authored loop protection is not known until a matching active snapshot arrives. Stale readback therefore conservatively refuses a new request rather than accidentally stealing a protected voice.

Handles use index + generation so stale stops after steal/reuse are no-ops. Protect long-lived handles with `loop` and/or `hold: true` (see [Steal policy](/docs/systems/audio.md#steal-policy)).

### Synchronization

- **Worker ↔ device:** bounded SPSC commands, immutable snapshots, atomic detachment receipts and stream-ring cursors; no mixer lock.
- **Game ↔ worker:** command ring plus the non-realtime wake condition. Handle/clock queries use callback snapshots and game-owned admission metadata.
- **Master mute UI:** atomic snapshot updated on post so the toolbar can read without waiting for the worker.

### Profiler

When Recording is on (`-Dprofiler-timing` + runtime Recording). Sections flip per drain / device callback (same model as physics), not for the whole thread lifetime.

| Thread section | Zones | Notes |
|----------------|--------|--------|
| **game** | `audio.tick` → `audio.listener`, `audio.sources` | Session posts only |
| **game** | `audio.play` | Client reserve + enqueue (under sources when from tick) |
| **audio.worker** | `audio.drain` | One section per wake/drain batch |
| **audio.worker** | `audio.play.apply` → `audio.decode` | Decode only on cache miss |
| **audio.worker** | `audio.invalidate` | Hot-reload drop of decoded cue |
| **audio.worker** | `audio.stream.fill` / `audio.decode.opus` | Stream ring top-up; Opus only here |
| **audio** (device) | `audio.mix` | Bounded command application + RT mix; allocation/I/O/wait-free callback work |

High-frequency cmds (`update`, `set_listener`, mute/bus) are counted inside `audio.drain` only — no per-cmd zones (would flood the ring).

The device profiler channel is owned by `AudioSystem`, registered before the device starts, and removed after callbacks stop. Its ring has a stable address independent of the OS callback thread, so CoreAudio thread rotation across suspend/resume neither consumes new registry slots nor takes the process-registry mutex on the real-time path.

### Diagnostics

Build with `--profiler-audio` (`-Dprofiler-audio`) to populate the editor right-dock **Audio** tab. Independent of CPU profiler Recording (`--profiler-timing`) and of `--profiler-residency`; defaults **off**. Without the flag the tab stays present and explains the rebuild option; sampler storage is zero-sized.

| Rule | Detail |
|------|--------|
| Publisher | Audio worker only (`audio/diagnostics.zig`) — periodically after drain/pump, and once before idle park after queued callback changes reach readback |
| Reader | Editor copies the published POD snapshot; never locks streamers / decode cache / device path |
| Contents | Callback bus/voice/DSP readback, game and render queue depths/capacities, dropped events, decode-cache count, streamer live/need-fill/min ring avail. Preparing game admissions remain separate from callback active counts. |

Same publish-copy model as `--profiler-physics`. See [UI and editor — Audio tab](/docs/systems/ui-and-editor.md#audio-tab).

## Playback features

- Device pulls interleaved **f32 mono or stereo** (macOS: AudioQueue; Windows: WASAPI shared).
- Mixer: fixed **64-voice** pool; per-bus caps **music 8 / voice 8 / sfx 48**, plus an enforced aggregate `max_voices` budget counting active and reserved slots. See [Steal policy](/docs/systems/audio.md#steal-policy).
- Playable buses are `music` / `sfx` / `voice`. Master gain/mute are dedicated controls, not a playable bus. Mute, zero gain, and complete spatial attenuation advance source position and fades; explicit pause freezes both.
- **Resample:** worker maps cue rate → device rate before mix (the device rate is fixed at bind, so static cache entries are keyed by logical path and keep the cue's channel count; streamers resample into the ring). `invalidateCue(path)` drops matching entries and streamers by stem. Pitch remains a separate playback-speed control.
- **Fades:** `fade_in_ms` on play; `fadeStop` / `crossfade` post worker cmds; mix path steps fade gain alloc-free (including under mute / zero bus gain). `crossfade(from, to, opts, fade_ms)` fade-outs `from` and starts `to` with `fade_in_ms = fade_ms` (when `fade_ms == 0`, hard-stops `from` and keeps any `opts.fade_in_ms`). Admission reserves `to` excluding `from.index`, so a full bus never steals `from` (generation bump would no-op the fade-out).
- **Ducking:** while any **voice**-bus voice is active, music/sfx bus gains are multiplied by duck amounts (defaults 0.25 / 0.5; attack ~50 ms, release ~200 ms). Voice bus itself is unducked. Tunable via `setDuckAmounts` (clamped 0..1 on worker).
- **Play vs Edit:** auto `play_on_start` only in **Play**. Edit is silent; Stop/reset posts `stop_all` and clears component handles.
- **Spatial (v2):** distance atten × **cone** × **occlusion** × constant-power pan, computed once per active spatial voice per buffer (`mixer/spatial.zig`). Shared stereo **reverb** send (Schroeder comb+allpass on wet bus; `mixer/reverb.zig`). No HRTF yet.
- **Cues:** cooked `.shinaudio` via Shinra → worker `retainAudioCue`. Short/non-stream cues decode to a mono/stereo f32 cache preserving the source channel count; `streaming_hint` cues keep a worker-owned ring filled from a payload cursor (file window or memory). Opus decodes on the worker via static **libopus** (never in the device dylib).

### Steal policy

When a bus or the aggregate budget is full, game-thread admission (`audio/admission.zig`) may steal an **active** same-bus voice (generation bump → old handle becomes a no-op). Budgets live on admission (`configs/audio.json`), and diagnostics report them from there. Rules:

| Rule | Detail |
|------|--------|
| Same bus only | Never steal across music / sfx / voice |
| Never music bus | `bus == .music` is never a victim (put beds on `.music`) |
| Never looping | A play-time `loop` or a cue-authored loop seen in readback is never a victim (sfx looping beds keep handles) |
| Never `hold` | `PlayOpts.hold` / `PlayCueOpts.hold` marks long-lived non-loop oneshots non-stealable |
| Priority gate | Only displace equal/lower `priority` (higher wins); ties prefer furthest cursor; reject when every candidate is stricter |
| Crossfade exclude | Reserving `to` excludes `from.index`, so it cannot steal `from` |

**Long-lived handles:** use `loop: true` and/or `hold: true`. Legitimate steals of non-held oneshots bump generation — stale `stop` / `fadeStop` are no-ops (by design). Soft fail: `playCue` returns invalid / `error.NoFreeVoice` when nothing stealable remains.

### Mix strip vs toolbar mute

| Control | Behaviour |
|---------|-----------|
| Toolbar master mute | Posts `setMasterMute`; shares the master-mute **atomic** with the Mix strip |
| Mix strip mute / gains | UI-local until post; one-way (does not pull script-driven bus gains) |
| Reset mix | Restores gains to `1.0` and clears bus mutes; **leaves master mute alone** |
| Diagnostics body | Read-only laggy snapshot (`--profiler-audio`); never mutates mix |

Toolbar chrome soft-syncs master mute from the atomic every frame so strip and toolbar never desync.

## Scene JSON

Merge this fragment into an actor’s `components` object.

```json
{
  "audio": {
    "cue": "asset://./audio/glass_break",
    "volume": 1.0,
    "pitch": 1.0,
    "loop": false,
    "spatial": true,
    "min_distance": 1.0,
    "max_distance": 50.0,
    "inner_cone_deg": 360,
    "outer_cone_deg": 360,
    "outer_gain": 0,
    "occlusion": 0,
    "occlusion_gain_factor": 1,
    "reverb_send": 0,
    "bus": "sfx",
    "play_on_start": true,
    "enabled": true
  }
}
```

Engine builtin archetype **`_audio`** (declares `hi.ComponentAudio`) hosts the component — same idea as `_script` / `_asset`, not a game package entity. Drop cooked/source audio into the viewport to spawn it with `audio.cue` set. Music beds typically use `spatial: false`, `bus: "music"`, `loop: true`.

### Spatial model

| Layer | Behaviour |
|-------|-----------|
| Distance | Linear 1→0 between min/max |
| Pan | Constant-power against the listener's normalized view right |
| Cone | Full angles `inner_cone_deg` / `outer_cone_deg` (default **360 = omni**); `outer_gain` outside outer; aim = source `forward` |
| Occlusion | Posted `occlusion` 0..1 drives gain and a smoothed low-pass; `occlusion_gain_factor` controls gain loss independently. **No raycast** in audio. |
| Reverb | Per-voice `reverb_send` → shared stereo wet bus; `setReverbMix` global wet |
| HRTF | Not yet |

**Cone model:** angle between source `forward` and **(listener − position)**. If angle ≤ inner/2 → factor 1; ≥ outer/2 → `outer_gain`; else linear blend. Outer clamped ≥ inner when both < 360. Session `tickAudio` posts entity **world forward** from transform into `Spatial.forward`.

**Occlusion:** game/session posts the factor (physics integration is a residual). Default 0 = clear path.

**Reverb:** dry mix as before; samples also accumulate into a preallocated wet scratch (max 4096 frames stereo). After all voices, one Schroeder network processes the wet bus and adds `wet * reverb_wet_mix` to the output. Master mute still steps fades and **decays** reverb with silence (no hang / no infinite wet). Once the tail has flushed to exact zero and no voice sends, the network is skipped entirely; its output would be exact zero, so skipping is bit-identical. No alloc on the device path.

**No HRTF** in this pass.

## Assets (Shinra)

| Input | Output |
|-------|--------|
| WAV, OGG (Vorbis or Opus) | `.shinaudio` |

Optional `<file>.shinmeta.json` → `audio` section: normalize, sample rate, loop, streaming hint, `Pcm16Le` / `PcmF32Le` / `OpusOgg`. Sample project assets: `hikari-games/example/assets/audio/`.

**Ogg codec:** Vorbis sources decode to PCM then cook. Opus sources with `format: OpusOgg` pass through into the cooked Opus payload (no re-encode). Opus + PCM output format is rejected — use WAV or Vorbis when you need PCM.

`streaming_hint: true` → worker `Streamer` + `StreamRing` with a **payload cursor**:

| Source | Behaviour |
|--------|-----------|
| **Loose / product `data/` file** | File-backed chunked I/O: cue create opens host `.shinaudio` only long enough to read the SHN1 header (+ optional metadata), then **closes** and stores path + payload range. Each live streamer opens its own read FD and refills a sliding window via `pread`. `AudioCue.residentBytes` charges header + window budget, not full payload. |
| **Pack (mmap / decompressed)** | Memory fallback: full payload stays resident (leased slice or owned decompress); cursor is a memory view. No pack-entry range I/O yet. `resolveLocalPath` returns null for catalogued pack members under product packs (no loose/data hybrid leakage). |

Worker may block briefly on `pread` while refilling a modest window (default ~128 KiB); the device callback never does file I/O — only mixes the ring. Loop rewinds reset PCM frame / recreate the Opus demux+decoder at payload byte 0. The Ogg demuxer reads pages straight from the cursor (`bytesAt`: zero-copy for memory payloads, the cursor's own window for files); there is no second demux buffer.

`false`/omitted → full decode into the f32 cache (payload always memory-resident).

## Editor

- Viewport (show-flag **Audio**): speaker billboard; spatial sources draw **min** + **max** distance rings (great-circle trios) matching mixer falloff, and when **both** `inner_cone_deg` and `outer_cone_deg` are &lt; 360 wire **outer** / **inner** aim cones along actor forward (local −Z; full angles → half-angles like the mixer). Either cone ≥ 360 (omni, including defaults) is rings-only. Non-spatial: speaker only (no range rings). Show-flag **Audio** gates wires; **Icons** gates the speaker billboard.
- Hierarchy / inspector: audio section, cue picker, bus/volume/spatial fields (including cone / occlusion / reverb send).
- **Inspector Preview / Stop preview** on a selected audio source with a cue: posts `playCue` with authored volume / pitch / bus / spatial (entity world position when available). **Does not** set component `started` or the permanent play_on_start handle. Only one preview voice at a time; stops on deselect, asset-inspector mode, Play/Stop transitions, and session destroy. **Master mute** still silences mix output (preview still posts — same as toolbar mute; mute zeros the mix while voices keep stepping).
- Edit mode stays silent for automatic `play_on_start`; preview is the explicit listen path in Edit.
- Drop `.wav`/`.ogg` (or cooked audio) into the viewport → `_audio` actor with `audio.cue`.
- Toolbar **mute** posts `AudioSystem.setMasterMute` (accent when muted). Toolbar chrome soft-syncs from the master-mute atomic every frame so the Audio tab Mix strip and toolbar never desync; no feedback loop (`setIcon`/`setStyle` no-op when unchanged).
- **Audio tab Mix strip** (always available when `session.audio` is live; independent of `--profiler-audio`): stable retained controls for master mute/gain and per-bus gain/mute (Music / SFX / Voice). Edits **post only** via `AudioSystem.setMasterGain` / `setBusGain` / `setBusMute` / `setMasterMute` — never touch callback-owned mixer state. Master mute soft-syncs from the atomic when the panel has no pending local edit. Leaving the tab clears the “live” latch so the next select re-posts strip locals (covers a replaced `AudioSystem`). **Reset mix** restores gains to `1.0` and clears bus mutes; master mute is left as-is (toolbar/session). Diagnostics below the strip still require `--profiler-audio` and rebuild on a timer without destroying Mix widgets.
- Asset browser tiles: when a cooked `.shinaudio` header is readable, the subtitle badge adds format (`PCM` / `OPUS`), optional duration, and `STREAM` when `streaming_hint` is set (64-byte header only; no full retain).

### Live output monitor

The Audio tab's **Live output** card works without `--profiler-audio`. Music, SFX, Voice and Master each show a two-second min/max waveform, peak/RMS meters (−60 to 0 dBFS), one-second peak hold and clip indication. Click a row to expand it; Master starts expanded. Stereo channels have separate lanes; mono Master shows the folded output.

Bus signals are post bus gain/mute, ducking, voice gain, occlusion filtering and spatial attenuation, but **pre-master and dry only**. Master includes reverb and master gain/mute, measured immediately before the limiter (after mono folding). Bus activity can therefore remain visible while Master is muted. Clip indicators report pre-limiter excursions, not device-side measurements. The Master row's **GR** is the minimum limiter gain in the captured bucket/block, expressed as positive dB reduction; the waveform intentionally retains the overload that caused it.

`audio/output_monitor.zig` owns preallocated bus scratch and a bounded single-producer/single-consumer queue. The callback publishes 200 min/max/RMS buckets per second; the retained `editor/panels/audio_output.zig` element drains at about 30 Hz. A full queue drops telemetry without waiting or affecting playback time. Capture adds no callback allocation, decoding or extra locks. Standalone builds compile out the tap and its storage.

Selecting the tab enables capture; hiding, closing, unmounting or suspending disables it. Disabled editor callbacks perform one atomic gate read and no signal scanning, accumulation or publication; the hidden panel does not drain or redraw telemetry. A callback already in flight may finish, but its old-epoch data is discarded on reopening. Panel teardown disables capture before the audio system is destroyed.

CPU-only comparison (does not start the editor, game or audio device), from `src/hikari`:

```sh
zig build audio-monitor-bench -Dgame-src=/absolute/path/to/hikari-games/example/src
```

This measures callback work with capture hidden/visible, not UI rendering or end-to-end device latency.

## Session

`SessionCore` allocates `AudioSystem`, creates the device with `renderCallback`, `bindDriver` (starts the worker), starts the device after the asset store is configured, and each `tickLogic` **posts** listener + source updates (never touches the mixer directly). Asset hot-reload of `.shinaudio` posts `invalidateCue` so the worker drops the decode cache and stops voices still holding that buffer. Matching **looping + `play_on_start`** scene sources then clear `voice` / `started` so the next Play-mode `tickAudio` re-posts the bed (one-shots with `started` stay finished and do not mid-fire restart).

**Residency:** cooked cues are store-retained while decoded; f32 PCM is freed when no voice still samples that buffer (`sweepIdleDecoded` + `releaseAudioCue`). Details: [Asset residency — audio](/docs/systems/asset-residency.md#layer-5--audio-worker-decoded-pcm).

Play mode `play_on_start`:

- Posts play when the handle is invalid; keeps the handle while reserved/playing.
- Marks `started` once `isPlaying` is true (worker applied).
- Soft-pending assets: retry until live or asset state is failed.
- `resetAudioSources` / leave Play: `stop_all` + clear handles and `started` flags.
- Hot-reload invalidate of a cue path: clear `voice` + `started` only for **looping + play_on_start** sources whose cue stem matches (see `runtime_session/audio_reload.zig`).

## Game API

Two ways to play (both post into the same command queue):

| Path | Use |
|------|-----|
| Scene `components.audio` / spawn `SpawnAudio` | Placed / looping sources |
| `hi.audio().playCue` / Kawa `Audio.play` | Fire-and-forget one-shots |

### Zig (`HostApi` subtable)

Nullable: `hi.audio()` is null when the device/worker failed to start.

```zig
const audio = hi.audio() orelse return;
const h = audio.playCue(hi.AssetRef.must(.audio, "asset://./audio/glass_break"), .{
    .volume = 1,
    .spatial = true,
    .position = pos,
    .forward = .{ 0, 0, -1 },
    .inner_cone_deg = 90,
    .outer_cone_deg = 180,
    .outer_gain = 0.05,
    .occlusion = 0,
    .reverb_send = 0.2,
    .bus = .sfx,
    .priority = 0,
    .fade_in_ms = 0,
    // .hold = true, // long-lived non-loop; never stolen
});
if (audio.isPlaying(h)) { /* reserved or live */ }
audio.fadeStop(h, 250);
// audio.setReverbMix(0.4);
// audio.stop(h); // hard stop
```

| Call | Notes |
|------|--------|
| `playCue(AssetRef, PlayCueOpts) → VoiceHandle` | Cue must be `AssetKind.audio`; invalid handle on soft fail |
| `stop` / `fadeStop` / `isPlaying` | Generation-safe; `fade_out_ms == 0` ≡ hard stop |
| `crossfade(from, to_cue, opts, fade_ms) → VoiceHandle` | Fades out `from`, starts `to` with matching fade-in (`fade_ms == 0` hard-stops `from`) |
| `setMasterMute` / `masterMute` | Atomic UI snapshot + worker cmd |
| `setMasterGain(gain)` | Master linear gain; clamped ≥ 0 on worker |
| `setBusGain(bus, gain)` | Per-bus linear gain (`music` / `sfx` / `voice`); clamped ≥ 0 on worker |
| `setBusMute(bus, muted)` | Per-bus mute (independent of gain) |
| `setListener` | Optional; session already tracks primary camera |
| `setDuckAmounts(music, sfx)` | Duck targets (0..1) while voice-bus is active |
| `setReverbMix(wet)` | Global wet scale for the shared reverb (clamped ≥ 0) |

`PlayCueOpts`: `volume`, `pitch`, `loop`, **`hold`**, `bus`, spatial v2 fields (position / min/max / cone / forward / occlusion / reverb_send), `priority`, `fade_in_ms`, `paused`, `start_frame`. Looping or `hold: true` → never a steal victim.

Bump: root `host_api_version_current` when the subtable pointer is added/removed; `audio_api_version_current` on table breaks (additive opts fields included, so games recompile with the editor). Host install: `host_bind/audio_api.zig` → `AudioSystem` posts only.

### Scene scripts (`Audio.*`)

Kawa marshals straight onto `AudioSystem`
(`scene/scripting/kawa_host/audio.zig`) and registers through
`SceneScriptBackend.registerNative` from the application session (keeps audio
out of the scripting dylib). Same post-only path as HostApi.

```kawa
let h = Audio.play("asset://./audio/glass_break", {
    volume = 1,
    spatial = true,
    position = Actor.get_position(),
    forward = Actor.get_forward(),
    inner_cone_deg = 90,
    outer_cone_deg = 180,
    outer_gain = 0.05,
    occlusion = 0,
    reverb_send = 0.2,
    bus = "sfx",
    priority = 0,
    fade_in_ms = 0
    // Add hold = true for a long-lived non-looping voice that must not be stolen.
});
if (Audio.is_playing(h)) {}
Audio.fade_stop(h, 250);
let next = Audio.crossfade(h, "asset://./audio/soft_fog_lines", 500, { bus = "music", loop = true });
Audio.set_master_mute(false);
Audio.set_master_gain(0.8); // linear; clamped ≥ 0 on worker
Audio.set_bus_gain("music", 0.5); // or index 0..2: music/sfx/voice
Audio.set_bus_mute("sfx", false);
Audio.set_duck_amounts(0.25, 0.5); // music/sfx targets while voice-bus active; clamped 0..1 on worker
Audio.set_reverb_mix(0.35); // global wet scale
```

Handle is `{i, g}` or nil. Same soft-fail rules as Zig. Play opts include `hold`, cone/occlusion/reverb_send (v5). Mix controls, `set_duck_amounts`, and `set_reverb_mix` post only (parity with HostApi).

### Sample

- Zig: `hikari-games/example/src/entities/sfx_oneshot_entity.zig` (archetype `sfx_oneshot`, Space / `move_up`)
- Kawa: `hikari-games/example/assets/scripts/sfx_oneshot.kawa`
- Scene: `scenes/audio_demo.json` places the Zig entity

Manual smoke (Play mode): music bed loops; Space oneshots over it; toolbar mute/unmute; Stop Play cleans up.

## Transport and synchronization

Audio API **v6** exposes `pause(handle)`, `resumeVoice(handle)`, `seek(handle, seconds)`, `transport(handle)`, `position(handle)`, and `clock()`. Kawa equivalents are `Audio.pause`, `Audio.resume`, `Audio.seek`, `Audio.position`, `Audio.state`, and `Audio.clock`. Mutations queue to the worker; their boolean result means accepted, not completed. Queries pin an immutable callback snapshot and can lag live playback by a callback (or longer if publication is delayed). Stale/completed handles report `stopped` and position zero; `isPlaying` retains its live-handle meaning, including preparing, scheduled, and paused voices.

`transport` reports `preparing`, `scheduled`, `playing`, `paused`, or `stopped` and source position in seconds (pitch changes position advancement; loops wrap). `seek` accepts finite seconds from 0 through 86400 and clamps to the last source frame. The worker queues a seek hold and waits for its callback receipt, then moves the decoder in source-rate frames and clears/refills the ring. A completion command reanchors playback in device-rate frames and restores the previous pause state. Failed stream seeks stop the voice. Only that stream is held; decoding and I/O never run on the callback.

`clock()` returns `{ frame, sample_rate }`: a monotonically advancing count of **mixed output frames**, including silent buffers. It is not a hardware presentation clock and does not compensate for OS/device buffering. It belongs to one AudioSystem lifetime and does not reset on Play/Stop. Source transport pauses independently of this clock.

Set `PlayCueOpts.start_frame` (Kawa `start_frame`) to an absolute future clock frame; zero requests immediate playback. The worker prepares PCM/ring immediately, and the callback starts at the exact offset within its buffer. Multiple prepared voices sharing a deadline start on the same sample. Allow decode/queue lead time: an already-missed deadline is rejected, and a voice not prepared by its deadline becomes stopped rather than starting late. Scheduled voices still consume budget; use `hold` to prevent steals. `paused = true` prepares without advancing; resume after a scheduled deadline starts from the held source position at the next callback. Crossfade is immediate-only and rejects paused/scheduled destination options; fade-stop on a paused or not-yet-started voice stops immediately.

```zig
const audio = hi.audio() orelse return;
const now = audio.clock();
const start = now.frame + now.sample_rate; // one second preparation lead
const a = audio.playCue(cue_a, .{ .start_frame = start, .hold = true });
const b = audio.playCue(cue_b, .{ .start_frame = start, .hold = true });
_ = audio.pause(a);
_ = audio.seek(a, 2.5);
_ = audio.resumeVoice(a);
_ = b;
```

`configs/audio.json` → `audio.output_channels` is 1 (mono) or 2 (stereo, default), applied at device creation. Mono output folds the complete stereo dry+wet mix as `(L + R) / 2` before clipping. Non-spatial mono cues duplicate to both stereo channels; spatial cues are point sources (stereo sources fold to mono before constant-power panning). Stream rings remain stereo staging buffers. Stream underruns output silence but advance source time; late PCM is discarded when the worker catches up.

## Streaming and Opus

| Cue flags | Path |
|-----------|------|
| `streaming_hint = false` | Full decode on worker → mono/stereo f32 cache → static voice |
| `streaming_hint = true` | Worker owns `Streamer` + `StreamRing`; mix reads ring only |
| format Opus | Ogg demux + **libopus** on worker (`opus_decode.zig`); never in the device dylib |

**What “streaming” means here:** the worker pulls PCM/Opus into a ring; the device never decodes. Loose/`data/` cues use a **file-backed payload cursor** (`pread` sliding window, default ~128 KiB) so full payload need not stay resident; pack members still keep a memory-resident payload (see [Assets](/docs/systems/audio.md#assets-shinra)). Residual vs UE-style async disk residency: fills are synchronous worker `pread`, not an async IO queue.

**Lifetime:** the device callback detaches the voice’s samples/ring and publishes a receipt after rendering. Only then may the worker free the `Streamer` and ring. There is no mixer lock; use the acknowledged handoff described above.

**Worker pacing:** fill only when a ring is below low-water (~1/4 capacity). Healthy rings → short sleep (~3 ms), not a spin. Idle (no streams, no cmds) → condition wait.

**Pitch:** stream and static voices both use a fractional cursor (same `PlayOpts.pitch` behaviour).

**Policy (content):** short SFX → PCM16, no stream; music/ambience → `format: OpusOgg` + `streaming_hint: true` in `.shinmeta.json` (see `soft_fog_lines`).

Profiler: `audio.decode.opus`, `audio.stream.fill` on the worker.

**Build:** application links **static libopus** staged from thin prebuilts `3rd-party/opus/<os>-<arch>/static/` + `include/` (Kaji `opus` unit copies → `bin/opus/…`). Official Xiph ships source only; prebuilts are built from the pinned tarball. **Not** Homebrew. **Not** in audio device dylibs.

## Signal conditioning and playback events

Master and bus gain/mute transitions ramp over **5 ms in sample time**, including unmute and changes to zero gain. Boot values apply directly before the first render. Muting still advances transport; only explicit pause freezes it. Bus envelopes also affect reverb sends. Master gain is applied **after** the shared reverb, so changing it scales the complete dry/wet mix.

The final output uses a **stereo-linked sample-peak limiter**: instantaneous attack, exponential release, no lookahead and no added buffering latency. Mono folds before limiting. This is not an oversampled true-peak limiter; it does not guarantee intersample peak protection. Non-finite final samples become silence; a hard clamp remains as a last safety bound. Startup configuration in `configs/audio.json`:

```json
{
  "audio": {
    "limiter_enabled": true,
    "limiter_ceiling": 0.98,
    "limiter_release_ms": 100,
    "occlusion_cutoff_hz": 800
  }
}
```

Spatial voices use a one-pole low-pass filter driven by the existing posted `occlusion` value. The cutoff moves logarithmically from the clear bandwidth toward `occlusion_cutoff_hz`; coefficients ramp over 5 ms. At zero occlusion the filter returns to exact bypass. Set cutoff to `0` to disable filtering. Existing `occlusion_gain_factor` still controls volume loss independently; full occlusion with its default `1` remains fully silent. Filter state resets on slot reuse and seek. No physics queries run on the callback.

Audio API **v7** adds `pollEvent()` and `droppedEvents()`. The game-thread consumer receives `{ voice, reason, frame }` records for `finished`, `stopped`, `stolen`, `decode_failed`, `missed_start`, and `invalidated`. Voice handles retain the ending generation. Frame is the mixer observation boundary (block end for rendered completions), not an exact source-end or device-presentation timestamp. Preparation/stream/seek failures share `decode_failed`; detailed errors remain in logs. The fixed 256-event queue drops the newest entry on overflow and counts losses. It is one consumable session queue, not a broadcast: coordinate consumers, including scripts and the two editor Worlds.

```zig
while (audio.pollEvent()) |event| {
    switch (event.reason) {
        .finished => {}, // advance dialogue/music sequencing
        else => {},
    }
}
```

Kawa equivalents: `Audio.poll_event()` returns `{ voice, reason, frame }` or `nil`, and `Audio.dropped_events()` returns the loss count. The profiler-enabled Audio panel also shows callback dropouts and event losses.

## Residuals

Current implementation limits.

| Residual | Note |
|----------|------|
| `invalidateCueSync` 50 ms timeout | Warns if the worker is wedged; race is soft |
| Mix strip vs script gains | Strip does not pull script-driven bus gains (UI-local one-way) |
| Streaming model | File-backed windowed `pread` for loose cues; pack members still fully resident |

## Extending

**New device backend:** platform `AudioBackend` + factory on `DriverModule.audio`. Keep the render callback mix-only.

## Related

| Need | Start |
|------|--------|
| Cook formats | [`asset-formats.md`](/docs/systems/asset-formats.md) |
| Driver selection | [`frontends-and-drivers.md`](/docs/systems/frontends-and-drivers.md), `backend/audio.zig` |
| Profiler UI | [`ui-and-editor.md`](/docs/systems/ui-and-editor.md#profiler) |
| Packaging backends | `HikariGameConductor.Package.cs` (`BackendPattern`, `hikari-backend-*`) |
