diff --git a/web/src/index.html b/web/src/index.html index aeda194..74179a8 100644 --- a/web/src/index.html +++ b/web/src/index.html @@ -2,7 +2,7 @@ - + WARP @@ -11,7 +11,7 @@
-
click for audio
+
click to play
@@ -31,4 +31,5 @@ - + + \ No newline at end of file diff --git a/web/src/index.ts b/web/src/index.ts index 5c46360..053f819 100644 --- a/web/src/index.ts +++ b/web/src/index.ts @@ -31,7 +31,7 @@ const player = new Player({ const play = document.querySelector("#screen #play")! const playFunc = (e: Event) => { - player.play({}) + player.play() e.preventDefault() play.removeEventListener('click', playFunc) diff --git a/web/src/player/audio.ts b/web/src/player/audio.ts index 361036d..cbfcd3e 100644 --- a/web/src/player/audio.ts +++ b/web/src/player/audio.ts @@ -2,24 +2,17 @@ import * as Message from "./message"; import { Ring } from "./ring" export default class Audio { - ring: Ring; + ring?: Ring; queue: Array; - sync?: DOMHighResTimeStamp; // the wall clock value for timestamp 0, in microseconds + render?: number; // non-zero if requestAnimationFrame has been called last?: number; // the timestamp of the last rendered frame, in microseconds - constructor(config: Message.AudioConfig) { - this.ring = new Ring(config.ring); - this.queue = []; + constructor(config: Message.Config) { + this.queue = [] } push(frame: AudioData) { - if (!this.sync) { - // Save the frame as the sync point - // TODO sync with video - this.sync = 1000 * performance.now() - frame.timestamp - } - // Drop any old frames if (this.last && frame.timestamp <= this.last) { frame.close() @@ -27,7 +20,7 @@ export default class Audio { } // Insert the frame into the queue sorted by timestamp. - if (this.queue.length > 0 && this.queue[this.queue.length-1].timestamp <= frame.timestamp) { + if (this.queue.length > 0 && this.queue[this.queue.length - 1].timestamp <= frame.timestamp) { // Fast path because we normally append to the end. this.queue.push(frame) } else { @@ -43,33 +36,44 @@ export default class Audio { this.queue.splice(low, 0, frame) } + + this.emit() } + emit() { + const ring = this.ring + if (!ring) { + return + } - draw() { - // Convert to microseconds - const now = 1000 * performance.now(); - - // Determine the target timestamp. - const target = now - this.sync! - - // Check if we should skip some frames while (this.queue.length) { - const next = this.queue[0] - - if (next.timestamp > target) { - const ok = this.ring.write(next) - if (!ok) { - console.warn("ring buffer is full") - // No more space in the ring - break - } - } else { - console.warn("dropping audio") + let frame = this.queue[0]; + if (ring.size() + frame.numberOfFrames > ring.capacity) { + // Buffer is full + break } - next.close() + const size = ring.write(frame) + if (size < frame.numberOfFrames) { + throw new Error("audio buffer is full") + } + + this.last = frame.timestamp + + frame.close() this.queue.shift() } } + + play(play: Message.Play) { + this.ring = new Ring(play.buffer) + + if (!this.render) { + const sampleRate = 44100 // TODO dynamic + + // Refresh every half buffer + const refresh = play.buffer.capacity / sampleRate * 1000 / 2 + this.render = setInterval(this.emit.bind(this), refresh) + } + } } \ No newline at end of file diff --git a/web/src/player/index.ts b/web/src/player/index.ts index 1ef73b2..a539167 100644 --- a/web/src/player/index.ts +++ b/web/src/player/index.ts @@ -19,26 +19,16 @@ export default class Player { this.transport = config.transport this.transport.callback = this; - const video = { - canvas: config.canvas, - }; - - // Assume 44.1kHz and two audio channels - const audio = { - sampleRate: 44100, - ring: new Ring.Buffer(2, 4410), // 100ms at 44.1khz - } - this.context = new AudioContext({ latencyHint: "interactive", - sampleRate: audio.sampleRate, + sampleRate: 44100, }) - this.worker = this.setupWorker({ audio, video }) - this.worklet = this.setupWorklet(audio) + this.worker = this.setupWorker(config) + this.worklet = this.setupWorklet(config) } - private setupWorker(config: Message.Config): Worker { + private setupWorker(config: Config): Worker { const url = new URL('worker.ts', import.meta.url) const worker = new Worker(url, { @@ -46,12 +36,16 @@ export default class Player { name: "media", }) - worker.postMessage({ config }, [ config.video.canvas ]) + const msg = { + canvas: config.canvas, + } + + worker.postMessage({ config: msg }, [msg.canvas]) return worker } - private async setupWorklet(config: Message.AudioConfig): Promise { + private async setupWorklet(config: Config): Promise { // Load the worklet source code. const url = new URL('worklet.ts', import.meta.url) await this.context.audioWorklet.addModule(url) @@ -65,8 +59,6 @@ export default class Player { console.error("Audio worklet error:", e) }; - worklet.port.postMessage({ config }) - // Connect the worklet to the volume node and then to the speakers worklet.connect(volume) volume.connect(this.context.destination) @@ -75,15 +67,22 @@ export default class Player { } onInit(init: Message.Init) { - this.worker.postMessage({ init }, [ init.buffer.buffer, init.reader ]) + this.worker.postMessage({ init }, [init.buffer.buffer, init.reader]) } onSegment(segment: Message.Segment) { - this.worker.postMessage({ segment }, [ segment.buffer.buffer, segment.reader ]) + this.worker.postMessage({ segment }, [segment.buffer.buffer, segment.reader]) } - play(play: Message.Play) { + async play() { this.context.resume() - //this.worker.postMessage({ play }) + + const play = { + buffer: new Ring.Buffer(2, 44100 / 10), // 100ms of audio + } + + const worklet = await this.worklet; + worklet.port.postMessage({ play }) + this.worker.postMessage({ play }) } } \ No newline at end of file diff --git a/web/src/player/message.ts b/web/src/player/message.ts index dcad2e0..0f4b00b 100644 --- a/web/src/player/message.ts +++ b/web/src/player/message.ts @@ -1,20 +1,10 @@ import * as Ring from "./ring" export interface Config { - audio: AudioConfig; - video: VideoConfig; -} - -export interface VideoConfig { + // video stuff canvas: OffscreenCanvas; } -export interface AudioConfig { - // audio stuff - sampleRate: number; - ring: Ring.Buffer; -} - export interface Init { buffer: Uint8Array; // unread buffered data reader: ReadableStream; // unread unbuffered data @@ -27,4 +17,5 @@ export interface Segment { export interface Play { timestamp?: number; + buffer: Ring.Buffer; } \ No newline at end of file diff --git a/web/src/player/renderer.ts b/web/src/player/renderer.ts index d2d6c86..3da2e85 100644 --- a/web/src/player/renderer.ts +++ b/web/src/player/renderer.ts @@ -1,136 +1,29 @@ import * as Message from "./message"; -import { Ring } from "./ring" +import Audio from "./audio" +import Video from "./video" export default class Renderer { - audioRing: Ring; - audioQueue: Array; - - videoCanvas: OffscreenCanvas; - videoQueue: Array; - - render: number; // non-zero if requestAnimationFrame has been called - sync?: DOMHighResTimeStamp; // the wall clock value for timestamp 0, in microseconds - last?: number; // the timestamp of the last rendered frame, in microseconds + audio: Audio; + video: Video; constructor(config: Message.Config) { - this.audioRing = new Ring(config.audio.ring); - this.audioQueue = []; - - this.videoCanvas = config.video.canvas; - this.videoQueue = []; - - this.render = 0; + this.audio = new Audio(config); + this.video = new Video(config); } push(frame: AudioData | VideoFrame) { - if (!this.sync) { - // Save the frame as the sync point - this.sync = 1000 * performance.now() - frame.timestamp - } - - // Drop any old frames - if (this.last && frame.timestamp <= this.last) { - frame.close() - return - } - - let queue if (isAudioData(frame)) { - queue = this.audioQueue; + this.audio.push(frame); } else if (isVideoFrame(frame)) { - queue = this.videoQueue; + this.video.push(frame); } else { throw new Error("unknown frame type") } - - // Insert the frame into the queue sorted by timestamp. - if (queue.length > 0 && queue[queue.length-1].timestamp <= frame.timestamp) { - // Fast path because we normally append to the end. - queue.push(frame as any) - } else { - // Do a full binary search - let low = 0 - let high = queue.length; - - while (low < high) { - var mid = (low + high) >>> 1; - if (queue[mid].timestamp < frame.timestamp) low = mid + 1; - else high = mid; - } - - queue.splice(low, 0, frame as any) - } - - // Queue up to render the next frame. - if (!this.render) { - this.render = self.requestAnimationFrame(this.draw.bind(this)) - } } - draw(now: DOMHighResTimeStamp) { - // Convert to microseconds - now *= 1000; - - // Determine the target timestamp. - const target = now - this.sync! - - this.drawAudio(now, target) - this.drawVideo(now, target) - - if (this.audioQueue.length || this.videoQueue.length) { - this.render = self.requestAnimationFrame(this.draw.bind(this)) - } else { - this.render = 0 - } - } - - drawAudio(now: DOMHighResTimeStamp, target: DOMHighResTimeStamp) { - // Check if we should skip some frames - while (this.audioQueue.length) { - const next = this.audioQueue[0] - - if (next.timestamp > target) { - let ok = this.audioRing.write(next) - if (!ok) { - console.warn("ring buffer is full") - // No more space in the ring - break - } - } else { - console.warn("dropping audio") - } - - next.close() - this.audioQueue.shift() - } - } - - drawVideo(now: DOMHighResTimeStamp, target: DOMHighResTimeStamp) { - if (!this.videoQueue.length) return; - - let frame = this.videoQueue[0]; - if (frame.timestamp >= target) { - // nothing to render yet, wait for the next animation frame - this.render = self.requestAnimationFrame(this.draw.bind(this)) - return - } - - this.videoQueue.shift(); - - // Check if we should skip some frames - while (this.videoQueue.length) { - const next = this.videoQueue[0] - if (next.timestamp > target) break - - frame.close() - frame = this.videoQueue.shift()!; - } - - const ctx = this.videoCanvas.getContext("2d"); - ctx!.drawImage(frame, 0, 0, this.videoCanvas.width, this.videoCanvas.height) // TODO aspect ratio - - this.last = frame.timestamp; - frame.close() + play(play: Message.Play) { + this.audio.play(play); + this.video.play(play); } } diff --git a/web/src/player/ring.ts b/web/src/player/ring.ts index ffc1c6b..ccf6f2a 100644 --- a/web/src/player/ring.ts +++ b/web/src/player/ring.ts @@ -1,123 +1,9 @@ // Ring buffer with audio samples. enum STATE { - READ_INDEX = 0, // Index of the current read position (mod capacity) - WRITE_INDEX, // Index of the current write position (mod capacity) - LENGTH // Clever way of saving the total number of enums values. -} - -export class Ring { - state: Int32Array; - channels: Float32Array[]; - capacity: number; - - constructor(buf: Buffer) { - this.state = new Int32Array(buf.state) - - this.channels = [] - for (let channel of buf.channels) { - this.channels.push(new Float32Array(channel)) - } - - this.capacity = buf.capacity - } - - // Add the samples for single audio frame - write(frame: AudioData): boolean { - let count = frame.numberOfFrames; - - let readIndex = Atomics.load(this.state, STATE.READ_INDEX) - let writeIndex = Atomics.load(this.state, STATE.WRITE_INDEX) - let writeIndexNew = writeIndex + count; - - // There's not enough space in the ring buffer - if (writeIndexNew - readIndex > this.capacity) { - return false - } - - let startIndex = writeIndex % this.capacity; - let endIndex = writeIndexNew % this.capacity; - - // Loop over each channel - for (let i = 0; i < this.channels.length; i += 1) { - const channel = this.channels[i] - - if (startIndex < endIndex) { - // One continuous range to copy. - const full = channel.subarray(startIndex, endIndex) - - frame.copyTo(full, { - planeIndex: i, - frameCount: count, - }) - } else { - const first = channel.subarray(startIndex) - const second = channel.subarray(0, endIndex) - - frame.copyTo(first, { - planeIndex: i, - frameCount: first.length, - }) - - frame.copyTo(second, { - planeIndex: i, - frameOffset: first.length, - frameCount: second.length, - }) - } - } - - Atomics.store(this.state, STATE.WRITE_INDEX, writeIndexNew) - - return true - } - - read(dst: Float32Array[]) { - let readIndex = Atomics.load(this.state, STATE.READ_INDEX) - let writeIndex = Atomics.load(this.state, STATE.WRITE_INDEX) - if (readIndex >= writeIndex) { - // nothing to read - return - } - - let readIndexNew = readIndex + dst[0].length - if (readIndexNew > writeIndex) { - // Partial read - readIndexNew = writeIndex - } - - let startIndex = readIndex % this.capacity; - let endIndex = readIndexNew % this.capacity; - - // Loop over each channel - for (let i = 0; i < dst.length; i += 1) { - if (i >= this.channels.length) { - // ignore excess channels - } - - const input = this.channels[i] - const output = dst[i] - - if (startIndex < endIndex) { - const full = input.subarray(startIndex, endIndex) - output.set(full) - } else { - const first = input.subarray(startIndex) - const second = input.subarray(0, endIndex) - - output.set(first) - output.set(second, first.length) - } - } - - Atomics.store(this.state, STATE.READ_INDEX, readIndexNew) - } - - // TODO not thread safe - clear() { - const writeIndex = Atomics.load(this.state, STATE.WRITE_INDEX) - Atomics.store(this.state, STATE.READ_INDEX, writeIndex) - } + READ_POS = 0, // The current read position + WRITE_POS, // The current write position + LENGTH // Clever way of saving the total number of enums values. } // No prototype to make this easier to send via postMessage @@ -140,4 +26,125 @@ export class Buffer { this.capacity = capacity } +} + +export class Ring { + state: Int32Array; + channels: Float32Array[]; + capacity: number; + + constructor(buffer: Buffer) { + this.state = new Int32Array(buffer.state) + + this.channels = [] + for (let channel of buffer.channels) { + this.channels.push(new Float32Array(channel)) + } + + this.capacity = buffer.capacity + } + + // Write samples for single audio frame, returning the total number written. + write(frame: AudioData): number { + let readPos = Atomics.load(this.state, STATE.READ_POS) + let writePos = Atomics.load(this.state, STATE.WRITE_POS) + + const startPos = writePos + let endPos = writePos + frame.numberOfFrames; + + if (endPos > readPos + this.capacity) { + endPos = readPos + this.capacity + if (endPos <= startPos) { + // No space to write + return 0 + } + } + + let startIndex = startPos % this.capacity; + let endIndex = endPos % this.capacity; + + // Loop over each channel + for (let i = 0; i < this.channels.length; i += 1) { + const channel = this.channels[i] + + if (startIndex < endIndex) { + // One continuous range to copy. + const full = channel.subarray(startIndex, endIndex) + + frame.copyTo(full, { + planeIndex: i, + frameCount: endIndex - startIndex, + }) + } else { + const first = channel.subarray(startIndex) + const second = channel.subarray(0, endIndex) + + frame.copyTo(first, { + planeIndex: i, + frameCount: first.length, + }) + + frame.copyTo(second, { + planeIndex: i, + frameOffset: first.length, + frameCount: second.length, + }) + } + } + + Atomics.store(this.state, STATE.WRITE_POS, endPos) + + return endPos - startPos + } + + read(dst: Float32Array[]): number { + let readPos = Atomics.load(this.state, STATE.READ_POS) + let writePos = Atomics.load(this.state, STATE.WRITE_POS) + + let startPos = readPos; + let endPos = startPos + dst[0].length; + + if (endPos > writePos) { + endPos = writePos + if (endPos <= startPos) { + // Nothing to read + return 0 + } + } + + let startIndex = startPos % this.capacity; + let endIndex = endPos % this.capacity; + + // Loop over each channel + for (let i = 0; i < dst.length; i += 1) { + if (i >= this.channels.length) { + // ignore excess channels + } + + const input = this.channels[i] + const output = dst[i] + + if (startIndex < endIndex) { + const full = input.subarray(startIndex, endIndex) + output.set(full) + } else { + const first = input.subarray(startIndex) + const second = input.subarray(0, endIndex) + + output.set(first) + output.set(second, first.length) + } + } + + Atomics.store(this.state, STATE.READ_POS, endPos) + + return endPos - startPos + } + + size() { + let readPos = Atomics.load(this.state, STATE.READ_POS) + let writePos = Atomics.load(this.state, STATE.WRITE_POS) + + return writePos - readPos + } } \ No newline at end of file diff --git a/web/src/player/video.ts b/web/src/player/video.ts index d112150..f725122 100644 --- a/web/src/player/video.ts +++ b/web/src/player/video.ts @@ -5,10 +5,10 @@ export default class Video { queue: Array; render: number; // non-zero if requestAnimationFrame has been called - sync?: DOMHighResTimeStamp; // the wall clock value for timestamp 0, in microseconds + sync?: number; // the wall clock value for timestamp 0, in microseconds last?: number; // the timestamp of the last rendered frame, in microseconds - constructor(config: Message.VideoConfig) { + constructor(config: Message.Config) { this.canvas = config.canvas; this.queue = []; @@ -16,11 +16,6 @@ export default class Video { } push(frame: VideoFrame) { - if (!this.sync) { - // Save the frame as the sync point - this.sync = 1000 * performance.now() - frame.timestamp - } - // Drop any old frames if (this.last && frame.timestamp <= this.last) { frame.close() @@ -28,7 +23,7 @@ export default class Video { } // Insert the frame into the queue sorted by timestamp. - if (this.queue.length > 0 && this.queue[this.queue.length-1].timestamp <= frame.timestamp) { + if (this.queue.length > 0 && this.queue[this.queue.length - 1].timestamp <= frame.timestamp) { // Fast path because we normally append to the end. this.queue.push(frame) } else { @@ -44,24 +39,35 @@ export default class Video { this.queue.splice(low, 0, frame) } - - // Queue up to render the next frame. - if (!this.render) { - this.render = self.requestAnimationFrame(this.draw.bind(this)) - } } - draw(now: DOMHighResTimeStamp) { + draw(now: number) { + // Draw and then queue up the next draw call. + this.drawOnce(now); + + // Queue up the new draw frame. + this.render = self.requestAnimationFrame(this.draw.bind(this)) + } + + drawOnce(now: number) { // Convert to microseconds now *= 1000; - // Determine the target timestamp. - const target = now - this.sync! + if (!this.queue.length) { + return + } let frame = this.queue[0]; + + if (!this.sync) { + this.sync = now - frame.timestamp; + } + + // Determine the target timestamp. + const target = now - this.sync + if (frame.timestamp >= target) { // nothing to render yet, wait for the next animation frame - this.render = self.requestAnimationFrame(this.draw.bind(this)) return } @@ -81,11 +87,12 @@ export default class Video { this.last = frame.timestamp; frame.close() + } - if (this.queue.length) { + play(play: Message.Play) { + // Queue up to render the next frame. + if (!this.render) { this.render = self.requestAnimationFrame(this.draw.bind(this)) - } else { - this.render = 0 } } } \ No newline at end of file diff --git a/web/src/player/worker.ts b/web/src/player/worker.ts index 4597c29..5a563f5 100644 --- a/web/src/player/worker.ts +++ b/web/src/player/worker.ts @@ -17,6 +17,9 @@ self.addEventListener('message', async (e: MessageEvent) => { } else if (e.data.segment) { const segment = e.data.segment as Message.Segment await decoder.receiveSegment(segment) + } else if (e.data.play) { + const play = e.data.play as Message.Play + await renderer.play(play) } }) diff --git a/web/src/player/worklet.ts b/web/src/player/worklet.ts index 4946bd8..fe3216e 100644 --- a/web/src/player/worklet.ts +++ b/web/src/player/worklet.ts @@ -19,19 +19,19 @@ class Renderer extends AudioWorkletProcessor { } onMessage(e: MessageEvent) { - if (e.data.config) { - this.onConfig(e.data.config) + if (e.data.play) { + this.onPlay(e.data.play) } } - onConfig(config: Message.AudioConfig) { - this.ring = new Ring(config.ring) + onPlay(play: Message.Play) { + this.ring = new Ring(play.buffer) } // Inputs and outputs in groups of 128 samples. process(inputs: Float32Array[][], outputs: Float32Array[][], parameters: Record): boolean { if (!this.ring) { - // Not initialized yet + // Paused return true } @@ -40,7 +40,11 @@ class Renderer extends AudioWorkletProcessor { } const output = outputs[0] - this.ring.read(output) + + const size = this.ring.read(output) + if (size < output.length) { + // TODO trigger rebuffering event + } return true; }