mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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:
1 parent
537e99dfa4
commit
240f7ae3da
10 files changed
+2305
-75
No files matched your search
+268
-1
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user