From f27080b8b51e6a5290700da9ce96dd6bd94dc788 Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Mon, 23 Mar 2026 16:38:09 +0100 Subject: [PATCH] Update web audio player and streamer diagnostics --- .../demod/gpudemod/build/gpudemod_kernels.exp | Bin 2820 -> 0 bytes internal/recorder/streamer.go | 40 ++- web/app.js | 286 +++++++----------- web/ring-player-processor.js | 128 ++++++++ 4 files changed, 264 insertions(+), 190 deletions(-) delete mode 100644 internal/demod/gpudemod/build/gpudemod_kernels.exp create mode 100644 web/ring-player-processor.js diff --git a/internal/demod/gpudemod/build/gpudemod_kernels.exp b/internal/demod/gpudemod/build/gpudemod_kernels.exp deleted file mode 100644 index d7039cb3c15ec6ac0fa58544bf56c33720e41516..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2820 zcmeHJOK;p%6h6tcNguRLc$KC+rX?+jprd(&G@(TrwG}FA8>*&MU08A*-%F_5D>Pp17h^%#v)h4#^YnxIKKKnLj%9j0gK2yJu|r8}Xr@9QYgzNsnS7t!{e zBycxvr(^4&r~R<2Z8uTQkWp#Z>p1b#EGCXL#wc}N_oC6_(>nB;_MYaBtvusK-l(}E zy=9wCZ#^C{<*se7p^aIyyHThTH{U<=#_Pp8-IX0lX^`;2kY*kPZYm^QKXjDM?;kr| zldcG7K{y}6DZLEbFRTij5XLta7Z=t!;ETfU0S^d!3wT@@-`<15>cE$TZ2}JoTL7LA z<^vB4TLR7q>jHTd6}kvKDJ%va5!MFs$|}?c@~y7W72qqvJ^>yRb`|)luup+g!ma~f z6ZRQ!TG&lsRoEB6XNBDco)Y#Y@Ht_3fv1Ii1$GlRHvXpASJ z%kNkp8XXzyBPXz~gxdjcys?am&hnA_j^3RncU%3_1(EJ+_PrT} z8tDudAT64M9 zsyA9KYt0L+tD%YSE-qMoJiZs)ukno7)1_(7S-b&zhJO`KoFDT}!)f3RcyM+c>5)YZ zXOfd}av{+CU=~h8GPg@0Li5?b| zW%_T*sQ>@~ diff --git a/internal/recorder/streamer.go b/internal/recorder/streamer.go index 815d670..f25c1f3 100644 --- a/internal/recorder/streamer.go +++ b/internal/recorder/streamer.go @@ -763,20 +763,34 @@ func (sess *streamSession) processSnippet(snippet []complex64, snipRate int) ([] var dec []complex64 if decim1 > 1 { - cutoff := float64(actualDemodRate) / 2.0 * 0.8 - - // Lazy-init or reinit stateful FIR if parameters changed - if sess.preDemodFIR == nil || sess.preDemodRate != snipRate || sess.preDemodCutoff != cutoff { - taps := dsp.LowpassFIR(cutoff, snipRate, 101) - sess.preDemodFIR = dsp.NewStatefulFIRComplex(taps) - sess.preDemodRate = snipRate - sess.preDemodCutoff = cutoff - sess.preDemodDecim = decim1 - sess.preDemodDecimPhase = 0 // reset decimation phase on FIR reinit - } + // For WFM: skip the pre-demod anti-alias FIR entirely. + // FM broadcast has ±75kHz deviation. The old cutoff at + // actualDemodRate/2*0.8 = 68kHz was BELOW the FM deviation, + // clipping the modulation and causing amplitude dips that + // the FM discriminator turned into audible clicks (4-5/sec). + // The extraction stage already bandlimited to ±125kHz (BW/2 * bwMult), + // which is sufficient anti-alias protection for the 512k→170k decimation. + // + // For NFM/other: use a cutoff that preserves the full signal bandwidth. + if isWFM { + // WFM: decimate without FIR — extraction filter is sufficient + dec = dsp.DecimateStateful(fullSnip, decim1, &sess.preDemodDecimPhase) + } else { + cutoff := float64(actualDemodRate) / 2.0 * 0.8 + + // Lazy-init or reinit stateful FIR if parameters changed + if sess.preDemodFIR == nil || sess.preDemodRate != snipRate || sess.preDemodCutoff != cutoff { + taps := dsp.LowpassFIR(cutoff, snipRate, 101) + sess.preDemodFIR = dsp.NewStatefulFIRComplex(taps) + sess.preDemodRate = snipRate + sess.preDemodCutoff = cutoff + sess.preDemodDecim = decim1 + sess.preDemodDecimPhase = 0 + } - filtered := sess.preDemodFIR.ProcessInto(fullSnip, sess.growIQ(len(fullSnip))) - dec = dsp.DecimateStateful(filtered, decim1, &sess.preDemodDecimPhase) + filtered := sess.preDemodFIR.ProcessInto(fullSnip, sess.growIQ(len(fullSnip))) + dec = dsp.DecimateStateful(filtered, decim1, &sess.preDemodDecimPhase) + } } else { dec = fullSnip } diff --git a/web/app.js b/web/app.js index 13ef7f9..80866b0 100644 --- a/web/app.js +++ b/web/app.js @@ -143,17 +143,24 @@ let decisionIndex = new Map(); // --------------------------------------------------------------------------- // LiveListenWS — WebSocket-based gapless audio streaming via /ws/audio // --------------------------------------------------------------------------- -// v2: Ring-buffer based playback with smooth underrun handling. +// v4: Jank-resistant scheduled playback. // -// Architecture: -// WebSocket → PCM chunks → ring buffer → AudioWorklet/ScriptProcessor → speakers +// 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). // -// Key improvements over v1: -// - 250ms ring buffer absorbs feed_gap jitter (150-220ms observed) -// - On underrun: fade to silence instead of hard resync (no click) -// - On overrun: skip oldest data instead of hard drop -// - Continuous pull-based playback (worklet pulls from ring) instead of -// push-based scheduling (BufferSource per chunk) +// 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 // --------------------------------------------------------------------------- class LiveListenWS { constructor(freq, bw, mode) { @@ -165,19 +172,15 @@ class LiveListenWS { this.sampleRate = 48000; this.channels = 1; this.playing = false; + this.nextTime = 0; + this.started = false; this._onStop = null; - - // Ring buffer (interleaved float32 samples) - this._ringBuf = null; - this._ringSize = 0; - this._ringWrite = 0; // write cursor (producer: WebSocket) - this._ringRead = 0; // read cursor (consumer: audio callback) - this._scriptNode = null; - this._fadeGain = 1.0; // smooth fade on underrun - this._started = false; - this._totalWritten = 0; - this._totalRead = 0; - this._underruns = 0; + // 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 } start() { @@ -207,9 +210,8 @@ class LiveListenWS { } catch (e) { /* ignore */ } return; } - // Binary PCM data (s16le) if (!this.audioCtx || !this.playing) return; - this._pushPCM(ev.data); + this._onPCM(ev.data); }; this.ws.onclose = () => { @@ -228,31 +230,20 @@ class LiveListenWS { stop() { this.playing = false; - if (this.ws) { - this.ws.close(); - this.ws = null; - } + if (this.ws) { this.ws.close(); this.ws = null; } this._teardownAudio(); } onStop(fn) { this._onStop = fn; } - // --- Internal: Audio setup --- - _teardownAudio() { - if (this._scriptNode) { - this._scriptNode.disconnect(); - this._scriptNode = null; - } - if (this.audioCtx) { - this.audioCtx.close().catch(() => {}); - this.audioCtx = null; - } - this._ringBuf = null; - this._ringWrite = 0; - this._ringRead = 0; - this._fadeGain = 1.0; - this._started = false; + if (this._flushTimer) { clearTimeout(this._flushTimer); this._flushTimer = 0; } + if (this.audioCtx) { this.audioCtx.close().catch(() => {}); this.audioCtx = null; } + this.nextTime = 0; + this.started = false; + this._pendingSamples = []; + this._pendingLen = 0; + this._lastEndSample = null; } _initAudio() { @@ -261,161 +252,102 @@ class LiveListenWS { sampleRate: this.sampleRate }); this.audioCtx.resume().catch(() => {}); - - // Ring buffer: 500ms capacity (handles jitter up to ~400ms) - const ringDurationSec = 0.5; - this._ringSize = Math.ceil(this.sampleRate * this.channels * ringDurationSec); - this._ringBuf = new Float32Array(this._ringSize); - this._ringWrite = 0; - this._ringRead = 0; - this._fadeGain = 1.0; - this._started = false; - this._totalWritten = 0; - this._totalRead = 0; - - // Use ScriptProcessorNode (deprecated but universally supported). - // Buffer size 2048 at 48kHz = 42.7ms callback interval — responsive enough. - const bufSize = 2048; - this._scriptNode = this.audioCtx.createScriptProcessor(bufSize, 0, this.channels); - this._scriptNode.onaudioprocess = (e) => this._audioCallback(e); - this._scriptNode.connect(this.audioCtx.destination); - } - - // --- Internal: Producer (WebSocket → ring buffer) --- - - _pushPCM(buf) { - if (!this._ringBuf) return; - - const samples = new Int16Array(buf); - const nSamples = samples.length; // interleaved: nFrames * channels - if (nSamples === 0) return; - - const ring = this._ringBuf; - const size = this._ringSize; - let w = this._ringWrite; - - // Check available space - const used = (w - this._ringRead + size) % size; - const free = size - used - 1; // -1 to distinguish full from empty - - if (nSamples > free) { - // Overrun: advance read cursor to make room (discard oldest audio). - // This keeps latency bounded instead of growing unbounded. - const deficit = nSamples - free; - this._ringRead = (this._ringRead + deficit) % size; - } - - // Write samples into ring - for (let i = 0; i < nSamples; i++) { - ring[w] = samples[i] / 32768; - w = (w + 1) % size; - } - this._ringWrite = w; - this._totalWritten += nSamples; - - // Start playback after buffering ~200ms (enough to absorb one feed_gap) - if (!this._started) { - const buffered = (this._ringWrite - this._ringRead + size) % size; - const target = Math.ceil(this.sampleRate * this.channels * 0.2); - if (buffered >= target) { - this._started = true; - } + this.nextTime = 0; + this.started = false; + this._lastEndSample = null; + } + + _onPCM(buf) { + // 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. + this._pendingSamples.push(new Int16Array(buf)); + this._pendingLen += buf.byteLength / 2; // Int16 = 2 bytes + + const minFrames = Math.ceil(this.sampleRate * 0.04); // 40ms worth + const haveFrames = Math.floor(this._pendingLen / 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); } } - // --- Internal: Consumer (ring buffer → speakers) --- - - _audioCallback(e) { - const ring = this._ringBuf; - const size = this._ringSize; - const ch = this.channels; - const outLen = e.outputBuffer.length; // frames per channel + _flushPending() { + if (this._flushTimer) { clearTimeout(this._flushTimer); this._flushTimer = 0; } + if (this._pendingSamples.length === 0) return; - if (!ring || !this._started) { - // Not ready yet — output silence - for (let c = 0; c < e.outputBuffer.numberOfChannels; c++) { - e.outputBuffer.getChannelData(c).fill(0); - } - return; + // Merge all pending into one Int16Array + const total = this._pendingLen; + const merged = new Int16Array(total); + let off = 0; + for (const chunk of this._pendingSamples) { + merged.set(chunk, off); + off += chunk.length; } + this._pendingSamples = []; + this._pendingLen = 0; - // How many interleaved samples do we need? - const need = outLen * ch; - const available = (this._ringWrite - this._ringRead + size) % size; - - if (available < need) { - // Underrun — not enough data. Output what we have, fade to silence. - this._underruns++; - const have = available; - const haveFrames = Math.floor(have / ch); - - // Get channel buffers - const outs = []; - for (let c = 0; c < e.outputBuffer.numberOfChannels; c++) { - outs.push(e.outputBuffer.getChannelData(c)); - } + this._scheduleChunk(merged); + } - let r = this._ringRead; + _scheduleChunk(samples) { + const ctx = this.audioCtx; + if (!ctx) return; + if (ctx.state === 'suspended') ctx.resume().catch(() => {}); - // Play available samples with fade-out over last 64 samples - const fadeLen = Math.min(64, haveFrames); - const fadeStart = haveFrames - fadeLen; + const nFrames = Math.floor(samples.length / this.channels); + if (nFrames === 0) return; - for (let i = 0; i < haveFrames; i++) { - // Fade envelope: full volume, then linear fade to 0 - let env = this._fadeGain; - if (i >= fadeStart) { - env *= 1.0 - (i - fadeStart) / fadeLen; - } - for (let c = 0; c < ch; c++) { - if (c < outs.length) { - outs[c][i] = ring[r] * env; - } - r = (r + 1) % size; - } - } - this._ringRead = r; + const audioBuffer = ctx.createBuffer(this.channels, nFrames, this.sampleRate); - // Fill rest with silence - for (let i = haveFrames; i < outLen; i++) { - for (let c = 0; c < outs.length; c++) { - outs[c][i] = 0; - } + // 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++) { + data[i] = samples[i * this.channels + ch] / 32768; } - - this._fadeGain = 0; // next chunk starts silent - this._totalRead += have; - return; } - // Normal path — enough data available - const outs = []; - for (let c = 0; c < e.outputBuffer.numberOfChannels; c++) { - outs.push(e.outputBuffer.getChannelData(c)); - } + const now = ctx.currentTime; - let r = this._ringRead; + // 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; - // If recovering from underrun, fade in over first 64 samples - const fadeInLen = (this._fadeGain < 1.0) ? Math.min(64, outLen) : 0; + // Max buffered: 900ms. Drop chunks if we're too far ahead. + const maxBuffered = 0.9; - for (let i = 0; i < outLen; i++) { - let env = 1.0; - if (i < fadeInLen) { - // Linear fade-in from current _fadeGain to 1.0 - env = this._fadeGain + (1.0 - this._fadeGain) * (i / fadeInLen); - } - for (let c = 0; c < ch; c++) { - if (c < outs.length) { - outs[c][i] = ring[r] * env; + if (!this.started || this.nextTime < now) { + // First chunk or underrun. + // Apply fade-in to avoid click at resync point. + 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; } - r = (r + 1) % size; } + this.nextTime = now + targetLatency; + this.started = true; + } + + if (this.nextTime > now + maxBuffered) { + // Too much buffered — drop to cap latency + return; } - this._ringRead = r; - this._fadeGain = 1.0; - this._totalRead += need; + const source = ctx.createBufferSource(); + source.buffer = audioBuffer; + source.connect(ctx.destination); + source.start(this.nextTime); + this.nextTime += audioBuffer.duration; } } diff --git a/web/ring-player-processor.js b/web/ring-player-processor.js new file mode 100644 index 0000000..78d4ece --- /dev/null +++ b/web/ring-player-processor.js @@ -0,0 +1,128 @@ +// ring-player-processor.js — AudioWorklet processor for LiveListenWS +// Runs on the audio rendering thread, immune to main-thread blocking. + +class RingPlayerProcessor extends AudioWorkletProcessor { + constructor(options) { + super(); + const ch = options.processorOptions?.channels || 1; + this._channels = ch; + // 500ms ring buffer at sampleRate + this._ringSize = Math.ceil(sampleRate * ch * 0.5); + 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.port.onmessage = (e) => { + if (e.data.type === 'pcm') { + this._pushSamples(e.data.samples); + } + }; + } + + _available() { + return (this._writePos - this._readPos + this._ringSize) % this._ringSize; + } + + _pushSamples(float32arr) { + const ring = this._ring; + const size = this._ringSize; + const n = float32arr.length; + + // Overrun: advance read cursor to make room + const used = this._available(); + const free = size - used - 1; + if (n > free) { + this._readPos = (this._readPos + (n - free)) % size; + } + + let w = this._writePos; + // Fast path: contiguous write + if (w + n <= size) { + ring.set(float32arr, w); + w += n; + if (w >= size) w = 0; + } else { + // Wrap around + const first = size - w; + ring.set(float32arr.subarray(0, first), w); + ring.set(float32arr.subarray(first), 0); + w = n - first; + } + this._writePos = w; + + if (!this._started && this._available() >= this._startThreshold) { + this._started = true; + } + } + + process(inputs, outputs, parameters) { + const output = outputs[0]; + const outLen = output[0]?.length || 128; + const ch = this._channels; + const ring = this._ring; + const size = this._ringSize; + + if (!this._started) { + for (let c = 0; c < output.length; c++) output[c].fill(0); + return true; + } + + const need = outLen * ch; + const avail = this._available(); + + if (avail < need) { + // Underrun: play what we have with fade-out, fill rest with silence + const have = avail; + const haveFrames = Math.floor(have / ch); + const fadeLen = Math.min(64, haveFrames); + const fadeStart = haveFrames - fadeLen; + let r = this._readPos; + + for (let i = 0; i < haveFrames; i++) { + let env = this._fadeGain; + if (i >= fadeStart) { + env *= 1.0 - (i - fadeStart) / fadeLen; + } + for (let c = 0; c < ch; c++) { + if (c < output.length) { + output[c][i] = ring[r] * env; + } + r = (r + 1) % size; + } + } + this._readPos = r; + + // Silence the rest + for (let i = haveFrames; i < outLen; i++) { + for (let c = 0; c < output.length; c++) output[c][i] = 0; + } + this._fadeGain = 0; + return true; + } + + // Normal path + let r = this._readPos; + const fadeInLen = (this._fadeGain < 1.0) ? Math.min(64, outLen) : 0; + + for (let i = 0; i < outLen; i++) { + let env = 1.0; + if (i < fadeInLen) { + env = this._fadeGain + (1.0 - this._fadeGain) * (i / fadeInLen); + } + for (let c = 0; c < ch; c++) { + if (c < output.length) { + output[c][i] = ring[r] * env; + } + r = (r + 1) % size; + } + } + this._readPos = r; + this._fadeGain = 1.0; + return true; + } +} + +registerProcessor('ring-player-processor', RingPlayerProcessor);