mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
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]>
1290 lines
42 KiB
JavaScript
1290 lines
42 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
|
|
import {
|
|
createRememberingWebUsb,
|
|
SDR_RECEIVER_PRESETS,
|
|
SdrController,
|
|
} from './controller.js';
|
|
import { RTL2832U_Provider } from '@jtarrio/webrtlsdr/rtlsdr.js';
|
|
import { RTL_SDR_USB_FILTERS } from './usbDevices.js';
|
|
import { SDR_GAIN_STORAGE_KEY } from './gain.js';
|
|
|
|
function replaceGlobal(name, value) {
|
|
const prior = Object.getOwnPropertyDescriptor(globalThis, name);
|
|
Object.defineProperty(globalThis, name, {
|
|
configurable: true,
|
|
writable: true,
|
|
value,
|
|
});
|
|
return () => {
|
|
if (prior) Object.defineProperty(globalThis, name, prior);
|
|
else delete globalThis[name];
|
|
};
|
|
}
|
|
|
|
function memoryStorage(initial = {}) {
|
|
const values = new Map(Object.entries(initial));
|
|
return {
|
|
values,
|
|
getItem: (key) => (values.has(key) ? values.get(key) : null),
|
|
setItem: (key, value) => values.set(key, String(value)),
|
|
};
|
|
}
|
|
|
|
class FakeWorker {
|
|
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() {}
|
|
}
|
|
|
|
function fakeDevice(events = [], name = 'device') {
|
|
return {
|
|
setSampleRate: async (rate) => rate,
|
|
setFrequencyCorrection: async () => {},
|
|
setCenterFrequency: async (frequency) => {
|
|
events.push(`${name}:tune:${frequency}`);
|
|
return frequency;
|
|
},
|
|
setGain: async (gain) => {
|
|
events.push(`${name}:gain:${gain}`);
|
|
},
|
|
_enableRtlAgc: async (enabled) => {
|
|
events.push(`rtl-agc:${enabled}`);
|
|
},
|
|
resetBuffer: async () => {},
|
|
// Resolve like hardware so queued USB work can run between blocks.
|
|
readSamples: () =>
|
|
new Promise((resolve) =>
|
|
setTimeout(() => resolve({ data: new ArrayBuffer(8) }), 2),
|
|
),
|
|
close: async () => {
|
|
events.push(`${name}:close`);
|
|
},
|
|
};
|
|
}
|
|
|
|
test('FM resumes audio before opening WebUSB and reports the running context', async (t) => {
|
|
const events = [];
|
|
class FakeAudioContext {
|
|
constructor() {
|
|
events.push('audio:create');
|
|
this.state = 'suspended';
|
|
this.destination = {};
|
|
this.audioWorklet = {
|
|
addModule: async () => {
|
|
events.push('audio:module');
|
|
},
|
|
};
|
|
this.onstatechange = null;
|
|
}
|
|
|
|
async resume() {
|
|
events.push('audio:resume');
|
|
this.state = 'running';
|
|
this.onstatechange?.();
|
|
}
|
|
|
|
async close() {
|
|
this.state = 'closed';
|
|
}
|
|
}
|
|
class FakeAudioWorkletNode {
|
|
constructor() {
|
|
this.port = { postMessage() {} };
|
|
}
|
|
|
|
connect() {}
|
|
}
|
|
const device = fakeDevice(events);
|
|
const restore = [
|
|
replaceGlobal('navigator', { usb: {} }),
|
|
replaceGlobal('AudioContext', FakeAudioContext),
|
|
replaceGlobal('AudioWorkletNode', FakeAudioWorkletNode),
|
|
replaceGlobal('Worker', FakeWorker),
|
|
];
|
|
t.after(() => restore.reverse().forEach((callback) => callback()));
|
|
|
|
const controller = new SdrController({
|
|
storage: memoryStorage(),
|
|
providerFactory: () => ({
|
|
async get() {
|
|
events.push('usb:get');
|
|
return device;
|
|
},
|
|
}),
|
|
});
|
|
t.after(() => controller.stop());
|
|
|
|
assert.equal(await controller.connect('fm'), true);
|
|
assert.ok(events.indexOf('audio:resume') < events.indexOf('usb:get'));
|
|
assert.equal(controller.getState().audioState, 'running');
|
|
assert.equal(controller.getState().connected, true);
|
|
assert.ok(events.includes('rtl-agc:false'));
|
|
assert.ok(events.includes('device:gain:null'), 'FM defaults to tuner AGC');
|
|
assert.equal(controller.getState().gain, 'auto');
|
|
});
|
|
|
|
test('receiver presets keep the known-working tuning settings', () => {
|
|
assert.deepEqual(SDR_RECEIVER_PRESETS.fm, {
|
|
frequencyHz: 98_500_000,
|
|
sampleRate: 2_048_000,
|
|
ppm: 0,
|
|
rtlAgc: false,
|
|
});
|
|
assert.deepEqual(SDR_RECEIVER_PRESETS.adsb, {
|
|
frequencyHz: 1_090_000_000,
|
|
sampleRate: 2_000_000,
|
|
ppm: 0,
|
|
rtlAgc: false,
|
|
});
|
|
});
|
|
|
|
test('ADS-B opens at manual 28.0 dB and gain changes apply live without reconnecting', async (t) => {
|
|
const events = [];
|
|
let opens = 0;
|
|
const storage = memoryStorage();
|
|
t.after(replaceGlobal('Worker', FakeWorker));
|
|
const controller = new SdrController({
|
|
storage,
|
|
webUsb: {},
|
|
providerFactory: () => ({
|
|
async get() {
|
|
opens += 1;
|
|
return fakeDevice(events);
|
|
},
|
|
}),
|
|
});
|
|
t.after(() => controller.stop());
|
|
assert.equal(controller.getState().gain, 'auto');
|
|
assert.equal(await controller.connect('adsb'), true);
|
|
assert.ok(events.includes('device:gain:28'));
|
|
assert.equal(controller.getState().gain, 28);
|
|
|
|
assert.equal(await controller.setGain('36.4'), 36.4);
|
|
assert.equal(events.at(-1), 'device:gain:36.4');
|
|
assert.equal(await controller.setGain('auto'), 'auto');
|
|
assert.equal(events.at(-1), 'device:gain:null');
|
|
assert.equal(opens, 1, 'gain changes never reopen the receiver');
|
|
assert.deepEqual(JSON.parse(storage.values.get(SDR_GAIN_STORAGE_KEY)), {
|
|
fm: 'auto',
|
|
adsb: 'auto',
|
|
});
|
|
|
|
const before = events.length;
|
|
assert.equal(await controller.setGain(28, { mode: 'fm' }), 28);
|
|
assert.equal(events.length, before, 'another mode is stored, not applied');
|
|
assert.equal(controller.getGain('fm'), 28);
|
|
assert.equal(controller.getState().gain, 'auto');
|
|
assert.equal(await controller.setGain('very loud'), null);
|
|
});
|
|
|
|
test('stored per-mode gain is restored on the next session', async (t) => {
|
|
const events = [];
|
|
t.after(replaceGlobal('Worker', FakeWorker));
|
|
const controller = new SdrController({
|
|
storage: memoryStorage({
|
|
[SDR_GAIN_STORAGE_KEY]: JSON.stringify({ fm: 'auto', adsb: 12.5 }),
|
|
}),
|
|
webUsb: {},
|
|
providerFactory: () => ({ get: async () => fakeDevice(events) }),
|
|
});
|
|
t.after(() => controller.stop());
|
|
await controller.connect('adsb');
|
|
assert.ok(events.includes('device:gain:12.5'));
|
|
});
|
|
|
|
test('ADS-B updates report CRC-valid messages per second, heard and positioned', () => {
|
|
const controller = new SdrController({
|
|
storage: memoryStorage(),
|
|
webUsb: {},
|
|
});
|
|
const heardOnly = {
|
|
icao: 'a15c54',
|
|
lat: null,
|
|
lon: null,
|
|
lastPositionAt: null,
|
|
lastMessageAt: 10_000,
|
|
};
|
|
const positioned = {
|
|
icao: 'ae5d8a',
|
|
lat: 30.27,
|
|
lon: -97.79,
|
|
lastPositionAt: 10_000,
|
|
lastMessageAt: 10_000,
|
|
};
|
|
controller._applyAdsbUpdate(
|
|
{ aircraft: [heardOnly, positioned], decodedCount: 12 },
|
|
10_000,
|
|
);
|
|
assert.equal(controller.getState().messagesPerSecond, null);
|
|
controller._applyAdsbUpdate(
|
|
{ aircraft: [heardOnly, positioned], decodedCount: 13 },
|
|
12_000,
|
|
);
|
|
const state = controller.getState();
|
|
assert.equal(state.messagesPerSecond, 12.5);
|
|
assert.equal(state.decodedMessages, 25);
|
|
assert.equal(state.aircraftHeard, 2);
|
|
assert.equal(state.aircraftPositioned, 1);
|
|
});
|
|
|
|
test('switching to ADS-B moves to the authorized 1090 MHz channel of a dual board', async (t) => {
|
|
const events = [];
|
|
t.after(replaceGlobal('Worker', FakeWorker));
|
|
const uat = {
|
|
vendorId: 0x0bda,
|
|
productId: 0x2838,
|
|
productName: 'FlyCatcher_UAT',
|
|
serialNumber: '00000001',
|
|
};
|
|
const adsb = { ...uat, productName: 'FlyCatcher_ADS_B' };
|
|
const webUsb = {
|
|
async getDevices() {
|
|
return [uat, adsb];
|
|
},
|
|
async requestDevice() {
|
|
throw new Error('picker should not open');
|
|
},
|
|
};
|
|
const controller = new SdrController({
|
|
storage: memoryStorage(),
|
|
webUsb,
|
|
providerFactory: (selectingUsb) => ({
|
|
async get() {
|
|
const chosen = await selectingUsb.requestDevice({
|
|
filters: RTL_SDR_USB_FILTERS,
|
|
});
|
|
return fakeDevice(events, chosen.productName);
|
|
},
|
|
}),
|
|
});
|
|
t.after(() => controller.stop());
|
|
// FM on a FlyCatcher has no preferred channel, so the first one opens.
|
|
controller.state.mode = 'fm';
|
|
controller._ensureAudio = async () => true;
|
|
assert.equal(await controller.connect('fm'), true);
|
|
assert.equal(controller.getState().deviceLabel, 'FlyCatcher_UAT');
|
|
|
|
assert.equal(await controller.setMode('adsb'), true);
|
|
assert.ok(events.includes('FlyCatcher_UAT:close'));
|
|
assert.ok(events.includes('FlyCatcher_ADS_B:tune:1090000000'));
|
|
assert.equal(controller.getState().deviceLabel, 'FlyCatcher_ADS_B');
|
|
assert.equal(controller.getState().mode, 'adsb');
|
|
assert.equal(controller.getState().connected, true);
|
|
});
|
|
|
|
test('CHANGE DEVICE reopens through the WebUSB picker', async (t) => {
|
|
t.after(replaceGlobal('Worker', FakeWorker));
|
|
let pickerCalls = 0;
|
|
const device = {
|
|
vendorId: 0x0bda,
|
|
productId: 0x2838,
|
|
productName: 'RTL2838UHIDIR',
|
|
serialNumber: '1',
|
|
};
|
|
const controller = new SdrController({
|
|
storage: memoryStorage(),
|
|
webUsb: {
|
|
async getDevices() {
|
|
return [device];
|
|
},
|
|
async requestDevice() {
|
|
pickerCalls += 1;
|
|
return device;
|
|
},
|
|
},
|
|
providerFactory: (selectingUsb) => ({
|
|
async get() {
|
|
await selectingUsb.requestDevice({ filters: RTL_SDR_USB_FILTERS });
|
|
return fakeDevice();
|
|
},
|
|
}),
|
|
});
|
|
t.after(() => controller.stop());
|
|
controller.state.mode = 'adsb';
|
|
assert.equal(await controller.connect('adsb'), true);
|
|
assert.equal(pickerCalls, 0, 'an authorized receiver opens without a picker');
|
|
assert.equal(await controller.changeDevice(), true);
|
|
assert.equal(pickerCalls, 1);
|
|
assert.equal(controller.getState().connected, true);
|
|
});
|
|
|
|
test('remembered WebUSB receiver is reused without opening the picker', async () => {
|
|
const remembered = {
|
|
vendorId: 0x0bda,
|
|
productId: 0x2838,
|
|
serialNumber: 'known',
|
|
};
|
|
let pickerCalls = 0;
|
|
const webUsb = createRememberingWebUsb({
|
|
async getDevices() {
|
|
return [remembered];
|
|
},
|
|
async requestDevice() {
|
|
pickerCalls += 1;
|
|
return null;
|
|
},
|
|
});
|
|
|
|
const result = await webUsb.requestDevice({
|
|
filters: [{ vendorId: 0x0bda, productId: 0x2838 }],
|
|
});
|
|
|
|
assert.equal(result, remembered);
|
|
assert.equal(pickerCalls, 0);
|
|
});
|
|
|
|
test('WebUSB picker remains available when no authorized receiver matches', async () => {
|
|
const selected = { vendorId: 0x0bda, productId: 0x2832 };
|
|
let pickerCalls = 0;
|
|
const webUsb = createRememberingWebUsb({
|
|
async getDevices() {
|
|
return [{ vendorId: 0x1234, productId: 0x5678 }];
|
|
},
|
|
async requestDevice() {
|
|
pickerCalls += 1;
|
|
return selected;
|
|
},
|
|
});
|
|
|
|
const result = await webUsb.requestDevice({
|
|
filters: [{ vendorId: 0x0bda, productId: 0x2832 }],
|
|
});
|
|
|
|
assert.equal(result, selected);
|
|
assert.equal(pickerCalls, 1);
|
|
});
|
|
|
|
/** Resolve with the promise's value, or 'timed out' after `ms`. */
|
|
function within(promise, ms = 1_000) {
|
|
let timer;
|
|
const timeout = new Promise((resolve) => {
|
|
timer = setTimeout(() => resolve('timed out'), ms);
|
|
});
|
|
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
}
|
|
|
|
/**
|
|
* A device whose bulk reads hang until the raw WebUSB device is closed,
|
|
* the way a stalled `transferIn` behaves: `USBDevice.close()` aborts it.
|
|
*/
|
|
function stallingDevice(events, name = 'stalled') {
|
|
const pendingReads = new Set();
|
|
const usb = {
|
|
opened: true,
|
|
async close() {
|
|
events.push(`${name}:usb-close`);
|
|
usb.opened = false;
|
|
for (const reject of pendingReads) reject(new Error('AbortError'));
|
|
pendingReads.clear();
|
|
},
|
|
};
|
|
const base = fakeDevice(events, name);
|
|
return {
|
|
...base,
|
|
com: { device: usb },
|
|
readSamples: () =>
|
|
new Promise((_, reject) => {
|
|
events.push(`${name}:read`);
|
|
pendingReads.add(reject);
|
|
}),
|
|
// The graceful close needs the bus, which the stalled transfer holds.
|
|
close: () => new Promise(() => events.push(`${name}:graceful-close`)),
|
|
};
|
|
}
|
|
|
|
test('a stalled USB read cannot wedge stop(): the raw device is closed and the queue replaced', async (t) => {
|
|
const events = [];
|
|
t.after(replaceGlobal('Worker', FakeWorker));
|
|
const devices = [stallingDevice(events), fakeDevice(events, 'fresh')];
|
|
const controller = new SdrController({
|
|
storage: memoryStorage(),
|
|
webUsb: {},
|
|
readStallMs: 60_000,
|
|
closeTimeoutMs: 50,
|
|
providerFactory: () => ({ get: async () => devices.shift() }),
|
|
});
|
|
t.after(() => controller.destroy());
|
|
assert.equal(await controller.connect('adsb'), true);
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
assert.ok(events.includes('stalled:read'), 'a read is in flight');
|
|
|
|
assert.equal(await within(controller.stop()), true, 'stop() returns');
|
|
assert.ok(events.includes('stalled:usb-close'), 'raw USB device closed');
|
|
assert.equal(controller.getState().connected, false);
|
|
|
|
// The next session is not queued behind the abandoned transfer.
|
|
assert.equal(await within(controller.connect('adsb')), true);
|
|
assert.ok(events.includes('fresh:tune:1090000000'));
|
|
});
|
|
|
|
test('a stalled USB read times out and tears the session down on its own', async (t) => {
|
|
const events = [];
|
|
t.after(replaceGlobal('Worker', FakeWorker));
|
|
const controller = new SdrController({
|
|
storage: memoryStorage(),
|
|
webUsb: {},
|
|
readStallMs: 30,
|
|
closeTimeoutMs: 30,
|
|
providerFactory: () => ({ get: async () => stallingDevice(events) }),
|
|
});
|
|
t.after(() => controller.destroy());
|
|
assert.equal(await controller.connect('adsb'), true);
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
const state = controller.getState();
|
|
assert.equal(state.connected, false);
|
|
assert.equal(state.status, 'error');
|
|
assert.equal(state.message, 'RTL-SDR sample stream stopped');
|
|
assert.ok(events.includes('stalled:usb-close'));
|
|
// Mode changes work again instead of queuing behind the dead read.
|
|
assert.equal(await within(controller.setMode('fm')), true);
|
|
});
|
|
|
|
test('destroy() during a pending connect closes the late device instead of resuming the session', async (t) => {
|
|
const events = [];
|
|
const workers = [];
|
|
class CountingWorker extends FakeWorker {
|
|
constructor() {
|
|
super();
|
|
workers.push(this);
|
|
}
|
|
}
|
|
t.after(replaceGlobal('Worker', CountingWorker));
|
|
let deliver = null;
|
|
const controller = new SdrController({
|
|
storage: memoryStorage(),
|
|
webUsb: {},
|
|
providerFactory: () => ({
|
|
get: () =>
|
|
new Promise((resolve) => {
|
|
deliver = resolve;
|
|
}),
|
|
}),
|
|
});
|
|
const connecting = controller.connect('adsb');
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
assert.equal(typeof deliver, 'function', 'device acquisition is pending');
|
|
await controller.destroy();
|
|
deliver(fakeDevice(events, 'late'));
|
|
assert.equal(await within(connecting), false);
|
|
assert.ok(events.includes('late:close'), 'late device is closed');
|
|
assert.ok(!events.includes('late:tune:1090000000'), 'never configured');
|
|
assert.equal(controller.getState().connected, false);
|
|
assert.notEqual(controller.getState().status, 'streaming');
|
|
assert.equal(workers.length, 0, 'no decoder worker is created');
|
|
});
|
|
|
|
test('a failed decoder worker is discarded and reconnect builds a fresh one', async (t) => {
|
|
const events = [];
|
|
const workers = [];
|
|
class RecordingWorker {
|
|
constructor() {
|
|
this.messages = [];
|
|
this.terminated = false;
|
|
workers.push(this);
|
|
}
|
|
|
|
postMessage(message) {
|
|
this.messages.push(message.type);
|
|
}
|
|
|
|
terminate() {
|
|
this.terminated = true;
|
|
}
|
|
}
|
|
t.after(replaceGlobal('Worker', RecordingWorker));
|
|
const controller = new SdrController({
|
|
storage: memoryStorage(),
|
|
webUsb: {},
|
|
providerFactory: () => ({ get: async () => fakeDevice(events) }),
|
|
});
|
|
t.after(() => controller.destroy());
|
|
assert.equal(await controller.connect('adsb'), true);
|
|
assert.equal(workers.length, 1);
|
|
workers[0].onerror?.({ message: 'module failed to load' });
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
assert.equal(controller.getState().status, 'error');
|
|
assert.equal(workers[0].terminated, true, 'failed worker terminated');
|
|
|
|
await controller.stop();
|
|
assert.equal(await controller.connect('adsb'), true);
|
|
assert.equal(workers.length, 2, 'reconnect creates a fresh worker');
|
|
assert.ok(workers[1].messages.includes('configure'));
|
|
assert.equal(controller.getState().status, 'streaming');
|
|
});
|
|
|
|
/**
|
|
* One WebUSB device shared by every session: WebUSB hands a reopened
|
|
* receiver back as the SAME `USBDevice` object. Like the real one, close()
|
|
* aborts pending transfers at once and resolves a little later.
|
|
*/
|
|
function sharedUsb(events, { closeNeverSettles = false } = {}) {
|
|
const usb = {
|
|
opened: false,
|
|
pendingReads: new Set(),
|
|
async open() {
|
|
events.push('usb-open');
|
|
usb.opened = true;
|
|
},
|
|
close() {
|
|
events.push('usb-close');
|
|
usb.opened = false;
|
|
for (const reject of usb.pendingReads) reject(new Error('AbortError'));
|
|
usb.pendingReads.clear();
|
|
if (closeNeverSettles) return new Promise(() => {});
|
|
return new Promise((resolve) => setTimeout(resolve, 5));
|
|
},
|
|
};
|
|
return usb;
|
|
}
|
|
|
|
/**
|
|
* An RTL2832U over `usb` that, like the library, reaches the hardware
|
|
* through `com.device`; its graceful close can be held open by `closeGate`.
|
|
*/
|
|
function rtlOver(usb, events, name, { stallReads = false, closeGate } = {}) {
|
|
const device = {
|
|
...fakeDevice(events, name),
|
|
com: { device: usb },
|
|
readSamples() {
|
|
if (stallReads)
|
|
return new Promise((_, reject) => {
|
|
events.push(`${name}:read`);
|
|
usb.pendingReads.add(reject);
|
|
});
|
|
return new Promise((resolve, reject) =>
|
|
setTimeout(
|
|
() =>
|
|
usb.opened
|
|
? resolve({ data: new ArrayBuffer(8) })
|
|
: reject(new Error('device closed')),
|
|
2,
|
|
),
|
|
);
|
|
},
|
|
async close() {
|
|
events.push(`${name}:graceful-close`);
|
|
if (closeGate) await closeGate;
|
|
await device.com.device.close();
|
|
},
|
|
};
|
|
return device;
|
|
}
|
|
|
|
function gate() {
|
|
let open;
|
|
const promise = new Promise((resolve) => {
|
|
open = resolve;
|
|
});
|
|
return { promise, open };
|
|
}
|
|
|
|
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
function sharedUsbController(usb, devices) {
|
|
return new SdrController({
|
|
storage: memoryStorage(),
|
|
webUsb: {},
|
|
readStallMs: 60_000,
|
|
closeTimeoutMs: 30,
|
|
providerFactory: () => ({
|
|
async get() {
|
|
await usb.open();
|
|
return devices.shift();
|
|
},
|
|
}),
|
|
});
|
|
}
|
|
|
|
test('a graceful close queued behind a stalled read never runs once the raw device is closed', async (t) => {
|
|
const events = [];
|
|
t.after(replaceGlobal('Worker', FakeWorker));
|
|
const usb = sharedUsb(events);
|
|
const held = gate();
|
|
const controller = sharedUsbController(usb, [
|
|
rtlOver(usb, events, 'old', { stallReads: true, closeGate: held.promise }),
|
|
rtlOver(usb, events, 'new'),
|
|
]);
|
|
t.after(() => controller.destroy());
|
|
assert.equal(await controller.connect('adsb'), true);
|
|
await pause(5);
|
|
assert.equal(await within(controller.stop()), true);
|
|
assert.equal(await within(controller.connect('adsb')), true);
|
|
// Whatever the old session still had queued is released now.
|
|
held.open();
|
|
await pause(30);
|
|
assert.ok(
|
|
!events.includes('old:graceful-close'),
|
|
'queued close abandoned before the raw close',
|
|
);
|
|
assert.equal(usb.opened, true, 'the reopened device stays open');
|
|
const state = controller.getState();
|
|
assert.equal(state.status, 'streaming');
|
|
assert.equal(state.connected, true);
|
|
});
|
|
|
|
test('a graceful close already running when abandoned cannot close the reopened device', async (t) => {
|
|
const events = [];
|
|
t.after(replaceGlobal('Worker', FakeWorker));
|
|
const usb = sharedUsb(events);
|
|
const held = gate();
|
|
const controller = sharedUsbController(usb, [
|
|
rtlOver(usb, events, 'old', { closeGate: held.promise }),
|
|
rtlOver(usb, events, 'new'),
|
|
]);
|
|
t.after(() => controller.destroy());
|
|
assert.equal(await controller.connect('adsb'), true);
|
|
await pause(5);
|
|
assert.equal(await within(controller.stop()), true);
|
|
assert.ok(events.includes('old:graceful-close'), 'graceful close started');
|
|
assert.equal(await within(controller.connect('adsb')), true);
|
|
// The old close resumes, as a stuck control transfer finally completes.
|
|
held.open();
|
|
await pause(30);
|
|
assert.equal(usb.opened, true, 'the reopened device stays open');
|
|
const state = controller.getState();
|
|
assert.equal(state.status, 'streaming');
|
|
assert.equal(state.connected, true);
|
|
});
|
|
|
|
test('teardown is single-flight: a second stop() and a connect() wait for the device close', async (t) => {
|
|
const events = [];
|
|
t.after(replaceGlobal('Worker', FakeWorker));
|
|
const usb = sharedUsb(events);
|
|
const controller = sharedUsbController(usb, [
|
|
// The graceful close never finishes: only the raw close ends it.
|
|
rtlOver(usb, events, 'old', { closeGate: new Promise(() => {}) }),
|
|
rtlOver(usb, events, 'new'),
|
|
]);
|
|
t.after(() => controller.destroy());
|
|
assert.equal(await controller.connect('adsb'), true);
|
|
const first = controller.stop();
|
|
let secondDone = false;
|
|
const second = controller.stop().then(() => {
|
|
secondDone = true;
|
|
events.push('second-stop-done');
|
|
});
|
|
const reconnect = controller.connect('adsb');
|
|
await pause(5);
|
|
assert.equal(secondDone, false, 'the second stop joins the teardown');
|
|
await within(Promise.all([first, second]));
|
|
assert.equal(await within(reconnect), true);
|
|
assert.ok(
|
|
events.indexOf('usb-close') < events.indexOf('second-stop-done'),
|
|
'second stop returns after the device is closed',
|
|
);
|
|
assert.ok(
|
|
events.indexOf('usb-close') < events.lastIndexOf('usb-open'),
|
|
'the device is reopened only after the old session closed it',
|
|
);
|
|
await pause(30);
|
|
assert.equal(usb.opened, true);
|
|
assert.equal(controller.getState().status, 'streaming');
|
|
assert.equal(controller.getState().connected, true);
|
|
});
|
|
|
|
test('stop() settles within its deadline even when the raw USB close never settles', async (t) => {
|
|
const events = [];
|
|
t.after(replaceGlobal('Worker', FakeWorker));
|
|
const usb = sharedUsb(events, { closeNeverSettles: true });
|
|
const controller = sharedUsbController(usb, [
|
|
rtlOver(usb, events, 'old', { stallReads: true }),
|
|
]);
|
|
t.after(() => controller.destroy());
|
|
assert.equal(await controller.connect('adsb'), true);
|
|
await pause(5);
|
|
const startedAt = Date.now();
|
|
assert.equal(await within(controller.stop(), 500), true);
|
|
// Graceful close deadline plus raw close deadline, with scheduling slack.
|
|
assert.ok(Date.now() - startedAt < 250, `${Date.now() - startedAt} ms`);
|
|
assert.ok(events.includes('usb-close'), 'raw close attempted');
|
|
assert.equal(controller.getState().connected, false);
|
|
});
|
|
|
|
test('a mode change queued behind a stalled read never reports streaming after the stall teardown', async (t) => {
|
|
const events = [];
|
|
t.after(replaceGlobal('Worker', FakeWorker));
|
|
const controller = new SdrController({
|
|
storage: memoryStorage(),
|
|
webUsb: {},
|
|
readStallMs: 30,
|
|
closeTimeoutMs: 30,
|
|
providerFactory: () => ({ get: async () => stallingDevice(events) }),
|
|
});
|
|
t.after(() => controller.destroy());
|
|
assert.equal(await controller.connect('adsb'), true);
|
|
const switched = await within(controller.setMode('adsb'));
|
|
assert.equal(switched, false, 'the superseded switch fails');
|
|
await pause(100);
|
|
const state = controller.getState();
|
|
assert.notEqual(state.status, 'streaming');
|
|
assert.equal(state.connected, false);
|
|
assert.equal(controller._device, null);
|
|
});
|
|
|
|
/**
|
|
* A raw WebUSB RTL-SDR for the installed `RTL2832U_Provider`: open and claim
|
|
* succeed, and the first initialization transfer fails, either at once or
|
|
* (with `hold`) only when the device is closed, like a stuck control transfer.
|
|
*/
|
|
function failingInitUsb(events, { hold = false } = {}) {
|
|
const pending = new Set();
|
|
const usb = {
|
|
vendorId: 0x0bda,
|
|
productId: 0x2838,
|
|
productName: 'RTL2838UHIDIR',
|
|
serialNumber: 'init-fails',
|
|
opened: false,
|
|
async open() {
|
|
events.push('open');
|
|
usb.opened = true;
|
|
},
|
|
async claimInterface() {
|
|
events.push('claim');
|
|
},
|
|
async releaseInterface() {},
|
|
controlTransferOut() {
|
|
events.push('init-transfer');
|
|
if (!hold) return Promise.reject(new Error('transfer error'));
|
|
return new Promise((_, reject) => pending.add(reject));
|
|
},
|
|
controlTransferIn() {
|
|
return Promise.reject(new Error('transfer error'));
|
|
},
|
|
async close() {
|
|
events.push('close');
|
|
usb.opened = false;
|
|
for (const reject of pending) reject(new Error('AbortError'));
|
|
pending.clear();
|
|
},
|
|
};
|
|
return usb;
|
|
}
|
|
|
|
function failingInitController(usb) {
|
|
return new SdrController({
|
|
storage: memoryStorage(),
|
|
closeTimeoutMs: 30,
|
|
// No providerFactory: the installed RTL2832U_Provider opens the device.
|
|
webUsb: {
|
|
async getDevices() {
|
|
return [usb];
|
|
},
|
|
async requestDevice() {
|
|
return usb;
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
test('a receiver whose initialization fails is closed and destroy() settles', async (t) => {
|
|
const events = [];
|
|
t.after(replaceGlobal('Worker', FakeWorker));
|
|
const usb = failingInitUsb(events);
|
|
const controller = failingInitController(usb);
|
|
assert.equal(await within(controller.connect('adsb')), false);
|
|
assert.deepEqual(events, ['open', 'claim', 'init-transfer', 'close']);
|
|
assert.equal(usb.opened, false, 'the raw device is not left open');
|
|
assert.equal(controller.getState().status, 'error');
|
|
assert.equal(controller.getState().connected, false);
|
|
assert.notEqual(await within(controller.destroy()), 'timed out');
|
|
assert.equal(controller._acquisitions.size, 0);
|
|
});
|
|
|
|
test('a superseded receiver initialization that then fails still closes the device', async (t) => {
|
|
const events = [];
|
|
t.after(replaceGlobal('Worker', FakeWorker));
|
|
const usb = failingInitUsb(events, { hold: true });
|
|
const controller = failingInitController(usb);
|
|
const connecting = controller.connect('adsb');
|
|
await pause(5);
|
|
assert.ok(events.includes('init-transfer'), 'initialization is in flight');
|
|
assert.notEqual(await within(controller.destroy()), 'timed out');
|
|
assert.equal(await within(connecting), false);
|
|
assert.ok(events.includes('close'));
|
|
assert.equal(usb.opened, false, 'the raw device is not left open');
|
|
assert.notEqual(controller.getState().status, 'streaming');
|
|
assert.equal(controller._acquisitions.size, 0);
|
|
});
|
|
|
|
/**
|
|
* A provider shaped like the library's: it records the raw device before
|
|
* opening it, and its initialization can be held open by `initGate`.
|
|
*/
|
|
function heldProvider(usb, events, name, initGate) {
|
|
return {
|
|
device: undefined,
|
|
async get() {
|
|
this.device = usb;
|
|
await usb.open();
|
|
events.push(`${name}:init`);
|
|
if (initGate) await initGate;
|
|
return rtlOver(usb, events, name);
|
|
},
|
|
};
|
|
}
|
|
|
|
function acquisitionController(providers, closeTimeoutMs = 30) {
|
|
return new SdrController({
|
|
storage: memoryStorage(),
|
|
webUsb: {},
|
|
readStallMs: 60_000,
|
|
closeTimeoutMs,
|
|
providerFactory: () => providers.shift(),
|
|
});
|
|
}
|
|
|
|
test('a newer connect waits for an older acquisition to finish closing its device', async (t) => {
|
|
const events = [];
|
|
t.after(replaceGlobal('Worker', FakeWorker));
|
|
const usb = sharedUsb(events);
|
|
const heldA = gate();
|
|
const controller = acquisitionController(
|
|
[
|
|
heldProvider(usb, events, 'A', heldA.promise),
|
|
heldProvider(usb, events, 'B'),
|
|
],
|
|
// Long enough that B does not give up on A while it is held.
|
|
1_000,
|
|
);
|
|
t.after(() => controller.destroy());
|
|
const connectA = controller.connect('adsb');
|
|
await pause(5);
|
|
const connectB = controller.connect('adsb');
|
|
await pause(5);
|
|
assert.ok(!events.includes('B:init'), 'B waits for A');
|
|
heldA.open();
|
|
assert.equal(await within(connectA), false, 'A was superseded');
|
|
assert.equal(await within(connectB), true);
|
|
assert.ok(
|
|
events.lastIndexOf('usb-close') < events.indexOf('B:init'),
|
|
'A closed its device before B opened it',
|
|
);
|
|
await pause(30);
|
|
assert.equal(usb.opened, true, "B's device stays open");
|
|
assert.equal(controller.getState().status, 'streaming');
|
|
assert.equal(controller.getState().connected, true);
|
|
});
|
|
|
|
test("a held older acquisition released after a newer session starts never closes that session's device", async (t) => {
|
|
const events = [];
|
|
t.after(replaceGlobal('Worker', FakeWorker));
|
|
const usb = sharedUsb(events);
|
|
const heldA = gate();
|
|
const controller = acquisitionController([
|
|
heldProvider(usb, events, 'A', heldA.promise),
|
|
heldProvider(usb, events, 'B'),
|
|
]);
|
|
t.after(() => controller.destroy());
|
|
const connectA = controller.connect('adsb');
|
|
await pause(5);
|
|
// B gives up waiting on the stuck acquisition A and completes.
|
|
assert.equal(await within(controller.connect('adsb')), true);
|
|
assert.equal(controller.getState().status, 'streaming');
|
|
const closes = () => events.filter((event) => event === 'usb-close').length;
|
|
const closesBeforeRelease = closes();
|
|
heldA.open();
|
|
assert.equal(await within(connectA), false);
|
|
await pause(30);
|
|
assert.equal(
|
|
closes(),
|
|
closesBeforeRelease,
|
|
"A's cleanup does not close the device B reopened",
|
|
);
|
|
assert.ok(!events.includes('A:graceful-close'));
|
|
assert.equal(usb.opened, true);
|
|
const state = controller.getState();
|
|
assert.equal(state.status, 'streaming');
|
|
assert.equal(state.connected, true);
|
|
assert.equal(controller._acquisitions.size, 0);
|
|
});
|
|
|
|
test("an older acquisition's cleanup that outlives the wait never closes the newer session's device", async (t) => {
|
|
const events = [];
|
|
t.after(replaceGlobal('Worker', FakeWorker));
|
|
const usb = sharedUsb(events);
|
|
const heldA = gate();
|
|
// A's graceful close never finishes: only its fallback raw close ends it.
|
|
const providerA = {
|
|
device: undefined,
|
|
async get() {
|
|
this.device = usb;
|
|
await usb.open();
|
|
events.push('A:init');
|
|
await heldA.promise;
|
|
return rtlOver(usb, events, 'A', { closeGate: new Promise(() => {}) });
|
|
},
|
|
};
|
|
const controller = acquisitionController(
|
|
[providerA, heldProvider(usb, events, 'B')],
|
|
40,
|
|
);
|
|
t.after(() => controller.destroy());
|
|
const connectA = controller.connect('adsb');
|
|
await pause(5);
|
|
const connectB = controller.connect('adsb');
|
|
// A's cleanup starts shortly before B stops waiting for it (3 x 40 ms), so
|
|
// its fallback raw close comes due after B is already streaming.
|
|
await pause(100);
|
|
heldA.open();
|
|
assert.equal(await within(connectA), false, 'A was superseded');
|
|
assert.ok(events.includes('A:graceful-close'), "A's cleanup is running");
|
|
assert.equal(await within(connectB), true);
|
|
const closesAfterB = events.filter((event) => event === 'usb-close').length;
|
|
await pause(80);
|
|
assert.equal(
|
|
events.filter((event) => event === 'usb-close').length,
|
|
closesAfterB,
|
|
"A's fallback close does not close B's device",
|
|
);
|
|
assert.equal(usb.opened, true, "B's device stays open");
|
|
const state = controller.getState();
|
|
assert.equal(state.status, 'streaming');
|
|
assert.equal(state.connected, true);
|
|
});
|
|
|
|
test("a device selection that resolves after its acquisition was superseded cannot touch the newer session's device", async (t) => {
|
|
const events = [];
|
|
t.after(replaceGlobal('Worker', FakeWorker));
|
|
const usb = Object.assign(sharedUsb(events), {
|
|
vendorId: 0x0bda,
|
|
productId: 0x2838,
|
|
productName: 'RTL2838UHIDIR',
|
|
serialNumber: 'shared',
|
|
async claimInterface() {
|
|
events.push('usb-claim');
|
|
},
|
|
async releaseInterface() {
|
|
events.push('usb-release');
|
|
},
|
|
async controlTransferOut() {
|
|
events.push('usb-write');
|
|
throw new Error('transfer error');
|
|
},
|
|
async controlTransferIn() {
|
|
events.push('usb-read');
|
|
throw new Error('transfer error');
|
|
},
|
|
});
|
|
const heldSelection = gate();
|
|
let getDevicesCalls = 0;
|
|
const webUsb = {
|
|
async getDevices() {
|
|
getDevicesCalls += 1;
|
|
// A's selection is held; B's resolves at once.
|
|
if (getDevicesCalls === 1) await heldSelection.promise;
|
|
return [usb];
|
|
},
|
|
async requestDevice() {
|
|
return usb;
|
|
},
|
|
};
|
|
let providers = 0;
|
|
const controller = new SdrController({
|
|
storage: memoryStorage(),
|
|
webUsb,
|
|
readStallMs: 60_000,
|
|
closeTimeoutMs: 20,
|
|
providerFactory: (webusb) => {
|
|
providers += 1;
|
|
// A is the installed library provider; B opens the same device
|
|
// through the same selecting wrapper and streams.
|
|
if (providers === 1) return new RTL2832U_Provider({ webusb });
|
|
return {
|
|
device: undefined,
|
|
async get() {
|
|
this.device = await webusb.requestDevice({
|
|
filters: RTL_SDR_USB_FILTERS,
|
|
});
|
|
await this.device.open();
|
|
return rtlOver(this.device, events, 'B');
|
|
},
|
|
};
|
|
},
|
|
});
|
|
t.after(() => controller.destroy());
|
|
const connectA = controller.connect('adsb');
|
|
await pause(5);
|
|
assert.equal(await within(controller.connect('adsb')), true);
|
|
assert.equal(controller.getState().status, 'streaming');
|
|
const eventsBeforeRelease = events.length;
|
|
heldSelection.open();
|
|
assert.equal(await within(connectA), false, 'A was superseded');
|
|
await pause(30);
|
|
const late = events
|
|
.slice(eventsBeforeRelease)
|
|
.filter((event) => event.startsWith('usb-'));
|
|
assert.deepEqual(late, [], "A's late selection never reaches the device");
|
|
assert.equal(usb.opened, true, "B's device stays open");
|
|
const state = controller.getState();
|
|
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);
|
|
});
|