/// import { Ring, RingShared } from "../common/ring" import type * as Catalog from "../karp/catalog" import type { Frame } from "../karp/frame" import type { Component } from "./timeline" // This is a non-standard way of importing worklet/workers. // Unfortunately, it's the only option because of a Vite bug: https://github.com/vitejs/vite/issues/11823 import workletURL from "./worklet/index.ts?worker&url" export class Renderer { #context: AudioContext #worklet: Promise #ring: Ring #ringShared: RingShared #timeline: Component #track: Catalog.Audio #decoder!: AudioDecoder #stream: TransformStream constructor(track: Catalog.Audio, timeline: Component) { this.#track = track this.#context = new AudioContext({ latencyHint: "interactive", sampleRate: track.sample_rate, }) this.#worklet = this.load(track) this.#timeline = timeline this.#ringShared = new RingShared(2, track.sample_rate / 10) // 100ms this.#ring = new Ring(this.#ringShared) this.#stream = new TransformStream({ start: this.#start.bind(this), transform: this.#transform.bind(this), }) this.#run().catch((err) => console.error("failed to run audio renderer: ", err)) } private async load(catalog: Catalog.Audio): Promise { // Load the worklet source code. await this.#context.audioWorklet.addModule(workletURL) const volume = this.#context.createGain() volume.gain.value = 2.0 // Create the worklet const worklet = new AudioWorkletNode(this.#context, "renderer") worklet.port.addEventListener("message", this.on.bind(this)) worklet.onprocessorerror = (e: Event) => { console.error("Audio worklet error:", e) } // Connect the worklet to the volume node and then to the speakers worklet.connect(volume) volume.connect(this.#context.destination) const config = { sampleRate: catalog.sample_rate, channelCount: catalog.channel_count, ring: this.#ringShared, } worklet.port.postMessage({ config }) return worklet } private on(_event: MessageEvent) { // TODO } play() { this.#context.resume().catch((err) => console.warn("failed to resume audio context: ", err)) } close() { this.#context.close().catch((err) => console.warn("failed to close audio context: ", err)) } #start(controller: TransformStreamDefaultController) { this.#decoder = new AudioDecoder({ output: (frame: AudioData) => { controller.enqueue(frame) }, error: console.warn, }) // We only support OPUS right now which doesn't need a description. this.#decoder.configure({ codec: this.#track.codec, sampleRate: this.#track.sample_rate, numberOfChannels: this.#track.channel_count, }) } #transform(frame: Frame) { const chunk = new EncodedAudioChunk({ type: frame.type, timestamp: frame.timestamp, data: frame.data, }) this.#decoder.decode(chunk) } async #run() { const reader = this.#timeline.frames.pipeThrough(this.#stream).getReader() for (;;) { const { value: frame, done } = await reader.read() if (done) break // Write audio samples to the ring buffer, dropping when there's no space. const written = this.#ring.write(frame) if (written < frame.numberOfFrames) { /* console.warn( `droppped ${frame.numberOfFrames - written} audio samples`, ); */ } } } }