/** * group-playout-processor — adaptive PCM playout for group voice (per remote speaker). * * PCM-only bufferedMs; fractional read with clamp + EMA-smoothed rate. * Under-target: tiered slowdown (delta-based) + optional panic (buffer hysteresis + dwell cap). * Startup: silence until bufferedMs >= max(INITIAL_GATE_MS, target - START_GATE_TARGET_MARGIN_MS). * Latency cap: shed when buffered PCM exceeds PCM_LATENCY_HARD_MS. * maxPlayoutTargetMs in processorOptions must stay aligned with GCALL_GLOBAL_PLAYOUT_CAP_MS on main. */ const RING_CAPACITY = 48000; const INITIAL_GATE_MS = 100; const START_GATE_TARGET_MARGIN_MS = 20; const DEFAULT_TARGET_MS = 100; const ERROR_CLAMP_MS = 80; const DEADZONE_MS = 6; /** Minimum playback rate (over-target catch-up); under-target/panic may go lower. */ const RATE_MIN = 0.98; const RATE_MAX = 1.01; const EMA_ALPHA_SLOW = 0.06; const EMA_ALPHA_FAST = 0.28; const OUTSIDE_BAND_MS = 35; const EMERGENCY_BAND_EXTRA_MS = 10; const METRICS_QUANTA = 47; const PCM_LATENCY_HARD_MS = 320; const PCM_LATENCY_RELEASE_MS = 240; const TARGET_RELEASE_MARGIN_MS = 40; const OVER_TARGET_TIER_STRONG_MS = 170; const OVER_TARGET_TIER_MID_MS = 90; const OVER_TARGET_RATE_MID = 1.0065; const OVER_TARGET_RATE_LIGHT = 1.0045; const OVER_TARGET_FAST_ALPHA_MS = 125; const CONCEALMENT_TAIL_SAMPLES = 240; const CONCEALMENT_FADE_SAMPLES = 240; const STATE_READ_HEAD = 0; const STATE_WRITE_HEAD = 1; const STATE_FILLED_SAMPLES = 2; const STATE_UNDERRUNS = 3; const SHARED_INGRESS_TIMESTAMP_MOD = 0x7fffffff; /** Align with main-thread gcallPlayoutPolicy global cap (max severe across profiles). */ const DEFAULT_MAX_PLAYOUT_TARGET_MS = 280; /** Under-target tiers (deltaMs = bufferedMs - target). */ const UNDER_TIER_DEEP_MS = -120; const UNDER_TIER_MID_MS = -80; const UNDER_TIER_SHALLOW_MS = -45; const UNDER_TIER_RECOVERING_MS = -35; const UNDER_RATE_DEEP = 0.94; const UNDER_RATE_MID = 0.96; const UNDER_RATE_SHALLOW = 0.98; const UNDER_RATE_RECOVERING = 0.965; const UNDER_RATE_DEEP_USABLE = 0.97; const UNDER_RATE_MID_USABLE = 0.985; const UNDER_RATE_SHALLOW_USABLE = 0.992; const UNDER_USABLE_BUFFER_MIN_MS = 85; const UNDER_USABLE_TARGET_RATIO = 0.62; const UNDER_USABLE_DELTA_MIN_MS = -70; const RATE_K_UNDER = 0.000125; const RATE_K_OVER = 0.0001; /** Panic: absolute PCM depth (hysteresis + dwell). */ const PANIC_ENTER_MS = 60; const PANIC_EXIT_MS = 78; const PANIC_RATE = 0.915; const PANIC_DWELL_MS = 400; const PANIC_RELAX_BLEND_MS = 500; class GroupPlayoutProcessor extends AudioWorkletProcessor { constructor(options) { super(); this._sourceAddr = options.processorOptions?.sourceAddr ?? ''; this._maxPlayoutTargetMs = typeof options.processorOptions?.maxPlayoutTargetMs === 'number' && Number.isFinite(options.processorOptions.maxPlayoutTargetMs) ? Math.max( 80, Math.min( DEFAULT_MAX_PLAYOUT_TARGET_MS, options.processorOptions.maxPlayoutTargetMs ) ) : DEFAULT_MAX_PLAYOUT_TARGET_MS; this._ring = new Float32Array(RING_CAPACITY); this._writePos = 0; this._readPos = 0; this._available = 0; this._sharedRing = null; this._sharedState = null; this._sharedIngressTimestamps = null; this._sharedFrameSamples = 0; this._sharedIngressCapacityFrames = 0; this._sharedSampleCapacity = 0; this._readFrac = 0; this._smoothedRate = 1; this._targetPlayoutMs = DEFAULT_TARGET_MS; this._playoutStarted = false; this._inPanic = false; this._panicSamplesInPanic = 0; this._panicZoneEnteredPending = false; this._panicEntryBufferedMs = null; this._lastTail = new Float32Array(CONCEALMENT_TAIL_SAMPLES); this._lastTailLen = 0; this._lastTailWritePos = 0; this._concealCursor = 0; this._metricsQuantumCount = 0; this._concealedThisBlock = false; const sharedRing = options.processorOptions?.sharedRing; const hasSharedArrayBuffer = typeof SharedArrayBuffer !== 'undefined'; if ( hasSharedArrayBuffer && sharedRing?.sampleBuffer instanceof SharedArrayBuffer && sharedRing?.stateBuffer instanceof SharedArrayBuffer ) { this._sharedRing = new Float32Array(sharedRing.sampleBuffer); this._sharedState = new Int32Array(sharedRing.stateBuffer); this._sharedIngressTimestamps = hasSharedArrayBuffer && sharedRing.ingressTimestampBuffer instanceof SharedArrayBuffer ? new Int32Array(sharedRing.ingressTimestampBuffer) : null; this._sharedFrameSamples = typeof sharedRing.frameSamples === 'number' && sharedRing.frameSamples > 0 ? sharedRing.frameSamples : 0; this._sharedIngressCapacityFrames = typeof sharedRing.ingressCapacityFrames === 'number' && sharedRing.ingressCapacityFrames > 0 ? sharedRing.ingressCapacityFrames : 0; this._sharedSampleCapacity = typeof sharedRing.capacitySamples === 'number' && sharedRing.capacitySamples > 0 ? sharedRing.capacitySamples : this._sharedRing.length; } this.port.onmessage = (e) => { const d = e.data; if (d?.pcm instanceof Float32Array && d.pcm.length > 0) { this._pushPcm(d.pcm); return; } if ( d?.type === 'pcm-batch' && d.pcmBatch instanceof Float32Array && d.pcmBatch.length > 0 ) { this._pushPcm(d.pcmBatch); return; } if (d?.type === 'target' && typeof d.targetPlayoutMs === 'number') { this._targetPlayoutMs = Math.max( 40, Math.min(this._maxPlayoutTargetMs, d.targetPlayoutMs) ); return; } if (d?.type === 'reset') { this._resetPlayoutState(); } }; } _resetPlayoutState() { this._writePos = 0; this._readPos = 0; this._available = 0; this._readFrac = 0; this._smoothedRate = 1; this._playoutStarted = false; this._inPanic = false; this._panicSamplesInPanic = 0; this._panicZoneEnteredPending = false; this._panicEntryBufferedMs = null; this._lastTail.fill(0); this._lastTailLen = 0; this._lastTailWritePos = 0; this._concealCursor = 0; this._concealedThisBlock = false; if (this._sharedState && this._sharedSampleCapacity > 0) { Atomics.store(this._sharedState, STATE_READ_HEAD, 0); Atomics.store(this._sharedState, STATE_WRITE_HEAD, 0); Atomics.store(this._sharedState, STATE_FILLED_SAMPLES, 0); this._sharedRing?.fill(0); } else { this._ring.fill(0); } } _pushPcm(pcm) { if (pcm.length > RING_CAPACITY) return; if (this._available + pcm.length > RING_CAPACITY) { const discard = this._available + pcm.length - RING_CAPACITY; this._advanceReadInt(discard); } let toWrite = pcm.length; let src = 0; while (toWrite > 0) { const chunk = Math.min(toWrite, RING_CAPACITY - this._writePos); this._ring.set(pcm.subarray(src, src + chunk), this._writePos); this._writePos = (this._writePos + chunk) % RING_CAPACITY; src += chunk; toWrite -= chunk; } this._available += pcm.length; } _advanceReadInt(n) { if (n <= 0) return; const take = Math.min(n, this._getAvailableSamples()); if (take <= 0) return; if (this._sharedState && this._sharedSampleCapacity > 0) { const readPos = Atomics.load(this._sharedState, STATE_READ_HEAD); Atomics.store( this._sharedState, STATE_READ_HEAD, (readPos + take) % this._sharedSampleCapacity ); Atomics.sub(this._sharedState, STATE_FILLED_SAMPLES, take); this._readPos = Atomics.load(this._sharedState, STATE_READ_HEAD); this._available = Atomics.load(this._sharedState, STATE_FILLED_SAMPLES); return; } this._readPos = (this._readPos + take) % RING_CAPACITY; this._available -= take; } _sampleAtRead() { if (this._getAvailableSamples() < 2) return 0; const capacity = this._sharedRing ? this._sharedSampleCapacity : RING_CAPACITY; const ring = this._sharedRing ?? this._ring; const readPos = this._sharedState ? Atomics.load(this._sharedState, STATE_READ_HEAD) : this._readPos; const i0 = readPos % capacity; const i1 = (readPos + 1) % capacity; const s0 = ring[i0]; const s1 = ring[i1]; return s0 * (1 - this._readFrac) + s1 * this._readFrac; } _rememberTailSample(sample) { this._lastTail[this._lastTailWritePos] = sample; this._lastTailWritePos = (this._lastTailWritePos + 1) % this._lastTail.length; if (this._lastTailLen < this._lastTail.length) { this._lastTailLen++; } } _tailSampleAt(offset) { if (offset < 0 || offset >= this._lastTailLen) return 0; const firstValid = this._lastTailLen === this._lastTail.length ? this._lastTailWritePos : 0; const idx = (firstValid + offset) % this._lastTail.length; return this._lastTail[idx]; } _stepReadOne(rate) { this._readFrac += rate; while (this._readFrac >= 1 && this._getAvailableSamples() > 0) { this._readFrac -= 1; if (this._sharedState && this._sharedSampleCapacity > 0) { const readPos = Atomics.load(this._sharedState, STATE_READ_HEAD); Atomics.store( this._sharedState, STATE_READ_HEAD, (readPos + 1) % this._sharedSampleCapacity ); Atomics.sub(this._sharedState, STATE_FILLED_SAMPLES, 1); this._readPos = Atomics.load(this._sharedState, STATE_READ_HEAD); this._available = Atomics.load(this._sharedState, STATE_FILLED_SAMPLES); } else { this._readPos = (this._readPos + 1) % RING_CAPACITY; this._available -= 1; } } } _shedExcessPcmLatency(sampleRateHz) { const availableSamples = this._getAvailableSamples(); const bufferedMs = (availableSamples / sampleRateHz) * 1000; if (bufferedMs <= PCM_LATENCY_HARD_MS) return; const releaseMs = Math.max( PCM_LATENCY_RELEASE_MS, this._targetPlayoutMs + TARGET_RELEASE_MARGIN_MS ); const maxSamples = (releaseMs / 1000) * sampleRateHz; const toDrop = Math.floor(availableSamples - maxSamples); if (toDrop > 0) { this._advanceReadInt(toDrop); this._readFrac = 0; } } _getAvailableSamples() { if (this._sharedState) { this._available = Atomics.load(this._sharedState, STATE_FILLED_SAMPLES); return this._available; } return this._available; } _computeOldestFrameAgeMs() { if ( !this._sharedState || !this._sharedIngressTimestamps || this._sharedFrameSamples <= 0 || this._sharedIngressCapacityFrames <= 0 || this._getAvailableSamples() < this._sharedFrameSamples ) { return 0; } const readPos = Atomics.load(this._sharedState, STATE_READ_HEAD); const frameSlot = Math.floor((readPos % this._sharedSampleCapacity) / this._sharedFrameSamples) % this._sharedIngressCapacityFrames; const ingressAtMs = Atomics.load(this._sharedIngressTimestamps, frameSlot); if (ingressAtMs <= 0) return 0; const nowMs = Date.now(); if (!Number.isFinite(nowMs) || nowMs <= 0) return 0; let deltaMs = Math.round(nowMs % SHARED_INGRESS_TIMESTAMP_MOD) - ingressAtMs; if (deltaMs < 0) { deltaMs += SHARED_INGRESS_TIMESTAMP_MOD; } return Math.max(0, deltaMs); } /** Tier rate from delta only (under-target path). */ _underTierRate(deltaMs, bufferedMs) { const usableBufferRelaxed = this._playoutStarted && deltaMs >= UNDER_USABLE_DELTA_MIN_MS && bufferedMs >= Math.max(UNDER_USABLE_BUFFER_MIN_MS, this._targetPlayoutMs * UNDER_USABLE_TARGET_RATIO); if (deltaMs < UNDER_TIER_DEEP_MS) { return usableBufferRelaxed ? UNDER_RATE_DEEP_USABLE : UNDER_RATE_DEEP; } if (deltaMs < UNDER_TIER_MID_MS) { return usableBufferRelaxed ? UNDER_RATE_MID_USABLE : UNDER_RATE_MID; } if (deltaMs < UNDER_TIER_SHALLOW_MS) { return usableBufferRelaxed ? UNDER_RATE_SHALLOW_USABLE : UNDER_RATE_SHALLOW; } return 1; } _computeRawTargetRate(bufferedMs, deltaMs, quantum, sampleRateHz) { const emergencyThresh = OUTSIDE_BAND_MS + EMERGENCY_BAND_EXTRA_MS; if (this._playoutStarted) { if (!this._inPanic && bufferedMs < PANIC_ENTER_MS) { this._inPanic = true; this._panicSamplesInPanic = 0; this._panicZoneEnteredPending = true; this._panicEntryBufferedMs = bufferedMs; } else if (this._inPanic && bufferedMs > PANIC_EXIT_MS) { this._inPanic = false; this._panicSamplesInPanic = 0; } if (this._inPanic) { this._panicSamplesInPanic += quantum; } } const panicMs = sampleRateHz > 0 ? (this._panicSamplesInPanic / sampleRateHz) * 1000 : 0; const tierUnder = this._underTierRate(deltaMs, bufferedMs); if (this._inPanic && this._playoutStarted) { if (panicMs < PANIC_DWELL_MS) { return { targetRate: PANIC_RATE, inPanic: true, panicZoneEntered: false }; } const t = Math.min( 1, Math.max(0, (panicMs - PANIC_DWELL_MS) / PANIC_RELAX_BLEND_MS) ); const blended = PANIC_RATE * (1 - t) + tierUnder * t; return { targetRate: blended, inPanic: true, panicZoneEntered: false }; } if (deltaMs < UNDER_TIER_DEEP_MS) { return { targetRate: UNDER_RATE_DEEP, inPanic: false, panicZoneEntered: false }; } if (deltaMs < UNDER_TIER_MID_MS) { return { targetRate: UNDER_RATE_MID, inPanic: false, panicZoneEntered: false }; } if (deltaMs < UNDER_TIER_SHALLOW_MS) { return { targetRate: UNDER_RATE_SHALLOW, inPanic: false, panicZoneEntered: false }; } if (deltaMs < UNDER_TIER_RECOVERING_MS) { return { targetRate: UNDER_RATE_RECOVERING, inPanic: false, panicZoneEntered: false, }; } if (deltaMs > OVER_TARGET_TIER_STRONG_MS) { return { targetRate: RATE_MAX, inPanic: false, panicZoneEntered: false }; } if (deltaMs > OVER_TARGET_TIER_MID_MS) { return { targetRate: OVER_TARGET_RATE_MID, inPanic: false, panicZoneEntered: false, }; } if (deltaMs > emergencyThresh) { return { targetRate: OVER_TARGET_RATE_LIGHT, inPanic: false, panicZoneEntered: false, }; } let errorMs = Math.max( -ERROR_CLAMP_MS, Math.min(ERROR_CLAMP_MS, deltaMs) ); if (Math.abs(errorMs) < DEADZONE_MS) errorMs = 0; const k = errorMs > 0 ? RATE_K_OVER : RATE_K_UNDER; const tr = 1 + Math.max(-0.01, Math.min(0.01, errorMs * k)); return { targetRate: tr, inPanic: false, panicZoneEntered: false }; } process(_inputs, outputs) { const output = outputs[0]?.[0]; if (!output) return true; const sampleRateHz = globalThis.sampleRate; const quantum = output.length; this._shedExcessPcmLatency(sampleRateHz); let bufferedMs = (this._getAvailableSamples() / sampleRateHz) * 1000; const preProcessBufferedMs = bufferedMs; const preProcessOldestFrameAgeMs = this._computeOldestFrameAgeMs(); this._concealedThisBlock = false; if (!this._playoutStarted) { const startGateMs = Math.max( INITIAL_GATE_MS, this._targetPlayoutMs - START_GATE_TARGET_MARGIN_MS ); if (bufferedMs < startGateMs) { output.fill(0); this._concealCursor = 0; this._maybePostMetrics( bufferedMs, quantum, false, 1, false, preProcessBufferedMs, preProcessOldestFrameAgeMs ); return true; } this._playoutStarted = true; } const deltaMs = bufferedMs - this._targetPlayoutMs; const raw = this._computeRawTargetRate( bufferedMs, deltaMs, quantum, sampleRateHz ); let targetRate = raw.targetRate; targetRate = Math.min(RATE_MAX, targetRate); const floorR = raw.inPanic ? 0.9 : RATE_MIN; targetRate = Math.max(floorR, targetRate); const underStress = raw.inPanic || deltaMs < UNDER_TIER_RECOVERING_MS || deltaMs > OVER_TARGET_FAST_ALPHA_MS; const alpha = underStress ? EMA_ALPHA_FAST : EMA_ALPHA_SLOW; this._smoothedRate += alpha * (targetRate - this._smoothedRate); this._smoothedRate = Math.max( raw.inPanic ? 0.9 : RATE_MIN, Math.min(RATE_MAX, this._smoothedRate) ); const rate = this._smoothedRate; for (let i = 0; i < quantum; i++) { if (this._getAvailableSamples() < 2) { this._concealedThisBlock = true; const conceal = this._concealSample(); output[i] = conceal; continue; } const s = this._sampleAtRead(); output[i] = s; this._rememberTailSample(s); this._concealCursor = 0; this._stepReadOne(rate); } this._maybePostMetrics( (this._getAvailableSamples() / sampleRateHz) * 1000, quantum, this._concealedThisBlock, rate, raw.inPanic, preProcessBufferedMs, preProcessOldestFrameAgeMs ); return true; } _concealSample() { if (this._lastTailLen < 2) return 0; const fadeLen = Math.min(this._lastTailLen, CONCEALMENT_FADE_SAMPLES); if (this._concealCursor >= fadeLen) return 0; const tailStart = this._lastTailLen - fadeLen; const sample = this._tailSampleAt(tailStart + this._concealCursor); const t = fadeLen <= 1 ? 1 : this._concealCursor / (fadeLen - 1); const g = 1 - t; this._concealCursor++; return sample * g * g; } _maybePostMetrics( bufferedMs, quantum, concealmentUsed, smoothedRate, panicActive, preProcessBufferedMs, preProcessOldestFrameAgeMs = 0 ) { this._metricsQuantumCount++; if (this._metricsQuantumCount < METRICS_QUANTA) return; this._metricsQuantumCount = 0; const deltaMs = bufferedMs - this._targetPlayoutMs; const outsideBandUnder = this._playoutStarted && deltaMs < -OUTSIDE_BAND_MS; const outsideBandOver = this._playoutStarted && deltaMs > OUTSIDE_BAND_MS; const outside = outsideBandUnder || outsideBandOver; const panicZoneEntered = this._panicZoneEnteredPending; const panicEntryBufferedMs = panicZoneEntered ? this._panicEntryBufferedMs : null; const oldestFrameAgeMs = typeof preProcessOldestFrameAgeMs === 'number' && Number.isFinite(preProcessOldestFrameAgeMs) && preProcessOldestFrameAgeMs > 0 ? preProcessOldestFrameAgeMs : this._computeOldestFrameAgeMs(); const playoutBand = outsideBandUnder ? 'under-target' : outsideBandOver ? 'over-target' : 'in-band'; this._panicZoneEnteredPending = false; this._panicEntryBufferedMs = null; this.port.postMessage({ type: 'gcallPlayoutMetrics', sourceAddr: this._sourceAddr, bufferedMs, preProcessBufferedMs, targetPlayoutMs: this._targetPlayoutMs, rate: smoothedRate, panicActive: !!panicActive, panicZoneEntered: !!panicZoneEntered, panicReason: panicZoneEntered ? 'absolute-low-buffer-entry' : undefined, panicEntryBufferedMs, panicEnterMs: PANIC_ENTER_MS, panicExitMs: PANIC_EXIT_MS, playoutBand, outsideBand: outside, outsideBandUnder, outsideBandOver, deltaMs, oldestFrameAgeMs, playoutStarted: this._playoutStarted, concealmentUsed: !!concealmentUsed, }); } } registerProcessor('group-playout-processor', GroupPlayoutProcessor);