Examples

Audio

A courtyard heard from the camera: a scripted bell, a muffled cart, lanterns on the kick.

Starts examples/audio in this page, on WebGPU where your browser has it.

Open on its own · Source

Sound and music is the chapter that walks through it.

From a checkout of the engine, npm run examples serves it at /audio/.

Source

main.ts

examples/audio/main.ts
/**
 * A courtyard at dusk, heard from wherever the camera stands.
 *
 * The camera is the listener. A bell in the tower strikes on a clock `courtyard.drs` keeps, heard
 * the cheap way, louder the nearer you are and on its own side, and the music ducks under it. A
 * cart rolls along the far road through an HRTF panner with doppler, muffled while the house is
 * between it and you, and the fire is placed the same way. In the crypt its reverb takes over and
 * the script crossfades the mix to a snapshot; the wind is a bus the script fades; the lanterns
 * keep time with the kick the music is making. Every sound is synthesised, so the page loads none.
 */
import {
  AudioGraph,
  addReverbZone,
  createListener,
  createSpatialSource,
  fireLoopBuffer,
  metalBuffer,
  noiseBuffer,
  windLoopBuffer,
} from '@driftengine/audio';
import type { OcclusionProbe } from '@driftengine/audio';
import {
  MeshBuilder,
  computeLightMatrix,
  createEnvironment,
  createPointLightBuffer,
  selectPointLights,
} from '@driftengine/core';
import type { PointLightSource, Vec3 } from '@driftengine/core';
import { patchModule } from 'driftscript';
import { createReadout } from '../common/readout';
import { exported, hostScript } from '../common/script';
import { controls, flag, openStage } from '../common/stage';
import { beatBuffer } from './beat';
import * as courtyardScript from './courtyard.drs';

const stage = await openStage({
  directionalShadows: true,
  outputTransform: 'aces',
  sceneSamples: 4,
});
const { renderer, camera } = stage;

// #region graph
/* The graph: one music stem into a mix of buses. A browser that will give no audio at all still
   gets a graph, on a context that never plays, so the page and its script run the same. */
const graph =
  (await AudioGraph.create({ stemCount: 1 })) ??
  (await AudioGraph.create({ stemCount: 1, context: new OfflineAudioContext(2, 1, 48000) }));
if (graph === null) throw new Error('no audio graph, even offline');
const mix = graph.console;

/* Every sound is a named slot: its files are tried first and its synth stands in when none loads.
   This page lists no files, so all five are made here. */
const sounds = graph.registry;
sounds.register('bell', { urls: [], synth: (context) => metalBuffer(context, 5, 155, 0.9) });
sounds.register('wheels', { urls: [], synth: (context) => noiseBuffer(context, 3, 0, () => 0.05) });
sounds.register('fire', { urls: [], synth: (context) => fireLoopBuffer(context) });
sounds.register('wind', { urls: [], synth: (context) => windLoopBuffer(context) });
sounds.register('beat', { urls: [], synth: beatBuffer });
await sounds.load(graph.context);
const slot = (name: string): AudioBuffer => {
  const buffer = sounds.get(name);
  if (buffer === undefined) throw new Error(`the ${name} slot did not build`);
  return buffer;
};
graph.loadStem(0, slot('beat'));
graph.setStemGain(0, 0.8);
// #endregion

// #region mix
/* The yard's own sounds on a bus of their own, under the effects, so the crypt can turn them down. */
const yard = mix.bus('yard', { parent: graph.layout.effects });
/* Two mixes the script recalls by name: the yard as it is, and the crypt, where the music and the
   yard are far away. */
mix.snapshot('yard');
graph.layout.music.setLevel(0.35);
yard.setLevel(0.4);
mix.snapshot('crypt');
graph.layout.music.setLevel(1);
yard.setLevel(1);
/* The wind, on a bus made after both snapshots: a recall leaves alone a bus it never saw, so the
   weather belongs to the script's `fade` and to nothing else. */
const wind = mix.bus('wind', { parent: graph.layout.effects, level: 0 });
const gusts = graph.context.createBufferSource();
gusts.buffer = slot('wind');
gusts.loop = true;
gusts.connect(wind.input);
// #endregion

// #region place
/* The ears, and the things heard from a place: a room the listener can stand in, the fire, and a
   cart that passes. A placed source is the expensive path, for when the direction is information. */
const listener = createListener(mix);
const crypt = addReverbZone(
  listener,
  'crypt',
  { x: 9.5, y: 1.5, z: -7.5, radius: 2.2, blend: 1.5 },
  { seconds: 3.4, decay: 2.2, wet: 0.85 },
);
const fire = createSpatialSource(listener, slot('fire'), { loop: true, bus: yard, refDistance: 2 });
const cart = createSpatialSource(listener, slot('wheels'), {
  loop: true,
  bus: yard,
  doppler: true,
  refDistance: 4,
});
fire.place(1.5, 0.4, -3, 0);

/* What a line from a sound to the ears can be blocked by: the house, the tower and the crypt's
   walls, as boxes. The listener asks, spread over the sources, and the answer is smoothed. */
const SOLIDS: readonly (readonly number[])[] = [
  [-5, 0, -12.2, 5, 4.4, -9.8],
  [-10.4, 0, -8.4, -7.6, 8, -5.6],
  [7, 0, -10, 12, 3, -9.65],
  [7, 0, -10, 7.35, 3, -5],
  [11.65, 0, -10, 12, 3, -5],
  [7, 0, -5.35, 8.8, 3, -5],
  [10.2, 0, -5.35, 12, 3, -5],
];
const walls: OcclusionProbe = (ax, ay, az, bx, by, bz) => {
  for (const box of SOLIDS) if (crosses(box, ax, ay, az, bx, by, bz)) return 0.85;
  return 0;
};
listener.probe = walls;
// #endregion

/** Whether the segment from a to b passes through a box, by the slab test. */
function crosses(
  box: readonly number[],
  ax: number,
  ay: number,
  az: number,
  bx: number,
  by: number,
  bz: number,
): boolean {
  let enter = 0;
  let leave = 1;
  for (let axis = 0; axis < 3; axis += 1) {
    const from = axis === 0 ? ax : axis === 1 ? ay : az;
    const span = (axis === 0 ? bx : axis === 1 ? by : bz) - from;
    const low = box[axis] ?? 0;
    const high = box[axis + 3] ?? 0;
    if (Math.abs(span) < 1e-9) {
      if (from < low || from > high) return false;
      continue;
    }
    const a = (low - from) / span;
    const b = (high - from) / span;
    enter = Math.max(enter, Math.min(a, b));
    leave = Math.min(leave, Math.max(a, b));
    if (enter > leave) return false;
  }
  return true;
}

// #region script
/* The script, bound to the mix and the sounds, and to the kick detector listening to the music. */
const kick = graph.createKickDetector();
const script = hostScript(courtyardScript, {
  audio: { graph, registry: sounds, ...(kick === null ? {} : { kick }) },
});
interface Courtyard {
  every: number;
  next: number;
  since: number;
  strikes: number;
  clock: number;
  windy: boolean;
  glow: number;
}
interface Ears {
  x: number;
  z: number;
  yaw: number;
  indoors: boolean;
}
const court = exported<() => Courtyard>(script, 'createCourtyard')();
const ears = exported<() => Ears>(script, 'createEars')();
type Tick = (yard: Courtyard, ears: Ears, dt: number) => void;
if (import.meta.hot) {
  import.meta.hot.accept('./courtyard.drs', (next) => {
    if (next !== undefined) {
      patchModule(script, next as Record<string, unknown>, { Courtyard: [court], Ears: [ears] });
    }
  });
}
// #endregion

// #region gesture
/* A context only plays after a gesture on the page, so the first click or key wakes it and starts
   the music and the loops. Started before one, each would only add a warning to the console. */
let started = false;
const listen = (): void => {
  graph.wake();
  if (started) return;
  started = true;
  graph.start();
  gusts.start();
  fire.start();
  cart.start();
};
addEventListener('pointerdown', listen);
addEventListener('keydown', listen);
// #endregion

