Merge pull request #4 from Boof2015/fix-mem-leak

Fix mem leak
This commit is contained in:
Boof2015
2026-03-30 23:34:38 -04:00
committed by GitHub
15 changed files with 663 additions and 230 deletions
+46
View File
@@ -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<Napi::Number>().FloatValue();
Napi::Float32Array output = info[1].As<Napi::Float32Array>();
const size_t count = output.ElementLength();
oscilloscope.getSamplesInterpolated(output.Data(), startPos, count);
return Napi::Number::New(env, static_cast<double>(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<Napi::Float32Array>();
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<double>(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>();
Napi::Float32Array yArray = info[1].As<Napi::Float32Array>();
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<double>(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));
+36 -23
View File
@@ -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()
@@ -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)
}
}
+33
View File
@@ -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)
+5
View File
@@ -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;
+4 -1
View File
@@ -1,5 +1,8 @@
import { create } from 'zustand'
import { isVisualizerFrameTarget, type VisualizerFrameTarget } from '../../types/performance'
import {
isVisualizerFrameTarget,
type VisualizerFrameTarget,
} from '../../types/performance'
const STORAGE_KEY = 'prism:performance'
const SYNC_CHANNEL_NAME = 'prism:performance'
+6
View File
@@ -1414,6 +1414,12 @@ select {
gap: 10px;
}
.bottom-bar__inline--memory {
gap: 6px;
flex-wrap: wrap;
white-space: normal;
}
.bottom-bar__divider {
width: 1px;
align-self: stretch;
+58 -22
View File
@@ -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 => {
+23 -22
View File
@@ -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()
}
+4 -1
View File
@@ -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) {
+103 -63
View File
@@ -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)
}
}
+98 -37
View File
@@ -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()
+13 -3
View File
@@ -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
+91 -47
View File
@@ -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 }
}
+129 -5
View File
@@ -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 () => {