diff --git a/zebra-report/index.html b/zebra-report/index.html
index b3aa45c..204c6a8 100644
--- a/zebra-report/index.html
+++ b/zebra-report/index.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);