/** Where the listener can stand, and what it looks at from there. */
const SPOTS: Record<string, { label: string; at: Vec3; look: Vec3 }> = {
  fire: { label: 'BY THE FIRE', at: [2.5, 1.7, 5], look: [0, 3, -8] },
  tower: { label: 'BY THE TOWER', at: [-4.5, 1.7, 3.5], look: [-9, 7, -7] },
  crypt: { label: 'IN THE CRYPT', at: [9.5, 1.6, -9.2], look: [9.5, 1.8, 0] },
  walk: { label: 'WALKING ROUND', at: [9, 1.7, -3], look: [1.5, 1.5, -3] },
};
let spot = flag('listen', 'fire');
court.every = flag('bell', 'slow') === 'quick' ? 4 : 10;
court.windy = flag('weather', 'calm') === 'windy';
const muffle = (on: boolean): void => {
  listener.probe = on ? walls : null;
  /* Without a probe a source keeps the last answer, so say plainly that nothing is in the way. */
  if (!on) {
    fire.setOcclusion(0);
    cart.setOcclusion(0);
  }
};
muffle(flag('walls', 'muffle') === 'muffle');
controls([
  {
    key: 'listen',
    label: 'listen',
    value: spot,
    options: Object.keys(SPOTS).map((s) => ({ text: s, value: s })),
    change: (value) => {
      spot = value;
    },
  },
  {
    key: 'bell',
    label: 'bell',
    value: court.every === 4 ? 'quick' : 'slow',
    options: ['slow', 'quick'].map((b) => ({ text: b, value: b })),
    change: (value) => {
      court.every = value === 'quick' ? 4 : 10;
    },
  },
  {
    key: 'weather',
    label: 'weather',
    value: court.windy ? 'windy' : 'calm',
    options: ['calm', 'windy'].map((w) => ({ text: w, value: w })),
    change: (value) => {
      court.windy = value === 'windy';
    },
  },
  {
    key: 'walls',
    label: 'walls',
    value: listener.probe === null ? 'ignore' : 'muffle',
    options: ['muffle', 'ignore'].map((w) => ({ text: w, value: w })),
    change: (value) => muffle(value === 'muffle'),
  },
]);

/* The yard, built once: ground and road, the house, the tower and its bell, the crypt, the fire's
   stones and the lantern posts. */
const BELL: Vec3 = [-9, 9.3, -7];
const FIRE: Vec3 = [1.5, 0, -3];
const LANTERNS: Vec3[] = [0, 1, 2, 3, 4].map((k) => {
  const angle = (k / 5) * Math.PI * 2 + 1;
  return [FIRE[0] + Math.cos(angle) * 4.6, 2.75, FIRE[2] + Math.sin(angle) * 4.6];
});
/* And one outside the crypt's door, which is what can be seen from inside it. */
LANTERNS.push([9.5, 2.75, 2.5]);
const stone: Vec3 = [0.46, 0.43, 0.4];
const built = new MeshBuilder()
  .addBox([0, -0.1, -4], [26, 0.1, 18], [0.2, 0.19, 0.18])
  .addBox([0, 0.005, -16], [30, 0.005, 1.6], [0.12, 0.11, 0.1])
  .addBox([0, 2.2, -11], [5, 2.2, 1.2], [0.42, 0.36, 0.3])
  .addBox([0, 4.55, -11], [5.4, 0.25, 1.6], [0.3, 0.18, 0.14])
  .addBox([-9, 4, -7], [1.4, 4, 1.4], stone)
  .addBox([-9, 10.6, -7], [1.6, 0.25, 1.6], [0.3, 0.18, 0.14])
  .addCylinder([BELL[0], BELL[1], BELL[2]], 0.5, 0.45, 'y', [0.55, 0.38, 0.16], 0, 24, 0.6)
  .addBox([9.5, 1.5, -9.825], [2.5, 1.5, 0.175], stone)
  .addBox([7.175, 1.5, -7.5], [0.175, 1.5, 2.5], stone)
  .addBox([11.825, 1.5, -7.5], [0.175, 1.5, 2.5], stone)
  .addBox([7.9, 1.5, -5.175], [0.9, 1.5, 0.175], stone)
  .addBox([11.1, 1.5, -5.175], [0.9, 1.5, 0.175], stone)
  .addBox([9.5, 2.6, -5.175], [0.7, 0.4, 0.175], stone)
  .addBox([9.5, 3.15, -7.5], [2.7, 0.15, 2.7], [0.3, 0.29, 0.28]);
for (const [dx, dz] of [
  [-1.25, -1.25],
  [1.25, -1.25],
  [-1.25, 1.25],
  [1.25, 1.25],
]) {
  built.addBox([-9 + (dx ?? 0), 9.2, -7 + (dz ?? 0)], [0.15, 1.2, 0.15], stone);
}
for (let k = 0; k < 12; k += 1) {
  const angle = (k / 12) * Math.PI * 2;
  built.addSphere(
    [FIRE[0] + Math.cos(angle) * 0.7, 0.1, FIRE[2] + Math.sin(angle) * 0.7],
    0.14,
    [0.3, 0.29, 0.28],
  );
}
for (const at of LANTERNS) built.addBox([at[0], 1.3, at[2]], [0.06, 1.3, 0.06], [0.1, 0.1, 0.1]);
const yardMesh = renderer.createMesh(built.build());
const flame = renderer.createMesh(
  new MeshBuilder()
    .addCapsule([0, 0.3, 0], 0.22, 0.12, [1, 0.38, 0.08], 1)
    .addCapsule([0.05, 0.55, -0.03], 0.13, 0.1, [1, 0.62, 0.2], 1)
    .addSphere([-0.03, 0.78, 0.02], 0.07, [1, 0.85, 0.45], 1)
    .build(),
);
const heads = new MeshBuilder();
for (const at of LANTERNS) {
  heads.addBox(at, [0.12, 0.16, 0.12], [1, 0.7, 0.35], 1);
  heads.addBox([at[0], at[1] + 0.2, at[2]], [0.16, 0.04, 0.16], [0.08, 0.08, 0.08]);
}
const headMesh = renderer.createMesh(heads.build());
const cartMesh = renderer.createMesh(
  new MeshBuilder()
    .addBox([0, 0.85, 0], [1.3, 0.35, 0.7], [0.36, 0.22, 0.12])
    .addCylinder([-0.8, 0.42, 0.75], 0.42, 0.06, 'z', [0.16, 0.12, 0.09], 0, 16)
    .addCylinder([0.8, 0.42, 0.75], 0.42, 0.06, 'z', [0.16, 0.12, 0.09], 0, 16)
    .addCylinder([-0.8, 0.42, -0.75], 0.42, 0.06, 'z', [0.16, 0.12, 0.09], 0, 16)
    .addCylinder([0.8, 0.42, -0.75], 0.42, 0.06, 'z', [0.16, 0.12, 0.09], 0, 16)
    .build(),
);
/* The ring drawn round the bell as it strikes, a circle one metre across scaled out as it fades. */
const circle: number[] = [];
for (let k = 0; k <= 48; k += 1) {
  const angle = (k / 48) * Math.PI * 2;
  circle.push(Math.cos(angle), 0, Math.sin(angle));
}
const ringMesh = renderer.createMesh(
  new MeshBuilder()
    .addTube(circle, new Array<number>(circle.length / 3).fill(0.025), [1, 0.85, 0.5], 1)
    .build(),
);

const env = createEnvironment({
  directionalDir: [0.35, 0.55, 0.6],
  directionalColor: [0.55, 0.5, 0.62],
  ambient: [0.13, 0.14, 0.22],
  ambientGround: [0.05, 0.05, 0.06],
  nightFactor: 0.8,
  emissiveGain: 1.4,
});
const lightMatrix = new Float32Array(16);
env.lightViewProj = lightMatrix;
env.shadowStrength = 0.6;
env.shadowDepthSpan = computeLightMatrix(
  env.directionalDir,
  0,
  2,
  -6,
  20,
  renderer.shadowMapSize,
  lightMatrix,
);
const lights: PointLightSource[] = [
  {
    x: FIRE[0],
    y: 0.6,
    z: FIRE[2],
    r: 3,
    g: 1.4,
    b: 0.45,
    radius: 7,
    flicker: 0.3,
    shadowNear: 0.1,
    sourceRadius: 0.25,
    castsShadow: false,
  },
  ...LANTERNS.map((at) => ({
    x: at[0],
    y: at[1],
    z: at[2],
    r: 0,
    g: 0,
    b: 0,
    radius: 5,
    flicker: 0,
    shadowNear: 0.1,
    sourceRadius: 0.15,
    castsShadow: false,
  })),
];
const chosen = createPointLightBuffer(renderer.shadedLights);
const IDENTITY = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
const model = new Float32Array(IDENTITY);
const readout = createReadout(renderer, 3);
const eye: Vec3 = [...(SPOTS[spot] ?? (SPOTS.fire as { at: Vec3 })).at];
const gaze: Vec3 = [...(SPOTS[spot] ?? (SPOTS.fire as { look: Vec3 })).look];
const cartAt: Vec3 = [-30, 0, -16];
let time = 0;

stage.run({
  simulate(dt) {
    time += dt;
    // #region frame
    /* The camera glides to where it was asked to stand, and the ears go with it. */
    const target = SPOTS[spot] ?? (SPOTS.fire as { at: Vec3; look: Vec3 });
    if (spot === 'walk') {
      const angle = time * ((Math.PI * 2) / 50);
      target.at[0] = 0.5 + Math.cos(angle) * 6.2;
      target.at[2] = -2.5 + Math.sin(angle) * 6.2;
    }
    const ease = 1 - Math.exp(-dt * 1.6);
    for (let axis = 0; axis < 3; axis += 1) {
      eye[axis] = (eye[axis] ?? 0) + ((target.at[axis] ?? 0) - (eye[axis] ?? 0)) * ease;
      gaze[axis] = (gaze[axis] ?? 0) + ((target.look[axis] ?? 0) - (gaze[axis] ?? 0)) * ease;
    }
    camera.position[0] = eye[0];
    camera.position[1] = eye[1];
    camera.position[2] = eye[2];
    camera.lookAt(gaze[0], gaze[1], gaze[2]);
    listener.set(eye[0], eye[1], eye[2], camera.yaw, camera.pitch, dt);

    /* The cart goes round: along the road, and back to the start without having travelled. */
    cartAt[0] += dt * 7;
    if (cartAt[0] > 30) {
      cartAt[0] = -30;
      cart.warp(cartAt[0], 0.6, cartAt[2]);
    }
    cart.place(cartAt[0], 0.6, cartAt[2], dt);
    fire.place(FIRE[0], 0.4, FIRE[2], dt);
    kick?.update(performance.now());

    /* What the script needs to know, and then the script. */
    ears.x = eye[0];
    ears.z = eye[2];
    ears.yaw = camera.yaw;
    ears.indoors = eye[0] > 7.35 && eye[0] < 11.65 && eye[2] > -9.65 && eye[2] < -5.35;
    exported<Tick>(script, 'tick')(court, ears, dt);
    // #endregion
  },
  render() {
    camera.fovYDeg = 62;
    for (let k = 0; k < LANTERNS.length; k += 1) {
      const light = lights[k + 1] as PointLightSource;
      light.r = 2.4 * court.glow;
      light.g = 1.5 * court.glow;
      light.b = 0.6 * court.glow;
    }
    selectPointLights(
      lights,
      camera.position[0],
      camera.position[1],
      camera.position[2],
      chosen,
      time,
    );
    env.lightCount = chosen.count;
    env.lightPositions = chosen.positions;
    env.lightColors = chosen.colors;
    env.lightRadii = chosen.radii;
    env.lightSourceRadii = chosen.sourceRadii;
    env.lightWeights = chosen.weights;
    model.set(IDENTITY);
    model[12] = cartAt[0];
    model[14] = cartAt[2];

    renderer.beginShadowPass(lightMatrix, 'static');
    renderer.drawShadowCasters((sink) => {
      sink.mesh(yardMesh, IDENTITY);
      sink.mesh(cartMesh, model);
    });
    renderer.endShadowPass();
    renderer.beginFrame([0.1, 0.11, 0.18]);
    renderer.bindMeshPass(camera, env);
    renderer.drawMesh(yardMesh, IDENTITY);
    renderer.drawMesh(cartMesh, model);
    const flicker = 1 + Math.sin(time * 13) * 0.06 + Math.sin(time * 29) * 0.04;
    model.set([1, 0, 0, 0, 0, flicker, 0, 0, 0, 0, 1, 0, FIRE[0], 0, FIRE[2], 1]);
    renderer.drawMesh(flame, model);
    /* The heads glow with the kick, through the pass's emissive gain and back. */
    renderer.setEmissiveGain(env.emissiveGain * court.glow);
    renderer.drawMesh(headMesh, IDENTITY);
    renderer.setEmissiveGain(env.emissiveGain);
    if (court.since >= 0 && court.since < 2) {
      const scale = 0.8 + court.since * 2;
      model.set([scale, 0, 0, 0, 0, scale, 0, 0, 0, 0, scale, 0, BELL[0], BELL[1], BELL[2], 1]);
      renderer.drawTranslucentMesh(ringMesh, model, (1 - court.since / 2) * 0.7);
    }

    const ducked = graph.layout.music.duckedTo;
    readout.set(
      0,
      `${graph.audible ? 'LISTENING' : 'CLICK ANYWHERE TO HEAR IT'}  ${SPOTS[spot]?.label ?? ''}  MUSIC x${ducked.toFixed(2)}`,
    );
    readout.set(
      1,
      `CART ${(cart.occlusion * 100).toFixed(0)}% MUFFLED  ${cart.detuneCents >= 0 ? '+' : ''}${cart.detuneCents.toFixed(0)} CENTS  CRYPT ${(listener.zoneSend(crypt) * 100).toFixed(0)}%`,
    );
    readout.set(
      2,
      `BELL ${court.strikes}, NEXT IN ${Math.max(0, court.next - court.clock).toFixed(1)} S  KICK ${(kick?.pulse ?? 0).toFixed(2)}`,
    );
    readout.draw(time);
    renderer.endFrame();
  },
});

