Files
qortal-go-2.0/public/worklets/group-playout-processor.js
2026-05-15 04:17:00 +03:00

590 lines
19 KiB
JavaScript

/**
* 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);