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() {