courtyard.drs

examples/audio/courtyard.drs
// The courtyard's sound: when the bell strikes and how it is heard from where you stand, the music
// stepping aside for it, the crypt's mix, the wind, and the lanterns keeping time with the kick.
//
// Under `npm run examples`, change a rule and save: the courtyard runs by it from the next tick.
// Try a bell that strikes twice, a music bed that ducks further, or lanterns that only flash.

import { distanceGain, duck, fade, playPanned, pulse, recall, sound, stereoPan } from "drift/audio"
import { sqrt } from "std/math"

// Where the bell hangs, in metres.
let BELL_X: f32 = -9
let BELL_Z: f32 = -7

// Where the listener is this tick and which way it faces, written by the page from the camera.
data Ears {
    x: f32 = 0
    z: f32 = 0
    yaw: f32 = 0
    indoors: bool = false
}

data Courtyard {
    clock: f32 = 0
    // Seconds between strikes, which the page's switch sets, and when the next one falls due.
    every: f32 = 10
    next: f32 = 2
    // Seconds since the last strike, which the page draws as a ring round the tower; -1 before.
    since: f32 = -1
    strikes: u32 = 0
    // When the music comes back from under the bell, or -1 while it is not ducked.
    returns: f32 = -1
    // The weather the page asked for, and what the wind bus was last told.
    windy: bool = false
    blowing: bool = false
    // Whether the mix is the crypt's, so a crossing asks for one recall and not one a tick.
    inside: bool = false
    // How bright the lanterns are, 0 to 1.
    glow: f32 = 0
}

fn tick(yard: mut Courtyard, ears: Ears, dt: f32) {
    yard.clock += dt
    if yard.since >= 0 {
        yard.since += dt
    }
    toll(yard, ears)
    room(yard, ears)
    weather(yard)
    // #region glow
    // The lanterns follow the kick the music is making, and keep a little light between kicks.
    yard.glow = 0.3 + audio.pulse() * 0.7
    // #endregion
}

