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:
parent
04f4076d33
commit
a01f1b4aac
1 changed files with 67 additions and 15 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue