diff --git a/web/chat.html b/web/chat.html index b3aa45c..204c6a8 100644 --- a/web/chat.html +++ b/web/chat.html @@ -454,6 +454,18 @@ var QRCode;(function(){function QR8bitByte(data){this.mode=QRMode.MODE_8BIT_BYTE * ============================================================== */ const ZEBRA_VOL_MARK = 0.95; /* near full-scale (no clip) for max margin */ const ZEBRA_VOL_SPACE = 0.10; /* low but non-zero so the tone stays present */ + +/* Multi-level encoding: each baud tick holds the volume at one of N levels, so + * a symbol carries log2(N) bits (N=2 -> 1 bit, the old binary scheme; N=16 -> 4 + * bits). Tunable via ?levels=N. N must be a power of 2 that divides 8 cleanly. */ +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); +} /* 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. @@ -851,39 +863,77 @@ async function setupWorkletDecoder() { /* one decoder per inbound RTC track (peer) */ const rxDecoders = new Map(); /* trackId → { uart, asm, node } */ -class UartDecoder { - constructor(baud, sampleRate) { +/* Multi-level demodulator. Each byte arrives framed as: + * START(level 0 = MIN) | symsPerByte data symbols | STOP(level N-1 = MAX) + * with the carrier idling at MAX between bytes, so every byte begins with a + * high->low edge we can lock onto. After collecting a full byte window we read + * the amplitude at each symbol centre, take START as the low reference and STOP + * as the high reference, and quantise the data symbols against that span — so + * the level mapping self-calibrates every byte and tolerates channel gain + * drift. N=2 collapses to the original binary UART. */ +class MultiLevelDecoder { + constructor(baud, sampleRate, levels) { 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.setBaud(baud); this.state = 'hunt'; - this.byte = 0; this.bitIdx = 0; this.curSample = 0; this.prevAbove = true; this.peak = 0; this.peakDecay = Math.pow(0.5, 1.0 / (2.0 * sampleRate)); /* 2s half-life */ + this.amps = []; + this.q = 0; + } + setBaud(b) { + this.baud = b; + this.sps = this.sampleRate / b; + this.windowQ = Math.ceil(this.frameSyms * this.sps); + } + /* average the middle ~40% of symbol i's window so we never average across a + * symbol boundary (which corrupts the level at small samples-per-symbol) */ + _symAmp(i) { + const lo = Math.max(0, Math.floor((i + 0.3) * this.sps)); + const hi = Math.min(this.amps.length - 1, Math.ceil((i + 0.7) * this.sps)); + let sum = 0, n = 0; + for (let q = lo; q <= hi; q++) { sum += this.amps[q]; n++; } + return n ? sum / n : this.amps[this.amps.length - 1]; + } + _decodeByte() { + const N1 = this.levels - 1; + const lowRef = this._symAmp(0); /* START */ + 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++) { + 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)); + } + return byte; } - setBaud(b) { this.baud = b; this.sps = this.sampleRate / b; } push(e) { this.peak *= this.peakDecay; if (e > this.peak) this.peak = e; if (this.peak < 0.02) { this.state = 'hunt'; this.prevAbove = true; return null; } - const thr = this.peak * 0.50; - const above = e >= thr; + const above = e >= this.peak * 0.50; if (this.state === 'hunt') { - if (this.prevAbove && !above) { - this.state = 'sample'; this.curSample = 1; this.byte = 0; this.bitIdx = 0; + if (this.prevAbove && !above) { /* high->low edge = START of a byte */ + this.state = 'collect'; + this.amps = [e]; + this.q = 1; } } else { - this.curSample++; - const center = (1.5 + this.bitIdx) * this.sps; - if (this.curSample >= center) { - const bit = above ? 1 : 0; - this.byte |= (bit << this.bitIdx); - this.bitIdx++; - if (this.bitIdx === 8) { - const out = this.byte; - this.state = 'hunt'; this.prevAbove = true; - return out; - } + this.amps.push(e); + this.q++; + if (this.q >= this.windowQ) { + const out = this._decodeByte(); + this.state = 'hunt'; + this.prevAbove = true; /* STOP was high; next START edge will fire */ + return out; } } this.prevAbove = above; @@ -956,7 +1006,7 @@ async function attachInboundTrack(stream, trackId) { src.connect(node); /* effective decoder rate: sampleRate / 128 (one peak per audio quantum) */ const effRate = audioCtx.sampleRate / 128; - const uart = new UartDecoder(ZEBRA_BAUD_HANDSHAKE, effRate); + const uart = new MultiLevelDecoder(ZEBRA_BAUD_HANDSHAKE, effRate, ZEBRA_LEVELS); const asm = new FrameAssembler(); node.port.onmessage = (ev) => { const peak = ev.data; @@ -967,7 +1017,9 @@ async function attachInboundTrack(stream, trackId) { if (frame) onRxFrame(frame); }; rxDecoders.set(trackId, { uart, asm, node, audio: a }); - logLine('sys', 'inbound track attached — decoder live'); + const bps = ZEBRA_BITS_PER_SYM, thru = ZEBRA_BAUD_HANDSHAKE * bps; + logLine('sys', `inbound track attached — decoder live (${ZEBRA_LEVELS} levels, ` + + `${ZEBRA_BAUD_HANDSHAKE} baud, ${bps} bit/sym ≈ ${thru} bit/s)`); } function detachInboundTrack(trackId) { @@ -1004,15 +1056,17 @@ async function txFrame(bytes, baud) { async function _txOne(bytes, baud) { const period = 1.0 / baud; + const N1 = ZEBRA_LEVELS - 1; let t = audioCtx.currentTime + 0.02; + /* 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) { - _scheduleGain(ZEBRA_VOL_SPACE, t); t += period; - for (let bit = 0; bit < 8; bit++) { - _scheduleGain(((byte >> bit) & 1) ? ZEBRA_VOL_MARK : ZEBRA_VOL_SPACE, t); - t += period; + _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; } - _scheduleGain(ZEBRA_VOL_MARK, t); t += period; + _scheduleGain(ampForLevel(N1), t); t += period; /* STOP = MAX */ } for (let i = 0; i < 5; i++) { _scheduleGain(ZEBRA_VOL_MARK, t); t += period; } _scheduleGain(ZEBRA_VOL_MARK, t);