cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

fix/docs-activation-truth
goq / portal / audio-ring.mjs
audio-ring.mjs 8.0K
1export const AUDIO_RING_CAPACITY_FRAMES = 2880;2export const AUDIO_RING_START_FRAMES = 1920;3export const AUDIO_SAMPLE_RATE = 48_000;4export const AUDIO_FRAME_DURATION_MICROS = 1_000_000 / AUDIO_SAMPLE_RATE;5export const MAX_AUDIO_WORKLET_MESSAGES_IN_FLIGHT = 3;67/** Bounded ownership tracker for decoded PCM transferred to an AudioWorklet. */8export class BoundedAudioMessageTracker {9 constructor(capacity = MAX_AUDIO_WORKLET_MESSAGES_IN_FLIGHT) {10 if (!Number.isSafeInteger(capacity) || capacity < 1) {11 throw new RangeError('message capacity must be a positive safe integer');12 }13 this.capacity = capacity;14 this.pending = new Set();15 this.nextId = 1;16 this.droppedMessages = 0;17 }1819 reserve() {20 if (this.pending.size >= this.capacity) {21 this.droppedMessages++;22 return null;23 }24 for (let attempt = 0; attempt <= this.capacity; attempt++) {25 const id = this.nextId;26 this.nextId = id === Number.MAX_SAFE_INTEGER ? 1 : id + 1;27 if (!this.pending.has(id)) {28 this.pending.add(id);29 return id;30 }31 }32 throw new Error('unable to allocate a unique audio message ID');33 }3435 accept(id) {36 if (!Number.isSafeInteger(id) || id < 1) return false;37 return this.pending.delete(id);38 }3940 clear() {41 this.pending.clear();42 }4344 reset() {45 this.clear();46 this.nextId = 1;47 this.droppedMessages = 0;48 }4950 get size() {51 return this.pending.size;52 }53}5455/** Fixed stereo planar ring used by the AudioWorklet. */56export class BoundedAudioRing {57 constructor({58 channels = 2,59 capacityFrames = AUDIO_RING_CAPACITY_FRAMES,60 startFrames = AUDIO_RING_START_FRAMES,61 } = {}) {62 if (!Number.isInteger(channels) || channels < 1) throw new RangeError('channels must be positive');63 if (!Number.isInteger(capacityFrames) || capacityFrames < 1) {64 throw new RangeError('capacityFrames must be positive');65 }66 if (!Number.isInteger(startFrames) || startFrames < 1 || startFrames > capacityFrames) {67 throw new RangeError('startFrames must fit within capacityFrames');68 }69 this.channels = channels;70 this.capacityFrames = capacityFrames;71 this.startFrames = startFrames;72 this.storage = Array.from({ length: channels }, () => new Float32Array(capacityFrames));73 // PTS storage is fixed alongside PCM storage so timestamp tracking cannot74 // allocate or grow on the AudioWorklet render path. NaN means that the75 // corresponding frame arrived through the backwards-compatible untimed API.76 this.frameEndPtsMicros = new Float64Array(capacityFrames);77 this.frameEndPtsMicros.fill(Number.NaN);78 this.readIndex = 0;79 this.writeIndex = 0;80 this.length = 0;81 this.started = false;82 this.droppedFrames = 0;83 this.recoveryDiscardedFrames = 0;84 this.underflows = 0;85 this.renderedFrames = 0;86 this.silentFrames = 0;87 this.underflowFrames = 0;88 this.underflowActive = false;89 this.renderedMediaEndPtsMicros = null;90 }9192 write(channelData, startPtsMicros = null) {93 if (!Array.isArray(channelData) || channelData.length !== this.channels) {94 throw new TypeError(`expected ${this.channels} planar audio channels`);95 }96 const frames = channelData[0]?.length;97 if (!Number.isInteger(frames) || frames < 1) throw new RangeError('audio packet is empty');98 for (const channel of channelData) {99 if (!(channel instanceof Float32Array) || channel.length !== frames) {100 throw new TypeError('audio channels must be equal-length Float32Array values');101 }102 }103 const timestamped = startPtsMicros !== null && startPtsMicros !== undefined;104 if (timestamped && !Number.isSafeInteger(startPtsMicros)) {105 throw new TypeError('audio packet timestamp must be a safe integer');106 }107108 let sourceOffset = 0;109 let writeFrames = frames;110 if (writeFrames > this.capacityFrames) {111 sourceOffset = writeFrames - this.capacityFrames;112 this.droppedFrames += sourceOffset;113 writeFrames = this.capacityFrames;114 }115 const overflow = Math.max(0, this.length + writeFrames - this.capacityFrames);116 if (overflow > 0) {117 this.readIndex = (this.readIndex + overflow) % this.capacityFrames;118 this.length -= overflow;119 this.droppedFrames += overflow;120 }121 const first = Math.min(writeFrames, this.capacityFrames - this.writeIndex);122 for (let channel = 0; channel < this.channels; channel++) {123 this.storage[channel].set(124 channelData[channel].subarray(sourceOffset, sourceOffset + first),125 this.writeIndex,126 );127 if (first < writeFrames) {128 this.storage[channel].set(129 channelData[channel].subarray(sourceOffset + first, sourceOffset + writeFrames),130 0,131 );132 }133 }134 for (let frame = 0; frame < writeFrames; frame++) {135 const destination = (this.writeIndex + frame) % this.capacityFrames;136 this.frameEndPtsMicros[destination] = timestamped137 ? startPtsMicros + (sourceOffset + frame + 1) * AUDIO_FRAME_DURATION_MICROS138 : Number.NaN;139 }140 this.writeIndex = (this.writeIndex + writeFrames) % this.capacityFrames;141 this.length += writeFrames;142 if (!this.started && this.length >= this.startFrames) {143 this.started = true;144 this.underflowActive = false;145 }146 return overflow + sourceOffset;147 }148149 read(outputs, muted = false) {150 if (!Array.isArray(outputs) || outputs.length !== this.channels) {151 throw new TypeError(`expected ${this.channels} output channels`);152 }153 const frames = outputs[0]?.length;154 for (const output of outputs) {155 if (!(output instanceof Float32Array) || output.length !== frames) {156 throw new TypeError('output channels must be equal-length Float32Array values');157 }158 output.fill(0);159 }160 if (frames === 0) return 0;161 if (!this.started) {162 this.renderedMediaEndPtsMicros = null;163 this.silentFrames += frames;164 if (this.underflowActive) this.underflowFrames += frames;165 return 0;166 }167168 const rendered = Math.min(frames, this.length);169 const first = Math.min(rendered, this.capacityFrames - this.readIndex);170 if (!muted) {171 for (let channel = 0; channel < this.channels; channel++) {172 outputs[channel].set(this.storage[channel].subarray(this.readIndex, this.readIndex + first));173 if (first < rendered) {174 outputs[channel].set(this.storage[channel].subarray(0, rendered - first), first);175 }176 }177 }178 const finalMediaFrame = (this.readIndex + rendered - 1) % this.capacityFrames;179 const finalMediaEndPtsMicros = this.frameEndPtsMicros[finalMediaFrame];180 this.renderedMediaEndPtsMicros = rendered === frames && Number.isFinite(finalMediaEndPtsMicros)181 ? finalMediaEndPtsMicros182 : null;183 this.readIndex = (this.readIndex + rendered) % this.capacityFrames;184 this.length -= rendered;185 this.renderedFrames += rendered;186 if (rendered < frames) {187 this.underflows++;188 const missingFrames = frames - rendered;189 this.silentFrames += missingFrames;190 this.underflowFrames += missingFrames;191 this.underflowActive = true;192 this.started = false;193 }194 return rendered;195 }196197 clear({ recovery = false } = {}) {198 if (recovery) this.recoveryDiscardedFrames += this.length;199 this.readIndex = 0;200 this.writeIndex = 0;201 this.length = 0;202 this.started = false;203 this.underflowActive = false;204 this.renderedMediaEndPtsMicros = null;205 this.frameEndPtsMicros.fill(Number.NaN);206 }207208 snapshot() {209 return {210 bufferedFrames: this.length,211 capacityFrames: this.capacityFrames,212 started: this.started,213 droppedFrames: this.droppedFrames,214 recoveryDiscardedFrames: this.recoveryDiscardedFrames,215 underflows: this.underflows,216 renderedFrames: this.renderedFrames,217 silentFrames: this.silentFrames,218 silentDurationMicros: this.silentFrames * AUDIO_FRAME_DURATION_MICROS,219 underflowFrames: this.underflowFrames,220 underflowDurationMicros: this.underflowFrames * AUDIO_FRAME_DURATION_MICROS,221 renderedMediaEndPtsMicros: this.renderedMediaEndPtsMicros,222 };223 }224}