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 _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() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue