From 0b158b38b7da025742795afbedd79726a64764a6 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Mon, 30 Mar 2026 05:05:50 -0400 Subject: [PATCH] initial commit to fix mem leak --- native/src/main.cpp | 46 +++++ src/main/index.ts | 59 ++++--- .../audio/NativeVisualizerTransport.ts | 20 ++- src/renderer/audio/native/index.ts | 33 ++++ src/renderer/audio/native/visualizer-dsp.d.ts | 5 + src/renderer/visualizers/LUFSMeter.ts | 80 ++++++--- src/renderer/visualizers/Oscilloscope.ts | 45 ++--- src/renderer/visualizers/Spectrogram.ts | 5 +- src/renderer/visualizers/SpectrumAnalyzer.ts | 166 +++++++++++------- src/renderer/visualizers/Vectorscope.ts | 135 ++++++++++---- src/renderer/visualizers/Waveform.ts | 16 +- src/renderer/visualizers/multibandSplitter.ts | 138 ++++++++++----- test/renderer-helpers.test.ts | 134 +++++++++++++- 13 files changed, 653 insertions(+), 229 deletions(-) diff --git a/native/src/main.cpp b/native/src/main.cpp index 716abbc..acf5d7c 100644 --- a/native/src/main.cpp +++ b/native/src/main.cpp @@ -98,6 +98,19 @@ Napi::Value OscilloscopeGetSamples(const Napi::CallbackInfo& info) { return output; } +Napi::Value OscilloscopeFillSamples(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsTypedArray()) { + Napi::TypeError::New(env, "Expected startPos (float) and output Float32Array").ThrowAsJavaScriptException(); + return env.Null(); + } + float startPos = info[0].As().FloatValue(); + Napi::Float32Array output = info[1].As(); + const size_t count = output.ElementLength(); + oscilloscope.getSamplesInterpolated(output.Data(), startPos, count); + return Napi::Number::New(env, static_cast(count)); +} + Napi::Value OscilloscopeReset(const Napi::CallbackInfo& info) { oscilloscope.reset(); return info.Env().Undefined(); @@ -158,6 +171,22 @@ Napi::Value SpectrumGetMagnitudes(const Napi::CallbackInfo& info) { return result; } +Napi::Value SpectrumFillMagnitudes(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 1 || !info[0].IsTypedArray()) { + Napi::TypeError::New(env, "Expected output Float32Array").ThrowAsJavaScriptException(); + return env.Null(); + } + + Napi::Float32Array output = info[0].As(); + const auto& magnitudes = spectrum.getMagnitudes(); + const size_t count = std::min(output.ElementLength(), magnitudes.size()); + if (count > 0) { + memcpy(output.Data(), magnitudes.data(), count * sizeof(float)); + } + return Napi::Number::New(env, static_cast(count)); +} + Napi::Value SpectrumProcess(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); if (info.Length() < 1 || !info[0].IsTypedArray()) { @@ -236,6 +265,20 @@ Napi::Value VectorscopeGetPoints(const Napi::CallbackInfo& info) { return result; } +Napi::Value VectorscopeFillPoints(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 2 || !info[0].IsTypedArray() || !info[1].IsTypedArray()) { + Napi::TypeError::New(env, "Expected output x/y Float32Arrays").ThrowAsJavaScriptException(); + return env.Null(); + } + + Napi::Float32Array xArray = info[0].As(); + Napi::Float32Array yArray = info[1].As(); + const size_t maxPoints = std::min(xArray.ElementLength(), yArray.ElementLength()); + const size_t actual = vectorscope.getPoints(xArray.Data(), yArray.Data(), maxPoints); + return Napi::Number::New(env, static_cast(actual)); +} + Napi::Value VectorscopeSetBufferSize(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); if (info.Length() < 1 || !info[0].IsNumber()) { @@ -289,6 +332,7 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) { oscExports.Set("pushSamples", Napi::Function::New(env, OscilloscopePushSamples)); oscExports.Set("processContinuous", Napi::Function::New(env, OscilloscopeProcessContinuous)); oscExports.Set("getWritePos", Napi::Function::New(env, OscilloscopeGetWritePos)); + oscExports.Set("fillSamples", Napi::Function::New(env, OscilloscopeFillSamples)); oscExports.Set("getSamples", Napi::Function::New(env, OscilloscopeGetSamples)); oscExports.Set("reset", Napi::Function::New(env, OscilloscopeReset)); exports.Set("oscilloscope", oscExports); @@ -300,6 +344,7 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) { specExports.Set("setSampleRate", Napi::Function::New(env, SpectrumSetSampleRate)); specExports.Set("setSmoothing", Napi::Function::New(env, SpectrumSetSmoothing)); specExports.Set("pushSamples", Napi::Function::New(env, SpectrumPushSamples)); + specExports.Set("fillMagnitudes", Napi::Function::New(env, SpectrumFillMagnitudes)); specExports.Set("getMagnitudes", Napi::Function::New(env, SpectrumGetMagnitudes)); specExports.Set("process", Napi::Function::New(env, SpectrumProcess)); specExports.Set("binToFrequency", Napi::Function::New(env, SpectrumBinToFrequency)); @@ -310,6 +355,7 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) { Napi::Object vecExports = Napi::Object::New(env); vecExports.Set("setSampleRate", Napi::Function::New(env, VectorscopeSetSampleRate)); vecExports.Set("pushSamples", Napi::Function::New(env, VectorscopePushSamples)); + vecExports.Set("fillPoints", Napi::Function::New(env, VectorscopeFillPoints)); vecExports.Set("getPoints", Napi::Function::New(env, VectorscopeGetPoints)); vecExports.Set("setBufferSize", Napi::Function::New(env, VectorscopeSetBufferSize)); vecExports.Set("getBufferSize", Napi::Function::New(env, VectorscopeGetBufferSize)); diff --git a/src/main/index.ts b/src/main/index.ts index 96648be..c45876e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1219,35 +1219,48 @@ function setupIPC(): void { function setupShortcuts(): void { if (!mainWindow) return - const scopeKeys = ['1', '2', '3', '4', '5', '6', '7', '8'] - scopeKeys.forEach((key) => { - mainWindow!.webContents.on('before-input-event', (_event, input) => { - if (input.type === 'keyDown' && input.key === key && !input.alt && !input.control && !input.meta && !input.shift) { - mainWindow?.webContents.send('shortcut:toggle-scope', parseInt(key) - 1) + const shortcutWindow = mainWindow + const scopeKeys = new Map([ + ['1', 0], + ['2', 1], + ['3', 2], + ['4', 3], + ['5', 4], + ['6', 5], + ['7', 6], + ['8', 7], + ]) + const beforeInputHandler = (_event: Electron.Event, input: Electron.Input) => { + if (input.type !== 'keyDown') { + return + } + + if (!input.alt && !input.control && !input.meta && !input.shift) { + const scopeIndex = scopeKeys.get(input.key) + if (scopeIndex !== undefined) { + shortcutWindow.webContents.send('shortcut:toggle-scope', scopeIndex) + return } - }) - }) - mainWindow.webContents.on('before-input-event', (_event, input) => { - if (input.type === 'keyDown' && input.key === 't' && !input.alt && !input.control && !input.meta && !input.shift) { - const current = mainWindow!.isAlwaysOnTop() - const next = !current - setAllWindowsAlwaysOnTop(next) - mainWindow!.webContents.send('window:always-on-top-changed', next) - } - }) + if (input.key === 't') { + const next = !shortcutWindow.isAlwaysOnTop() + setAllWindowsAlwaysOnTop(next) + shortcutWindow.webContents.send('window:always-on-top-changed', next) + return + } - mainWindow.webContents.on('before-input-event', (_event, input) => { - if (input.type === 'keyDown' && input.key === ' ' && !input.alt && !input.control && !input.meta && !input.shift) { - mainWindow?.webContents.send('shortcut:toggle-capture') + if (input.key === ' ') { + shortcutWindow.webContents.send('shortcut:toggle-capture') + return + } } - }) - mainWindow.webContents.on('before-input-event', (_event, input) => { - if (input.type === 'keyDown' && input.key === ',' && input.meta && !input.alt && !input.control && !input.shift) { - mainWindow?.webContents.send('shortcut:toggle-settings') + if (input.key === ',' && input.meta && !input.alt && !input.control && !input.shift) { + shortcutWindow.webContents.send('shortcut:toggle-settings') } - }) + } + + shortcutWindow.webContents.on('before-input-event', beforeInputHandler) } const hasSingleInstanceLock = app.requestSingleInstanceLock() diff --git a/src/renderer/audio/NativeVisualizerTransport.ts b/src/renderer/audio/NativeVisualizerTransport.ts index 32a85fd..77311f5 100644 --- a/src/renderer/audio/NativeVisualizerTransport.ts +++ b/src/renderer/audio/NativeVisualizerTransport.ts @@ -23,7 +23,7 @@ export interface NativeVisualizerTransportBridge { spectrum: { setSampleRate: (sampleRate: number) => void pushSamples: (samples: Float32Array) => void - getMagnitudes: () => Float32Array | null + fillMagnitudes: (output: Float32Array) => number reset: () => void } vectorscope: { @@ -53,7 +53,7 @@ const defaultBridge: NativeVisualizerTransportBridge = { spectrum: { setSampleRate: (sampleRate) => spectrum.setSampleRate(sampleRate), pushSamples: (samples) => spectrum.pushSamples(samples), - getMagnitudes: () => spectrum.getMagnitudes(), + fillMagnitudes: (output) => spectrum.fillMagnitudes(output), reset: () => spectrum.reset(), }, vectorscope: { @@ -82,11 +82,19 @@ export class NativeVisualizerTransport { private sampleRate = 48000 private capturing = false private hasSpectrumData = false + private spectrumMonoScratch = new Float32Array(0) constructor(bridge: NativeVisualizerTransportBridge = defaultBridge) { this.bridge = bridge } + private ensureSpectrumMonoScratch(length: number): Float32Array { + if (this.spectrumMonoScratch.length !== length) { + this.spectrumMonoScratch = new Float32Array(length) + } + return this.spectrumMonoScratch + } + setDemand(demand: VisualizerConsumerDemand): void { const nextDemand = normalizeDemand(demand) if ( @@ -159,7 +167,7 @@ export class NativeVisualizerTransport { } if (this.demand.spectrum) { - const mono = new Float32Array(length) + const mono = this.ensureSpectrumMonoScratch(length) for (let index = 0; index < length; index += 1) { mono[index] = (leftSamples[index] + rightSamples[index]) * 0.5 } @@ -197,12 +205,12 @@ export class NativeVisualizerTransport { } } - getLatestSpectrumMagnitudes(): Float32Array | null { + fillLatestSpectrumMagnitudes(output: Float32Array): number { if (!this.bridge.isAvailable() || !this.demand.spectrum || !this.hasSpectrumData) { - return null + return 0 } - return this.bridge.spectrum.getMagnitudes() + return this.bridge.spectrum.fillMagnitudes(output) } } diff --git a/src/renderer/audio/native/index.ts b/src/renderer/audio/native/index.ts index a5f06ca..27acfa2 100644 --- a/src/renderer/audio/native/index.ts +++ b/src/renderer/audio/native/index.ts @@ -65,6 +65,18 @@ export const oscilloscope = { return nativeModule?.oscilloscope.getWritePos() ?? 0 }, + fillSamples: (startPos: number, output: Float32Array): number => { + if (!nativeModule) return 0 + // `visualizerAPI` crosses Electron's context bridge, so mutating a renderer-owned + // typed array in preload/native does not write back into the caller's buffer. + const samples = nativeModule.oscilloscope.getSamples(startPos, output.length) + const count = Math.min(output.length, samples.length) + if (count > 0) { + output.set(samples.subarray(0, count), 0) + } + return count + }, + // Get samples from circular buffer for rendering getSamples: (startPos: number, count: number): Float32Array | null => { if (!nativeModule) return null @@ -97,6 +109,16 @@ export const spectrum = { nativeModule?.spectrum.pushSamples(audioData) }, + fillMagnitudes: (output: Float32Array): number => { + if (!nativeModule) return 0 + const magnitudes = nativeModule.spectrum.getMagnitudes() + const count = Math.min(output.length, magnitudes.length) + if (count > 0) { + output.set(magnitudes.subarray(0, count), 0) + } + return count + }, + getMagnitudes: (): Float32Array | null => { if (!nativeModule) return null return nativeModule.spectrum.getMagnitudes() @@ -125,6 +147,17 @@ export const vectorscope = { nativeModule?.vectorscope.pushSamples(leftChannel, rightChannel) }, + fillPoints: (xOut: Float32Array, yOut: Float32Array): number => { + if (!nativeModule) return 0 + const result = nativeModule.vectorscope.getPoints(Math.min(xOut.length, yOut.length)) + const count = Math.min(xOut.length, yOut.length, result.count, result.x.length, result.y.length) + if (count > 0) { + xOut.set(result.x.subarray(0, count), 0) + yOut.set(result.y.subarray(0, count), 0) + } + return count + }, + getPoints: (maxPoints: number): VectorscopePointsResult | null => { if (!nativeModule) return null return nativeModule.vectorscope.getPoints(maxPoints) diff --git a/src/renderer/audio/native/visualizer-dsp.d.ts b/src/renderer/audio/native/visualizer-dsp.d.ts index 3a4380b..90d7ace 100644 --- a/src/renderer/audio/native/visualizer-dsp.d.ts +++ b/src/renderer/audio/native/visualizer-dsp.d.ts @@ -38,6 +38,9 @@ export interface OscilloscopeModule { // Get current write position in circular buffer getWritePos(): number; + // Fill a caller-owned buffer with rendered samples, returning the number written. + fillSamples(startPos: number, output: Float32Array): number; + // Get samples from circular buffer for rendering getSamples(startPos: number, count: number): Float32Array; @@ -50,6 +53,7 @@ export interface SpectrumModule { setSampleRate(sampleRate: number): void; setSmoothing(smoothing: number): void; pushSamples(audioData: Float32Array): void; + fillMagnitudes(output: Float32Array): number; getMagnitudes(): Float32Array; process(audioData: Float32Array): Float32Array; binToFrequency(bin: number): number; @@ -59,6 +63,7 @@ export interface SpectrumModule { export interface VectorscopeModule { setSampleRate(sampleRate: number): void; pushSamples(leftChannel: Float32Array, rightChannel: Float32Array): void; + fillPoints(xOut: Float32Array, yOut: Float32Array): number; getPoints(maxPoints: number): VectorscopePointsResult; setBufferSize(size: number): void; getBufferSize(): number; diff --git a/src/renderer/visualizers/LUFSMeter.ts b/src/renderer/visualizers/LUFSMeter.ts index 3ead42c..aa03f80 100644 --- a/src/renderer/visualizers/LUFSMeter.ts +++ b/src/renderer/visualizers/LUFSMeter.ts @@ -46,6 +46,12 @@ const ABSOLUTE_GATE_LUFS = -70 const RELATIVE_GATE_OFFSET = -10 const TARGET_LUFS = -14 const SMOOTHING = 0.7 +const INTEGRATED_HISTOGRAM_MIN_LUFS = ABSOLUTE_GATE_LUFS +const INTEGRATED_HISTOGRAM_MAX_LUFS = 10 +const INTEGRATED_HISTOGRAM_BIN_WIDTH = 0.1 +const INTEGRATED_HISTOGRAM_BIN_COUNT = Math.round( + (INTEGRATED_HISTOGRAM_MAX_LUFS - INTEGRATED_HISTOGRAM_MIN_LUFS) / INTEGRATED_HISTOGRAM_BIN_WIDTH +) + 1 // ---- K-weighting filter coefficients (ITU-R BS.1770) ---- @@ -107,6 +113,15 @@ function applyBiquad(coeffs: BiquadCoeffs, state: BiquadState, input: number): n return output } +function histogramIndexFromLufs(lufs: number): number { + const normalized = (lufs - INTEGRATED_HISTOGRAM_MIN_LUFS) / INTEGRATED_HISTOGRAM_BIN_WIDTH + return Math.max(0, Math.min(INTEGRATED_HISTOGRAM_BIN_COUNT - 1, Math.round(normalized))) +} + +function histogramLufsAtIndex(index: number): number { + return INTEGRATED_HISTOGRAM_MIN_LUFS + (index * INTEGRATED_HISTOGRAM_BIN_WIDTH) +} + // ---- LUFS Meter class ---- export class LUFSMeter { @@ -135,7 +150,8 @@ export class LUFSMeter { private integratedBlockSumR = 0 private integratedBlockSamples = 0 private integratedHopCounter = 0 - private integratedBlockLoudness: number[] = [] // LUFS per block + private integratedHistogramCounts = new Uint32Array(INTEGRATED_HISTOGRAM_BIN_COUNT) + private integratedHistogramPowerSums = new Float64Array(INTEGRATED_HISTOGRAM_BIN_COUNT) // Smoothed display values private momentaryLUFS = METER_MIN_LUFS @@ -193,7 +209,8 @@ export class LUFSMeter { this.integratedBlockSumR = 0 this.integratedBlockSamples = 0 this.integratedHopCounter = 0 - this.integratedBlockLoudness = [] + this.integratedHistogramCounts.fill(0) + this.integratedHistogramPowerSums.fill(0) this.preFilterL = createBiquadState() this.preFilterR = createBiquadState() this.rlbFilterL = createBiquadState() @@ -281,8 +298,13 @@ export class LUFSMeter { if (this.integratedHopCounter >= hopSamples && this.integratedBlockSamples >= blockSamples) { const meanSqL = this.integratedBlockSumL / this.integratedBlockSamples const meanSqR = this.integratedBlockSumR / this.integratedBlockSamples - const blockLUFS = -0.691 + 10 * Math.log10(Math.max(meanSqL + meanSqR, 1e-10)) - this.integratedBlockLoudness.push(blockLUFS) + const blockPower = Math.max(meanSqL + meanSqR, 1e-10) + const blockLUFS = -0.691 + 10 * Math.log10(blockPower) + if (blockLUFS > ABSOLUTE_GATE_LUFS) { + const histogramIndex = histogramIndexFromLufs(blockLUFS) + this.integratedHistogramCounts[histogramIndex] += 1 + this.integratedHistogramPowerSums[histogramIndex] += blockPower + } // Slide the block window: remove oldest hop worth of samples // Approximate by keeping a running sum and subtracting the hop fraction @@ -336,27 +358,41 @@ export class LUFSMeter { } private computeGatedIntegratedLoudness(): number { - const blocks = this.integratedBlockLoudness - if (blocks.length === 0) return METER_MIN_LUFS + let absoluteCount = 0 + let absolutePowerSum = 0 + for (let index = 0; index < this.integratedHistogramCounts.length; index += 1) { + const count = this.integratedHistogramCounts[index] + if (count === 0) { + continue + } + absoluteCount += count + absolutePowerSum += this.integratedHistogramPowerSums[index] + } + if (absoluteCount === 0 || absolutePowerSum <= 0) { + return METER_MIN_LUFS + } - // Absolute gate: remove blocks below -70 LUFS - const afterAbsolute = blocks.filter(l => l > ABSOLUTE_GATE_LUFS) - if (afterAbsolute.length === 0) return METER_MIN_LUFS - - // Compute mean of blocks passing absolute gate - let sum = 0 - for (const l of afterAbsolute) sum += Math.pow(10, l / 10) - const ungatedMean = 10 * Math.log10(sum / afterAbsolute.length) - - // Relative gate: remove blocks below (ungatedMean - 10) LUFS + const ungatedMean = -0.691 + 10 * Math.log10(absolutePowerSum / absoluteCount) const relativeThreshold = ungatedMean + RELATIVE_GATE_OFFSET - const afterRelative = afterAbsolute.filter(l => l > relativeThreshold) - if (afterRelative.length === 0) return METER_MIN_LUFS - // Final integrated loudness - let finalSum = 0 - for (const l of afterRelative) finalSum += Math.pow(10, l / 10) - return Math.max(METER_MIN_LUFS, 10 * Math.log10(finalSum / afterRelative.length)) + let relativeCount = 0 + let relativePowerSum = 0 + for (let index = 0; index < this.integratedHistogramCounts.length; index += 1) { + const count = this.integratedHistogramCounts[index] + if (count === 0) { + continue + } + if (histogramLufsAtIndex(index) <= relativeThreshold) { + continue + } + relativeCount += count + relativePowerSum += this.integratedHistogramPowerSums[index] + } + if (relativeCount === 0 || relativePowerSum <= 0) { + return METER_MIN_LUFS + } + + return Math.max(METER_MIN_LUFS, -0.691 + 10 * Math.log10(relativePowerSum / relativeCount)) } private drawFrame = (): void => { diff --git a/src/renderer/visualizers/Oscilloscope.ts b/src/renderer/visualizers/Oscilloscope.ts index 4560e4e..767a511 100644 --- a/src/renderer/visualizers/Oscilloscope.ts +++ b/src/renderer/visualizers/Oscilloscope.ts @@ -83,6 +83,7 @@ export class Oscilloscope { private staticLayerCanvas: HTMLCanvasElement private staticLayerCtx: CanvasRenderingContext2D private staticLayerKey = '' + private renderBuffer = new Float32Array(0) private static readonly WARMUP_SAMPLES = 4096 constructor(canvas: HTMLCanvasElement, options: OscilloscopeOptions = {}) { @@ -176,6 +177,13 @@ export class Oscilloscope { this.invalidate() } + private ensureRenderBuffer(size: number): Float32Array { + if (this.renderBuffer.length !== size) { + this.renderBuffer = new Float32Array(size) + } + return this.renderBuffer + } + private drawFrame = (): void => { const { canvas, ctx, options } = this const width = canvas.width @@ -224,34 +232,25 @@ export class Oscilloscope { while (triggerIndex < 0) triggerIndex += OSCILLOSCOPE_BUFFER_SIZE } - const renderData = nativeOscilloscope.getSamples(Math.floor(triggerIndex), samplesToShow) - if (!renderData || renderData.length === 0) { + const renderData = this.ensureRenderBuffer(samplesToShow) + const sampleCount = nativeOscilloscope.fillSamples(triggerIndex, renderData) + if (sampleCount < 2) { return } - const sliceWidth = width / samplesToShow + const sliceWidth = width / sampleCount const centerY = height / 2 const visualGain = 1.8 - const points: Array<{ x: number; y: number }> = [] - - for (let i = 0; i < samplesToShow && i < renderData.length; i++) { - const sample = renderData[i] - const y = ((1 - sample * visualGain) / 2) * height - const x = i * sliceWidth - points.push({ x, y }) - } - - if (points.length < 2) { - return - } if (options.underfillEnabled) { ctx.beginPath() - ctx.moveTo(points[0].x, centerY) - for (const point of points) { - ctx.lineTo(point.x, point.y) + ctx.moveTo(0, centerY) + for (let i = 0; i < sampleCount; i += 1) { + const x = i * sliceWidth + const y = ((1 - renderData[i] * visualGain) / 2) * height + ctx.lineTo(x, y) } - ctx.lineTo(points[points.length - 1].x, centerY) + ctx.lineTo((sampleCount - 1) * sliceWidth, centerY) ctx.closePath() const peakAlpha = 0.28 const shoulderAlpha = peakAlpha * 0.74 @@ -273,9 +272,11 @@ export class Oscilloscope { ctx.lineCap = 'round' ctx.lineJoin = 'round' ctx.beginPath() - ctx.moveTo(points[0].x, points[0].y) - for (let i = 1; i < points.length; i++) { - ctx.lineTo(points[i].x, points[i].y) + ctx.moveTo(0, ((1 - renderData[0] * visualGain) / 2) * height) + for (let i = 1; i < sampleCount; i += 1) { + const x = i * sliceWidth + const y = ((1 - renderData[i] * visualGain) / 2) * height + ctx.lineTo(x, y) } ctx.stroke() } diff --git a/src/renderer/visualizers/Spectrogram.ts b/src/renderer/visualizers/Spectrogram.ts index 3a38780..f089c6e 100644 --- a/src/renderer/visualizers/Spectrogram.ts +++ b/src/renderer/visualizers/Spectrogram.ts @@ -299,6 +299,7 @@ export class Spectrogram { private fftRe: Float32Array private fftIm: Float32Array + private fftMagnitudes: Float32Array private sampleBuffer: Float32Array private sampleBufferPos = 0 @@ -342,6 +343,7 @@ export class Spectrogram { const paddedSize = windowSize * FFT_PAD_FACTOR this.fftRe = new Float32Array(paddedSize) this.fftIm = new Float32Array(paddedSize) + this.fftMagnitudes = new Float32Array(paddedSize / 2) this.sampleBuffer = new Float32Array(windowSize) this.waterfallCanvas = document.createElement('canvas') @@ -389,6 +391,7 @@ export class Spectrogram { const paddedSize = windowSize * FFT_PAD_FACTOR this.fftRe = new Float32Array(paddedSize) this.fftIm = new Float32Array(paddedSize) + this.fftMagnitudes = new Float32Array(paddedSize / 2) this.sampleBuffer = new Float32Array(windowSize) this.sampleBufferPos = 0 this.lastFftSize = 0 @@ -533,7 +536,7 @@ export class Spectrogram { fft(this.fftRe, this.fftIm) const numBins = paddedSize / 2 - const magnitudes = new Float32Array(numBins) + const magnitudes = this.fftMagnitudes const scale = 2 / windowSize // normalize by window size, not padded size for (let index = 0; index < numBins; index += 1) { diff --git a/src/renderer/visualizers/SpectrumAnalyzer.ts b/src/renderer/visualizers/SpectrumAnalyzer.ts index 3f35f76..bb9b5b1 100644 --- a/src/renderer/visualizers/SpectrumAnalyzer.ts +++ b/src/renderer/visualizers/SpectrumAnalyzer.ts @@ -16,12 +16,6 @@ type SpectrumStereoChunk = { right: Float32Array } -type SpectrumPoint = { - x: number - y: number - heatmapIntensity: number -} - export interface SpectrumAnalyzerDataSource extends VisualizerSessionSource { getPendingSpectrumSamples: () => Float32Array[] getPendingSpectrumStereoSamples: () => SpectrumStereoChunk[] @@ -249,6 +243,12 @@ export class SpectrumAnalyzer { private jsFftIm = new Float32Array(defaultOptions.fftSize) private jsBufferedSamples = 0 private jsHasSpectrumData = false + private nativeMagnitudeBuffer = new Float32Array(0) + private primaryPointX = new Float32Array(0) + private primaryPointY = new Float32Array(0) + private primaryPointHeatmap = new Float32Array(0) + private secondaryPointX = new Float32Array(0) + private secondaryPointY = new Float32Array(0) constructor(canvas: HTMLCanvasElement, options: SpectrumAnalyzerOptions = {}) { this.canvas = canvas @@ -476,23 +476,30 @@ export class SpectrumAnalyzer { return db + tiltDbPerOctave * octaves } - private mergePendingSpectrumChunks(pendingSpectrum: Float32Array[]): Float32Array | null { - if (pendingSpectrum.length === 0) return null - if (pendingSpectrum.length === 1) return pendingSpectrum[0] - - let totalLength = 0 - for (const chunk of pendingSpectrum) { - totalLength += chunk.length + private ensureNativeMagnitudeBuffer(): Float32Array { + const length = Math.max(1, Math.floor(this.options.fftSize / 2)) + if (this.nativeMagnitudeBuffer.length !== length) { + this.nativeMagnitudeBuffer = new Float32Array(length) } + return this.nativeMagnitudeBuffer + } - const monoData = new Float32Array(totalLength) - let offset = 0 - for (const chunk of pendingSpectrum) { - monoData.set(chunk, offset) - offset += chunk.length + private ensurePointBuffers(pointCount: number): void { + if (this.primaryPointX.length !== pointCount) { + this.primaryPointX = new Float32Array(pointCount) + this.primaryPointY = new Float32Array(pointCount) + this.primaryPointHeatmap = new Float32Array(pointCount) + this.secondaryPointX = new Float32Array(pointCount) + this.secondaryPointY = new Float32Array(pointCount) } + } - return monoData + private pushPendingSpectrumChunks(pendingSpectrum: Float32Array[]): void { + for (const chunk of pendingSpectrum) { + if (chunk.length > 0) { + nativeSpectrum.pushSamples(chunk) + } + } } private clearPendingSpectrumQueues(): void { @@ -578,17 +585,23 @@ export class SpectrumAnalyzer { this.jsHasSpectrumData = true } - private buildSpectrumPoints( + private fillSpectrumPoints( frequencyData: Float32Array, + dataLength: number, width: number, height: number, minFrequency: number, maxFrequency: number, nyquist: number, - ): SpectrumPoint[] { - const bufferLength = frequencyData.length + xOut: Float32Array, + yOut: Float32Array, + heatmapIntensityOut: Float32Array | null, + ): number { + const bufferLength = Math.min(dataLength, frequencyData.length) + if (bufferLength <= 0) { + return 0 + } const binWidth = nyquist / bufferLength - const points: SpectrumPoint[] = [] const numPoints = Math.max(2, Math.floor(width)) for (let index = 0; index < numPoints; index += 1) { @@ -612,28 +625,28 @@ export class SpectrumAnalyzer { const normalized = (db - this.options.minDecibels) / (this.options.maxDecibels - this.options.minDecibels) const heatmapNormalized = (heatmapDb - this.options.minDecibels) / (this.options.maxDecibels - this.options.minDecibels) - points.push({ - x, - y: height - Math.max(0, Math.min(1, normalized)) * height, - heatmapIntensity: Math.pow(Math.max(0, Math.min(1, heatmapNormalized)), HEATMAP_GAMMA), - }) + xOut[index] = x + yOut[index] = height - Math.max(0, Math.min(1, normalized)) * height + if (heatmapIntensityOut) { + heatmapIntensityOut[index] = Math.pow(Math.max(0, Math.min(1, heatmapNormalized)), HEATMAP_GAMMA) + } } - return points + return numPoints } - private renderHeatmap(points: SpectrumPoint[], width: number, height: number): void { - for (let index = 0; index < points.length; index += 1) { - const x = Math.floor(points[index].x) - const y = points[index].y - const nextX = index < points.length - 1 ? Math.floor(points[index + 1].x) : width + private renderHeatmap(xPoints: Float32Array, yPoints: Float32Array, heatmapIntensity: Float32Array, pointCount: number, width: number, height: number): void { + for (let index = 0; index < pointCount; index += 1) { + const x = Math.floor(xPoints[index]) + const y = yPoints[index] + const nextX = index < pointCount - 1 ? Math.floor(xPoints[index + 1]) : width const columnWidth = Math.max(1, nextX - x) const fillHeight = height - y if (fillHeight <= 0) { continue } - const lutIndex = Math.round(points[index].heatmapIntensity * 255) + const lutIndex = Math.round(heatmapIntensity[index] * 255) const r = this.heatLut[lutIndex * 3] const g = this.heatLut[lutIndex * 3 + 1] const b = this.heatLut[lutIndex * 3 + 2] @@ -643,12 +656,12 @@ export class SpectrumAnalyzer { } } - private renderGradientFill(points: SpectrumPoint[], width: number, height: number): void { + private renderGradientFill(xPoints: Float32Array, yPoints: Float32Array, pointCount: number, width: number, height: number): void { this.ctx.beginPath() - this.ctx.moveTo(points[0].x, points[0].y) + this.ctx.moveTo(xPoints[0], yPoints[0]) - for (let index = 1; index < points.length; index += 1) { - this.ctx.lineTo(points[index].x, points[index].y) + for (let index = 1; index < pointCount; index += 1) { + this.ctx.lineTo(xPoints[index], yPoints[index]) } this.ctx.lineTo(width, height) @@ -665,15 +678,15 @@ export class SpectrumAnalyzer { this.ctx.fill() } - private renderStroke(points: SpectrumPoint[], color: string, lineWidth: number): void { - if (points.length === 0) { + private renderStroke(xPoints: Float32Array, yPoints: Float32Array, pointCount: number, color: string, lineWidth: number): void { + if (pointCount === 0) { return } this.ctx.beginPath() - this.ctx.moveTo(points[0].x, points[0].y) - for (let index = 1; index < points.length; index += 1) { - this.ctx.lineTo(points[index].x, points[index].y) + this.ctx.moveTo(xPoints[0], yPoints[0]) + for (let index = 1; index < pointCount; index += 1) { + this.ctx.lineTo(xPoints[index], yPoints[index]) } this.ctx.lineWidth = lineWidth @@ -710,11 +723,15 @@ export class SpectrumAnalyzer { let primaryData: Float32Array | null = null let secondaryData: Float32Array | null = null + let primaryDataLength = 0 + let secondaryDataLength = 0 if (options.showSideLine) { this.processJsSpectrumChunks(this.dataSource.getPendingSpectrumStereoSamples()) primaryData = this.jsHasSpectrumData ? this.jsMidMagnitudes : null secondaryData = this.jsHasSpectrumData ? this.jsSideMagnitudes : null + primaryDataLength = primaryData?.length ?? 0 + secondaryDataLength = secondaryData?.length ?? 0 } else { if (!isNativeAvailable()) { console.error('SpectrumAnalyzer: Native DSP required') @@ -725,39 +742,62 @@ export class SpectrumAnalyzer { const nativeTransport = this.dataSource.getNativeVisualizerTransport?.() ?? null const pendingSpectrum = this.dataSource.getPendingSpectrumSamples() if (!nativeTransport) { - const monoData = this.mergePendingSpectrumChunks(pendingSpectrum) - if (monoData) { - nativeSpectrum.pushSamples(monoData) - } + this.pushPendingSpectrumChunks(pendingSpectrum) } - primaryData = nativeTransport - ? nativeTransport.getLatestSpectrumMagnitudes() - : nativeSpectrum.getMagnitudes() + const nativeMagnitudes = this.ensureNativeMagnitudeBuffer() + primaryData = nativeMagnitudes + primaryDataLength = nativeTransport + ? nativeTransport.fillLatestSpectrumMagnitudes(nativeMagnitudes) + : nativeSpectrum.fillMagnitudes(nativeMagnitudes) } - if (!primaryData || primaryData.length === 0) { + if (!primaryData || primaryDataLength === 0) { this.renderStaticLayer(minFrequency, maxFrequency) return } - const primaryPoints = this.buildSpectrumPoints(primaryData, width, height, minFrequency, maxFrequency, nyquist) - const secondaryPoints = secondaryData && secondaryData.length > 0 - ? this.buildSpectrumPoints(secondaryData, width, height, minFrequency, maxFrequency, nyquist) - : null + const pointCount = Math.max(2, Math.floor(width)) + this.ensurePointBuffers(pointCount) + const primaryPointCount = this.fillSpectrumPoints( + primaryData, + primaryDataLength, + width, + height, + minFrequency, + maxFrequency, + nyquist, + this.primaryPointX, + this.primaryPointY, + this.primaryPointHeatmap, + ) + const secondaryPointCount = secondaryData && secondaryDataLength > 0 + ? this.fillSpectrumPoints( + secondaryData, + secondaryDataLength, + width, + height, + minFrequency, + maxFrequency, + nyquist, + this.secondaryPointX, + this.secondaryPointY, + null, + ) + : 0 this.renderStaticLayer(minFrequency, maxFrequency) - if (options.heatmapFill && primaryPoints.length > 0) { - this.renderHeatmap(primaryPoints, width, height) - } else if (options.fillGradient && primaryPoints.length > 0) { - this.renderGradientFill(primaryPoints, width, height) + if (options.heatmapFill && primaryPointCount > 0) { + this.renderHeatmap(this.primaryPointX, this.primaryPointY, this.primaryPointHeatmap, primaryPointCount, width, height) + } else if (options.fillGradient && primaryPointCount > 0) { + this.renderGradientFill(this.primaryPointX, this.primaryPointY, primaryPointCount, width, height) } - this.renderStroke(primaryPoints, options.lineColor, options.lineWidth * dpr) - if (secondaryPoints && secondaryPoints.length > 0) { + this.renderStroke(this.primaryPointX, this.primaryPointY, primaryPointCount, options.lineColor, options.lineWidth * dpr) + if (secondaryPointCount > 0) { const secondaryLineWidth = Math.max(dpr, options.lineWidth * SIDE_LINE_WIDTH_RATIO * dpr) - this.renderStroke(secondaryPoints, options.secondaryLineColor, secondaryLineWidth) + this.renderStroke(this.secondaryPointX, this.secondaryPointY, secondaryPointCount, options.secondaryLineColor, secondaryLineWidth) } } diff --git a/src/renderer/visualizers/Vectorscope.ts b/src/renderer/visualizers/Vectorscope.ts index be8692a..0734458 100644 --- a/src/renderer/visualizers/Vectorscope.ts +++ b/src/renderer/visualizers/Vectorscope.ts @@ -1,7 +1,7 @@ import { audioRouter } from '../audio/AudioRouter' import { vectorscope as nativeVectorscope, isNativeAvailable } from '../audio/native' -import { transformPoint, drawVectorscopeGridForMode, getVectorscopeLayout } from './vectorscopeGrids' -import { MultibandSplitter, MultibandBuffer } from './multibandSplitter' +import { drawVectorscopeGridForMode, getVectorscopeLayout } from './vectorscopeGrids' +import { MultibandSplitter, MultibandBuffer, createMultibandChunk, type MultibandChunk } from './multibandSplitter' import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource' import { FrameScheduler } from './frameScheduler' import { VisualizerFrameLoop } from './visualizerFrameLoop' @@ -56,6 +56,7 @@ const defaultVectorscopeDataSource: VectorscopeDataSource = { } const BAND_ORDER = ['low', 'mid', 'high'] as const +const INV_SQRT2 = 1 / Math.sqrt(2) export class Vectorscope { private canvas: HTMLCanvasElement @@ -72,6 +73,10 @@ export class Vectorscope { private unsubscribeSessionChange: (() => void) | null = null private splitter: MultibandSplitter = new MultibandSplitter() private multibandBuffer: MultibandBuffer = new MultibandBuffer() + private multibandScratch: MultibandChunk = createMultibandChunk(0) + private multibandPointScratch: MultibandChunk = createMultibandChunk(0) + private nativePointX = new Float32Array(0) + private nativePointY = new Float32Array(0) private staticLayerKey = '' constructor(canvas: HTMLCanvasElement, options: VectorscopeOptions = {}) { @@ -217,9 +222,9 @@ export class Vectorscope { } } - const pointsResult = nativeVectorscope.getPoints(options.displayPoints) - if (pointsResult && pointsResult.count > 0) { - this.drawPoints(offscreenCtx, pointsResult.x, pointsResult.y, pointsResult.count, centerX, centerY, scale) + const count = this.fillNativePoints(options.displayPoints) + if (count > 0) { + this.drawPoints(offscreenCtx, this.nativePointX, this.nativePointY, count, centerX, centerY, scale) } } else { this.drawFallbackPoints(offscreenCtx, pendingSamples, centerX, centerY, scale) @@ -295,12 +300,7 @@ export class Vectorscope { ctx.globalAlpha = alpha for (let i = startIdx; i < endIdx; i++) { - const point = transformPoint(y[i], x[i], mode) - if (!point) continue - - const px = centerX + point.dx * scale - const py = centerY - point.dy * scale - ctx.fillRect(px - dotSize / 2, py - dotSize / 2, dotSize, dotSize) + this.drawProjectedDot(ctx, y[i], x[i], mode, centerX, centerY, scale, dotSize) } } ctx.globalAlpha = 1.0 @@ -325,12 +325,7 @@ export class Vectorscope { for (const chunk of pendingSamples) { for (let i = 0; i < chunk.left.length; i++) { - const point = transformPoint(chunk.left[i], chunk.right[i], mode) - if (!point) continue - - const px = centerX + point.dx * scale - const py = centerY - point.dy * scale - ctx.fillRect(px - dotSize / 2, py - dotSize / 2, dotSize, dotSize) + this.drawProjectedDot(ctx, chunk.left[i], chunk.right[i], mode, centerX, centerY, scale, dotSize) } } ctx.globalAlpha = 1.0 @@ -353,48 +348,114 @@ export class Vectorscope { this.splitter.configure(sampleRate) } - if (isNativeAvailable()) { - for (const chunk of pendingSamples) { - nativeVectorscope.pushSamples(chunk.left, chunk.right) - } - } - for (const chunk of pendingSamples) { - const bands = this.splitter.split(chunk.left, chunk.right) - this.multibandBuffer.push(bands) + const bands = this.ensureMultibandScratch(chunk.left.length, chunk.right.length) + const count = this.splitter.splitInto(chunk.left, chunk.right, bands) + this.multibandBuffer.push(bands, count) } - const result = this.multibandBuffer.getPoints(options.displayPoints) - if (result.count === 0) return + const result = this.ensureMultibandPointScratch(options.displayPoints) + const count = this.multibandBuffer.fillPointsInto(result, options.displayPoints) + if (count === 0) return const segments = 8 - const pointsPerSegment = Math.ceil(result.count / segments) + const pointsPerSegment = Math.ceil(count / segments) for (let seg = 0; seg < segments; seg++) { const startIdx = seg * pointsPerSegment - const endIdx = Math.min((seg + 1) * pointsPerSegment, result.count) - if (startIdx >= result.count) break + const endIdx = Math.min((seg + 1) * pointsPerSegment, count) + if (startIdx >= count) break const alpha = 0.15 + 0.85 * (seg / Math.max(segments - 1, 1)) ctx.globalAlpha = alpha for (const band of BAND_ORDER) { - const bandData = result.bands[band] + const bandData = result[band] ctx.fillStyle = options.bandColors[band] for (let i = startIdx; i < endIdx; i++) { - const point = transformPoint(bandData.left[i], bandData.right[i], mode) - if (!point) continue - - const px = centerX + point.dx * scale - const py = centerY - point.dy * scale - ctx.fillRect(px - dotSize / 2, py - dotSize / 2, dotSize, dotSize) + this.drawProjectedDot(ctx, bandData.left[i], bandData.right[i], mode, centerX, centerY, scale, dotSize) } } } ctx.globalAlpha = 1.0 } + private ensureNativePointBuffers(displayPoints: number): void { + if (this.nativePointX.length !== displayPoints) { + this.nativePointX = new Float32Array(displayPoints) + this.nativePointY = new Float32Array(displayPoints) + } + } + + private fillNativePoints(displayPoints: number): number { + this.ensureNativePointBuffers(displayPoints) + return nativeVectorscope.fillPoints(this.nativePointX, this.nativePointY) + } + + private ensureMultibandScratch(leftLength: number, rightLength: number): MultibandChunk { + const length = Math.min(leftLength, rightLength) + if (this.multibandScratch.low.left.length < length) { + this.multibandScratch = createMultibandChunk(length) + } + return this.multibandScratch + } + + private ensureMultibandPointScratch(displayPoints: number): MultibandChunk { + if (this.multibandPointScratch.low.left.length !== displayPoints) { + this.multibandPointScratch = createMultibandChunk(displayPoints) + } + return this.multibandPointScratch + } + + private drawProjectedDot( + ctx: CanvasRenderingContext2D, + left: number, + right: number, + mode: VectorscopeMode, + centerX: number, + centerY: number, + scale: number, + dotSize: number, + ): void { + let dx: number + let dy: number + + if (mode === 'lissajous') { + dx = right + dy = left + } else { + const mid = (left + right) * INV_SQRT2 + const side = (right - left) * INV_SQRT2 + const isUnipolar = mode === 'polar-unipolar' || mode === 'linear-unipolar' + if (isUnipolar && mid < 0) { + return + } + + const isPolar = mode === 'polar-unipolar' || mode === 'polar-bipolar' + if (isPolar) { + const amplitudeSquared = (mid * mid) + (side * side) + if (amplitudeSquared < 1e-12) { + dx = 0 + dy = 0 + } else { + const amplitude = Math.sqrt(amplitudeSquared) + const scaledAmplitude = Math.pow(amplitude, 0.35) + const factor = scaledAmplitude / amplitude + dx = side * factor + dy = mid * factor + } + } else { + dx = side + dy = mid + } + } + + const px = centerX + dx * scale + const py = centerY - dy * scale + ctx.fillRect(px - dotSize / 2, py - dotSize / 2, dotSize, dotSize) + } + dispose(): void { this.stop() this.frameLoop.dispose() diff --git a/src/renderer/visualizers/Waveform.ts b/src/renderer/visualizers/Waveform.ts index 7c6692a..0a25b4a 100644 --- a/src/renderer/visualizers/Waveform.ts +++ b/src/renderer/visualizers/Waveform.ts @@ -11,7 +11,7 @@ import { clampWaveformScrollSpeed, type WaveformMode, } from '../../types/waveform' -import { MultibandSplitter } from './multibandSplitter' +import { MultibandSplitter, createMultibandChunk, type MultibandChunk } from './multibandSplitter' export interface WaveformStereoChunk { left: Float32Array @@ -97,6 +97,7 @@ export class Waveform { private rightBandLowAcc: Float32Array = new Float32Array(0) private rightBandMidAcc: Float32Array = new Float32Array(0) private rightBandHighAcc: Float32Array = new Float32Array(0) + private multibandScratch: MultibandChunk = createMultibandChunk(0) private unsubscribeSessionChange: (() => void) | null = null constructor(canvas: HTMLCanvasElement, options: WaveformOptions = {}) { @@ -458,7 +459,8 @@ export class Waveform { let midBand: Float32Array | null = null let highBand: Float32Array | null = null if (this.options.multiband) { - const bands = this.splitter.split(chunk, chunk) + const bands = this.ensureMultibandScratch(chunk.length) + this.splitter.splitInto(chunk, chunk, bands) lowBand = bands.low.left midBand = bands.mid.left highBand = bands.high.left @@ -499,7 +501,8 @@ export class Waveform { let midRight: Float32Array | null = null let highRight: Float32Array | null = null if (this.options.multiband) { - const bands = this.splitter.split(leftSamples, rightSamples) + const bands = this.ensureMultibandScratch(length) + this.splitter.splitInto(leftSamples, rightSamples, bands) lowLeft = bands.low.left midLeft = bands.mid.left highLeft = bands.high.left @@ -535,6 +538,13 @@ export class Waveform { } } + private ensureMultibandScratch(length: number): MultibandChunk { + if (this.multibandScratch.low.left.length < length) { + this.multibandScratch = createMultibandChunk(length) + } + return this.multibandScratch + } + private drawFrame = (): void => { const width = this.canvas.width const height = this.canvas.height diff --git a/src/renderer/visualizers/multibandSplitter.ts b/src/renderer/visualizers/multibandSplitter.ts index c0059ae..dbc0e88 100644 --- a/src/renderer/visualizers/multibandSplitter.ts +++ b/src/renderer/visualizers/multibandSplitter.ts @@ -27,6 +27,14 @@ export interface MultibandChunk { high: { left: Float32Array; right: Float32Array } } +export function createMultibandChunk(length: number): MultibandChunk { + return { + low: { left: new Float32Array(length), right: new Float32Array(length) }, + mid: { left: new Float32Array(length), right: new Float32Array(length) }, + high: { left: new Float32Array(length), right: new Float32Array(length) }, + } +} + // ---------- Biquad filter ---------- class BiquadFilter { @@ -114,6 +122,15 @@ export class MultibandSplitter { private highHpR = new BiquadFilter() private configuredSampleRate = 0 + private midTmpL = new Float32Array(0) + private midTmpR = new Float32Array(0) + + private ensureScratch(size: number): void { + if (this.midTmpL.length !== size) { + this.midTmpL = new Float32Array(size) + this.midTmpR = new Float32Array(size) + } + } configure(sampleRate: number): void { if (sampleRate === this.configuredSampleRate) return @@ -134,38 +151,42 @@ export class MultibandSplitter { } split(left: Float32Array, right: Float32Array): MultibandChunk { - const n = left.length + const target = createMultibandChunk(Math.min(left.length, right.length)) + this.splitInto(left, right, target) + return target + } - const lowL = new Float32Array(n) - const lowR = new Float32Array(n) - const midL = new Float32Array(n) - const midR = new Float32Array(n) - const highL = new Float32Array(n) - const highR = new Float32Array(n) - - // Temp buffers for mid band (highpass then lowpass) - const midTmpL = new Float32Array(n) - const midTmpR = new Float32Array(n) - - // Low band - this.lowLpL.process(left, lowL) - this.lowLpR.process(right, lowR) - - // Mid band (highpass → lowpass) - this.midHpL.process(left, midTmpL) - this.midHpR.process(right, midTmpR) - this.midLpL.process(midTmpL, midL) - this.midLpR.process(midTmpR, midR) - - // High band - this.highHpL.process(left, highL) - this.highHpR.process(right, highR) - - return { - low: { left: lowL, right: lowR }, - mid: { left: midL, right: midR }, - high: { left: highL, right: highR }, + splitInto(left: Float32Array, right: Float32Array, target: MultibandChunk): number { + const n = Math.min( + left.length, + right.length, + target.low.left.length, + target.low.right.length, + target.mid.left.length, + target.mid.right.length, + target.high.left.length, + target.high.right.length, + ) + if (n <= 0) { + return 0 } + + const leftInput = left.length === n ? left : left.subarray(0, n) + const rightInput = right.length === n ? right : right.subarray(0, n) + this.ensureScratch(n) + + this.lowLpL.process(leftInput, target.low.left) + this.lowLpR.process(rightInput, target.low.right) + + this.midHpL.process(leftInput, this.midTmpL) + this.midHpR.process(rightInput, this.midTmpR) + this.midLpL.process(this.midTmpL, target.mid.left) + this.midLpR.process(this.midTmpR, target.mid.right) + + this.highHpL.process(leftInput, target.high.left) + this.highHpR.process(rightInput, target.high.right) + + return n } reset(): void { @@ -211,8 +232,16 @@ export class MultibandBuffer { } } - push(bands: MultibandChunk): void { - const n = bands.low.left.length + push(bands: MultibandChunk, count = bands.low.left.length): void { + const n = Math.min( + count, + bands.low.left.length, + bands.low.right.length, + bands.mid.left.length, + bands.mid.right.length, + bands.high.left.length, + bands.high.right.length, + ) for (let i = 0; i < n; i++) { const pos = this.writePos this.buffers.low.left[pos] = bands.low.left[i] @@ -233,6 +262,34 @@ export class MultibandBuffer { * Returns the most recent `maxPoints` samples for each band, * ordered oldest-first (matching the native getPoints() convention). */ + fillPointsInto(target: MultibandChunk, maxPoints: number): number { + const count = Math.min( + maxPoints, + this.validSamples, + target.low.left.length, + target.low.right.length, + target.mid.left.length, + target.mid.right.length, + target.high.left.length, + target.high.right.length, + ) + if (count === 0) { + return 0 + } + + for (let i = 0; i < count; i++) { + const idx = (this.writePos + this.capacity - count + i) % this.capacity + target.low.left[i] = this.buffers.low.left[idx] + target.low.right[i] = this.buffers.low.right[idx] + target.mid.left[i] = this.buffers.mid.left[idx] + target.mid.right[i] = this.buffers.mid.right[idx] + target.high.left[i] = this.buffers.high.left[idx] + target.high.right[i] = this.buffers.high.right[idx] + } + + return count + } + getPoints(maxPoints: number): { bands: Record<'low' | 'mid' | 'high', { left: Float32Array; right: Float32Array }> count: number @@ -249,21 +306,8 @@ export class MultibandBuffer { } } - const out = { - low: { left: new Float32Array(count), right: new Float32Array(count) }, - mid: { left: new Float32Array(count), right: new Float32Array(count) }, - high: { left: new Float32Array(count), right: new Float32Array(count) }, - } - - for (let i = 0; i < count; i++) { - const idx = (this.writePos + this.capacity - count + i) % this.capacity - out.low.left[i] = this.buffers.low.left[idx] - out.low.right[i] = this.buffers.low.right[idx] - out.mid.left[i] = this.buffers.mid.left[idx] - out.mid.right[i] = this.buffers.mid.right[idx] - out.high.left[i] = this.buffers.high.left[idx] - out.high.right[i] = this.buffers.high.right[idx] - } + const out = createMultibandChunk(count) + this.fillPointsInto(out, count) return { bands: out, count } } diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index c897f1e..796f093 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -47,6 +47,12 @@ import { NativeVisualizerTransport, type NativeVisualizerTransportBridge, } from '../src/renderer/audio/NativeVisualizerTransport' +import { LUFSMeter } from '../src/renderer/visualizers/LUFSMeter' +import { + MultibandBuffer, + MultibandSplitter, + createMultibandChunk, +} from '../src/renderer/visualizers/multibandSplitter' import { DEFAULT_PROFILE_ID, DEFAULT_PROFILE_NAME, @@ -373,7 +379,16 @@ function createFakeTransportBridge(): { calls.spectrumPushes.push(samples) latestSpectrumMagnitudes = new Float32Array([samples[0] ?? 0, samples[samples.length - 1] ?? 0]) }, - getMagnitudes: () => latestSpectrumMagnitudes, + fillMagnitudes: (output) => { + if (!latestSpectrumMagnitudes) { + return 0 + } + const count = Math.min(output.length, latestSpectrumMagnitudes.length) + for (let index = 0; index < count; index += 1) { + output[index] = latestSpectrumMagnitudes[index] + } + return count + }, reset: () => { calls.spectrumResets += 1 latestSpectrumMagnitudes = null @@ -395,6 +410,51 @@ function createFakeTransportBridge(): { } } +function readSpectrumMagnitudes(transport: NativeVisualizerTransport, size = 8): number[] { + const output = new Float32Array(size) + const count = transport.fillLatestSpectrumMagnitudes(output) + return Array.from(output.subarray(0, count)) +} + +function createFakeCanvasContext(): CanvasRenderingContext2D { + return { + clearRect() {}, + fillRect() {}, + fillText() {}, + beginPath() {}, + moveTo() {}, + lineTo() {}, + stroke() {}, + save() {}, + restore() {}, + translate() {}, + rotate() {}, + createLinearGradient() { + return { + addColorStop() {}, + } as CanvasGradient + }, + measureText() { + return { width: 0 } as TextMetrics + }, + fillStyle: '', + strokeStyle: '', + lineWidth: 1, + font: '', + textAlign: 'left', + textBaseline: 'top', + } as unknown as CanvasRenderingContext2D +} + +function createFakeCanvas(): HTMLCanvasElement { + const context = createFakeCanvasContext() + return { + width: 320, + height: 180, + getContext: (kind: string) => kind === '2d' ? context : null, + } as unknown as HTMLCanvasElement +} + test('parseColorToRgb handles hex, rgb, rgba, and percentage formats', () => { assert.deepEqual(parseColorToRgb('#38bdf8'), { r: 56, g: 189, b: 248 }) assert.deepEqual(parseColorToRgb('#3bf'), { r: 51, g: 187, b: 255 }) @@ -1406,7 +1466,7 @@ test('NativeVisualizerTransport feeds native scope state from chunk arrival with assert.equal(calls.vectorscopePushes[0]?.left, left) assert.equal(calls.vectorscopePushes[0]?.right, right) assert.deepEqual(Array.from(calls.spectrumPushes[0]), [0.5, 0.5, 0.5]) - assert.deepEqual(Array.from(transport.getLatestSpectrumMagnitudes() ?? []), [0.5, 0.5]) + assert.deepEqual(readSpectrumMagnitudes(transport), [0.5, 0.5]) }) test('NativeVisualizerTransport resets cached state on session changes and sample-rate updates', () => { @@ -1424,7 +1484,7 @@ test('NativeVisualizerTransport resets cached state on session changes and sampl sessionId: 7, channelCount: 2, }) - assert.deepEqual(Array.from(transport.getLatestSpectrumMagnitudes() ?? []), [1, 1]) + assert.deepEqual(readSpectrumMagnitudes(transport), [1, 1]) transport.reset({ sessionId: 8, @@ -1439,7 +1499,7 @@ test('NativeVisualizerTransport resets cached state on session changes and sampl assert.equal(calls.oscilloscopeResets >= 2, true) assert.equal(calls.spectrumResets >= 2, true) assert.equal(calls.vectorscopeResets >= 2, true) - assert.equal(transport.getLatestSpectrumMagnitudes(), null) + assert.equal(transport.fillLatestSpectrumMagnitudes(new Float32Array(4)), 0) }) test('NativeVisualizerTransport stops feeding scopes when demand is removed', () => { @@ -1467,7 +1527,71 @@ test('NativeVisualizerTransport stops feeding scopes when demand is removed', () assert.equal(calls.spectrumPushes.length, 1) assert.equal(calls.spectrumResets >= 1, true) - assert.equal(transport.getLatestSpectrumMagnitudes(), null) + assert.equal(transport.fillLatestSpectrumMagnitudes(new Float32Array(4)), 0) +}) + +test('MultibandSplitter and MultibandBuffer reuse caller-owned buffers', () => { + const splitter = new MultibandSplitter() + splitter.configure(48000) + + const splitTarget = createMultibandChunk(8) + const leftRef = splitTarget.low.left + const rightRef = splitTarget.high.right + const left = new Float32Array([1, 0.75, 0.25, -0.25, -0.75, -1, -0.5, 0.5]) + const right = new Float32Array([0.5, 0.25, -0.5, -1, -0.5, 0.25, 0.75, 1]) + + const splitCount = splitter.splitInto(left, right, splitTarget) + assert.equal(splitCount, 8) + assert.equal(splitTarget.low.left, leftRef) + assert.equal(splitTarget.high.right, rightRef) + + const buffer = new MultibandBuffer(16) + buffer.push(splitTarget, splitCount) + + const pointTarget = createMultibandChunk(8) + const pointRef = pointTarget.mid.left + const pointCount = buffer.fillPointsInto(pointTarget, 8) + assert.equal(pointCount, 8) + assert.equal(pointTarget.mid.left, pointRef) + assert.notEqual(pointTarget.low.left[0], 0) +}) + +test('LUFSMeter keeps integrated history bounded over long runs', () => { + const chunkQueue: Array<{ left: Float32Array; right: Float32Array }> = [] + const dataSource = { + getPendingLUFSMeterSamples: () => { + const drained = chunkQueue.slice() + chunkQueue.length = 0 + return drained + }, + getSampleRate: () => 48000, + isPlaying: () => true, + subscribeToSessionChanges: () => () => {}, + } + const meter = new LUFSMeter(createFakeCanvas(), { dataSource }) + const processAudio = (meter as unknown as { processAudio: () => void }).processAudio.bind(meter) + const leftChunk = new Float32Array(4800) + const rightChunk = new Float32Array(4800) + for (let index = 0; index < leftChunk.length; index += 1) { + const sample = index % 2 === 0 ? 0.35 : -0.35 + leftChunk[index] = sample + rightChunk[index] = sample + } + + for (let iteration = 0; iteration < 400; iteration += 1) { + chunkQueue.push({ + left: leftChunk, + right: rightChunk, + }) + processAudio() + } + + const histogramCounts = (meter as unknown as { integratedHistogramCounts: Uint32Array }).integratedHistogramCounts + const storedBlocks = histogramCounts.reduce((total, count) => total + count, 0) + assert.equal(Object.prototype.hasOwnProperty.call(meter, 'integratedBlockLoudness'), false) + assert.equal(histogramCounts.length > 0, true) + assert.equal(storedBlocks > 100, true) + assert.equal(Number.isFinite((meter as unknown as { integratedLUFS: number }).integratedLUFS), true) }) test('NativePolledCaptureBackend schedules immediate, backoff, and hidden-document polls and cancels on stop', async () => {