zebra-report: multi-level volume modem (N-level symbols, ?levels=N)

This commit is contained in:
russell@unturf.com 2026-05-28 11:17:37 -04:00
parent 23b7214b74
commit c618c2c10d
No known key found for this signature in database

View file

@ -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);