chat.html: Hamming(12,8) FEC + Gray-coded levels on the modem

Each data byte is carried as a 12-bit Hamming codeword (8 data + 4 parity)
inside one modem byte-frame, so any single-bit error self-corrects instead of
failing the frame CRC and stalling the outbox on "no ack". Symbol levels are
Gray-coded so an off-by-one quantization (the dominant error) is a single-bit
flip Hamming can fix. Toggle ?fec=0 (both peers must match). Validated in Node:
all 256 bytes x 12 bit positions corrected; modem recovers 30/30 with a +/-1
symbol error per byte.
This commit is contained in:
Russell Ballestrini 2026-05-28 12:41:13 -04:00
parent 04f4076d33
commit a01f1b4aac
No known key found for this signature in database

View file

@ -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 _LEVELS_PARAM = parseInt(new URLSearchParams(location.search).get('levels'), 10);
const ZEBRA_LEVELS = [2, 4, 16].includes(_LEVELS_PARAM) ? _LEVELS_PARAM : 4; 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_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] */ /* map a level index 0..N-1 onto the usable amplitude band [SPACE, MARK] */
function ampForLevel(L) { function ampForLevel(L) {
return ZEBRA_VOL_SPACE + (L / (ZEBRA_LEVELS - 1)) * (ZEBRA_VOL_MARK - ZEBRA_VOL_SPACE); 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 /* 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: * 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. * lower baud = more audio quanta per symbol = survives the smear, but slower.
@ -697,8 +747,8 @@ class MultiLevelDecoder {
this.sampleRate = sampleRate; this.sampleRate = sampleRate;
this.levels = levels; this.levels = levels;
this.bps = Math.log2(levels); this.bps = Math.log2(levels);
this.spb = 8 / this.bps; /* data symbols per byte */ this.cwSyms = ZEBRA_CW_BITS / this.bps; /* symbols per codeword (8 or 12 bits) */
this.frameSyms = this.spb + 2; /* + START + STOP */ this.frameSyms = this.cwSyms + 2; /* + START + STOP */
this.setBaud(baud); this.setBaud(baud);
this.state = 'hunt'; this.state = 'hunt';
this.prevAbove = true; this.prevAbove = true;
@ -727,14 +777,14 @@ class MultiLevelDecoder {
const highRef = this._symAmp(this.frameSyms - 1); /* STOP */ const highRef = this._symAmp(this.frameSyms - 1); /* STOP */
const span = highRef - lowRef; const span = highRef - lowRef;
if (span < 0.03) return null; /* not a real START..STOP frame */ if (span < 0.03) return null; /* not a real START..STOP frame */
let byte = 0; let cw = 0;
for (let j = 0; j < this.spb; j++) { for (let j = 0; j < this.cwSyms; j++) {
const amp = this._symAmp(1 + j); const amp = this._symAmp(1 + j);
let L = Math.round((amp - lowRef) / span * N1); let phys = Math.round((amp - lowRef) / span * N1);
if (L < 0) L = 0; else if (L > N1) L = N1; if (phys < 0) phys = 0; else if (phys > N1) phys = N1;
byte |= (L << (j * this.bps)); cw |= (grayPhys2Val[phys] << (j * this.bps)); /* un-Gray: level -> value */
} }
return byte; return ZEBRA_FEC ? hammingDecode(cw) : cw; /* correct single-bit error */
} }
push(e) { push(e) {
this.peak *= this.peakDecay; this.peak *= this.peakDecay;
@ -844,9 +894,10 @@ async function attachInboundTrack(stream, trackId) {
if (frame) onRxFrame(frame); if (frame) onRxFrame(frame);
}; };
rxDecoders.set(trackId, { uart, asm, node, audio: a }); 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, ` 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) { 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 */ /* 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 (let i = 0; i < 10; i++) { _scheduleGain(ZEBRA_VOL_MARK, t); t += period; }
for (const byte of bytes) { for (const byte of bytes) {
const cw = ZEBRA_FEC ? hammingEncode(byte) : byte; /* 12 or 8 bits */
_scheduleGain(ampForLevel(0), t); t += period; /* START = MIN */ _scheduleGain(ampForLevel(0), t); t += period; /* START = MIN */
for (let j = 0; j < ZEBRA_SYMS_PER_BYTE; j++) { /* data, LSB-first */ for (let j = 0; j < ZEBRA_SYMS_PER_CW; j++) { /* codeword, LSB-first */
const level = (byte >> (j * ZEBRA_BITS_PER_SYM)) & N1; const v = (cw >> (j * ZEBRA_BITS_PER_SYM)) & N1;
_scheduleGain(ampForLevel(level), t); t += period; _scheduleGain(ampForLevel(grayVal2Phys[v]), t); t += period; /* Gray: value -> level */
} }
_scheduleGain(ampForLevel(N1), t); t += period; /* STOP = MAX */ _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 */ /* rough on-air time for a frame of byteLen bytes at the current link settings */
function estimateTxMs(byteLen) { 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; return ticks / ZEBRA_BAUD_HANDSHAKE * 1000 + 80;
} }
function renderOutbox() { function renderOutbox() {