// #region toll
// The bell strikes on its own clock and is heard the cheap way: louder the nearer you stand, and
// on the side it is on. The music steps aside for it and comes back as the ring dies away.
fn toll(yard: mut Courtyard, ears: Ears) {
    if yard.next > yard.clock + yard.every {
        yard.next = yard.clock + yard.every
    }
    if yard.returns >= 0 && yard.clock >= yard.returns {
        audio.duck("music", 1, 2.5)
        yard.returns = -1
    }
    if yard.clock < yard.next {
        return
    }
    yard.next = yard.clock + yard.every
    yard.since = 0
    yard.strikes += 1
    let dx = BELL_X - ears.x
    let dz = BELL_Z - ears.z
    let near = audio.distanceGain(math.sqrt(dx * dx + dz * dz), 80m)
    if let clang = audio.sound("bell") {
        audio.playPanned(clang, near, audio.stereoPan(dx, dz, ears.yaw))
    }
    audio.duck("music", 0.3, 0.12)
    yard.returns = yard.clock + 2.5
}
// #endregion

// #region room
// Inside the crypt the yard falls away: the mix crossfades to the snapshot the page took for it,
// and back again on the way out.
fn room(yard: mut Courtyard, ears: Ears) {
    if ears.indoors == yard.inside {
        return
    }
    yard.inside = ears.indoors
    if yard.inside {
        audio.recall("crypt", 1.2)
    } else {
        audio.recall("yard", 1.2)
    }
}
// #endregion

// #region weather
// The wind has a bus of its own, made after the snapshots were taken, so a recall never touches it
// and only this fades it.
fn weather(yard: mut Courtyard) {
    if yard.windy == yard.blowing {
        return
    }
    yard.blowing = yard.windy
    if yard.blowing {
        audio.fade("wind", 0.9, 4)
    } else {
        audio.fade("wind", 0, 3)
    }
}
// #endregion

beat.ts

examples/audio/beat.ts
/**
 * Two bars of a beat, written sample by sample, so the example ships no audio file.
 *
 * A kick that sweeps down from 150 Hz, a clap on two and four, hats on the eighths and a sub note
 * under each kick: enough low end for the kick detector to find, and enough of a groove to hear the
 * music duck under the bell. The noise comes from a fixed sequence, so the loop is the same loop
 * every time the page opens.
 */
const BPM = 92;
const BEATS = 8;
/** Where the kicks fall, in beats from the start of the two bars. */
const KICKS = [0, 1.5, 2, 3.75, 4, 5.5, 6, 6.75];

export function beatBuffer(context: BaseAudioContext): AudioBuffer {
  const rate = context.sampleRate;
  const beat = 60 / BPM;
  const length = Math.floor(rate * beat * BEATS);
  const buffer = context.createBuffer(2, length, rate);
  const left = buffer.getChannelData(0);
  const right = buffer.getChannelData(1);
  let seed = 0x2f6b1d;
  const noise = (): number => {
    seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
    return seed / 0x80000000 - 1;
  };
  /* One hit at `at` beats, `seconds` long, panned from -1 to 1; `voice` is the sample at time t. */
  const hit = (at: number, seconds: number, pan: number, voice: (t: number) => number): void => {
    const start = Math.floor(at * beat * rate);
    const count = Math.min(Math.floor(seconds * rate), length - start);
    for (let i = 0; i < count; i += 1) {
      const sample = voice(i / rate);
      left[start + i] = (left[start + i] ?? 0) + sample * (1 - Math.max(0, pan));
      right[start + i] = (right[start + i] ?? 0) + sample * (1 + Math.min(0, pan));
    }
  };
  for (const at of KICKS) {
    let phase = 0;
    hit(at, 0.45, 0, (t) => {
      phase += ((45 + 105 * Math.exp(-t * 28)) / rate) * Math.PI * 2;
      return Math.sin(phase) * Math.exp(-t * 7) * 0.55;
    });
    hit(at, 0.6, 0, (t) => Math.sin(t * 55 * Math.PI * 2) * Math.exp(-t * 4) * 0.2);
  }
  for (const at of [1, 3, 5, 7]) {
    hit(at, 0.25, 0.1, (t) => noise() * Math.exp(-t * 22) * 0.25);
  }
  for (let at = 0; at < BEATS; at += 0.5) {
    let last = 0;
    hit(at, 0.06, at % 1 === 0 ? -0.4 : 0.4, (t) => {
      /* The difference of two noise samples: noise with its low end taken out, which is a hat. */
      const next = noise();
      const sample = (next - last) * Math.exp(-t * 70) * 0.08;
      last = next;
      return sample;
    });
  }
  return buffer;
}