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));
      }
    }
  }
}