ignifx
GitHubnpm · soon
All examples

Audio mixer

Audio3D

  • Mouse
  • Touch
  • Gamepad

The four faders are the four buses of `game.audio.json` — `Master`, with `Music`, `SFX` and `UI` routed into it — and the rail on the floor is that routing. A fader's knob is the bus's own gain; the lamp above it is the gain that actually reaches the output, so pulling `Master` down dims every lamp while the three faders under it stay where they are. Click a pad to fire a sound through its bus, click a fader to mute one. The ring on the floor is the browser's autoplay lock: it pulses amber until the audio context runs, and the chime this example asks for while it is still locked is queued rather than dropped.

Three coloured faders — orange, cyan and amber — stand in a row on a dark grid floor, with a fourth green fader raised behind them and a pale rail linking all four. Five round pads sit across the front of the rig, one brown, three teal and one olive, and a thin amber ring is drawn on the floor around the desk.
Open standalone

` devtools

WebGPU: checking…

Try this

  • Click anywhere: the ring turns green, and the chime queued before that first click plays now.
  • Pull the Master fader down and watch every lamp on the desk dim while the three faders under it hold their place.
  • Click the leftmost pad to start the loop, then click the Music fader: the knob turns red and the loop goes quiet.

Read the guide

Show source

Source

main.ts
import { AUDIO_ASSET_TYPE, audio, AudioSource, Camera, MusicPlayer, parseAudioBusesFile } from "ignifx";import { bootExample } from "../_kit/boot.ts";import { attachOrbit } from "../_kit/orbit.ts";import { bind, button, readout, slider } from "../_kit/panel.ts";import { createGridGround, createLightRig } from "../_kit/stage.ts";import busesSource from "./game.audio.json?raw";import { attachMixer } from "./mixer.ts";import type { MixerDesk, PadId } from "./mixer.ts";import type { PanelControl } from "../_kit/panel.ts";import type { App, AssetHandle, AudioClip } from "ignifx";/** * The mixer: a bus tree from an `.audio.json`, four faders that **are** those buses, five pads that * fire sounds through them, and the browser's autoplay lock shown rather than hidden. * * ## The tree comes from the document, before the first frame * * `game.audio.json` beside this file is the mixer: `Master`, with `Music`, `SFX` and `UI` under it. * It is imported with `?raw`, checked by `parseAudioBusesFile` — the same parser the asset loader * uses — and handed to `audio({ busTree })`, which builds the tree during `createApp`. A tree named * as an **address** (`audio({ buses: "audio/game.audio.json" })`) is what a game normally writes, * and it arrives a frame later, because asset delivery needs a stepped frame; `busTree` is the only * way to have a custom tree standing before the first one. It is imported here, rather than * vendored under `website/examples/assets/`, so the viewer page can show it as a tab beside this * file — which is the point of the example. * * ## Nothing is audible before a gesture, and nothing is lost either * * `app.audio.state` reads `"locked"` until the audio context runs. Every `play()` made before then * is **held on its voice and flushed in order** the moment it does, so game code never has to * check: the ring on the floor pulses amber, the panel reads `locked`, and the queue counter shows * how many plays are waiting. Clicking anywhere unlocks it — Babylon Lite resumes the context on * the first click in the document — and the panel's Unlock button calls `app.audio.unlock()` * itself, which is what a "tap to start" screen does. * * ## Two ways to play, and when each is right * * The three impact pads own an `AudioSource`: a component with a clip, a bus and an instance limit, * which is what a thing in the world that makes a noise should be. The UI click goes through * `app.audio.playOneShot`, which is fire-and-forget and needs no entity. `pitch` is a **field** on * the source rather than an argument, because Babylon Lite fixes an instance's playback rate when * it starts — so setting it applies to the next `play()`. *//** The clips this example loads, by the address the asset manifest resolves. */const CLIPS = {  music: "audio/ambient.wav",  pickup: "audio/pickup.wav",  jump: "audio/jump.wav",  land: "audio/land.wav",  click: "audio/ui-click.wav",} as const;/** The buses, in fader order: the names `game.audio.json` declares. */const BUSES = ["Master", "Music", "SFX", "UI"] as const;/** How much a one-shot's pitch may wander, so a repeated impact does not sound like a machine. */const PITCH_SPREAD = 0.14;/** Where the camera starts. */const SHOT = { yaw: 0, pitch: 28, distance: 4.75, target: { x: 0, y: 0.56, z: 0.12 } } as const;/** `JSON.parse` answers `any`; the document is `unknown` until the parser has checked it. */const busesJson: unknown = JSON.parse(busesSource);/** * Loads one clip. * * @param app - The app whose asset service loads it. * @param address - The manifest address. * @returns The handle, with one holder. */function loadClip(app: App, address: string): AssetHandle<AudioClip> {  return app.assets.load<AudioClip>(address, { type: AUDIO_ASSET_TYPE });}/** * Writes a gain as a percentage. * * @param value - The linear gain, `0` to `1`. * @returns The text for a slider's value cell. */function gain(value: number): string {  return `${String(Math.round(value * 100))}%`;}bootExample({  title: "Audio mixer",  extensions: [audio({ busTree: parseAudioBusesFile(busesJson, "game.audio.json"), masterVolume: 0.9 })],  settings: {    rendering: {      clearColor: { r: 0.035, g: 0.043, b: 0.059, a: 1 },      msaaSamples: 4,      // No `shadows` feature: nothing on the desk casts one, and asking for the pass anyway made      // Dawn refuse the frame — the empty shadow map ends up read and written in one synchronisation      // scope ("usage includes writable usage and another usage in the same synchronization scope",      // measured on SwiftShader 2026-09-08). A feature is read once at `app.start()`, so this is the      // place it has to be right.      features: {},    },    time: { fixedDeltaTime: 1 / 60 },  },  async setup({ app, panel, random }) {    const eye = app.world.createEntity("Main Camera");    const camera = eye.addComponent(Camera, { near: 0.05, far: 120, fov: 42 });    attachOrbit(app, eye, { ...SHOT, minDistance: 2.4, maxDistance: 12, minPitch: 6, maxPitch: 72 });    // Awaited before `app.start()`: a load that completes before the loop runs settles at once,    // where one awaited afterwards waits for a `PreUpdate`.    await createGridGround(app, { size: 60, color: { r: 0.17, g: 0.19, b: 0.24, a: 1 } });    createLightRig(app, { focus: { x: 0, y: 0.4, z: 0.2 }, keyIntensity: 2.2, rimIntensity: 0.8, shadows: false });    const clips = {      music: loadClip(app, CLIPS.music),      pickup: loadClip(app, CLIPS.pickup),      jump: loadClip(app, CLIPS.jump),      land: loadClip(app, CLIPS.land),      click: loadClip(app, CLIPS.click),    };    await Promise.all(Object.values(clips).map((handle: AssetHandle<AudioClip>) => handle.promise));    // One `AudioSource` per impact: a clip, a bus and an instance limit. Past `maxInstances` the    // oldest instance is stolen, which is what stops a held-down pad stacking into a roar.    const source = (name: string, clip: AssetHandle<AudioClip>): AudioSource =>      app.world        .createEntity(`${name} source`)        .addComponent(AudioSource, { clip, bus: "SFX", volume: 0.85, maxInstances: 6 });    const impacts = {      pickup: source("Pickup", clips.pickup),      jump: source("Jump", clips.jump),      land: source("Land", clips.land),    };    const music = app.world      .createEntity("Music")      .addComponent(MusicPlayer, { crossfadeSeconds: 1.5, loopTrack: true, volume: 1 });    let queued = 0;    let playing = false;    // The desk is built below and lights its own lamps; the only thing these handlers need it for    // is latching the music pad, so it is read through a variable the builder fills in.    let desk: MixerDesk | null = null;    /** Counts a play made before the unlock, so the panel can show that none was dropped. */    const countIfQueued = (): void => {      if (app.audio.isLocked && app.audio.queueWhileLocked) {        queued += 1;      }    };    const impact = (name: "jump" | "land" | "pickup"): void => {      const emitter = impacts[name];      // `random` is the kit's seeded generator; an example never calls `Math.random`.      emitter.pitch = 1 - PITCH_SPREAD / 2 + random() * PITCH_SPREAD;      emitter.play();      countIfQueued();    };    const uiClick = (): void => {      // Fire and forget, on a bus `app.pause()` never pauses: a menu keeps its own clicks audible.      app.audio.playOneShot(clips.click.value, { bus: "UI", volume: 0.8 });      countIfQueued();    };    const toggleMusic = (): void => {      playing = !playing;      if (playing) {        music.play(clips.music.value, { fadeIn: 1.2 });      } else {        music.stop({ fadeOut: 0.8 });      }      countIfQueued();      desk?.latch("music", playing);    };    desk = attachMixer(app, camera, {      play: (pad: PadId): void => {        if (pad === "music") {          toggleMusic();        } else if (pad === "click") {          uiClick();        } else {          impact(pad);        }      },      // One bus, one flag: `muted` silences a bus and everything under it without losing where its      // fader was. Muting `Master` is how a game's "mute all" works.      toggleMute: (name: string): void => {        const bus = app.audio.bus(name);        bus.muted = !bus.muted;      },    });    // A start-up chime, made while the context is still locked. It is **not** dropped: the voice    // holds it and the service flushes every held play, in the order it was asked for, the moment    // the context runs — which is why the queue counter below reads 1 before anyone has clicked and    // "flushed" afterwards. A game's menu music is the same call in the same place.    impact("pickup");    // A slider and a button per bus, and one readout for the mutes. Not a `toggle`: a checkbox is    // written once when the panel mounts, so it would go stale the moment a click on a fader in the    // scene muted the same bus. A button cannot disagree with anything, and the readout is re-read    // four times a second.    const faders: PanelControl[] = BUSES.flatMap((name: string): readonly PanelControl[] => {      const bus = app.audio.bus(name);      return [        slider(name, { min: 0, max: 1, step: 0.01, format: gain }, bind(bus, "volume")),        button(`Mute ${name}`, (): void => {          bus.muted = !bus.muted;        }),      ];    });    faders.push(      readout("Muted", (): string => {        const muted = BUSES.filter((name: string) => app.audio.bus(name).muted);        return muted.length === 0 ? "none" : muted.join(", ");      }),      // The output gain sits after every bus, which is why it is not a fader on the desk: it is the      // system volume, not part of the tree.      slider("Output gain", { min: 0, max: 1, step: 0.01, format: gain }, bind(app.audio, "masterVolume")),    );    panel({      title: "Audio mixer",      groups: [        {          label: "Sound",          controls: [            readout("State", (): string => app.audio.state),            readout("Queued while locked", (): string => {              if (queued === 0) {                return "0";              }              return `${String(queued)} ${app.audio.isUnlocked ? "flushed" : "waiting"}`;            }),            button("Unlock", (): void => {              // A real gesture: a click handler is exactly where a resume belongs.              void app.audio.unlock();            }),          ],        },        { label: "Mixer", controls: faders },        {          label: "Play",          controls: [            button("Pickup", (): void => {              impact("pickup");            }),            button("Jump", (): void => {              impact("jump");            }),            button("Land", (): void => {              impact("land");            }),            button("UI click", uiClick),            button("Music", toggleMusic),          ],        },        {          label: "Frame",          collapsed: true,          controls: [            readout("Music playing", (): string => (music.isPlaying ? "yes" : "no")),            readout("Draw calls", (): string => String(app.renderer.drawCalls)),          ],        },      ],    });  },});
game.audio.json
{  "format": "ignifx.audiobuses",  "formatVersion": 1,  "buses": [    { "name": "Master", "parent": "", "volume": 1 },    { "name": "Music", "parent": "Master", "volume": 0.55, "pausable": true },    { "name": "SFX", "parent": "Master", "volume": 0.85, "pausable": true },    { "name": "UI", "parent": "Master", "volume": 0.9, "pausable": false }  ]}
mixer.ts
import {  clamp,  createMaterialAsset,  createRay,  defineInputActions,  MeshAsset,  MeshRenderer,  pbrMaterialDefinition,  Script,} from "ignifx";import type {  App,  AssetHandle,  AudioBus,  Camera,  ColorLike,  Entity,  InputAction,  InputActionsDefinition,  MaterialAsset,  Ray,  ScriptCallbacks,} from "ignifx";/** * The mixing desk `audio-mixer` is operated on: four faders that are the four buses of * `game.audio.json`, five pads that fire the sounds, and a ring on the floor that is the browser's * autoplay lock. * * Everything here is geometry, for the reason the example's poster gives away: the poster and the * golden are captured with `?nopanel=1`, so a mixer that only existed in the kit's parameter panel * would not be in the picture. The panel keeps the numbers; this keeps the instrument. * * ## What each part reads * * | Part            | Reads                                                                    | * | --------------- | ------------------------------------------------------------------------ | * | Fader knob      | `bus.volume` — the bus's **own** gain, which is what a fader is          | * | Fader fill      | The same, drawn from the plinth up to the knob                            | * | Signal lamp     | `bus.effectiveVolume` — own gain times every parent's — while it is busy | * | Knob colour     | `bus.muted`                                                               | * | Rails           | Nothing. They are the tree: three children into one root                  | * | Floor ring      | `app.audio.state`, pulsing while it reads `"locked"`                      | * * Turning `Master` down dims all four signal lamps while the three faders stay where they are, * which is the difference between `volume` and `effectiveVolume` made visible. * * ## Clicks, and why a click is not a press * * The camera is the kit's orbit camera, which drags on `<Pointer>/press` — so a pad that fired on * press would fire every time the visitor turned the scene. A click is therefore a press and a * release with less than {@link CLICK_SLOP} backing-store pixels between them, which is what a * game does and what makes both gestures usable on a touch screen. *//** The site's dark palette (`website/plan/02-design-system.md` §2.3), decoded to sRGB `0…1`. */const INK = {  /** A plinth: a step lighter than the palette's `--surface`, which reads as black at this size. */  surface: { r: 0.098, g: 0.118, b: 0.153, a: 1 },  /** A well: a fader's track, a pad's rim. Darker than the floor, so a knob sits *in* it. */  sunk: { r: 0.035, g: 0.043, b: 0.059, a: 1 },  /** A knob at rest, before its accent is written over it. */  rule: { r: 0.235, g: 0.275, b: 0.337, a: 1 },  /** A quiet mark: a rail, a lamp that is off. */  quiet: { r: 0.4, g: 0.451, b: 0.522, a: 1 },  /** `--flame`: the Music bus and its pad. */  flame: { r: 1, g: 0.62, b: 0.29, a: 1 },  /** `--cool`: the SFX bus, its pads, and every fader's fill. */  cool: { r: 0.353, g: 0.82, b: 0.784, a: 1 },  /** `--ok`: the Master bus, and the floor ring once the audio context is running. */  ok: { r: 0.357, g: 0.831, b: 0.541, a: 1 },  /** `--warn`: the UI bus, its pad, and the floor ring while the audio is locked. */  warn: { r: 0.949, g: 0.757, b: 0.306, a: 1 },  /** A muted fader knob. */  muted: { r: 0.55, g: 0.16, b: 0.16, a: 1 },  /** White: the frame a pad was clicked in. */  hot: { r: 1, g: 1, b: 1, a: 1 },} as const;/** How far a pointer may travel between press and release and still count as a click, in pixels. */const CLICK_SLOP = 12;/** A fader's travel, in metres: the length of its track. */const FADER_TRAVEL = 0.9;/** A fader's track width, in metres. */const FADER_WIDTH = 0.13;/** How high a fader's track starts above the floor, in metres. */const FADER_BASE = 0.07;/** A pad's diameter, in metres. */const PAD_DIAMETER = 0.56;/** How long a pad stays lit after it was clicked, in seconds. */const PAD_FLASH_SECONDS = 0.18;/** How long a bus's signal lamp stays lit after something played on it, in seconds. */const SIGNAL_SECONDS = 0.32;/** How fast the floor ring pulses while the audio is locked, in cycles per second. */const LOCK_PULSE_HZ = 0.6;/** The smallest scale an element is given, so no transform is ever singular. */const MIN_SCALE = 0.004;/** How many steps a level is quantised to before it is written. */const LEVEL_STEPS = 48;/** The action map the desk reads its clicks from. Its own name, so the kit's `KitOrbit` is intact. */export const MIXER_ACTION_MAP = "AudioMixer";/** Which pad was clicked. */export type PadId = "click" | "jump" | "land" | "music" | "pickup";/** What the desk asks the example to do when a pad is clicked. */export interface MixerHooks {  /**   * Plays whatever that pad plays.   *   * @param pad - The pad that was clicked.   */  readonly play: (pad: PadId) => void;  /**   * Toggles a bus's mute, when a fader knob is clicked.   *   * @param bus - The bus name.   */  readonly toggleMute: (bus: string) => void;}/** The actions the desk binds: one press and one position, both from the unified pointer. */export const MIXER_ACTIONS: InputActionsDefinition = defineInputActions({  maps: [    {      name: MIXER_ACTION_MAP,      actions: [        { name: "deskPress", type: "button", bindings: [{ path: "<Pointer>/press" }] },        { name: "deskPointer", type: "vector2", bindings: [{ path: "<Pointer>/position" }] },      ],    },  ],});/** One element that changes colour. */interface Lamp {  /** The element's own material. */  readonly material: AssetHandle<MaterialAsset>;  /** The colour at level 0. */  readonly off: ColorLike;  /** The colour at level 1. */  readonly on: ColorLike;  /** The last level written, quantised, so an unchanged lamp costs nothing. */  written: number;}/** One fader: the bus it is, the knob that shows its gain, and the lamp that shows its signal. */interface Fader {  /** The bus name, as `game.audio.json` declares it. */  readonly name: string;  /** The bus, or `null` when the tree does not declare it. */  readonly bus: AudioBus | null;  /** The knob, slid up the track by the bus's own gain. */  readonly knob: Entity;  /** The track behind it; clickable too, so the whole fader mutes its bus. */  readonly track: Entity;  /** The knob's lamp: the bus's accent, or red when the bus is muted. */  readonly knobLamp: Lamp;  /** The fill under the knob. */  readonly fill: Entity;  /** The fill's lamp. */  readonly fillLamp: Lamp;  /** The lamp on top, lit by `effectiveVolume` while the bus is busy. */  readonly signal: Lamp;  /** Where the track starts above the floor, in metres: `FADER_BASE` plus the fader's own lift. */  readonly base: number;  /** Seconds of signal left, counted down on the unscaled clock. */  busy: number;}/** One pad: a drum you click to fire a sound. */interface Pad {  /** Which pad it is. */  readonly id: PadId;  /** The bus the sound it fires is routed to, so a click can light the right lamps. */  readonly bus: string;  /** The disc's lamp. */  readonly lamp: Lamp;  /** Seconds of flash left. */  flash: number;  /** Whether the pad is a latch that is currently on, which is how the music pad reads. */  latched: boolean;}/** The parts one desk is made of. */interface Parts {  /** The four faders, left to right, root last. */  readonly faders: readonly Fader[];  /** The five pads, left to right. */  readonly pads: readonly Pad[];  /** The ring on the floor: the autoplay lock. */  readonly ring: Lamp;  /** Which entity does what when it is clicked. */  readonly targets: ReadonlyMap<Entity, () => void>;  /** The camera the click's ray is cast through. */  readonly camera: Camera;  /** The press action. */  readonly press: InputAction | null;  /** The pointer-position action, in backing-store pixels. */  readonly pointer: InputAction | null;}/** * Writes a lamp's colour for a level in `0…1`. * * @param lamp - The lamp to write. * @param level - How lit it is, `0` to `1`. */function setLevel(lamp: Lamp, level: number): void {  const step = Math.round(clamp(level, 0, 1) * LEVEL_STEPS);  if (step === lamp.written) {    return;  }  lamp.written = step;  const t = step / LEVEL_STEPS;  lamp.material.value.setBaseColor({    r: lamp.off.r + (lamp.on.r - lamp.off.r) * t,    g: lamp.off.g + (lamp.on.g - lamp.off.g) * t,    b: lamp.off.b + (lamp.on.b - lamp.off.b) * t,    a: 1,  });}/** * A dark shade of one colour: what a lamp of that colour looks like switched off. * * @remarks * Lamps rest at a shade of their **own** accent rather than at one neutral grey, because a level * between a blue-grey and an orange passes through mud, and half the desk sits at a level between * the two most of the time. * * @param color - The accent. * @param scale - How much of it is left; `0.22` is the resting shade. * @returns The darkened colour, opaque. */function shade(color: ColorLike, scale: number): ColorLike {  return { r: color.r * scale, g: color.g * scale, b: color.b * scale, a: 1 };}/** * The colour a bus's accent is. * * @param name - The bus name. * @returns Its accent colour. */function accentOf(name: string): ColorLike {  if (name === "Music") {    return INK.flame;  }  if (name === "UI") {    return INK.warn;  }  if (name === "Master") {    return INK.ok;  }  return INK.cool;}/** * The desk's per-frame update: read the buses, write the geometry, and route a click. * * @remarks * A `Script`, so it runs in `Update` with everything else and the devtools inspector lists it. */export class MixerDesk extends Script implements ScriptCallbacks {  /** The namespaced registration id. */  static typeId = "audio-mixer/MixerDesk";  #parts: Parts | null = null;  /** Where the pointer was pressed, in backing-store pixels; `null` while nothing is held. */  #pressedAt: { x: number; y: number } | null = null;  /**   * Reused so the per-frame path allocates nothing (coding standards §7).   *   * @remarks   * `createRay()` rather than an object literal, and the difference is not cosmetic: a ray's   * `length` is how far it reaches, `createRay` sets it to `Number.MAX_VALUE`, and a literal   * that forgets it stops the cast short of everything in the scene.   */  readonly #ray: Ray = createRay();  /**   * Hands the script the parts {@link attachMixer} built.   *   * @param parts - The desk's parts.   */  install(parts: Parts): void {    this.#parts = parts;  }  /**   * Pings one bus's signal lamp, and its ancestors' with it.   *   * @remarks   * The example calls this when it plays something, because nothing in `app.audio` reports which   * bus a sound is on: a voice is created against a bus and the mixer is a chain of gains, not a   * meter. Walking to the root is what makes `Master`'s lamp light for every sound.   *   * @param bus - The bus a sound was just played on.   */  ping(bus: string): void {    const parts = this.#parts;    if (parts === null) {      return;    }    for (const fader of parts.faders) {      if (fader.name === bus || isAncestorOf(fader.name, parts, bus)) {        fader.busy = SIGNAL_SECONDS;      }    }  }  /**   * Latches or unlatches one pad, which is how the music pad shows that the loop is running.   *   * @param pad - Which pad.   * @param on - Whether it is latched on.   */  latch(pad: PadId, on: boolean): void {    const found = this.#parts?.pads.find((candidate: Pad) => candidate.id === pad);    if (found !== undefined) {      found.latched = on;    }  }  /** Reads the buses and the click, and writes the desk. */  update(): void {    const parts = this.#parts;    if (parts === null) {      return;    }    const unscaled = this.app.time.unscaledDeltaTime;    this.#readClick(parts);    for (const fader of parts.faders) {      updateFader(fader, unscaled);    }    for (const pad of parts.pads) {      updatePad(pad, unscaled);    }    this.#updateRing(parts);  }  /**   * Turns a press-and-release into a click on whatever was under it.   *   * @param parts - The desk's parts.   */  #readClick(parts: Parts): void {    const press = parts.press;    const pointer = parts.pointer;    if (press === null || pointer === null) {      return;    }    if (press.wasPressedThisFrame) {      this.#pressedAt = { x: pointer.vector.x, y: pointer.vector.y };    }    // No early return after the press: a frame's events are all applied together in `PreUpdate`,    // so a press and its release can resolve in the same frame and a handler that returned above    // would drop that click. A click shorter than one frame is a different thing and is not    // recoverable here at all — the control is back at zero before anything reads it, so neither    // edge flag is ever set. `app.input.events` still holds both records for a game that needs    // them; an action reports the state of a control, not the history of it.    if (!press.wasReleasedThisFrame) {      return;    }    const from = this.#pressedAt;    this.#pressedAt = null;    if (from === null || Math.hypot(pointer.vector.x - from.x, pointer.vector.y - from.y) > CLICK_SLOP) {      return;    }    // `<Pointer>/position` is in the canvas's **backing-store pixels**, which is the space    // `Camera.screenToRay` and `renderer.pickAsync` both speak — so the action's vector goes    // straight in. DOM code is the side that divides by `devicePixelRatio`.    const ray = parts.camera.screenToRay(pointer.vector.x, pointer.vector.y, this.#ray);    if (ray === null) {      return;    }    // A synchronous CPU raycast over the pickable renderers, rather than `pickAsync`, because the    // answer is wanted in this frame and a desk is a handful of meshes.    const hit = this.app.world.raycastRender(ray);    if (hit === null) {      return;    }    const run = parts.targets.get(hit.entity);    if (run !== undefined) {      run();    }  }  /**   * Lights the ring on the floor: amber and pulsing while the audio is locked, green once it runs.   *   * @remarks   * The pulse is on `time.time`, the **scaled** clock, so `?static=1` — which stops the clock   * before the first frame — freezes the ring at a known brightness and the golden does not   * depend on when the page happened to load.   *   * @param parts - The desk's parts.   */  #updateRing(parts: Parts): void {    const running = this.app.audio.state === "running";    const pulse = running ? 1 : 0.5 + 0.5 * Math.sin(this.app.time.time * LOCK_PULSE_HZ * Math.PI * 2);    // The two states are two different colours, not two ends of one ramp: a level between amber and    // green is mud, and the ring is the one thing on the desk that has to be readable at a glance.    const step = Math.round(pulse * LEVEL_STEPS) + (running ? LEVEL_STEPS + 1 : 0);    if (step === parts.ring.written) {      return;    }    parts.ring.written = step;    const from = running ? INK.ok : shade(INK.warn, 0.3);    const to = running ? INK.ok : INK.warn;    parts.ring.material.value.setBaseColor({      r: from.r + (to.r - from.r) * pulse,      g: from.g + (to.g - from.g) * pulse,      b: from.b + (to.b - from.b) * pulse,      a: 1,    });  }}/** * Whether one bus is an ancestor of another, by walking the tree the service built. * * @param candidate - The bus that might be an ancestor. * @param parts - The desk's parts, for the fader table. * @param of - The bus a sound was played on. * @returns `true` when `candidate` is `of`'s parent, grandparent, and so on. */function isAncestorOf(candidate: string, parts: Parts, of: string): boolean {  let bus = parts.faders.find((fader: Fader) => fader.name === of)?.bus?.parent ?? null;  while (bus !== null) {    if (bus.name === candidate) {      return true;    }    bus = bus.parent;  }  return false;}/** * Slides one fader's knob, scales its fill, and lights its signal lamp. * * @param fader - The fader to update. * @param unscaled - The unscaled frame delta, in seconds. */function updateFader(fader: Fader, unscaled: number): void {  const bus = fader.bus;  const own = clamp(bus?.volume ?? 0, 0, 1);  fader.knob.transform.localPosition.y = fader.base + own * FADER_TRAVEL;  const height = Math.max(own * FADER_TRAVEL, MIN_SCALE);  fader.fill.transform.localScale.set(FADER_WIDTH * 0.55, height, FADER_WIDTH * 0.55);  fader.fill.transform.localPosition.y = fader.base + height / 2;  setLevel(fader.fillLamp, own);  setLevel(fader.knobLamp, bus?.muted === true ? 0 : 1);  if (fader.busy > 0) {    fader.busy = Math.max(fader.busy - unscaled, 0);  }  // `effectiveVolume` is the bus's own gain times every parent's, so turning `Master` down dims  // every lamp on the desk while the three faders under it stay exactly where they are.  setLevel(fader.signal, fader.busy > 0 ? clamp(bus?.effectiveVolume ?? 0, 0, 1) : 0);}/** * Lights one pad: white for the frames after a click, its accent while it is latched on. * * @param pad - The pad to update. * @param unscaled - The unscaled frame delta, in seconds. */function updatePad(pad: Pad, unscaled: number): void {  if (pad.flash > 0) {    pad.flash = Math.max(pad.flash - unscaled, 0);  }  setLevel(pad.lamp, pad.flash > 0 ? 1 : pad.latched ? 0.9 : 0.44);}/** * Builds an unlit material for one part of the desk. * * @param app - The app the asset belongs to. * @param name - The material's name. * @param color - The colour it starts at. * @returns The handle, with one holder. */function createInkMaterial(app: App, name: string, color: ColorLike): AssetHandle<MaterialAsset> {  return createMaterialAsset(    app,    pbrMaterialDefinition({      name: `mixer/${name}`,      baseColor: color,      metallic: 0,      roughness: 1,      unlit: true,      doubleSided: true,    }),    [],  );}/** Where a part sits and how big it is, in the parent's metres. */interface Placement {  /** Metres along X. */  readonly x: number;  /** Metres along Y. */  readonly y: number;  /** Metres along Z. */  readonly z: number;  /** Scale along X. */  readonly sx: number;  /** Scale along Y. */  readonly sy: number;  /** Scale along Z. */  readonly sz: number;}/** * Adds one part of the desk. * * @param app - The app. * @param name - The entity's name, which the devtools scene tree shows. * @param mesh - The template it clones. * @param material - The material it wears. * @param at - Where it sits and how big it is. * @param pickable - Whether a click can find it. * @returns The entity. */function addPart(  app: App,  name: string,  mesh: AssetHandle<MeshAsset>,  material: AssetHandle<MaterialAsset>,  at: Placement,  pickable = false,): Entity {  const entity = app.world.createEntity(name);  entity.transform.localPosition.set(at.x, at.y, at.z);  entity.transform.localScale.set(at.sx, at.sy, at.sz);  entity.addComponent(MeshRenderer, {    mesh,    materials: [material],    castShadows: false,    receiveShadows: false,    pickable,  });  return entity;}/** The templates the desk shares. */interface Shapes {  /** A unit box: plinths, tracks, fills, knobs, rails, lamps. */  readonly box: AssetHandle<MeshAsset>;  /** A unit cylinder standing along Y: the pads. */  readonly disc: AssetHandle<MeshAsset>;  /** A unit ring in the XZ plane: the pad rims. */  readonly ring: AssetHandle<MeshAsset>;  /** A much thinner unit ring: the floor ring, whose tube would otherwise scale to a hoop. */  readonly hoop: AssetHandle<MeshAsset>;}/** * Builds one fader. * * @param app - The app. * @param shapes - The shared templates. * @param name - The bus's name. * @param x - Where the fader stands, in metres right of the desk's centre. * @param z - Where the fader stands, in metres forward of the desk's centre. * @param lift - How far the whole fader is raised, in metres; the root stands on a taller plinth. * @returns The fader, and the knob a click can find. */function addFader(app: App, shapes: Shapes, name: string, x: number, z: number, lift: number): Fader {  const accent = accentOf(name);  const base = FADER_BASE + lift;  addPart(app, `${name} plinth`, shapes.box, createInkMaterial(app, `${name} plinth`, INK.surface), {    x,    y: (base + 0.01) / 2,    z,    sx: 0.32,    sy: base + 0.01,    sz: 0.32,  });  // The track is pickable as well as the knob: a knob is nine centimetres tall and a click has to  // land somewhere, so the whole fader is the target for its bus's mute.  const track = addPart(    app,    `${name} track`,    shapes.box,    createInkMaterial(app, `${name} track`, INK.sunk),    { x, y: base + FADER_TRAVEL / 2, z, sx: FADER_WIDTH, sy: FADER_TRAVEL, sz: FADER_WIDTH },    true,  );  const fillMaterial = createInkMaterial(app, `${name} fill`, accent);  const fill = addPart(app, `${name} fill`, shapes.box, fillMaterial, {    x,    y: base,    z: z - 0.045,    sx: FADER_WIDTH * 0.55,    sy: MIN_SCALE,    sz: FADER_WIDTH * 0.55,  });  const knobMaterial = createInkMaterial(app, `${name} knob`, accent);  const knob = addPart(    app,    `${name} knob`,    shapes.box,    knobMaterial,    { x, y: base, z, sx: 0.3, sy: 0.13, sz: 0.26 },    true,  );  const signalMaterial = createInkMaterial(app, `${name} signal`, INK.quiet);  addPart(app, `${name} signal`, shapes.box, signalMaterial, {    x,    y: base + FADER_TRAVEL + 0.08,    z,    sx: 0.2,    sy: 0.06,    sz: 0.2,  });  return {    name,    bus: app.audio.tryBus(name),    knob,    track,    knobLamp: { material: knobMaterial, off: INK.muted, on: accent, written: -1 },    fill,    fillLamp: { material: fillMaterial, off: shade(accent, 0.22), on: accent, written: -1 },    signal: { material: signalMaterial, off: INK.quiet, on: accent, written: -1 },    base,    busy: 0,  };}/** * Builds one pad. * * @param app - The app. * @param shapes - The shared templates. * @param id - Which pad it is. * @param label - The pad's name, for the scene tree. * @param bus - The bus the sound it fires is routed to. * @param x - Where it sits, in metres right of the desk's centre. * @param accent - Its accent colour, which is its bus's. * @returns The pad and its clickable disc. */function addPad(  app: App,  shapes: Shapes,  id: PadId,  label: string,  bus: string,  x: number,  accent: ColorLike,): { readonly pad: Pad; readonly disc: Entity } {  const z = -0.72;  addPart(app, `${label} rim`, shapes.ring, createInkMaterial(app, `${label} rim`, INK.sunk), {    x,    y: 0.03,    z,    sx: PAD_DIAMETER + 0.09,    sy: 0.07,    sz: PAD_DIAMETER + 0.09,  });  const material = createInkMaterial(app, `${label} pad`, accent);  const disc = addPart(    app,    `${label} pad`,    shapes.disc,    material,    { x, y: 0.05, z, sx: PAD_DIAMETER, sy: 0.1, sz: PAD_DIAMETER },    true,  );  return {    pad: { id, bus, lamp: { material, off: shade(accent, 0.24), on: accent, written: -1 }, flash: 0, latched: false },    disc,  };}/** * Builds the desk and attaches the script that drives it. * * @param app - The running app; needs `audio()`, `input()`, and a built bus tree. * @param camera - The camera a click's ray is cast through. * @param hooks - What a click on a pad or a knob does. * @returns The desk's script, so the example can ping its lamps and latch its music pad. * * @example * ```ts * const desk = attachMixer(app, camera, { play, toggleMute }); * desk.ping("SFX"); * ``` */export function attachMixer(app: App, camera: Camera, hooks: MixerHooks): MixerDesk {  app.registerComponents([MixerDesk]);  app.input.loadActions(MIXER_ACTIONS);  const shapes: Shapes = {    box: MeshAsset.box(app, { size: 1 }),    disc: MeshAsset.cylinder(app, { diameter: 1, height: 1, tessellation: 24 }),    ring: MeshAsset.torus(app, { diameter: 1, thickness: 0.09, tessellation: 32 }),    hoop: MeshAsset.torus(app, { diameter: 1, thickness: 0.022, tessellation: 64 }),  };  const faders = [    addFader(app, shapes, "Music", -1.25, 0.75, 0),    addFader(app, shapes, "SFX", 0, 0.75, 0),    addFader(app, shapes, "UI", 1.25, 0.75, 0),    // The root stands behind the three and a third of a metre higher, so its column clears the    // fader in front of it from the camera's own start pose.    addFader(app, shapes, "Master", 0, 2.05, 0.34),  ];  // Three rails into one: the routing `game.audio.json` declares, drawn rather than described.  for (const x of [-1.25, 0, 1.25]) {    addPart(app, `Rail ${String(x)}`, shapes.box, createInkMaterial(app, `rail ${String(x)}`, INK.quiet), {      x,      y: 0.012,      z: 1.24,      sx: 0.05,      sy: 0.024,      sz: 0.98,    });  }  addPart(app, "Rail bus", shapes.box, createInkMaterial(app, "rail bus", INK.quiet), {    x: 0,    y: 0.012,    z: 1.44,    sx: 2.55,    sy: 0.024,    sz: 0.05,  });  const built = [    addPad(app, shapes, "music", "Music loop", "Music", -1.7, INK.flame),    addPad(app, shapes, "pickup", "Pickup", "SFX", -0.85, INK.cool),    addPad(app, shapes, "jump", "Jump", "SFX", 0, INK.cool),    addPad(app, shapes, "land", "Land", "SFX", 0.85, INK.cool),    addPad(app, shapes, "click", "UI click", "UI", 1.7, INK.warn),  ];  const ringMaterial = createInkMaterial(app, "lock ring", INK.warn);  addPart(app, "Lock ring", shapes.hoop, ringMaterial, {    x: 0,    y: 0.014,    z: 0.45,    sx: 4.3,    sy: 0.06,    sz: 4.3,  });  const desk = app.world.createEntity("Mixer").addComponent(MixerDesk);  const targets = new Map<Entity, () => void>();  for (const entry of built) {    targets.set(entry.disc, (): void => {      entry.pad.flash = PAD_FLASH_SECONDS;      // The desk lights its own lamps, so the example's hook only has to make the sound.      desk.ping(entry.pad.bus);      hooks.play(entry.pad.id);    });  }  for (const fader of faders) {    const mute = (): void => {      hooks.toggleMute(fader.name);    };    targets.set(fader.knob, mute);    targets.set(fader.track, mute);  }  desk.install({    faders,    pads: built.map((entry) => entry.pad),    ring: { material: ringMaterial, off: INK.warn, on: INK.ok, written: -1 },    targets,    camera,    press: app.input.actions.find("deskPress"),    pointer: app.input.actions.find("deskPointer"),  });  return desk;}

Uses:parseAudioBusesFileapp.audio.busAudioBusAudioSourceMusicPlayerapp.audio.playOneShotapp.audio.unlock

Assets:ambient.wav — the music loop — Apache-2.0, Astrum Forge Studiospickup.wav, jump.wav, land.wav — the SFX pads — Apache-2.0, Astrum Forge Studiosui-click.wav — the UI pad — Apache-2.0, Astrum Forge Studios