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

+566 -44
View File
@@ -22,11 +22,45 @@ export { createRememberingWebUsb } from './usbDevices.js';
const FM_MIN_HZ = 87_500_000;
const FM_MAX_HZ = 108_000_000;
// Seek frames step around a lap one step longer than the band, so the frames
// either side of the wrap, clamped into the band, each reach their edge.
const FM_SEEK_STEP_HZ = 1_200_000;
const FM_SEEK_LAP_HZ = FM_MAX_HZ - FM_MIN_HZ + FM_SEEK_STEP_HZ;
const FM_SEEK_LAP_START_HZ = FM_MIN_HZ - FM_SEEK_STEP_HZ / 2;
// 19 frames; the last is one channel short of a full lap.
const FM_SEEK_FRAMES = Math.ceil(FM_SEEK_LAP_HZ / FM_SEEK_STEP_HZ);
const READ_SAMPLE_COUNT = 32_768;
// Bulk reads kept queued at the host. With one read at a time, nothing is
// requested while this thread is busy between reads, and delivered IQ falls
// short of the configured rate; queued reads keep a transfer pending meanwhile.
const READ_PIPELINE_DEPTH = 4;
const RATE_WINDOW_MS = 1_000;
// A bulk read that has not completed in this long is treated as a stalled
// USB transfer: the session is torn down instead of waiting forever.
export const SDR_READ_STALL_MS = 5_000;
// Decoder admission. Every block sent to the worker is numbered and must be
// acknowledged; past these bounds the receiver stops with an overload error
// rather than queueing raw IQ the decoder cannot keep up with. Eight 16 ms
// blocks is 128 ms of input, about forty times the measured decode cost.
export const SDR_WORKER_ADMISSION = Object.freeze({
maxBlocks: 8,
maxBytes: 8 * 65_536,
maxAgeMs: 1_000,
watchdogMs: 250,
// Pending-work diagnostics are published at most this often, never per ack.
reportMs: 1_000,
// A cold module worker can take far longer to load than 8 blocks of input:
// no block is sent before it reports ready, and a worker that never does
// stops the receiver.
readyTimeoutMs: 10_000,
});
const WORKER_READY = Symbol('workerReady');
// SNR and signal levels are display values: published at most this often.
const FM_LEVEL_STATE_MS = 250;
// Diagnostic mode: per-worker ring of recent block timings, and per-report
// samples kept for peaks. Both are bounded.
const DECODER_TIMING_RING = 256;
const DECODER_WINDOW_SAMPLES = 256;
// Upper bound on a graceful close queued behind other USB work; past it the
// raw USB device is closed directly, which aborts any pending transfer. The
// raw close is bounded by the same deadline, so a teardown settles within
@@ -177,6 +211,53 @@ function fenceDevice(device) {
}
}
/** Wall-aligned milliseconds comparable between this page and its workers. */
function absoluteNow() {
const clock = globalThis.performance;
return clock?.timeOrigin !== undefined
? clock.timeOrigin + clock.now()
: Date.now();
}
function newTimingWindow() {
return {
queueMs: [],
computeMs: [],
ackDelayMs: [],
rttMs: [],
audioDelayMs: [],
max: {},
pendingPeak: 0,
oldestPeakMs: 0,
};
}
function percentile(values, fraction) {
const sorted = [...values].sort((a, b) => a - b);
return sorted[
Math.min(sorted.length - 1, Math.floor(fraction * sorted.length))
];
}
/** Monotonic milliseconds for ages and deadlines (wall-clock can jump). */
function monotonicNow() {
return globalThis.performance?.now?.() ?? Date.now();
}
function newUsbGate() {
return { waiting: 0, idle: [], reads: [], retired: false };
}
function wakeUsbGate(gate) {
for (const resolve of gate.idle.splice(0)) resolve();
}
/** Resolves once the gate has no pending control work, or is retired. */
function usbGateIdle(gate) {
if (gate.waiting === 0 || gate.retired) return Promise.resolve();
return new Promise((resolve) => gate.idle.push(resolve));
}
/** Resolve with the operation's outcome, or reject once `ms` elapses. */
function withDeadline(promise, ms, message) {
let timer = null;
@@ -242,6 +323,8 @@ export class SdrController {
providerFactory = null,
readStallMs = SDR_READ_STALL_MS,
closeTimeoutMs = SDR_CLOSE_TIMEOUT_MS,
workerAdmission = SDR_WORKER_ADMISSION,
decoderDiagnostics = false,
storage = defaultStorage(),
webUsb = typeof navigator !== 'undefined' ? navigator.usb : null,
} = {}) {
@@ -294,6 +377,29 @@ export class SdrController {
// Bumped whenever a stalled queue is abandoned; work queued in an older
// epoch refuses to touch whatever device is current when it finally runs.
this._usbEpoch = 0;
// Control admission for one USB epoch: pending control work, read-loop
// waiters and the reads that control work drains. Abandoning the queue
// retires the gate, so nothing from an old session can hold a new one.
this._usbGate = newUsbGate();
this._admission = { ...SDR_WORKER_ADMISSION, ...workerAdmission };
// Blocks sent to the current worker and not yet acknowledged, in order.
this._pendingBlocks = new Map();
this._pendingBytes = 0;
this._nextBlockId = 0;
this._watchdog = null;
this._lastAdmissionReport = 0;
this._ackAnomalies = 0;
this._lastAckId = null;
this._workerSerial = 0;
// Optional timing instrumentation (test and soak use); off in production.
this._decoderDiagnostics = Boolean(decoderDiagnostics);
this._timingRing = [];
this._timingWindow = newTimingWindow();
this._fmLevelStateAt = -Infinity;
this._fmLevelPatch = null;
// Audio player epoch: only samples of the active FM session are accepted.
this._audioSessionId = 0;
this._audioSessionActive = false;
this._readGeneration = 0;
// Bumped by every connect() and stop(); a connect, mode change or
// configuration whose token is stale after an await has been superseded
@@ -313,6 +419,7 @@ export class SdrController {
this._fmFrequencyHz = FM_DEFAULT_HZ;
this._spectrum = null;
this._spectrumSequence = 0;
this._fmTune = 0;
this._spectrumWaiters = new Set();
this._seekToken = 0;
// Every provider opens its device through the selecting WebUSB wrapper.
@@ -370,12 +477,27 @@ export class SdrController {
_queueUsb(operation) {
const epoch = this._usbEpoch;
const run = () => {
const gate = this._usbGate;
// Reads stop being issued from here, and the ones already queued are
// delivered before control work runs, so every pre-control block reaches
// the worker ahead of any reset that follows.
gate.waiting += 1;
const run = async () => {
if (epoch !== this._usbEpoch)
throw new Error('USB work abandoned after a stalled transfer');
await Promise.allSettled(gate.reads);
// A stalled read may have ended this session while it drained.
if (epoch !== this._usbEpoch)
throw new Error('USB work abandoned after a stalled transfer');
return operation();
};
const queued = this._usbTail.then(run, run);
// Only the gate this work entered is updated, even if it settles late.
const done = () => {
gate.waiting -= 1;
if (gate.waiting === 0) wakeUsbGate(gate);
};
queued.then(done, done);
this._usbTail = queued.catch(() => {});
return queued;
}
@@ -384,6 +506,10 @@ export class SdrController {
_resetUsbQueue() {
this._usbEpoch += 1;
this._usbTail = Promise.resolve();
const retired = this._usbGate;
this._usbGate = newUsbGate();
retired.retired = true;
wakeUsbGate(retired);
}
_ensureWorker() {
@@ -392,9 +518,33 @@ export class SdrController {
type: 'module',
});
this._worker = worker;
// A new worker tags its spectra from 0 until its first reset.
this._fmTune = 0;
// Block numbers and pending work belong to one worker.
this._clearPendingBlocks();
this._nextBlockId = 0;
let markReady;
this._workerSerial += 1;
this._timingRing = [];
this._timingWindow = newTimingWindow();
this._lastAckId = null;
worker[WORKER_READY] = {
ready: false,
promise: new Promise((resolve) => (markReady = resolve)),
};
worker.onmessage = (event) => {
if (this._worker !== worker) return;
this._handleWorkerMessage(event.data || {});
const data = event.data || {};
if (data.type === 'ready') {
worker[WORKER_READY].ready = true;
markReady();
return;
}
if (data.type === 'ack') {
this._acknowledgeBlock(data.id, data);
return;
}
this._handleWorkerMessage(data);
};
worker.onerror = (event) => {
if (this._worker !== worker) return;
@@ -411,6 +561,7 @@ export class SdrController {
_discardWorker() {
const worker = this._worker;
this._worker = null;
this._clearPendingBlocks();
if (!worker) return;
worker.onmessage = null;
worker.onerror = null;
@@ -421,9 +572,255 @@ export class SdrController {
}
}
/** Start an audio epoch: the player accepts only this session's samples. */
_beginAudioSession() {
if (!this._audioNode) return;
this._audioSessionId += 1;
this._audioSessionActive = true;
const port = this._audioNode.port;
port.postMessage({
type: 'session',
sessionId: this._audioSessionId,
enabled: true,
});
port.postMessage({ type: 'diagnostics', enabled: true });
}
_endAudioSession() {
if (!this._audioSessionActive) return;
this._audioSessionActive = false;
const port = this._audioNode?.port;
port?.postMessage({ type: 'diagnostics', enabled: false });
port?.postMessage({
type: 'session',
sessionId: this._audioSessionId,
enabled: false,
});
port?.postMessage({ type: 'clear' });
}
/** Send one block to the worker if admission allows; otherwise stop. */
_postSamples(buffer) {
const worker = this._ensureWorker();
const { maxBlocks, maxBytes } = this._admission;
if (
this._pendingBlocks.size >= maxBlocks ||
this._pendingBytes + buffer.byteLength > maxBytes
) {
this._overload('backlog');
return false;
}
const id = this._nextBlockId++;
const postedAt = this._decoderDiagnostics ? absoluteNow() : undefined;
this._pendingBlocks.set(id, {
bytes: buffer.byteLength,
at: monotonicNow(),
postedAt,
});
this._pendingBytes += buffer.byteLength;
if (this._decoderDiagnostics) {
const window = this._timingWindow;
window.pendingPeak = Math.max(
window.pendingPeak,
this._pendingBlocks.size,
);
}
// Spectra are used only by seek, so the decoder computes them only then.
const spectrum = this.state.seeking === true;
worker.postMessage(
postedAt === undefined
? { type: 'samples', buffer, id, spectrum }
: { type: 'samples', buffer, id, spectrum, postedAt },
[buffer],
);
return true;
}
_acknowledgeBlock(id, ack = {}) {
const entry = this._pendingBlocks.get(id);
// Unknown, duplicate or already-cleared ids change nothing.
if (!entry) {
this._ackAnomalies += 1;
return;
}
this._pendingBlocks.delete(id);
this._pendingBytes -= entry.bytes;
this._lastAckId = id;
if (this._decoderDiagnostics && entry.postedAt !== undefined)
this._recordBlockTiming(id, entry.postedAt, ack);
}
/** Diagnostic mode: split one block's round trip into its hops. */
_recordBlockTiming(id, postedAt, ack) {
const received = absoluteNow();
const finite = (value) => (Number.isFinite(value) ? value : null);
const timing = {
id,
postedAt,
// Waiting in the worker's queue before it started this block.
queueMs: finite(ack.receivedAt - postedAt),
computeMs: finite(ack.computeMs),
// From the worker posting the ack to this thread handling it.
ackDelayMs: finite(received - ack.ackPostedAt),
rttMs: received - postedAt,
};
this._timingRing.push(timing);
if (this._timingRing.length > DECODER_TIMING_RING) this._timingRing.shift();
const window = this._timingWindow;
for (const key of ['queueMs', 'computeMs', 'ackDelayMs', 'rttMs']) {
if (timing[key] === null) continue;
const values = window[key];
if (values.length < DECODER_WINDOW_SAMPLES) values.push(timing[key]);
window.max[key] = Math.max(window.max[key] ?? -Infinity, timing[key]);
}
}
/** Diagnostic mode: worker post to this thread, for FM audio messages. */
_recordAudioDelay(workerPostedAt) {
if (!this._decoderDiagnostics || !Number.isFinite(workerPostedAt)) return;
const delay = absoluteNow() - workerPostedAt;
const window = this._timingWindow;
if (window.audioDelayMs.length < DECODER_WINDOW_SAMPLES)
window.audioDelayMs.push(delay);
window.max.audioDelayMs = Math.max(
window.max.audioDelayMs ?? -Infinity,
delay,
);
}
_publishTimingWindow(now) {
const window = this._timingWindow;
this._timingWindow = newTimingWindow();
const summary = {};
for (const key of [
'queueMs',
'computeMs',
'ackDelayMs',
'rttMs',
'audioDelayMs',
]) {
const values = window[key];
summary[key] = values.length
? {
count: values.length,
p95: percentile(values, 0.95),
max: window.max[key],
}
: null;
}
summary.pendingPeak = Math.max(
window.pendingPeak,
this._pendingBlocks.size,
);
summary.oldestPendingPeakMs = Math.max(
window.oldestPeakMs,
this._oldestPendingAgeMs(now),
);
return summary;
}
_clearPendingBlocks() {
this._pendingBlocks.clear();
this._pendingBytes = 0;
}
_oldestPendingAgeMs(now = monotonicNow()) {
const first = this._pendingBlocks.values().next().value;
return first ? now - first.at : 0;
}
/** Bounds unacked age even when the worker hangs and no block arrives. */
_startWatchdog() {
this._stopWatchdog();
this._watchdog = setInterval(() => {
if (!this._device) return;
const now = monotonicNow();
const oldest = this._oldestPendingAgeMs(now);
if (oldest > this._admission.maxAgeMs) {
this._overload('stalled');
return;
}
if (this._decoderDiagnostics) {
const window = this._timingWindow;
window.oldestPeakMs = Math.max(window.oldestPeakMs, oldest);
}
if (now - this._lastAdmissionReport >= this._admission.reportMs) {
this._lastAdmissionReport = now;
this._setState({
decoderPendingBlocks: this._pendingBlocks.size,
decoderPendingBytes: this._pendingBytes,
decoderOldestPendingMs: oldest,
...(this._decoderDiagnostics
? { decoderTimings: this._publishTimingWindow(now) }
: {}),
});
}
}, this._admission.watchdogMs);
this._watchdog.unref?.();
}
_stopWatchdog() {
if (this._watchdog) clearInterval(this._watchdog);
this._watchdog = null;
}
/** Containment: stop with an explicit error instead of queueing input. */
_overload(reason) {
if (!this._device) return;
console.warn(`[SDR] Decoder overload (${reason}); stopping receiver`);
// Captured before stop() clears pending work and discards the worker.
const snapshot = this._overloadSnapshot(reason);
// Published before stopping; stop() preserves it, and nothing is set
// afterwards, so a reconnect started during teardown keeps its own state.
this._setState({
status: 'error',
connected: false,
decoderOverload: reason,
decoderOverloadSnapshot: snapshot,
message:
reason === 'startup'
? 'SDR decoder did not start; receiver stopped'
: reason === 'stalled'
? 'SDR decoder stopped responding; receiver stopped'
: 'SDR decoder fell behind; receiver stopped',
});
void this.stop({ preserveMessage: true });
}
_overloadSnapshot(reason) {
const now = monotonicNow();
const snapshot = {
reason,
worker: this._workerSerial,
pendingBlocks: this._pendingBlocks.size,
pendingBytes: this._pendingBytes,
oldestPendingMs: this._oldestPendingAgeMs(now),
inflightReads: this._usbGate.reads.length,
controlWaiting: this._usbGate.waiting,
lastAckId: this._lastAckId,
nextBlockId: this._nextBlockId,
ackAnomalies: this._ackAnomalies,
};
if (this._decoderDiagnostics) {
snapshot.pending = [...this._pendingBlocks].map(([id, entry]) => ({
id,
ageMs: now - entry.at,
bytes: entry.bytes,
}));
snapshot.timings = this._timingRing.slice();
snapshot.window = this._publishTimingWindow(now);
}
return snapshot;
}
_configureWorker() {
this._ensureWorker().postMessage({
const worker = this._ensureWorker();
// Every configuration starts a new decoder epoch, so output of blocks
// queued before it (another mode, or a failed retune) is never current.
const tune = ++this._fmTune;
worker.postMessage({
type: 'configure',
tune,
mode: this.state.mode,
sampleRate: this.state.sampleRate,
location: this.state.receiverLocation,
@@ -452,6 +849,25 @@ export class SdrController {
outputChannelCount: [1],
},
);
// Local diagnostic candidate: expose playback accounting for the live test.
const audioNode = this._audioNode;
audioNode.port.onmessage = (event) => {
if (
this._audioNode !== audioNode ||
event.data?.type !== 'audioStats' ||
!this._device ||
this.state.mode !== 'fm'
)
return;
const { underruns, waiting, empty, dropped, queued } = event.data;
this._setState({
audioUnderruns: underruns,
audioWaitingFrames: waiting,
audioEmptyFrames: empty,
audioDroppedFrames: dropped,
audioQueuedFrames: queued,
});
};
this._audioNode.connect(this._audioContext.destination);
this._audioNode.port.postMessage({
type: 'volume',
@@ -466,37 +882,78 @@ export class SdrController {
}
_handleWorkerMessage(message) {
// Output of an earlier decoder epoch (before a retune or reconfiguration)
// is stale in every field, not only its audio.
if (message.tune !== this._fmTune) return;
const diagnostics = message.diagnostics;
if (diagnostics && Number.isFinite(diagnostics.workerBlocks)) {
this._setState({
workerBlocks: diagnostics.workerBlocks,
iqLevelDbfs: Number.isFinite(diagnostics.iqLevelDbfs)
? diagnostics.iqLevelDbfs
: this.state.iqLevelDbfs,
audioLevelDbfs: Number.isFinite(diagnostics.audioLevelDbfs)
? diagnostics.audioLevelDbfs
: this.state.audioLevelDbfs,
});
const levels =
diagnostics && Number.isFinite(diagnostics.workerBlocks)
? {
workerBlocks: diagnostics.workerBlocks,
iqLevelDbfs: Number.isFinite(diagnostics.iqLevelDbfs)
? diagnostics.iqLevelDbfs
: this.state.iqLevelDbfs,
audioLevelDbfs: Number.isFinite(diagnostics.audioLevelDbfs)
? diagnostics.audioLevelDbfs
: this.state.audioLevelDbfs,
}
: null;
if (message.type === 'fm') {
this._recordAudioDelay(message.workerPostedAt);
// Display values only: merged and published at most every 250 ms, so
// every subscriber is not run for each 16 ms block.
const patch = {
...(levels || {}),
...(Number.isFinite(message.snr) ? { snr: message.snr } : {}),
};
if (Object.keys(patch).length)
this._fmLevelPatch = { ...(this._fmLevelPatch || {}), ...patch };
const now = monotonicNow();
if (
this._fmLevelPatch &&
now - this._fmLevelStateAt >= FM_LEVEL_STATE_MS
) {
this._fmLevelStateAt = now;
const pending = this._fmLevelPatch;
this._fmLevelPatch = null;
this._setState(pending);
}
} else if (levels) {
this._setState(levels);
}
if (message.type === 'fm') {
// Audio from before the latest retune, from another mode, or from a
// session that has ended never reaches the player.
if (
message.samples instanceof Float32Array &&
message.samples.length > 0 &&
this._audioNode &&
this._device &&
this._audioSessionActive &&
this.state.mode === 'fm' &&
message.tune === this._fmTune &&
!this.state.seeking
) {
this._audioNode.port.postMessage(
{ type: 'samples', samples: message.samples },
{
type: 'samples',
sessionId: this._audioSessionId,
samples: message.samples,
},
[message.samples.buffer],
);
}
if (message.bins instanceof Float32Array) {
// A spectrum of samples from before the latest retune is dropped.
if (
message.bins instanceof Float32Array &&
message.tune === this._fmTune
) {
this._spectrum = message.bins;
this._spectrumSequence += 1;
for (const waiter of this._spectrumWaiters)
waiter(this._spectrumSequence);
this._spectrumWaiters.clear();
}
if (Number.isFinite(message.snr)) this._setState({ snr: message.snr });
return;
}
if (message.type === 'adsb') {
@@ -592,6 +1049,8 @@ export class SdrController {
if (!owned || !this._owns(token, device)) return false;
this._configureWorker();
this._audioNode?.port.postMessage({ type: 'clear' });
if (mode === 'fm') this._beginAudioSession();
else this._endAudioSession();
return true;
}
@@ -665,6 +1124,7 @@ export class SdrController {
audioLevelDbfs: null,
...this._resetAdsbCounters(),
});
this._startWatchdog();
void this._readLoop(this._readGeneration);
return true;
} catch (error) {
@@ -967,35 +1427,75 @@ export class SdrController {
return connected;
}
/** Wait (bounded) until the decoder has loaded; false ends this loop. */
async _decoderReady(generation) {
const startup = this._ensureWorker()[WORKER_READY];
if (!startup || startup.ready) return true;
let timer = null;
await Promise.race([
startup.promise,
new Promise((resolve) => {
timer = setTimeout(resolve, this._admission.readyTimeoutMs);
}),
]);
clearTimeout(timer);
if (generation !== this._readGeneration) return false;
if (startup.ready) return true;
this._overload('startup');
return false;
}
async _readLoop(generation) {
if (!(await this._decoderReady(generation))) return;
// Each read's delivery is chained to it in order, so a drained read has
// also been handed to the worker before any control work runs.
const inflight = [];
let chain = Promise.resolve();
const deliver = (block) => {
if (!this._device || generation !== this._readGeneration) return;
const buffer = block.data;
this._sampleWindowSamples += buffer.byteLength / 2;
const now = Date.now();
const elapsed = now - this._sampleWindowStartedAt;
if (elapsed >= RATE_WINDOW_MS) {
this._setState({
samplesPerSecond: Math.round(
(this._sampleWindowSamples * 1_000) / elapsed,
),
});
this._sampleWindowStartedAt = now;
this._sampleWindowSamples = 0;
}
this._postSamples(buffer);
};
while (this._device && generation === this._readGeneration) {
try {
const device = this._device;
// A transfer that never settles would hold the shared USB queue
// forever; past the deadline the session is torn down (stop() closes
// the raw USB device, which aborts the transfer).
const block = await this._queueUsb(() =>
withDeadline(
const gate = this._usbGate;
// Control work in this epoch drains this loop's reads; a retired
// gate's waiters are released and re-check the session above.
gate.reads = inflight;
// A transfer that never settles would hold control work forever; past
// the deadline the session is torn down (stop() closes the raw USB
// device, which aborts the transfer).
while (gate.waiting === 0 && inflight.length < READ_PIPELINE_DEPTH) {
const read = withDeadline(
device.readSamples(READ_SAMPLE_COUNT),
this._readStallMs,
'RTL-SDR sample read stalled',
),
);
if (!this._device || generation !== this._readGeneration) break;
const buffer = block.data;
this._sampleWindowSamples += buffer.byteLength / 2;
const now = Date.now();
const elapsed = now - this._sampleWindowStartedAt;
if (elapsed >= RATE_WINDOW_MS) {
this._setState({
samplesPerSecond: Math.round(
(this._sampleWindowSamples * 1_000) / elapsed,
),
});
this._sampleWindowStartedAt = now;
this._sampleWindowSamples = 0;
);
read.catch(() => {});
chain = chain.then(() => read).then(deliver);
const handled = chain;
handled.catch(() => {});
inflight.push(handled);
}
this._ensureWorker().postMessage({ type: 'samples', buffer }, [buffer]);
if (!inflight.length) {
await usbGateIdle(gate);
continue;
}
await inflight[0];
inflight.shift();
} catch (error) {
if (generation !== this._readGeneration) break;
console.warn('[SDR] Sample read failed:', error);
@@ -1146,6 +1646,9 @@ export class SdrController {
return true;
}
if (this.state.mode !== 'fm') await this.setMode('fm');
// Spectra are dropped from here until the worker takes this tag, so a
// retune that fails part-way never has them read at the wrong frequency.
const tune = ++this._fmTune;
try {
let actualFrequency = frequency;
await this._queueUsb(async () => {
@@ -1153,7 +1656,7 @@ export class SdrController {
if (Number.isFinite(tuned)) actualFrequency = tuned;
await this._device.resetBuffer();
});
this._worker?.postMessage({ type: 'reset' });
this._worker?.postMessage({ type: 'reset', tune });
this._audioNode?.port.postMessage({ type: 'clear' });
this._setState({
frequencyHz: actualFrequency,
@@ -1200,12 +1703,23 @@ export class SdrController {
});
this._audioNode?.port.postMessage({ type: 'volume', value: 0 });
try {
for (let scan = 0; scan < 18 && token === this._seekToken; scan += 1) {
const span = FM_MAX_HZ - FM_MIN_HZ;
const rawCenter = origin + seekDirection * scan * 1_200_000;
const center = FM_MIN_HZ + positiveModulo(rawCenter - FM_MIN_HZ, span);
for (
let scan = 0;
scan < FM_SEEK_FRAMES && token === this._seekToken;
scan += 1
) {
const rawCenter = origin + seekDirection * scan * FM_SEEK_STEP_HZ;
const position =
FM_SEEK_LAP_START_HZ +
positiveModulo(rawCenter - FM_SEEK_LAP_START_HZ, FM_SEEK_LAP_HZ);
const center = clampFmFrequency(position);
// tuneFm reports the failure; no spectrum is valid for this frame.
if (!(await this.tuneFm(center, { quiet: true }))) {
if (token === this._seekToken)
this._setState({ seeking: false, seekMessage: 'Seek stopped' });
return null;
}
const sequence = this._spectrumSequence;
await this.tuneFm(center, { quiet: true });
this._setState({
seekMessage: `Scanning ${(center / 1_000_000).toFixed(1)} MHz…`,
});
@@ -1219,7 +1733,10 @@ export class SdrController {
this.state.frequencyHz,
this.state.sampleRate,
seekDirection,
origin,
// Past the wrap the origin lies a lap behind this frame.
origin + position - rawCenter,
// A full lap ends back on the origin's own station.
{ skipFrequency: origin },
);
if (!peak) continue;
await this.tuneFm(peak.frequency, { quiet: true });
@@ -1311,6 +1828,11 @@ export class SdrController {
async stop({ preserveMessage = false, message = null } = {}) {
this.cancelSeek();
this._readGeneration += 1;
// Before any await: the worker and its queued input and output are gone,
// and the player refuses this session, so nothing late is handled.
this._stopWatchdog();
this._discardWorker();
this._endAudioSession();
const token = ++this._sessionToken;
const device = this._device;
const provider = this._provider;