zebra-spaces: deploy Phase 3 adaptive time-stretching
This commit is contained in:
parent
4885cab72e
commit
fa0666f7cc
1 changed files with 201 additions and 105 deletions
|
|
@ -1766,6 +1766,24 @@ const listenerAudioNodes = new Map(); /* uuid -> { src, gain, jbuf?, stream } */
|
||||||
* Loaded as a Blob URL because this is a single-file app — no
|
* Loaded as a Blob URL because this is a single-file app — no
|
||||||
* separate JS file shipped. */
|
* separate JS file shipped. */
|
||||||
const JITTER_BUFFER_WORKLET_CODE = `
|
const JITTER_BUFFER_WORKLET_CODE = `
|
||||||
|
/* Adaptive jitter buffer with variable playback rate (Phase 3 of Double
|
||||||
|
* Dragon). The buffer's target depth is set from JS; the worklet
|
||||||
|
* automatically tunes its INTERNAL playback rate to drive the actual
|
||||||
|
* buffer level toward target.
|
||||||
|
*
|
||||||
|
* - buffered < target → stretchFactor > 1.0 (slow playback → buffer grows)
|
||||||
|
* - buffered > target → stretchFactor < 1.0 (fast playback → buffer shrinks)
|
||||||
|
* - buffered ≈ target → stretchFactor = 1.0 (normal)
|
||||||
|
*
|
||||||
|
* Rate change is capped at ±8% and ramped smoothly per-block so the
|
||||||
|
* pitch shift during adaptation is ≤1 semitone, brief, and centred.
|
||||||
|
* Listeners can disable stretching entirely via the 'lock_rate' command
|
||||||
|
* so music never gets resampled.
|
||||||
|
*
|
||||||
|
* Resampling: linear interpolation between adjacent input samples. Pure
|
||||||
|
* pitch-preserving WSOLA grain processing is ~10x more code; ship this
|
||||||
|
* first and upgrade if the brief pitch shift is audible enough to
|
||||||
|
* matter. */
|
||||||
class JitterBufferProcessor extends AudioWorkletProcessor {
|
class JitterBufferProcessor extends AudioWorkletProcessor {
|
||||||
constructor(opts){
|
constructor(opts){
|
||||||
super();
|
super();
|
||||||
|
|
@ -1774,18 +1792,16 @@ class JitterBufferProcessor extends AudioWorkletProcessor {
|
||||||
this.maxSeconds = o.maxSeconds || (this.targetSeconds * 1.5);
|
this.maxSeconds = o.maxSeconds || (this.targetSeconds * 1.5);
|
||||||
this.targetSamples = Math.round(this.targetSeconds * sampleRate);
|
this.targetSamples = Math.round(this.targetSeconds * sampleRate);
|
||||||
this.maxSamples = Math.round(this.maxSeconds * sampleRate);
|
this.maxSamples = Math.round(this.maxSeconds * sampleRate);
|
||||||
/* re-arm only after this many consecutive empty blocks. 128 samples
|
|
||||||
* per block at 48 kHz = 2.67 ms; 100 blocks ≈ 267 ms of silence.
|
|
||||||
* brief upstream drains (a single empty process() tick) MUST NOT
|
|
||||||
* tear down playback, or a 4s re-buffer kicks in every time —
|
|
||||||
* which is what made the phone choppy. */
|
|
||||||
this.rearmThresholdBlocks = 100;
|
this.rearmThresholdBlocks = 100;
|
||||||
/* periodic buffer-depth report so JS can drive the lip-sync
|
/* Variable-rate playback state. cursor is the fractional sample
|
||||||
* algorithm (video.playoutDelayHint must match audio total delay
|
* index into the head of the queue. stretchFactor controls how
|
||||||
* including this worklet's cushion, else mouths move ~4s ahead
|
* fast the cursor advances per output sample. */
|
||||||
* of the words on listeners). 256 blocks * 128 samples / 48000Hz
|
this.cursor = 0;
|
||||||
* ≈ 683ms — frequent enough to track real changes, sparse enough
|
this.stretchFactor = 1.0;
|
||||||
* to not flood the port. */
|
this.targetStretch = 1.0;
|
||||||
|
this.stretchRampPerBlock = 0.0002; /* ~0.075/sec ramp */
|
||||||
|
this.maxStretch = 1.08; /* cap rate at ±8% */
|
||||||
|
this.minStretch = 0.92;
|
||||||
this.bufferedReportEvery = 256;
|
this.bufferedReportEvery = 256;
|
||||||
this.bufferedReportCounter = 0;
|
this.bufferedReportCounter = 0;
|
||||||
this.queue = [];
|
this.queue = [];
|
||||||
|
|
@ -1793,21 +1809,28 @@ class JitterBufferProcessor extends AudioWorkletProcessor {
|
||||||
this.started = false;
|
this.started = false;
|
||||||
this.emptyStreak = 0;
|
this.emptyStreak = 0;
|
||||||
this.dropped = 0;
|
this.dropped = 0;
|
||||||
/* role-change retarget — JS posts {cmd:'retarget', targetSeconds}
|
|
||||||
* when the user is promoted/demoted; we recompute the sample
|
|
||||||
* targets and shrink the queue if the new max is smaller. */
|
|
||||||
this.port.onmessage = (e) => {
|
this.port.onmessage = (e) => {
|
||||||
if (!e.data || e.data.cmd !== 'retarget') return;
|
if (!e.data) return;
|
||||||
const t = +e.data.targetSeconds;
|
if (e.data.cmd === 'retarget'){
|
||||||
if (!isFinite(t) || t <= 0) return;
|
const t = +e.data.targetSeconds;
|
||||||
this.targetSeconds = t;
|
if (!isFinite(t) || t <= 0) return;
|
||||||
this.maxSeconds = t * 1.5;
|
this.targetSeconds = t;
|
||||||
this.targetSamples = Math.round(this.targetSeconds * sampleRate);
|
this.maxSeconds = t * 1.5;
|
||||||
this.maxSamples = Math.round(this.maxSeconds * sampleRate);
|
this.targetSamples = Math.round(this.targetSeconds * sampleRate);
|
||||||
while (this.buffered > this.maxSamples && this.queue.length > 0){
|
this.maxSamples = Math.round(this.maxSeconds * sampleRate);
|
||||||
const drop = this.queue.shift();
|
while (this.buffered > this.maxSamples && this.queue.length > 0){
|
||||||
this.buffered -= drop[0].length;
|
const drop = this.queue.shift();
|
||||||
this.dropped += drop[0].length;
|
this.buffered -= drop[0].length;
|
||||||
|
this.dropped += drop[0].length;
|
||||||
|
}
|
||||||
|
} else if (e.data.cmd === 'lock_rate'){
|
||||||
|
/* listeners get this — explicitly forbid time-stretching so
|
||||||
|
* music playback stays at exactly 1.0 always. */
|
||||||
|
const r = +e.data.rate;
|
||||||
|
if (isFinite(r) && r > 0){
|
||||||
|
this.stretchFactor = this.targetStretch = r;
|
||||||
|
this.minStretch = this.maxStretch = r;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -1816,38 +1839,84 @@ class JitterBufferProcessor extends AudioWorkletProcessor {
|
||||||
const outBlk = outputs[0];
|
const outBlk = outputs[0];
|
||||||
if (!outBlk || outBlk.length === 0) return true;
|
if (!outBlk || outBlk.length === 0) return true;
|
||||||
const nch = outBlk.length;
|
const nch = outBlk.length;
|
||||||
/* push the incoming block (must copy — host may reuse the buffer
|
const blkLen = outBlk[0].length;
|
||||||
|
/* push the incoming block (copy — host may reuse the buffer
|
||||||
* after process() returns) */
|
* after process() returns) */
|
||||||
if (inBlk && inBlk.length > 0 && inBlk[0] && inBlk[0].length > 0){
|
if (inBlk && inBlk.length > 0 && inBlk[0] && inBlk[0].length > 0){
|
||||||
const copy = [];
|
const copy = [];
|
||||||
for (let c = 0; c < inBlk.length; c++) copy.push(new Float32Array(inBlk[c]));
|
for (let c = 0; c < inBlk.length; c++) copy.push(new Float32Array(inBlk[c]));
|
||||||
this.queue.push(copy);
|
this.queue.push(copy);
|
||||||
this.buffered += copy[0].length;
|
this.buffered += copy[0].length;
|
||||||
/* overflow guard — drop oldest if clock drift or network surge
|
|
||||||
* pushes us above the cap */
|
|
||||||
while (this.buffered > this.maxSamples && this.queue.length > 0){
|
while (this.buffered > this.maxSamples && this.queue.length > 0){
|
||||||
const drop = this.queue.shift();
|
const drop = this.queue.shift();
|
||||||
this.buffered -= drop[0].length;
|
this.buffered -= drop[0].length;
|
||||||
this.dropped += drop[0].length;
|
this.dropped += drop[0].length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* lock onto the buffer once it fills. Do NOT un-lock on a single
|
/* Adaptive rate: target stretch from buffer depth ratio. */
|
||||||
* empty queue tick — that's what made the phone choppy: any
|
if (this.maxStretch !== this.minStretch){
|
||||||
* 2.67ms drain forced a full 4s re-buffer. emptyStreak tracks
|
const ratio = this.buffered / Math.max(1, this.targetSamples);
|
||||||
* sustained silence and only re-arms after ~267ms. */
|
if (ratio < 0.9){
|
||||||
|
this.targetStretch = this.maxStretch; /* slow → grow */
|
||||||
|
} else if (ratio > 1.1){
|
||||||
|
this.targetStretch = this.minStretch; /* fast → shrink */
|
||||||
|
} else {
|
||||||
|
this.targetStretch = 1.0;
|
||||||
|
}
|
||||||
|
if (this.stretchFactor < this.targetStretch){
|
||||||
|
this.stretchFactor = Math.min(this.targetStretch, this.stretchFactor + this.stretchRampPerBlock);
|
||||||
|
} else if (this.stretchFactor > this.targetStretch){
|
||||||
|
this.stretchFactor = Math.max(this.targetStretch, this.stretchFactor - this.stretchRampPerBlock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* lock onto buffer once it fills */
|
||||||
if (!this.started && this.buffered >= this.targetSamples){
|
if (!this.started && this.buffered >= this.targetSamples){
|
||||||
this.started = true;
|
this.started = true;
|
||||||
/* notify JS — listener UI stays in "buffering" state until the
|
|
||||||
* first started message arrives. */
|
|
||||||
try { this.port.postMessage({ cmd: 'started', targetSeconds: this.targetSeconds }); } catch(_){}
|
try { this.port.postMessage({ cmd: 'started', targetSeconds: this.targetSeconds }); } catch(_){}
|
||||||
}
|
}
|
||||||
if (this.started && this.queue.length > 0){
|
if (this.started && this.queue.length > 0){
|
||||||
const head = this.queue.shift();
|
const invStretch = 1.0 / this.stretchFactor;
|
||||||
this.buffered -= head[0].length;
|
|
||||||
this.emptyStreak = 0;
|
this.emptyStreak = 0;
|
||||||
for (let c = 0; c < nch; c++){
|
/* generate blkLen output samples by reading at fractional cursor
|
||||||
const srcCh = head[c] || head[0]; /* mono → stereo: dup L→R */
|
* positions with linear interpolation between adjacent input
|
||||||
outBlk[c].set(srcCh.subarray(0, outBlk[c].length));
|
* samples. */
|
||||||
|
for (let i = 0; i < blkLen; i++){
|
||||||
|
const inFloat = this.cursor + i * invStretch;
|
||||||
|
const inIdx = Math.floor(inFloat);
|
||||||
|
const frac = inFloat - inIdx;
|
||||||
|
/* find which block + offset inIdx falls into */
|
||||||
|
let offsetInBlock = inIdx;
|
||||||
|
let blockIdx = -1;
|
||||||
|
for (let b = 0; b < this.queue.length; b++){
|
||||||
|
if (offsetInBlock < this.queue[b][0].length){ blockIdx = b; break; }
|
||||||
|
offsetInBlock -= this.queue[b][0].length;
|
||||||
|
}
|
||||||
|
if (blockIdx < 0){
|
||||||
|
for (let c = 0; c < nch; c++) outBlk[c][i] = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (let c = 0; c < nch; c++){
|
||||||
|
const srcCh = this.queue[blockIdx][c] || this.queue[blockIdx][0];
|
||||||
|
const s1 = srcCh[offsetInBlock];
|
||||||
|
let s2;
|
||||||
|
if (offsetInBlock + 1 < srcCh.length){
|
||||||
|
s2 = srcCh[offsetInBlock + 1];
|
||||||
|
} else if (blockIdx + 1 < this.queue.length){
|
||||||
|
const nb = this.queue[blockIdx + 1];
|
||||||
|
s2 = (nb[c] || nb[0])[0];
|
||||||
|
} else {
|
||||||
|
s2 = s1;
|
||||||
|
}
|
||||||
|
outBlk[c][i] = s1 * (1 - frac) + s2 * frac;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.cursor += blkLen * invStretch;
|
||||||
|
/* drop fully-consumed blocks from queue front, bring cursor back
|
||||||
|
* into the new head block's index space */
|
||||||
|
while (this.queue.length > 0 && this.cursor >= this.queue[0][0].length){
|
||||||
|
this.cursor -= this.queue[0][0].length;
|
||||||
|
this.buffered -= this.queue[0][0].length;
|
||||||
|
this.queue.shift();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (let c = 0; c < nch; c++) outBlk[c].fill(0);
|
for (let c = 0; c < nch; c++) outBlk[c].fill(0);
|
||||||
|
|
@ -1856,12 +1925,17 @@ class JitterBufferProcessor extends AudioWorkletProcessor {
|
||||||
if (this.emptyStreak >= this.rearmThresholdBlocks){
|
if (this.emptyStreak >= this.rearmThresholdBlocks){
|
||||||
this.started = false;
|
this.started = false;
|
||||||
this.emptyStreak = 0;
|
this.emptyStreak = 0;
|
||||||
|
this.cursor = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (++this.bufferedReportCounter >= this.bufferedReportEvery){
|
if (++this.bufferedReportCounter >= this.bufferedReportEvery){
|
||||||
this.bufferedReportCounter = 0;
|
this.bufferedReportCounter = 0;
|
||||||
try { this.port.postMessage({ cmd: 'buffered', seconds: this.buffered / sampleRate }); } catch(_){}
|
try { this.port.postMessage({
|
||||||
|
cmd: 'buffered',
|
||||||
|
seconds: this.buffered / sampleRate,
|
||||||
|
stretchFactor: this.stretchFactor,
|
||||||
|
}); } catch(_){}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -1911,12 +1985,25 @@ function installJitterBuffer(uuid, node){
|
||||||
onWorkletStarted(uuid);
|
onWorkletStarted(uuid);
|
||||||
} else if (e.data.cmd === 'buffered'){
|
} else if (e.data.cmd === 'buffered'){
|
||||||
/* worklet's current buffer depth (seconds). Used by lip-sync
|
/* worklet's current buffer depth (seconds). Used by lip-sync
|
||||||
* to make video receivers track the audio's total delay. */
|
* to make video receivers track the audio's total delay.
|
||||||
|
* stretchFactor is reported too — JS controller in Phase 3
|
||||||
|
* uses it to drive the per-publisher target. */
|
||||||
const n = listenerAudioNodes.get(uuid);
|
const n = listenerAudioNodes.get(uuid);
|
||||||
if (n) n.bufferedSeconds = e.data.seconds;
|
if (n){
|
||||||
|
n.bufferedSeconds = e.data.seconds;
|
||||||
|
n.stretchFactor = e.data.stretchFactor;
|
||||||
|
}
|
||||||
refreshLipSyncForUuid(uuid);
|
refreshLipSyncForUuid(uuid);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
/* Listener-role worklets never time-stretch — music quality on the
|
||||||
|
* deep cushion is paramount, and the buffer drifts gradually with
|
||||||
|
* clock drift. Speaker / cohost / host worklets DO stretch
|
||||||
|
* adaptively to drive the per-publisher target the Double Dragon
|
||||||
|
* controller sets. */
|
||||||
|
if (myRole === 'listener'){
|
||||||
|
try { jbuf.port.postMessage({ cmd: 'lock_rate', rate: 1.0 }); } catch(_){}
|
||||||
|
}
|
||||||
try { node.src.disconnect(node.gain); } catch(_){}
|
try { node.src.disconnect(node.gain); } catch(_){}
|
||||||
node.src.connect(jbuf).connect(node.gain);
|
node.src.connect(jbuf).connect(node.gain);
|
||||||
node.jbuf = jbuf;
|
node.jbuf = jbuf;
|
||||||
|
|
@ -2061,49 +2148,53 @@ async function refreshLipSyncForUuid(uuid){
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==================================================================
|
/* ==================================================================
|
||||||
* Double Dragon — twin-stream auto-engage controller.
|
* Double Dragon — adaptive time-stretching controller (Phase 3).
|
||||||
*
|
*
|
||||||
* Fox 2026-06-04 framing: "think of it like a CD that is literally
|
* Fox 2026-06-04 framing: "think of it like a CD that is literally
|
||||||
* being skipped in a physical disc man and solve it with two lasers
|
* being skipped in a physical disc man … two lasers one moving fast
|
||||||
* one moving fast mesh as fast as possible and one for the broadcast
|
* mesh as fast as possible and one for the broadcast also as fast as
|
||||||
* also as fast as possible … double headed hydra! double dragon!"
|
* possible dynamic based on … hardware performance and network
|
||||||
|
* performance and feed performance double headed hydra!"
|
||||||
*
|
*
|
||||||
* Per-publisher health monitor. Every telemetry tick (5s), we look
|
* Per-publisher controller. Every 5s telemetry tick, we look at the
|
||||||
* at the active mesh receiver's stats. If the publisher's network or
|
* active audio receiver's stats. If the publisher's network/encoder
|
||||||
* encoder is hitting the listener with loss/jitter, auto-engage the
|
* is hitting the listener with loss/jitter, we GROW the worklet's
|
||||||
* HTTP /stream path (deeper buffer, glitch-free, ~2.5s behind). When
|
* target buffer depth from DD_BASE_TARGET (0.5s, conversational) up
|
||||||
* the publisher's mesh path is clean for sustained time, auto-
|
* toward DD_MAX_TARGET (4s, full wiggle cushion). The worklet's
|
||||||
* disengage and return to low-latency mesh.
|
* built-in adaptive resampling (±8% rate cap) slowly stretches
|
||||||
|
* playback to grow the actual buffer toward the new target without
|
||||||
|
* audible click or gap — pitch shifts by ≤1 semitone during the
|
||||||
|
* adaptation, returns to normal once the target is reached.
|
||||||
|
*
|
||||||
|
* When the publisher's mesh path is clean for sustained time, we
|
||||||
|
* SHRINK the target back to DD_BASE_TARGET. Worklet compresses
|
||||||
|
* playback (≤8% faster) to shrink the buffer.
|
||||||
*
|
*
|
||||||
* State per pubHex:
|
* State per pubHex:
|
||||||
* 'mesh' — listening via worklet (mesh source), HTTP off
|
* 'auto' — controller-managed; target adjusts with stats
|
||||||
* 'http' — auto-engaged HTTP, worklet muted
|
* 'manual-on' — user toggled HTTP /stream; controller hands off
|
||||||
* 'manual-on' — user toggled HTTP manually; we don't touch it
|
* 'manual-off' — user toggled HTTP off; controller hands off
|
||||||
* 'manual-off' — user toggled HTTP off; we don't auto-engage
|
|
||||||
*
|
*
|
||||||
* Transitions (auto only):
|
* Manual state is sticky — clicking the toggle overrides the
|
||||||
* mesh → http: 2 consecutive samples show instability
|
* controller until the user leaves + re-enters.
|
||||||
* http → mesh: 6 consecutive clean samples (~30s)
|
|
||||||
*
|
*
|
||||||
* Manual state is sticky — if the user clicks the toggle, that
|
* Phase 4 (deferred): grain-based PSOLA/WSOLA pitch-preservation in
|
||||||
* intent overrides the controller until they leave + re-enter.
|
* the worklet to remove the ≤1 semitone shift during adaptation. */
|
||||||
*
|
|
||||||
* The switch is audibly a ~2.5s time-jump (mesh latency vs HTTP
|
|
||||||
* latency). Listener hears past content briefly. Phase 3 (deferred)
|
|
||||||
* does sample-aligned dual-decode via cross-correlation to remove
|
|
||||||
* the jump. */
|
|
||||||
const doubleDragon = new Map(); /* pubHex → state */
|
const doubleDragon = new Map(); /* pubHex → state */
|
||||||
const DD_LOSS_PER_SEC_THRESH = 2; /* > 2 loss/sec = unstable */
|
const DD_LOSS_PER_SEC_THRESH = 2; /* > 2 loss/sec = unstable */
|
||||||
const DD_JITTER_THRESH_SEC = 0.030; /* > 30ms = unstable */
|
const DD_JITTER_THRESH_SEC = 0.030; /* > 30ms = unstable */
|
||||||
const DD_UNSTABLE_SAMPLES_NEEDED = 2;
|
const DD_UNSTABLE_SAMPLES_NEEDED = 2;
|
||||||
const DD_CLEAN_SAMPLES_NEEDED = 6; /* 6 × 5s = 30s clean → recover */
|
const DD_CLEAN_SAMPLES_NEEDED = 6; /* 6 × 5s = 30s clean → recover */
|
||||||
const DD_TELEMETRY_TICK_SEC = 5;
|
const DD_TELEMETRY_TICK_SEC = 5;
|
||||||
|
const DD_BASE_TARGET_SEC = 0.5; /* normal conversational cushion */
|
||||||
|
const DD_MAX_TARGET_SEC = 4.0; /* maximum cushion under instability */
|
||||||
|
|
||||||
function ddEntry(pubHex){
|
function ddEntry(pubHex){
|
||||||
let e = doubleDragon.get(pubHex);
|
let e = doubleDragon.get(pubHex);
|
||||||
if (!e){
|
if (!e){
|
||||||
e = { state: 'mesh', unstableStreak: 0, cleanStreak: 0,
|
e = { state: 'auto', unstableStreak: 0, cleanStreak: 0,
|
||||||
lastLost: 0, lastSeenStatsAt: 0 };
|
lastLost: 0, lastSeenStatsAt: 0,
|
||||||
|
currentTargetSec: DD_BASE_TARGET_SEC };
|
||||||
doubleDragon.set(pubHex, e);
|
doubleDragon.set(pubHex, e);
|
||||||
}
|
}
|
||||||
return e;
|
return e;
|
||||||
|
|
@ -2115,6 +2206,26 @@ function ddNoteManualToggle(pubHex, isOn){
|
||||||
logLine('', 'double-dragon pub='+pubHex.slice(0,4)+' → '+e.state+' (manual)');
|
logLine('', 'double-dragon pub='+pubHex.slice(0,4)+' → '+e.state+' (manual)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Find the listener-audio node uuid for a publisher pubHex and post a
|
||||||
|
* retarget message to its worklet. The worklet's built-in rate-control
|
||||||
|
* loop will then stretch playback to drive its actual buffer toward
|
||||||
|
* the new target — no audible click, just a gradual time-stretch over
|
||||||
|
* ~5-10 seconds. */
|
||||||
|
function ddSetTargetForPub(pubHex, targetSec){
|
||||||
|
for (const [u, mm] of members){
|
||||||
|
try {
|
||||||
|
if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){
|
||||||
|
const node = listenerAudioNodes.get(u);
|
||||||
|
if (node && node.jbuf && node.jbuf.port){
|
||||||
|
node.targetSeconds = targetSec;
|
||||||
|
try { node.jbuf.port.postMessage({ cmd: 'retarget', targetSeconds: targetSec }); } catch(_){}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch(_){}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function evaluateDoubleDragonForPub(pubHex){
|
async function evaluateDoubleDragonForPub(pubHex){
|
||||||
const e = doubleDragon.get(pubHex) || ddEntry(pubHex);
|
const e = doubleDragon.get(pubHex) || ddEntry(pubHex);
|
||||||
/* manual state: hands off */
|
/* manual state: hands off */
|
||||||
|
|
@ -2125,14 +2236,6 @@ async function evaluateDoubleDragonForPub(pubHex){
|
||||||
const ls = lipSync.get(pubHex);
|
const ls = lipSync.get(pubHex);
|
||||||
const audRx = (ls && ls.audioReceiver) || sfuAudioReceivers.get(pubHex);
|
const audRx = (ls && ls.audioReceiver) || sfuAudioReceivers.get(pubHex);
|
||||||
if (!audRx) return;
|
if (!audRx) return;
|
||||||
/* find the uuid for the publisher (needed for start/stopStream) */
|
|
||||||
let uuid = null;
|
|
||||||
for (const [u, mm] of members){
|
|
||||||
try {
|
|
||||||
if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){ uuid = u; break; }
|
|
||||||
} catch(_){}
|
|
||||||
}
|
|
||||||
if (!uuid) return;
|
|
||||||
/* read stats */
|
/* read stats */
|
||||||
let lossRate = 0, jitter = 0;
|
let lossRate = 0, jitter = 0;
|
||||||
try {
|
try {
|
||||||
|
|
@ -2155,24 +2258,23 @@ async function evaluateDoubleDragonForPub(pubHex){
|
||||||
(jitter > DD_JITTER_THRESH_SEC);
|
(jitter > DD_JITTER_THRESH_SEC);
|
||||||
if (unstable){
|
if (unstable){
|
||||||
e.unstableStreak++; e.cleanStreak = 0;
|
e.unstableStreak++; e.cleanStreak = 0;
|
||||||
if (e.state === 'mesh' && e.unstableStreak >= DD_UNSTABLE_SAMPLES_NEEDED){
|
if (e.unstableStreak >= DD_UNSTABLE_SAMPLES_NEEDED &&
|
||||||
e.state = 'http';
|
e.currentTargetSec < DD_MAX_TARGET_SEC){
|
||||||
|
e.currentTargetSec = DD_MAX_TARGET_SEC;
|
||||||
|
ddSetTargetForPub(pubHex, DD_MAX_TARGET_SEC);
|
||||||
logLine('', 'double-dragon pub='+pubHex.slice(0,4)+
|
logLine('', 'double-dragon pub='+pubHex.slice(0,4)+
|
||||||
' AUTO-ENGAGE HTTP (loss/s='+lossRate.toFixed(1)+
|
' GROW target → '+DD_MAX_TARGET_SEC+'s (loss/s='+
|
||||||
' jitter='+(jitter*1000).toFixed(0)+'ms)');
|
lossRate.toFixed(1)+' jitter='+(jitter*1000).toFixed(0)+'ms)');
|
||||||
streamMode.add(pubHex);
|
|
||||||
try { await startStream(uuid, pubHex); } catch(_){}
|
|
||||||
try { renderRoom(); } catch(_){}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
e.cleanStreak++; e.unstableStreak = 0;
|
e.cleanStreak++; e.unstableStreak = 0;
|
||||||
if (e.state === 'http' && e.cleanStreak >= DD_CLEAN_SAMPLES_NEEDED){
|
if (e.cleanStreak >= DD_CLEAN_SAMPLES_NEEDED &&
|
||||||
e.state = 'mesh';
|
e.currentTargetSec > DD_BASE_TARGET_SEC){
|
||||||
|
e.currentTargetSec = DD_BASE_TARGET_SEC;
|
||||||
|
ddSetTargetForPub(pubHex, DD_BASE_TARGET_SEC);
|
||||||
logLine('', 'double-dragon pub='+pubHex.slice(0,4)+
|
logLine('', 'double-dragon pub='+pubHex.slice(0,4)+
|
||||||
' AUTO-DISENGAGE (clean '+e.cleanStreak+' samples)');
|
' SHRINK target → '+DD_BASE_TARGET_SEC+'s (clean '+
|
||||||
streamMode.delete(pubHex);
|
e.cleanStreak+' samples)');
|
||||||
try { stopStream(uuid); } catch(_){}
|
|
||||||
try { renderRoom(); } catch(_){}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2487,20 +2589,14 @@ const VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS = 120000;
|
||||||
* out wiggle-stalls without a glitch. Big enough to survive any
|
* out wiggle-stalls without a glitch. Big enough to survive any
|
||||||
* realistic publisher-side hiccup. */
|
* realistic publisher-side hiccup. */
|
||||||
const RECV_PLAYOUT_DELAY_SEC = 4.0;
|
const RECV_PLAYOUT_DELAY_SEC = 4.0;
|
||||||
/* Speaker/cohost/host cushion — empirically validated at 4s for
|
/* Speaker/cohost/host INITIAL cushion — 0.5s for conversational
|
||||||
* 20-second wiggle absorption. Previous 0.5s was conversation-friendly
|
* latency, then the Double Dragon adaptive controller grows it to
|
||||||
* but cohost-on-Fedora-Chrome glitched during host's X11 wiggles;
|
* DD_MAX_TARGET_SEC (4s) when the publisher's network or encoder
|
||||||
* smaller buffers untested. 4s pays a conversation-latency cost but
|
* shows instability and shrinks it back to DD_BASE_TARGET_SEC (0.5s)
|
||||||
* gives same wiggle-immunity as listeners. Fox 2026-06-04: "we needed
|
* during sustained clean periods. The worklet's ±8% rate-limited
|
||||||
* 4 secs before for the wiggle. it was at least 4 secs for 20 sec
|
* resampling makes the transition smooth: brief ≤1 semitone pitch
|
||||||
* wiggles; we didn't test less."
|
* shift during the ramp, silence-free, click-free. */
|
||||||
*
|
const SPEAKER_PLAYOUT_DELAY_SEC = 0.5;
|
||||||
* Future: twin-stream double-dragon controller (mesh + HTTP /stream
|
|
||||||
* running in parallel, adaptive switching based on mesh loss/jitter
|
|
||||||
* stats + hardware/network/feed performance). Until that lands, this
|
|
||||||
* single-source buffer is the floor that keeps everyone glitch-free
|
|
||||||
* under the most common stalls. */
|
|
||||||
const SPEAKER_PLAYOUT_DELAY_SEC = 4.0;
|
|
||||||
/* HTTP /stream estimated end-to-end delay used for lip-sync when the
|
/* HTTP /stream estimated end-to-end delay used for lip-sync when the
|
||||||
* per-speaker stream toggle is ON for a publisher. The actual delay
|
* per-speaker stream toggle is ON for a publisher. The actual delay
|
||||||
* varies (~1-3s depending on browser buffer + network), so this is a
|
* varies (~1-3s depending on browser buffer + network), so this is a
|
||||||
|
|
@ -6694,8 +6790,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');
|
||||||
|
|
||||||
<footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
|
<footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
|
||||||
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> · built <span class="stamp-date">2026-06-04</span><br>
|
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> · built <span class="stamp-date">2026-06-04</span><br>
|
||||||
md5 <span class="stamp-md5">0146cbc9be2f6f26292bedf734f66c0c</span><br>
|
md5 <span class="stamp-md5">42afccff3f68bba3b3a2959cec4adff2</span><br>
|
||||||
sha256 <span class="stamp-sha">bfd85c39d05fa3733f0beb026cc9e8329797f6daf6da13973192a72006bb040c</span><br>
|
sha256 <span class="stamp-sha">b4c8dd2cc581545d770c4c7ae254d298dad91215c64033d9f656658f01ff2224</span><br>
|
||||||
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br>
|
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br>
|
||||||
<span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify against these hashes; point at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
|
<span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify against these hashes; point at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue