fix(sdr): bound decoder work and stabilize FM playback

Keep USB reads in a bounded ordered pipeline, acknowledge and cap decoder
work, terminate stopped workers, and buffer short playback jitter. Throttle
display telemetry and compute spectra only while seeking.

Preserve PR #1174's wrapped seeking and fresh-spectrum tagging. Add opt-in
bounded timing diagnostics and decoder/playback lifecycle regressions.

Co-authored-by: Aditya <[email protected]>
This commit is contained in:
Mazeyar Moeini FeizabadiandAditya committed 2026-10-16 18:37:15 +02:00
1 parent 537e99dfa4
commit 240f7ae3da
10 files changed
+2305 -75

No files matched your search

+268 -1
View File
@@ -33,7 +33,18 @@ function memoryStorage(initial = {}) {
}
class FakeWorker {
postMessage() {}
constructor() {
// Like the real worker, report ready once loaded.
queueMicrotask(() => this.onmessage?.({ data: { type: 'ready' } }));
}
// Like the real worker, every numbered samples block is acknowledged.
postMessage(message) {
if (message?.type === 'samples' && Number.isSafeInteger(message.id))
queueMicrotask(() =>
this.onmessage?.({ data: { type: 'ack', id: message.id } }),
);
}
terminate() {}
}
@@ -1020,3 +1031,259 @@ test("a device selection that resolves after its acquisition was superseded cann
assert.equal(state.status, 'streaming');
assert.equal(state.connected, true);
});
/** An FM spectrum frame centred on `center` with carriers at `stations`. */
function fmSpectrum(stations, center, sampleRate) {
const bins = new Float32Array(512).fill(-80);
for (const station of stations) {
const index = Math.round(
(station - center) / (sampleRate / bins.length) + bins.length / 2,
);
for (let bin = index - 5; bin <= index + 5; bin += 1) {
if (bin >= 0 && bin < bins.length) bins[bin] = -10;
}
}
return bins;
}
/**
* An open FM receiver hearing `stations`. Like the real worker, spectra of
* samples read before a retune keep arriving after it: one during the retune
* and one after the worker's reset, each drawn at the previous centre and
* tagged with the previous tune, before the first spectrum at the new centre.
*/
function fmSeekController(stations) {
const events = [];
const controller = new SdrController({
storage: memoryStorage(),
webUsb: {},
});
const { sampleRate } = controller.getState();
let center = null;
let workerTune = 0;
const deliver = (tune, at) =>
controller._handleWorkerMessage({
type: 'fm',
bins: fmSpectrum(stations, at, sampleRate),
tune,
});
let previous = null;
controller._worker = {
postMessage(message) {
if (message.type !== 'reset') return;
const stale = [workerTune, previous];
const fresh = [message.tune, center];
workerTune = message.tune;
if (stale[1] !== null) setTimeout(() => deliver(...stale), 0);
setTimeout(() => deliver(...fresh), 1);
},
terminate() {},
};
const device = fakeDevice(events);
const setCenterFrequency = device.setCenterFrequency;
device.setCenterFrequency = async (frequency) => {
if (center !== null) deliver(workerTune, center);
previous = center;
center = frequency;
return setCenterFrequency(frequency);
};
controller._device = device;
return { controller, events };
}
async function seekFrom(origin, direction, stations, prepare = () => {}) {
const { controller, events } = fmSeekController(stations);
await controller.tuneFm(origin);
await new Promise((resolve) => setTimeout(resolve, 0));
prepare(controller);
events.length = 0;
const scanned = new Set();
controller.subscribe(({ seekMessage }) => {
const match = /^Scanning ([\d.]+) MHz/.exec(seekMessage);
if (match) scanned.add(Number(match[1]));
});
const peak = await controller.seekFm(direction);
const tuned = events.filter((event) => event.startsWith('device:tune:'));
return { peak, state: controller.getState(), tuned, scanned };
}
test('FM seek wraps past either band edge and finds the next station', async () => {
const cases = [
// [origin, direction, stations, expected]
[107_500_000, 1, [88_500_000], 88_500_000],
[107_500_000, 1, [106_000_000, 88_500_000, 87_900_000], 87_900_000],
[107_900_000, 1, [87_500_000], 87_500_000],
[88_000_000, -1, [107_000_000], 107_000_000],
[88_000_000, -1, [89_000_000, 107_000_000, 107_600_000], 107_600_000],
[87_600_000, -1, [108_000_000], 108_000_000],
];
for (const [origin, direction, stations, expected] of cases) {
const { peak, state } = await seekFrom(origin, direction, stations);
const label = `${direction > 0 ? 'up' : 'down'} from ${origin}`;
assert.equal(peak?.frequency, expected, label);
assert.equal(state.frequencyHz, expected, label);
assert.equal(state.seeking, false, label);
}
});
test('FM seek reaches both band-edge channels from anywhere in the band', async () => {
for (let origin = 87_600_000; origin < 108_000_000; origin += 1_300_000) {
for (const direction of [1, -1]) {
for (const station of [87_500_000, 108_000_000]) {
const { peak } = await seekFrom(origin, direction, [station]);
assert.equal(
peak?.frequency,
station,
`${direction > 0 ? 'up' : 'down'} from ${origin} to ${station}`,
);
}
}
}
});
test('FM seek laps the band to a station just behind the origin', async () => {
assert.equal(
(await seekFrom(98_500_000, 1, [98_200_000])).peak?.frequency,
98_200_000,
);
assert.equal(
(await seekFrom(98_500_000, -1, [98_800_000])).peak?.frequency,
98_800_000,
);
});
test('FM seek with no other station stops after one lap and returns to the origin', async () => {
for (const stations of [[], [98_500_000]]) {
for (const direction of [1, -1]) {
const { peak, state, tuned, scanned } = await seekFrom(
98_500_000,
direction,
stations,
);
const label = `${stations.length} station(s), direction ${direction}`;
assert.equal(peak, null, label);
assert.equal(state.seeking, false, label);
assert.equal(state.seekMessage, 'No FM signal found', label);
assert.equal(state.frequencyHz, 98_500_000, label);
// 19 frames, then the retune to the origin.
assert.equal(tuned.length, 20, label);
for (const mhz of scanned) {
assert.ok(mhz >= 87.5 && mhz <= 108, `${label}: scanning ${mhz} MHz`);
}
}
}
});
test('FM seek passes over its own station to the next one in the same frame', async () => {
// The first frame's spectrum never arrives, so 98.7 is first seen in the
// last frame of the lap, behind the origin's own carrier.
const { peak } = await seekFrom(
98_500_000,
1,
[98_500_000, 98_700_000],
(controller) => {
const wait = controller._waitForSpectrum.bind(controller);
let first = true;
controller._waitForSpectrum = async (...args) => {
if (!first) return wait(...args);
first = false;
return false;
};
},
);
assert.equal(peak?.frequency, 98_700_000);
});
test('FM seek stops when a retune fails instead of reading the old frame', async () => {
const { peak, state, tuned } = await seekFrom(
98_500_000,
1,
[102_100_000],
(controller) => {
const device = controller._device;
const setCenterFrequency = device.setCenterFrequency;
let calls = 0;
device.setCenterFrequency = async (frequency) => {
calls += 1;
if (calls === 2) throw new Error('transfer error');
return setCenterFrequency(frequency);
};
},
);
assert.equal(peak, null);
assert.equal(state.seeking, false);
assert.equal(state.seekMessage, 'Seek stopped');
assert.equal(state.status, 'error');
assert.equal(tuned.length, 1, 'no frame is tuned after the failure');
});
test('a retune that fails part-way drops spectra until a retune succeeds', async () => {
const resets = [];
const controller = new SdrController({
storage: memoryStorage(),
webUsb: {},
});
const device = fakeDevice();
controller._device = device;
controller._worker = {
postMessage: (message) => {
if (message.type === 'reset') resets.push(message.tune);
},
terminate() {},
};
const spectrum = (tune) =>
controller._handleWorkerMessage({
type: 'fm',
bins: new Float32Array(512),
tune,
});
assert.equal(await controller.tuneFm(98_500_000), true);
const workerTune = resets.at(-1);
spectrum(workerTune);
const accepted = controller._spectrumSequence;
assert.equal(accepted, 1);
// The tuner moves, then the buffer reset fails: the worker is never reset,
// so samples at the new frequency still carry its old tag.
device.resetBuffer = async () => {
throw new Error('transfer error');
};
assert.equal(await controller.tuneFm(99_700_000), false);
assert.deepEqual(resets, [workerTune]);
spectrum(workerTune);
assert.equal(controller._spectrumSequence, accepted);
device.resetBuffer = async () => {};
assert.equal(await controller.tuneFm(99_700_000), true);
spectrum(resets.at(-1));
assert.equal(controller._spectrumSequence, accepted + 1);
});
test("a rebuilt decoder worker's spectra are accepted before its first reset", async (t) => {
const workers = [];
class TaggedWorker {
constructor() {
workers.push(this);
}
postMessage() {}
terminate() {}
}
t.after(replaceGlobal('Worker', TaggedWorker));
const controller = new SdrController({
storage: memoryStorage(),
webUsb: {},
});
controller._device = fakeDevice();
controller._ensureWorker();
await controller.tuneFm(98_500_000);
await controller.tuneFm(99_700_000);
controller._discardWorker();
controller._ensureWorker();
assert.equal(workers.length, 2);
const before = controller._spectrumSequence;
// Like sdr.worker.js, a fresh worker tags its spectra 0 until a reset.
workers[1].onmessage({
data: { type: 'fm', bins: new Float32Array(512), tune: 0 },
});
assert.equal(controller._spectrumSequence, before + 1);
});