cat / goq
clone

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

fix/hero-release-note
goq / portal / audio-envelope.mjs
audio-envelope.mjs 5.7K
1export const AUDIO_ENVELOPE_HEADER_LENGTH = 48;2export const AUDIO_CHANNEL_PREFIX_LENGTH = 24;3export const MAX_AUDIO_PAYLOAD_LENGTH = 512;4export const OPUS_SAMPLE_RATE = 48000;5export const OPUS_CHANNELS = 2;6export const OPUS_FRAME_SAMPLES = 960;7export const AUDIO_TERMINAL_STATE_CAPACITY = 4;89const CHANNEL_MAGIC = [0x53, 0x47, 0x41, 0x43]; // SGAC10const CHANNEL_VERSION = 1;11const MAGIC = [0x53, 0x47, 0x41, 0x31]; // SGA112const VERSION = 1;13const OPUS_CODEC = 1;14const DISCONTINUITY_FLAG = 1 << 0;15const KNOWN_FLAGS = DISCONTINUITY_FLAG;16const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);1718function exactUnsigned(value, field) {19 if (value > MAX_SAFE_BIGINT) throw new Error(`${field} exceeds JavaScript's exact integer range`);20 return Number(value);21}2223function exactTimestamp(value) {24 if (value < 0n || value > MAX_SAFE_BIGINT) {25 throw new Error('audio PTS is outside the supported exact range');26 }27 return Number(value);28}2930export function isCurrentAudioGeneration(generation, expectedGeneration) {31 return Number.isSafeInteger(generation)32 && generation >= 033 && Number.isSafeInteger(expectedGeneration)34 && expectedGeneration >= 035 && generation === expectedGeneration;36}3738export function isCurrentAudioDelivery(packet, expectedGeneration) {39 return isCurrentAudioGeneration(packet?.generation, expectedGeneration);40}4142export function stageAudioTerminalState(pendingStates, payload) {43 if (!Array.isArray(pendingStates)44 || !Number.isSafeInteger(payload?.generation)45 || payload.generation <= 046 || payload.available !== false) {47 throw new TypeError('invalid audio terminal state');48 }49 const duplicate = pendingStates.findIndex(50 (state) => state.generation === payload.generation,51 );52 if (duplicate >= 0) pendingStates.splice(duplicate, 1);53 pendingStates.push(payload);54 pendingStates.sort((left, right) => right.generation - left.generation);55 if (pendingStates.length > AUDIO_TERMINAL_STATE_CAPACITY) {56 pendingStates.length = AUDIO_TERMINAL_STATE_CAPACITY;57 }58}5960export function takeAudioTerminalState(pendingStates, generation) {61 if (!Array.isArray(pendingStates)) throw new TypeError('invalid audio terminal buffer');62 const terminal = pendingStates.find(63 (state) => isCurrentAudioGeneration(state.generation, generation),64 ) ?? null;65 pendingStates.length = 0;66 return terminal;67}6869/** Parse one validated, delivery-token-prefixed Opus datagram from the Tauri channel. */70export function parseAudioEnvelope(message) {71 if (!(message instanceof ArrayBuffer)) {72 throw new TypeError('audio channel message must be an ArrayBuffer');73 }74 const minimumLength = AUDIO_CHANNEL_PREFIX_LENGTH + AUDIO_ENVELOPE_HEADER_LENGTH;75 if (message.byteLength < minimumLength) {76 throw new Error('audio channel message is shorter than its header');77 }78 const bytes = new Uint8Array(message);79 const view = new DataView(message);80 for (let index = 0; index < CHANNEL_MAGIC.length; index++) {81 if (bytes[index] !== CHANNEL_MAGIC[index]) throw new Error('invalid audio delivery magic');82 }83 if (view.getUint16(4, false) !== CHANNEL_VERSION) {84 throw new Error('unsupported audio delivery version');85 }86 if (view.getUint16(6, false) !== AUDIO_CHANNEL_PREFIX_LENGTH) {87 throw new Error('invalid audio delivery header length');88 }89 const generation = exactUnsigned(view.getBigUint64(8, false), 'audio generation');90 const deliveryId = exactUnsigned(view.getBigUint64(16, false), 'audio delivery ID');91 const base = AUDIO_CHANNEL_PREFIX_LENGTH;92 for (let index = 0; index < MAGIC.length; index++) {93 if (bytes[base + index] !== MAGIC[index]) throw new Error('invalid audio channel magic');94 }95 if (view.getUint16(base + 4, false) !== VERSION) {96 throw new Error('unsupported audio protocol version');97 }98 if (bytes[base + 6] !== AUDIO_ENVELOPE_HEADER_LENGTH) {99 throw new Error('invalid audio header length');100 }101 if (bytes[base + 7] !== OPUS_CODEC) {102 throw new Error(`unsupported audio codec: ${bytes[base + 7]}`);103 }104 const flags = bytes[base + 8];105 if ((flags & ~KNOWN_FLAGS) !== 0) throw new Error('audio channel has unknown flag bits');106 if (bytes[base + 9] !== OPUS_CHANNELS) {107 throw new Error(`unsupported audio channel count: ${bytes[base + 9]}`);108 }109 if (110 bytes[base + 10] !== 0111 || bytes[base + 11] !== 0112 || bytes[base + 18] !== 0113 || bytes[base + 19] !== 0114 ) {115 throw new Error('audio channel reserved bytes must be zero');116 }117 const sampleRate = view.getUint32(base + 12, false);118 const frameSamples = view.getUint16(base + 16, false);119 if (sampleRate !== OPUS_SAMPLE_RATE || frameSamples !== OPUS_FRAME_SAMPLES) {120 throw new Error(`unsupported audio format: ${sampleRate} Hz / ${frameSamples} samples`);121 }122 const payloadLength = view.getUint32(base + 20, false);123 if (payloadLength === 0 || payloadLength > MAX_AUDIO_PAYLOAD_LENGTH) {124 throw new Error(`invalid audio payload length: ${payloadLength}`);125 }126 const expectedLength = AUDIO_CHANNEL_PREFIX_LENGTH + AUDIO_ENVELOPE_HEADER_LENGTH + payloadLength;127 if (message.byteLength !== expectedLength) {128 throw new Error(`audio channel length mismatch: expected ${expectedLength}, got ${message.byteLength}`);129 }130 return {131 generation,132 deliveryId,133 codec: 'opus',134 discontinuity: (flags & DISCONTINUITY_FLAG) !== 0,135 channels: OPUS_CHANNELS,136 sampleRate,137 frameSamples,138 sequence: exactUnsigned(view.getBigUint64(base + 24, false), 'audio sequence'),139 captureTimestampMicros: exactUnsigned(140 view.getBigUint64(base + 32, false),141 'audio capture timestamp',142 ),143 ptsMicros: exactTimestamp(view.getBigInt64(base + 40, false)),144 data: new Uint8Array(message, base + AUDIO_ENVELOPE_HEADER_LENGTH, payloadLength),145 };146}