zebra-report: multi-level volume modem (N-level symbols, ?levels=N)
This commit is contained in:
parent
23b7214b74
commit
c618c2c10d
1 changed files with 80 additions and 26 deletions
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue