Переглянути джерело

web: switch live listen to audio worklet playback

debug/audio-stutter-after-10min
Jan Svabenik 3 місяці тому
джерело
коміт
9cb0c618e3
3 змінених файлів з 275 додано та 205 видалено
  1. +6
    -6
      config.autosave.yaml
  2. +243
    -195
      web/app.js
  3. +26
    -4
      web/ring-player-processor.js

+ 6
- 6
config.autosave.yaml Переглянути файл

@@ -2,16 +2,16 @@ bands:
- name: uk-fm-broadcast
start_hz: 8.75e+07
end_hz: 1.08e+08
center_hz: 1.02e+08
center_hz: 1.0114876073619634e+08
sample_rate: 4096000
fft_size: 512
fft_size: 16384
gain_db: 32
tuner_bw_khz: 5000
use_gpu_fft: true
classifier_mode: combined
agc: true
dc_block: true
iq_balance: true
dc_block: false
iq_balance: false
pipeline:
mode: wideband-balanced
profile: wideband-balanced
@@ -51,7 +51,7 @@ pipeline:
- WFM_STEREO
- RDS
surveillance:
analysis_fft_size: 512
analysis_fft_size: 16384
frame_rate: 12
strategy: multi-resolution
display_bins: 2048
@@ -274,7 +274,7 @@ detector:
min_stable_frames: 4
gap_tolerance_ms: 2000
cfar_mode: GOSCA
cfar_guard_hz: 200000
cfar_guard_hz: 250000
cfar_train_hz: 100000
cfar_guard_cells: 3
cfar_train_cells: 24


+ 243
- 195
web/app.js Переглянути файл

@@ -180,24 +180,12 @@ const operatorPanel = window.OperatorPanel?.create
// ---------------------------------------------------------------------------
// LiveListenWS — WebSocket-based gapless audio streaming via /ws/audio
// ---------------------------------------------------------------------------
// v4: Jank-resistant scheduled playback.
// v5: AudioWorklet-first playback.
//
// Problem: Main-thread Canvas rendering blocks for 150-250ms, starving
// ScriptProcessorNode callbacks and causing audio underruns.
// AudioWorklet would fix this but requires Secure Context (HTTPS/localhost).
//
// Solution: Use BufferSource scheduling (like v1) but with a much larger
// jitter budget. We pre-schedule audio 400ms into the future so that even
// a 300ms main-thread hang doesn't cause a gap. The AudioContext's internal
// scheduling is sample-accurate and runs on a system thread — once a
// BufferSource is scheduled, it plays regardless of main-thread state.
//
// Key differences from v1:
// - 400ms target latency (was 100ms) — survives observed 250ms hangs
// - Soft resync: on underrun, schedule next chunk with a short crossfade
// gap instead of hard jump
// - Overrun cap at 800ms (was 500ms)
// - Chunk coalescing: merge small chunks to reduce scheduling overhead
// - Audio is pushed into an AudioWorklet ring buffer when available, so
// canvas/DOM jank on the main thread no longer directly starves playback.
// - Fallback remains scheduled BufferSource playback for environments where
// AudioWorklet is unavailable.
// ---------------------------------------------------------------------------
class LiveListenWS {
constructor(freq, bw, mode) {
@@ -212,26 +200,17 @@ class LiveListenWS {
this.nextTime = 0;
this.started = false;
this._onStop = null;
// Chunk coalescing buffer
this._audioInitPromise = null;
this._workletNode = null;
this._workletReady = false;
this._useWorklet = false;
this._workletStats = { underruns: 0, overruns: 0, availableFrames: 0, lastTs: 0 };
this._pendingWorkletChunks = [];
this._pendingWorkletSamples = 0;
// Fallback chunk coalescing buffer
this._pendingSamples = [];
this._pendingLen = 0;
this._flushTimer = 0;
// Fade state for soft resync
this._lastEndSample = null; // last sample value per channel for crossfade
this._summaryTimer = 0;
this._stats = {
startedAtMs: performance.now(),
pcmChunksRx: 0,
pcmSamplesRx: 0,
acceptedChunks: 0,
droppedMaxBuffered: 0,
underruns: 0,
resyncs: 0,
lastAcceptedChunkAtMs: 0,
lastLeadMs: 0,
maxLeadMs: Number.NEGATIVE_INFINITY,
minLeadMs: Number.POSITIVE_INFINITY
};
}

start() {
@@ -240,7 +219,6 @@ class LiveListenWS {
this.ws = new WebSocket(url);
this.ws.binaryType = 'arraybuffer';
this.playing = true;
this._startSummaryTicker();

this.ws.onmessage = (ev) => {
if (typeof ev.data === 'string') {
@@ -262,21 +240,16 @@ class LiveListenWS {
} catch (e) { /* ignore */ }
return;
}
if (!this.audioCtx || !this.playing) return;
this._stats.pcmChunksRx++;
if (!this.playing) return;
this._onPCM(ev.data);
};

this.ws.onclose = () => {
this.playing = false;
this._emitSummary('ws_close');
this._stopSummaryTicker();
if (this._onStop) this._onStop();
};
this.ws.onerror = () => {
this.playing = false;
this._emitSummary('ws_error');
this._stopSummaryTicker();
if (this._onStop) this._onStop();
};

@@ -286,8 +259,6 @@ class LiveListenWS {
}

stop() {
this._emitSummary('stop');
this._stopSummaryTicker();
this.playing = false;
if (this.ws) { this.ws.close(); this.ws = null; }
this._teardownAudio();
@@ -298,42 +269,78 @@ class LiveListenWS {
_teardownAudio() {
if (this._flushTimer) { clearTimeout(this._flushTimer); this._flushTimer = 0; }
if (this.audioCtx) { this.audioCtx.close().catch(() => {}); this.audioCtx = null; }
this._audioInitPromise = null;
this._workletNode = null;
this._workletReady = false;
this._useWorklet = false;
this._pendingWorkletChunks = [];
this._pendingWorkletSamples = 0;
this.nextTime = 0;
this.started = false;
this._pendingSamples = [];
this._pendingLen = 0;
this._lastEndSample = null;
}

_startSummaryTicker() {
this._stopSummaryTicker();
this._summaryTimer = setInterval(() => {
if (!this.playing) return;
this._emitSummary('periodic');
}, 5000);
}

_stopSummaryTicker() {
if (this._summaryTimer) {
clearInterval(this._summaryTimer);
this._summaryTimer = 0;
}
_canUseWorklet() {
return !!(window.AudioWorkletNode && window.AudioContext && (window.isSecureContext || location.hostname === 'localhost' || location.hostname === '127.0.0.1'));
}

_initAudio() {
if (this.audioCtx) return;
if (this.audioCtx) return this._audioInitPromise || Promise.resolve();
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)({
sampleRate: this.sampleRate
sampleRate: this.sampleRate,
latencyHint: 'interactive'
});
this.audioCtx.resume().catch(() => {});
this.nextTime = 0;
this.started = false;
this._lastEndSample = null;
this._useWorklet = this._canUseWorklet();

if (!this._useWorklet) {
this._audioInitPromise = Promise.resolve();
return this._audioInitPromise;
}

this._audioInitPromise = this.audioCtx.audioWorklet.addModule('ring-player-processor.js')
.then(() => {
if (!this.audioCtx) return;
this._workletNode = new AudioWorkletNode(this.audioCtx, 'ring-player-processor', {
numberOfInputs: 0,
numberOfOutputs: 1,
outputChannelCount: [this.channels],
processorOptions: {
channels: this.channels,
startThresholdSeconds: 0.22,
ringSeconds: 1.0
}
});
this._workletNode.connect(this.audioCtx.destination);
this._workletNode.port.onmessage = (ev) => {
if (!ev?.data || ev.data.type !== 'stats') return;
this._workletStats = {
underruns: ev.data.underruns || 0,
overruns: ev.data.overruns || 0,
availableFrames: ev.data.availableFrames || 0,
lastTs: performance.now()
};
};
this._workletReady = true;
if (this._pendingLen > 0) this._flushPending();
})
.catch((err) => {
console.warn('audio_worklet_init_failed', err);
this._useWorklet = false;
if (this._pendingLen > 0) this._flushPending();
});

return this._audioInitPromise;
}

_onPCM(buf) {
this._initAudio();
if (!this.audioCtx) return;

const chunk = new Int16Array(buf);
this._stats.pcmSamplesRx += chunk.length;
const maxPendingFrames = Math.ceil(this.sampleRate * 0.25);
const maxPendingSamples = maxPendingFrames * Math.max(1, this.channels);

@@ -349,28 +356,24 @@ class LiveListenWS {
this._pendingLen += chunk.length;
}

// Coalesce small chunks: accumulate until we have >= 40ms or 50ms passes.
// This reduces BufferSource scheduling overhead from ~12/sec to ~6/sec
// and produces larger, more stable buffers.
const minFrames = Math.ceil(this.sampleRate * 0.04); // 40ms worth
const haveFrames = Math.floor(this._pendingLen / this.channels);
const minFrames = Math.ceil(this.sampleRate * 0.04);
const haveFrames = Math.floor(this._pendingLen / Math.max(1, this.channels));

if (haveFrames >= minFrames) {
this._flushPending();
} else if (!this._flushTimer) {
// Flush after 50ms even if we don't have enough (prevents stale data)
this._flushTimer = setTimeout(() => {
this._flushTimer = 0;
if (this._pendingLen > 0) this._flushPending();
}, 50);
}, 40);
}
}

_flushPending() {
if (this._flushTimer) { clearTimeout(this._flushTimer); this._flushTimer = 0; }
if (this._pendingSamples.length === 0) return;
if (this._pendingSamples.length === 0 || !this.audioCtx) return;
if (this._useWorklet && !this._workletReady) return;

// Merge all pending into one Int16Array
const total = this._pendingLen;
const merged = new Int16Array(total);
let off = 0;
@@ -381,10 +384,17 @@ class LiveListenWS {
this._pendingSamples = [];
this._pendingLen = 0;

this._scheduleChunk(merged);
if (this._useWorklet && this._workletNode) {
const floatSamples = new Float32Array(merged.length);
for (let i = 0; i < merged.length; i++) floatSamples[i] = merged[i] / 32768;
this._workletNode.port.postMessage({ type: 'pcm', samples: floatSamples.buffer }, [floatSamples.buffer]);
return;
}

this._scheduleChunkFallback(merged);
}

_scheduleChunk(samples) {
_scheduleChunkFallback(samples) {
const ctx = this.audioCtx;
if (!ctx) return;
if (ctx.state === 'suspended') ctx.resume().catch(() => {});
@@ -393,8 +403,6 @@ class LiveListenWS {
if (nFrames === 0) return;

const audioBuffer = ctx.createBuffer(this.channels, nFrames, this.sampleRate);

// Decode interleaved s16le → per-channel float32
for (let ch = 0; ch < this.channels; ch++) {
const data = audioBuffer.getChannelData(ch);
for (let i = 0; i < nFrames; i++) {
@@ -403,40 +411,20 @@ class LiveListenWS {
}

const now = ctx.currentTime;
const leadMsBefore = (this.nextTime - now) * 1000;
this._stats.lastLeadMs = leadMsBefore;
if (leadMsBefore > this._stats.maxLeadMs) this._stats.maxLeadMs = leadMsBefore;
if (leadMsBefore < this._stats.minLeadMs) this._stats.minLeadMs = leadMsBefore;

// Target latency: 400ms. This means we schedule audio to play 400ms
// from now. Even if the main thread hangs for 300ms, the already-
// scheduled BufferSources continue playing on the system audio thread.
const targetLatency = 0.4;

// Max buffered: 900ms. Drop chunks if we're too far ahead.
const maxBuffered = 0.9;

if (!this.started || this.nextTime < now) {
// First chunk or underrun.
// Apply fade-in to avoid click at resync point.
if (this.started && this.nextTime < now) {
this._stats.underruns++;
this._stats.resyncs++;
}
const fadeIn = Math.min(64, nFrames);
for (let ch = 0; ch < this.channels; ch++) {
const data = audioBuffer.getChannelData(ch);
for (let i = 0; i < fadeIn; i++) {
data[i] *= i / fadeIn;
}
for (let i = 0; i < fadeIn; i++) data[i] *= i / fadeIn;
}
this.nextTime = now + targetLatency;
this.started = true;
}

if (this.nextTime > now + maxBuffered) {
// Too much buffered — drop to cap latency
this._stats.droppedMaxBuffered++;
return;
}

@@ -444,53 +432,8 @@ class LiveListenWS {
source.buffer = audioBuffer;
source.connect(ctx.destination);
source.start(this.nextTime);
this._stats.acceptedChunks++;
this._stats.lastAcceptedChunkAtMs = performance.now();
this.nextTime += audioBuffer.duration;
}

_emitSummary(reason) {
const nowMs = performance.now();
const audioNow = this.audioCtx ? this.audioCtx.currentTime : null;
const leadMs = this.audioCtx ? (this.nextTime - this.audioCtx.currentTime) * 1000 : null;
const sinceAcceptedMs = this._stats.lastAcceptedChunkAtMs > 0
? nowMs - this._stats.lastAcceptedChunkAtMs
: -1;
const payload = {
ts_client: new Date().toISOString(),
reason,
freq_hz: this.freq,
bw_hz: this.bw,
mode: this.mode,
sample_rate: this.sampleRate,
channels: this.channels,
playing: this.playing,
audio_current_time: audioNow,
audio_next_time: this.nextTime,
lead_ms: leadMs,
lead_ms_last: this._stats.lastLeadMs,
lead_ms_max: Number.isFinite(this._stats.maxLeadMs) ? this._stats.maxLeadMs : null,
lead_ms_min: Number.isFinite(this._stats.minLeadMs) ? this._stats.minLeadMs : null,
pcm_chunks_rx: this._stats.pcmChunksRx,
pcm_samples_rx: this._stats.pcmSamplesRx,
accepted_chunks: this._stats.acceptedChunks,
max_buffered_drops: this._stats.droppedMaxBuffered,
underruns: this._stats.underruns,
resyncs: this._stats.resyncs,
ms_since_last_accepted_chunk: sinceAcceptedMs,
uptime_ms: nowMs - this._stats.startedAtMs
};
postBrowserAudioSummary(payload);
}
}

function postBrowserAudioSummary(payload) {
fetch('/api/debug/audio-stutter/browser-summary', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
keepalive: true
}).catch(() => {});
}

const liveListenDefaults = {
@@ -698,9 +641,21 @@ let timelineFrozen = false;
const TARGET_VISUAL_FPS = 24;
const VISUAL_FRAME_INTERVAL_MS = 1000 / TARGET_VISUAL_FPS;
const WATERFALL_FRAME_INTERVAL_MS = 1000 / 10;
const DETAIL_RENDER_INTERVAL_MS = 1000 / 6;
const LIST_RENDER_INTERVAL_MS = 250;
const HERO_RENDER_INTERVAL_MS = 200;
const STATUS_RENDER_INTERVAL_MS = 250;
const MAX_RENDER_DPR = 1.25;
const WATERFALL_MIN_INTERNAL_WIDTH = 640;
const DETAIL_MIN_INTERNAL_WIDTH = 480;
const COLOR_LUT_SIZE = 1024;
const COLOR_LUT = new Uint8ClampedArray(COLOR_LUT_SIZE * 3);
for (let i = 0; i < COLOR_LUT_SIZE; i++) {
const x = i / (COLOR_LUT_SIZE - 1);
COLOR_LUT[i * 3] = Math.floor(255 * Math.pow(x, 0.55));
COLOR_LUT[i * 3 + 1] = Math.floor(255 * Math.pow(x, 1.08));
COLOR_LUT[i * 3 + 2] = Math.floor(220 * Math.pow(1 - x, 1.15));
}

let renderFrames = 0;
let renderFps = 0;
@@ -714,6 +669,13 @@ let pendingWaterfallRender = true;
let pendingListRender = true;
let pendingHeroRender = true;
let pendingStatusRender = true;
let lastDetailRenderTs = 0;
let waterfallRowImageData = null;
let detailRowImageData = null;
let detailRowCanvas = null;
let detailRowCtx = null;
let waterfallRangeCache = null;
let detailRangeCache = null;

let wsReconnectTimer = null;
let eventsFetchInFlight = false;
@@ -725,7 +687,7 @@ let timelineRects = [];
let liveSignalRects = [];
let recordings = [];
let recordingsFetchInFlight = false;
let showDebugOverlay = localStorage.getItem('spectre.debugOverlay') !== '0';
let showDebugOverlay = localStorage.getItem('spectre.debugOverlay') === '1';
let hoveredSignal = null;
let popoverHideTimer = null;

@@ -886,12 +848,93 @@ function renderSignalPopover(rect, signal) {
signalPopover.setAttribute('aria-hidden', 'false');
}


function getLutColor(v) {
const idx = Math.max(0, Math.min(COLOR_LUT_SIZE - 1, Math.round(v * (COLOR_LUT_SIZE - 1))));
const base = idx * 3;
return [COLOR_LUT[base], COLOR_LUT[base + 1], COLOR_LUT[base + 2]];
}

function fillSpectrumRowRGBA(target, width, rangeCache, display, centerHz, sampleRate, minDb = -120, maxDb = 0) {
if (!target || !display || width <= 0) return;
const spanMap = rangeCache?.map;
const bins = display.length;
const dbSpan = Math.max(1e-6, maxDb - minDb);
for (let x = 0; x < width; x++) {
let start = 0;
let end = bins - 1;
if (spanMap) {
start = spanMap[x * 2];
end = spanMap[x * 2 + 1];
}
let v = -1e9;
for (let i = start; i <= end; i++) {
const cur = display[i];
if (cur > v) v = cur;
}
const norm = Math.max(0, Math.min(1, (v - minDb) / dbSpan));
const lutBase = Math.max(0, Math.min(COLOR_LUT_SIZE - 1, Math.round(norm * (COLOR_LUT_SIZE - 1)))) * 3;
const di = x * 4;
target[di] = COLOR_LUT[lutBase];
target[di + 1] = COLOR_LUT[lutBase + 1];
target[di + 2] = COLOR_LUT[lutBase + 2];
target[di + 3] = 255;
}
}

function getRangeCache(prevCache, width, startHz, endHz, centerHz, sampleRate, n) {
const key = `${width}|${startHz.toFixed(3)}|${endHz.toFixed(3)}|${centerHz.toFixed(3)}|${sampleRate}|${n}`;
if (prevCache && prevCache.key === key) return prevCache;
const map = new Int32Array(width * 2);
for (let x = 0; x < width; x++) {
const f1 = startHz + (x / width) * (endHz - startHz);
const f2 = startHz + ((x + 1) / width) * (endHz - startHz);
let b0 = binForFreq(f1, centerHz, sampleRate, n);
let b1 = binForFreq(f2, centerHz, sampleRate, n);
if (b1 < b0) [b0, b1] = [b1, b0];
map[x * 2] = Math.max(0, Math.min(n - 1, b0));
map[x * 2 + 1] = Math.max(0, Math.min(n - 1, b1));
}
return { key, map };
}

function getCanvas2DContext(canvas, opts = {}) {
if (!canvas) return null;
if (!canvas.__ctx2d) {
canvas.__ctx2d = canvas.getContext('2d', {
alpha: false,
desynchronized: true,
willReadFrequently: !!opts.willReadFrequently
}) || canvas.getContext('2d');
}
return canvas.__ctx2d;
}

function ensureDetailRowCanvas(width) {
if (!detailRowCanvas) {
detailRowCanvas = document.createElement('canvas');
detailRowCanvas.width = Math.max(1, width);
detailRowCanvas.height = 1;
detailRowCtx = detailRowCanvas.getContext('2d', { alpha: false, desynchronized: true }) || detailRowCanvas.getContext('2d');
}
if (detailRowCanvas.width !== width) {
detailRowCanvas.width = Math.max(1, width);
detailRowCanvas.height = 1;
detailRowCtx = detailRowCanvas.getContext('2d', { alpha: false, desynchronized: true }) || detailRowCanvas.getContext('2d');
detailRowImageData = null;
}
}

function getWaterfallInternalWidth(canvas) {
return Math.max(WATERFALL_MIN_INTERNAL_WIDTH, Math.min(canvas.width, Math.floor(canvas.width * 0.85)));
}

function getDetailInternalWidth(canvas) {
return Math.max(DETAIL_MIN_INTERNAL_WIDTH, Math.min(canvas.width, Math.floor(canvas.width * 0.9)));
}

function colorMap(v) {
const x = Math.max(0, Math.min(1, v));
const r = Math.floor(255 * Math.pow(x, 0.55));
const g = Math.floor(255 * Math.pow(x, 1.08));
const b = Math.floor(220 * Math.pow(1 - x, 1.15));
return [r, g, b];
return getLutColor(Math.max(0, Math.min(1, v)));
}

function snrColor(snr) {
@@ -1024,12 +1067,23 @@ function getProcessedSpectrum() {
function resizeCanvas(canvas) {
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const width = Math.max(1, Math.floor(rect.width * dpr));
const dpr = Math.min(window.devicePixelRatio || 1, MAX_RENDER_DPR);
let width = Math.max(1, Math.floor(rect.width * dpr));
const height = Math.max(1, Math.floor(rect.height * dpr));
if (canvas === waterfallCanvas) width = getWaterfallInternalWidth({ width });
if (canvas === detailSpectrogram) width = getDetailInternalWidth({ width });
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
if (canvas === waterfallCanvas) {
waterfallRowImageData = null;
waterfallRangeCache = null;
pendingWaterfallRender = true;
}
if (canvas === detailSpectrogram) {
detailRowImageData = null;
detailRangeCache = null;
}
}
}

@@ -1647,12 +1701,10 @@ function renderSpectrum() {

function renderWaterfall() {
if (!latest) return;
const ctx = waterfallCanvas.getContext('2d');
const ctx = getCanvas2DContext(waterfallCanvas);
const w = waterfallCanvas.width;
const h = waterfallCanvas.height;

const prev = ctx.getImageData(0, 0, w, h - 1);
ctx.putImageData(prev, 0, 1);
if (!ctx || w <= 0 || h <= 0) return;

const display = getProcessedSpectrum();
if (!display) return;
@@ -1660,33 +1712,30 @@ function renderWaterfall() {
const span = latest.sample_rate / zoom;
const startHz = latest.center_hz - span / 2 + pan * span;
const endHz = latest.center_hz + span / 2 + pan * span;
const minDb = -120;
const maxDb = 0;

const row = ctx.createImageData(w, 1);
for (let x = 0; x < w; x++) {
const f1 = startHz + (x / w) * (endHz - startHz);
const f2 = startHz + ((x + 1) / w) * (endHz - startHz);
const b0 = binForFreq(f1, latest.center_hz, latest.sample_rate, n);
const b1 = binForFreq(f2, latest.center_hz, latest.sample_rate, n);
const v = maxInBinRange(display, b0, b1);
const norm = Math.max(0, Math.min(1, (v - minDb) / (maxDb - minDb)));
const [r, g, b] = colorMap(norm);
row.data[x * 4] = r;
row.data[x * 4 + 1] = g;
row.data[x * 4 + 2] = b;
row.data[x * 4 + 3] = 255;
waterfallRangeCache = getRangeCache(waterfallRangeCache, w, startHz, endHz, latest.center_hz, latest.sample_rate, n);
if (!waterfallRowImageData || waterfallRowImageData.width !== w) {
waterfallRowImageData = ctx.createImageData(w, 1);
}
ctx.putImageData(row, 0, 0);

fillSpectrumRowRGBA(waterfallRowImageData.data, w, waterfallRangeCache, display, latest.center_hz, latest.sample_rate);

if (h > 1) {
ctx.save();
ctx.globalCompositeOperation = 'copy';
ctx.drawImage(waterfallCanvas, 0, 0, w, h - 1, 0, 1, w, h - 1);
ctx.restore();
}
ctx.putImageData(waterfallRowImageData, 0, 0);

drawCfarEdgeOverlay(ctx, w, h, startHz, endHz);

// Waterfall signal markers: thin vertical lines at signal center frequencies
if (Array.isArray(latest.signals)) {
latest.signals.forEach(s => {
if (!s.center_hz) return;
const xc = ((s.center_hz - startHz) / (endHz - startHz)) * w;
if (xc < 0 || xc > w) return;
const mod = s.class?.mod_type || '';
for (const sig of latest.signals) {
if (!sig.center_hz) continue;
const xc = ((sig.center_hz - startHz) / (endHz - startHz)) * w;
if (xc < 0 || xc > w) continue;
const mod = sig.class?.mod_type || '';
ctx.strokeStyle = modColorStr(mod, 0.35);
ctx.lineWidth = 1;
ctx.setLineDash([2, 3]);
@@ -1694,8 +1743,8 @@ function renderWaterfall() {
ctx.moveTo(xc, 0);
ctx.lineTo(xc, h);
ctx.stroke();
ctx.setLineDash([]);
});
}
ctx.setLineDash([]);
}
}

@@ -1820,9 +1869,10 @@ function renderTimeline() {

function renderDetailSpectrogram() {
const ev = eventsById.get(selectedEventId);
const ctx = detailSpectrogram.getContext('2d');
const ctx = getCanvas2DContext(detailSpectrogram);
const w = detailSpectrogram.width;
const h = detailSpectrogram.height;
if (!ctx || w <= 0 || h <= 0) return;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = '#071018';
ctx.fillRect(0, 0, w, h);
@@ -1834,25 +1884,16 @@ function renderDetailSpectrogram() {
const localSpan = Math.min(latest.sample_rate, Math.max(ev.bandwidth_hz * 4, latest.sample_rate / 10));
const startHz = ev.center_hz - localSpan / 2;
const endHz = ev.center_hz + localSpan / 2;
const minDb = -120;
const maxDb = 0;

const row = ctx.createImageData(w, 1);
for (let x = 0; x < w; x++) {
const f1 = startHz + (x / w) * (endHz - startHz);
const f2 = startHz + ((x + 1) / w) * (endHz - startHz);
const b0 = binForFreq(f1, latest.center_hz, latest.sample_rate, n);
const b1 = binForFreq(f2, latest.center_hz, latest.sample_rate, n);
const v = maxInBinRange(display, b0, b1);
const norm = Math.max(0, Math.min(1, (v - minDb) / (maxDb - minDb)));
const [r, g, b] = colorMap(norm);
row.data[x * 4] = r;
row.data[x * 4 + 1] = g;
row.data[x * 4 + 2] = b;
row.data[x * 4 + 3] = 255;
detailRangeCache = getRangeCache(detailRangeCache, w, startHz, endHz, latest.center_hz, latest.sample_rate, n);
ensureDetailRowCanvas(w);
if (!detailRowImageData || detailRowImageData.width !== w) {
detailRowImageData = detailRowCtx.createImageData(w, 1);
}

for (let y = 0; y < h; y++) ctx.putImageData(row, 0, y);
fillSpectrumRowRGBA(detailRowImageData.data, w, detailRangeCache, display, latest.center_hz, latest.sample_rate);
detailRowCtx.putImageData(detailRowImageData, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(detailRowCanvas, 0, 0, w, 1, 0, 0, w, h);

const centerX = w / 2;
ctx.strokeStyle = 'rgba(255,255,255,0.65)';
@@ -2374,6 +2415,10 @@ function decodeBinaryFrame(buf) {
}

function renderLoop(now) {
if (document.hidden) {
requestAnimationFrame(renderLoop);
return;
}
flushOperatorStatus(now);
flushHeroMetrics(now);
flushLists(now);
@@ -2397,7 +2442,10 @@ function renderLoop(now) {
}
renderOccupancy();
renderTimeline();
if (drawerEl.classList.contains('open')) renderDetailSpectrogram();
if (drawerEl.classList.contains('open') && (lastDetailRenderTs === 0 || now - lastDetailRenderTs >= DETAIL_RENDER_INTERVAL_MS)) {
renderDetailSpectrogram();
lastDetailRenderTs = now;
}
}
requestAnimationFrame(renderLoop);
}


+ 26
- 4
web/ring-player-processor.js Переглянути файл

@@ -5,23 +5,40 @@ class RingPlayerProcessor extends AudioWorkletProcessor {
constructor(options) {
super();
const ch = options.processorOptions?.channels || 1;
const ringSeconds = options.processorOptions?.ringSeconds || 1.0;
const startThresholdSeconds = options.processorOptions?.startThresholdSeconds || 0.2;
this._channels = ch;
// 500ms ring buffer at sampleRate
this._ringSize = Math.ceil(sampleRate * ch * 0.5);
this._underruns = 0;
this._overruns = 0;
this._lastStatsFrame = 0;
// Ring buffer duration at sampleRate.
this._ringSize = Math.ceil(sampleRate * ch * ringSeconds);
this._ring = new Float32Array(this._ringSize);
this._writePos = 0;
this._readPos = 0;
this._started = false;
this._fadeGain = 1.0;
this._startThreshold = Math.ceil(sampleRate * ch * 0.2); // 200ms
this._startThreshold = Math.ceil(sampleRate * ch * startThresholdSeconds);

this.port.onmessage = (e) => {
if (e.data.type === 'pcm') {
this._pushSamples(e.data.samples);
this._pushSamples(new Float32Array(e.data.samples));
}
};
}

_postStats(force = false) {
const frame = currentFrame;
if (!force && frame - this._lastStatsFrame < sampleRate) return;
this._lastStatsFrame = frame;
this.port.postMessage({
type: 'stats',
underruns: this._underruns,
overruns: this._overruns,
availableFrames: Math.floor(this._available() / Math.max(1, this._channels))
});
}

_available() {
return (this._writePos - this._readPos + this._ringSize) % this._ringSize;
}
@@ -35,6 +52,7 @@ class RingPlayerProcessor extends AudioWorkletProcessor {
const used = this._available();
const free = size - used - 1;
if (n > free) {
this._overruns++;
this._readPos = (this._readPos + (n - free)) % size;
}

@@ -67,6 +85,7 @@ class RingPlayerProcessor extends AudioWorkletProcessor {

if (!this._started) {
for (let c = 0; c < output.length; c++) output[c].fill(0);
this._postStats();
return true;
}

@@ -74,6 +93,7 @@ class RingPlayerProcessor extends AudioWorkletProcessor {
const avail = this._available();

if (avail < need) {
this._underruns++;
// Underrun: play what we have with fade-out, fill rest with silence
const have = avail;
const haveFrames = Math.floor(have / ch);
@@ -100,6 +120,7 @@ class RingPlayerProcessor extends AudioWorkletProcessor {
for (let c = 0; c < output.length; c++) output[c][i] = 0;
}
this._fadeGain = 0;
this._postStats(true);
return true;
}

@@ -121,6 +142,7 @@ class RingPlayerProcessor extends AudioWorkletProcessor {
}
this._readPos = r;
this._fadeGain = 1.0;
this._postStats();
return true;
}
}


Завантаження…
Відмінити
Зберегти