audioStream.ts
examples/film/audioStream.ts · raw · all examples · site home
Exports: heardTimeScoreStream
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163
// Plays the GPU-rendered score in real time: GpuScore renders chunks ahead
// of the playhead (from the moment prepare() is called, so they are ready
// before Play), each chunk's buffer is transferred to the AudioWorklet in
// scoreSink.ts, and the film's clock is the audio clock. Rendering keeps up
// to AHEAD seconds ahead, which rides out the film's own stalls (a shader
// compiled on a shot's first frame, the water replaying its simulation in
// the montage, the 6M-splat galaxy at a few frames a second). Each readback
// waits for the frames queued before it, so the pump tops up gently (CALM
// chunks a readback, a few ms of GPU work between frames) and only renders
// in bursts of up to BURST chunks once the lead falls under LOW seconds. A
// chunk that came too late plays as silence and is counted in `missing`
// (frames). pause() and resume() suspend the context, which stops the
// clock; restart() starts over from a film time, paused.
import { CHUNK, type GpuScore } from "./gpuAudio.js";
import { RATE } from "./score.js";
const AHEAD = 10;
// The context time being heard now: the output timestamp (which counts the
// hardware's latency) carried forward on performance.now(), so it moves
// between the context's coarse audio blocks; else currentTime less the
// output latency. Frozen while the context is suspended.
export function heardTime(context: AudioContext) {
const stamp = context.getOutputTimestamp?.();
if (stamp?.contextTime && stamp.performanceTime) {
const since = context.state === "running" ? (performance.now() - stamp.performanceTime) / 1000 : 0;
return stamp.contextTime + Math.min(Math.max(since, 0), 0.1);
}
return context.currentTime - (context.outputLatency || 0);
}
const LOW = 4;
const CALM = 2;
const BURST = 8;
export class ScoreStream {
readonly score: GpuScore;
readonly context: AudioContext;
// Between the worklet and the speakers: the listener's volume
readonly gain: GainNode;
missing = 0;
private node?: AudioWorkletNode;
private ready: Promise<void>;
private queued: { index: number; samples: Float32Array }[] = [];
// Chunks rendered so far, from the start chunk on
rendered = 0;
private from = 0;
private next = 0;
// Context time at which film time `from` is heard
private startAt = NaN;
private running = false;
private pumping?: Promise<void>;
// Bumped by restart: chunks rendered before it are not sent
private generation = 0;
// A film time to render from, applied by the pump between renders
private restartAt?: number;
constructor(score: GpuScore) {
this.score = score;
this.context = new AudioContext({ sampleRate: RATE, latencyHint: "playback" });
this.gain = this.context.createGain();
this.gain.connect(this.context.destination);
this.ready = this.context.audioWorklet.addModule(new URL("./scoreSink.js", import.meta.url)).then(() => {
const node = new AudioWorkletNode(this.context, "film-score", { numberOfInputs: 0, outputChannelCount: [2], processorOptions: { chunk: CHUNK, frames: Math.round(score.duration * RATE) } });
node.connect(this.gain);
node.port.onmessage = ({ data }) => {
if (data.type === "missing") {
this.missing = data.frames;
}
};
this.node = node;
this.queued.forEach((chunk) => this.post(chunk.index, chunk.samples));
this.queued = [];
});
}
// Film seconds being heard (before play, where it will start)
clock() {
if (Number.isNaN(this.startAt)) {
return this.from;
}
return this.from + heardTime(this.context) - this.startAt;
}
// Start rendering from film time `from`
prepare(from: number) {
this.from = from;
this.restartAt = from;
this.running = true;
this.pumping ??= this.pump().finally(() => (this.pumping = undefined));
}
// Call from a user gesture
async play() {
await this.ready;
await this.context.resume();
this.startAt = this.context.currentTime + 0.1;
const offset = Math.round(this.startAt * RATE) - Math.round(this.from * RATE);
this.node!.port.postMessage({ type: "start", offset });
}
stop() {
this.running = false;
this.node?.port.postMessage({ type: "stop" });
void this.context.suspend();
}
pause() {
void this.context.suspend();
}
resume() {
void this.context.resume();
}
// Silence, and render again from film time `from`; play() starts there
restart(from: number) {
this.generation += 1;
this.queued = [];
this.node?.port.postMessage({ type: "stop" });
this.startAt = NaN;
void this.context.suspend();
this.prepare(from);
}
// Consecutive chunks from `index`, in one transferred buffer
private post(index: number, samples: Float32Array) {
if (!this.node) {
this.queued.push({ index, samples });
return;
}
this.node.port.postMessage({ type: "chunks", index, samples }, [samples.buffer]);
}
// Seconds of audio rendered past what is being heard
ahead() {
return (this.next * CHUNK) / RATE - this.clock();
}
private async pump() {
while (this.running) {
if (this.restartAt !== undefined) {
this.next = Math.floor((this.restartAt * RATE) / CHUNK);
this.restartAt = undefined;
this.score.reset();
}
const behind = AHEAD - this.ahead();
if (this.next < this.score.chunks && behind > 0) {
const index = this.next;
const batch = this.ahead() < LOW ? BURST : CALM;
const count = Math.min(batch, this.score.chunks - index, Math.max(1, Math.ceil(behind / (CHUNK / RATE))));
const generation = this.generation;
this.next += count;
this.rendered += count;
const samples = await this.score.render(index, count);
if (generation === this.generation) {
this.post(index, samples);
}
} else {
await new Promise((resolve) => setTimeout(resolve, 25));
}
}
}
}