zebra-spaces: deploy Phase 3 adaptive time-stretching

This commit is contained in:
russell@unturf.com 2026-06-04 19:46:24 -04:00
parent 4885cab72e
commit fa0666f7cc
No known key found for this signature in database

View file

@ -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> &nbsp;·&nbsp; 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> &nbsp;·&nbsp; 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>