mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +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
+566
-44
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user