diff --git a/web/chat.html b/web/chat.html index d941569..29632a1 100644 --- a/web/chat.html +++ b/web/chat.html @@ -311,11 +311,61 @@ const ZEBRA_VOL_SPACE = 0.10; /* low but non-zero so the tone stays prese const _LEVELS_PARAM = parseInt(new URLSearchParams(location.search).get('levels'), 10); const ZEBRA_LEVELS = [2, 4, 16].includes(_LEVELS_PARAM) ? _LEVELS_PARAM : 4; const ZEBRA_BITS_PER_SYM = Math.log2(ZEBRA_LEVELS); /* 1 | 2 | 4 */ -const ZEBRA_SYMS_PER_BYTE = 8 / ZEBRA_BITS_PER_SYM; /* 8 | 4 | 2 */ /* map a level index 0..N-1 onto the usable amplitude band [SPACE, MARK] */ function ampForLevel(L) { return ZEBRA_VOL_SPACE + (L / (ZEBRA_LEVELS - 1)) * (ZEBRA_VOL_MARK - ZEBRA_VOL_SPACE); } + +/* ============================================================== * + * forward error correction — Hamming(12,8) single-bit correct * + * * + * Each data byte becomes a 12-bit codeword (8 data + 4 parity) * + * carried inside one modem byte-frame, so any single flipped bit * + * self-corrects on the far side instead of failing the frame CRC. * + * Toggle with ?fec=0 (both peers must match). Combined with Gray- * + * coded levels below, an off-by-one quantization is a single-bit * + * flip — exactly what Hamming repairs. * + * ============================================================== */ +const ZEBRA_FEC = new URLSearchParams(location.search).get('fec') !== '0'; +const ZEBRA_CW_BITS = ZEBRA_FEC ? 12 : 8; /* bits carried per byte-frame */ +const ZEBRA_SYMS_PER_CW = ZEBRA_CW_BITS / ZEBRA_BITS_PER_SYM; +const _HAM_DATA_POS = [3, 5, 6, 7, 9, 10, 11, 12]; /* 1-indexed data positions */ +function hammingEncode(byte) { + const bit = new Array(13).fill(0); + for (let i = 0; i < 8; i++) bit[_HAM_DATA_POS[i]] = (byte >> i) & 1; + for (const p of [1, 2, 4, 8]) { + let par = 0; + for (let pos = 1; pos <= 12; pos++) if (pos !== p && (pos & p)) par ^= bit[pos]; + bit[p] = par; + } + let code = 0; + for (let pos = 1; pos <= 12; pos++) code |= bit[pos] << (pos - 1); + return code; +} +function hammingDecode(code) { + const bit = new Array(13).fill(0); + for (let pos = 1; pos <= 12; pos++) bit[pos] = (code >> (pos - 1)) & 1; + let syndrome = 0; + for (const p of [1, 2, 4, 8]) { + let par = 0; + for (let pos = 1; pos <= 12; pos++) if (pos & p) par ^= bit[pos]; + if (par) syndrome |= p; + } + if (syndrome >= 1 && syndrome <= 12) bit[syndrome] ^= 1; /* correct single-bit error */ + let byte = 0; + for (let i = 0; i < 8; i++) byte |= bit[_HAM_DATA_POS[i]] << i; + return byte; +} + +/* Gray map: physical level <-> symbol value, so an off-by-one level error is a + * single-bit value error (which Hamming then corrects). */ +const grayVal2Phys = new Array(ZEBRA_LEVELS); +const grayPhys2Val = new Array(ZEBRA_LEVELS); +for (let L = 0; L < ZEBRA_LEVELS; L++) { + const v = L ^ (L >> 1); /* value carried by physical level L */ + grayPhys2Val[L] = v; + grayVal2Phys[v] = L; +} /* Link baud for the whole audio modem (handshake + data). Tunable via ?baud=N * because the WebRTC Opus path encodes in 20ms frames and smears bit edges: * lower baud = more audio quanta per symbol = survives the smear, but slower. @@ -697,8 +747,8 @@ class MultiLevelDecoder { this.sampleRate = sampleRate; this.levels = levels; this.bps = Math.log2(levels); - this.spb = 8 / this.bps; /* data symbols per byte */ - this.frameSyms = this.spb + 2; /* + START + STOP */ + this.cwSyms = ZEBRA_CW_BITS / this.bps; /* symbols per codeword (8 or 12 bits) */ + this.frameSyms = this.cwSyms + 2; /* + START + STOP */ this.setBaud(baud); this.state = 'hunt'; this.prevAbove = true; @@ -727,14 +777,14 @@ class MultiLevelDecoder { const highRef = this._symAmp(this.frameSyms - 1); /* STOP */ const span = highRef - lowRef; if (span < 0.03) return null; /* not a real START..STOP frame */ - let byte = 0; - for (let j = 0; j < this.spb; j++) { + let cw = 0; + for (let j = 0; j < this.cwSyms; j++) { const amp = this._symAmp(1 + j); - let L = Math.round((amp - lowRef) / span * N1); - if (L < 0) L = 0; else if (L > N1) L = N1; - byte |= (L << (j * this.bps)); + let phys = Math.round((amp - lowRef) / span * N1); + if (phys < 0) phys = 0; else if (phys > N1) phys = N1; + cw |= (grayPhys2Val[phys] << (j * this.bps)); /* un-Gray: level -> value */ } - return byte; + return ZEBRA_FEC ? hammingDecode(cw) : cw; /* correct single-bit error */ } push(e) { this.peak *= this.peakDecay; @@ -844,9 +894,10 @@ async function attachInboundTrack(stream, trackId) { if (frame) onRxFrame(frame); }; rxDecoders.set(trackId, { uart, asm, node, audio: a }); - const bps = ZEBRA_BITS_PER_SYM, thru = ZEBRA_BAUD_HANDSHAKE * bps; + const frameSyms = ZEBRA_SYMS_PER_CW + 2; + const dataBps = Math.round(ZEBRA_BAUD_HANDSHAKE * 8 / frameSyms); logLine('sys', `inbound track attached — decoder live (${ZEBRA_LEVELS} levels, ` - + `${ZEBRA_BAUD_HANDSHAKE} baud, ${bps} bit/sym ≈ ${thru} bit/s)`); + + `${ZEBRA_BAUD_HANDSHAKE} baud, FEC ${ZEBRA_FEC ? 'on' : 'off'} ≈ ${dataBps} data bit/s)`); } function detachInboundTrack(trackId) { @@ -888,10 +939,11 @@ async function _txOne(bytes, baud) { /* preamble: idle high (MAX) so the first START is a clean high->low edge */ for (let i = 0; i < 10; i++) { _scheduleGain(ZEBRA_VOL_MARK, t); t += period; } for (const byte of bytes) { + const cw = ZEBRA_FEC ? hammingEncode(byte) : byte; /* 12 or 8 bits */ _scheduleGain(ampForLevel(0), t); t += period; /* START = MIN */ - for (let j = 0; j < ZEBRA_SYMS_PER_BYTE; j++) { /* data, LSB-first */ - const level = (byte >> (j * ZEBRA_BITS_PER_SYM)) & N1; - _scheduleGain(ampForLevel(level), t); t += period; + for (let j = 0; j < ZEBRA_SYMS_PER_CW; j++) { /* codeword, LSB-first */ + const v = (cw >> (j * ZEBRA_BITS_PER_SYM)) & N1; + _scheduleGain(ampForLevel(grayVal2Phys[v]), t); t += period; /* Gray: value -> level */ } _scheduleGain(ampForLevel(N1), t); t += period; /* STOP = MAX */ } @@ -936,7 +988,7 @@ function frameCrcOf(f) { } /* rough on-air time for a frame of byteLen bytes at the current link settings */ function estimateTxMs(byteLen) { - const ticks = 10 + byteLen * (2 + ZEBRA_SYMS_PER_BYTE) + 5; /* preamble + frame + trailer */ + const ticks = 10 + byteLen * (2 + ZEBRA_SYMS_PER_CW) + 5; /* preamble + frame + trailer */ return ticks / ZEBRA_BAUD_HANDSHAKE * 1000 + 80; } function renderOutbox() {