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 1/5] 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 () => { From 6cab2f86ee5adcae0234584a8973997ba448fce9 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:39:09 -0400 Subject: [PATCH 2/5] logging --- src/main/index.ts | 39 ++++++++ src/preload/index.ts | 17 ++++ src/renderer/App.tsx | 10 ++ src/renderer/components/BottomBar.tsx | 28 ++++++ src/renderer/env.d.ts | 2 + src/renderer/stores/performanceStore.ts | 122 +++++++++++++++++++++++- src/renderer/styles/globals.css | 13 +++ src/types/performance.ts | 12 +++ 8 files changed, 242 insertions(+), 1 deletion(-) diff --git a/src/main/index.ts b/src/main/index.ts index c45876e..314360d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -22,6 +22,7 @@ import type { ThemeLibrarySnapshot, } from '../types/theme' import { RESIZE_DIRECTIONS, type ResizeDirection } from '../types/windowResize' +import type { PerformanceMemorySnapshot } from '../types/performance' import { normalizeProfile } from '../shared/profileState' import { calculateResizedWindowBounds } from '../shared/windowResize' import { FileBackedProfileLibrary } from './profileLibrary' @@ -70,6 +71,25 @@ const POPOUT_DEFAULTS = { minHeight: 160, } +function kilobytesToMegabytes(value: number | undefined): number { + return Math.round((((value ?? 0) / 1024) * 10)) / 10 +} + +function workingSetForMetric(metric: Electron.ProcessMetric | undefined): number { + return kilobytesToMegabytes(metric?.memory.workingSetSize) +} + +function sumWorkingSet(metrics: Electron.ProcessMetric[], predicate: (metric: Electron.ProcessMetric) => boolean): number { + let totalKilobytes = 0 + for (const metric of metrics) { + if (!predicate(metric)) { + continue + } + totalKilobytes += metric.memory.workingSetSize + } + return kilobytesToMegabytes(totalKilobytes) +} + function getProfileLibrary(): FileBackedProfileLibrary { if (!profileLibrary) { profileLibrary = new FileBackedProfileLibrary( @@ -903,6 +923,25 @@ function setupIPC(): void { return getWindowFromSender(event.sender)?.isAlwaysOnTop() ?? true }) + ipcMain.handle('performance:get-memory-snapshot', async (event): Promise => { + const metrics = app.getAppMetrics() + const senderPid = event.sender.getOSProcessId() + const browserMetric = metrics.find((metric) => metric.type === 'Browser') + const rendererMetric = metrics.find((metric) => metric.pid === senderPid) + + return { + capturedAt: Date.now(), + appMb: sumWorkingSet(metrics, () => true), + mainMb: workingSetForMetric(browserMetric), + rendererMb: workingSetForMetric(rendererMetric), + rendererPrivateMb: null, + gpuMb: sumWorkingSet(metrics, (metric) => metric.type === 'GPU'), + utilityMb: sumWorkingSet(metrics, (metric) => metric.type === 'Utility'), + jsHeapUsedMb: null, + jsHeapLimitMb: null, + } + }) + ipcMain.handle('audio:get-desktop-sources', async () => { const sources = await desktopCapturer.getSources({ types: ['screen'] }) return sources.map((s) => ({ id: s.id, name: s.name })) diff --git a/src/preload/index.ts b/src/preload/index.ts index a3d6620..64b7be6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -28,9 +28,14 @@ import type { } from '../types/theme' import type { ResizeDirection } from '../types/windowResize' import type { VisualizerDSP } from '../renderer/audio/native/visualizer-dsp' +import type { PerformanceMemorySnapshot } from '../types/performance' type NativeAddonModule = VisualizerDSP & NativeCaptureAPI +function kilobytesToMegabytes(value: number | undefined): number { + return Math.round((((value ?? 0) / 1024) * 10)) / 10 +} + // Expose Electron API to renderer contextBridge.exposeInMainWorld('electronAPI', { platform: process.platform, @@ -45,6 +50,18 @@ contextBridge.exposeInMainWorld('electronAPI', { repositionWindow: (position: 'top' | 'bottom') => ipcRenderer.send('window:reposition', position), toggleAlwaysOnTop: () => ipcRenderer.send('window:toggle-always-on-top'), isAlwaysOnTop: () => ipcRenderer.invoke('window:is-always-on-top'), + getPerformanceMemorySnapshot: async () => { + const snapshot = await ipcRenderer.invoke('performance:get-memory-snapshot') as PerformanceMemorySnapshot + try { + const rendererMemory = await process.getProcessMemoryInfo() + return { + ...snapshot, + rendererPrivateMb: kilobytesToMegabytes(rendererMemory.private), + } satisfies PerformanceMemorySnapshot + } catch { + return snapshot + } + }, getDesktopSources: () => ipcRenderer.invoke('audio:get-desktop-sources') as Promise<{ id: string; name: string }[]>, getCaptureBackendSupport: async () => { const support = await ipcRenderer.invoke('capture:get-backend-support') as CaptureBackendSupport diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index f0fdcd6..1c4fa1d 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -8,6 +8,7 @@ import WindowResizeOverlay from './components/WindowResizeOverlay' import { useSettingsStore } from './stores/settingsStore' import { useAstraStore } from './stores/astraStore' import { useAudioStore } from './stores/audioStore' +import { usePerformanceStore } from './stores/performanceStore' import { useThemeStore } from './stores/themeStore' import { SCOPE_KINDS } from '../types/scope' @@ -27,6 +28,8 @@ export default function App(): JSX.Element { const guardProfileTransition = useSettingsStore((s) => s.guardProfileTransition) const importProfileFromPath = useSettingsStore((s) => s.importProfileFromPath) const updateMainWindowBounds = useSettingsStore((s) => s.updateMainWindowBounds) + const startMemoryMonitoring = usePerformanceStore((s) => s.startMemoryMonitoring) + const stopMemoryMonitoring = usePerformanceStore((s) => s.stopMemoryMonitoring) const initializeThemes = useThemeStore((s) => s.initializeThemes) const applyExternalThemeSnapshot = useThemeStore((s) => s.applyExternalThemeSnapshot) const initializeAstra = useAstraStore((s) => s.initialize) @@ -102,6 +105,13 @@ export default function App(): JSX.Element { updateMainWindowBounds, ]) + useEffect(() => { + startMemoryMonitoring() + return () => { + stopMemoryMonitoring() + } + }, [startMemoryMonitoring, stopMemoryMonitoring]) + const measuredSettingsHeight = settingsPanelHeight > 0 && bottomBarHeight > 0 ? settingsPanelHeight + bottomBarHeight : DEFAULT_SETTINGS_HEIGHT diff --git a/src/renderer/components/BottomBar.tsx b/src/renderer/components/BottomBar.tsx index 6892ea3..27d7561 100644 --- a/src/renderer/components/BottomBar.tsx +++ b/src/renderer/components/BottomBar.tsx @@ -46,6 +46,9 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): const toggleScope = useSettingsStore((s) => s.toggleScope) const frameTarget = usePerformanceStore((s) => s.frameTarget) const dockedRenderFps = usePerformanceStore((s) => s.dockedRenderFps) + const memorySample = usePerformanceStore((s) => s.memorySample) + const rendererMemoryDeltaMb = usePerformanceStore((s) => s.rendererMemoryDeltaMb) + const appMemoryDeltaMb = usePerformanceStore((s) => s.appMemoryDeltaMb) const setFrameTarget = usePerformanceStore((s) => s.setFrameTarget) const themeId = useSettingsStore((s) => s.themeId) const setThemeId = useSettingsStore((s) => s.setThemeId) @@ -155,6 +158,9 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): const trimPercent = Math.min(100, Math.max(0, ((inputGainDb + 12) / 24) * 100)) const roundedDockedRenderFps = Math.max(0, Math.round(dockedRenderFps)) const themeEntries = Object.entries(themes) + const rendererMemoryMb = memorySample?.rendererPrivateMb ?? memorySample?.rendererMb ?? 0 + const jsHeapUsedMb = memorySample?.jsHeapUsedMb ?? null + const jsHeapLimitMb = memorySample?.jsHeapLimitMb ?? null const handleThemeChange = async (value: string): Promise => { await loadTheme(value) @@ -435,6 +441,28 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): {roundedDockedRenderFps} FPS + + {memorySample ? ( +
+
+ App {memorySample.appMb.toFixed(1)} MB +
+
+ Renderer {rendererMemoryMb.toFixed(1)} MB +
+
+ ΔR {rendererMemoryDeltaMb >= 0 ? '+' : ''}{rendererMemoryDeltaMb.toFixed(1)} MB +
+
+ ΔA {appMemoryDeltaMb >= 0 ? '+' : ''}{appMemoryDeltaMb.toFixed(1)} MB +
+ {jsHeapUsedMb !== null ? ( +
+ JS {jsHeapUsedMb.toFixed(1)} MB +
+ ) : null} +
+ ) : null} diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index a1b9465..fbcebaa 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -29,6 +29,7 @@ import type { ThemeLibrarySnapshot, } from '../types/theme' import type { ResizeDirection } from '../types/windowResize' +import type { PerformanceMemorySnapshot } from '../types/performance' declare global { interface Window { @@ -47,6 +48,7 @@ declare global { repositionWindow: (position: 'top' | 'bottom') => void toggleAlwaysOnTop: () => void isAlwaysOnTop: () => Promise + getPerformanceMemorySnapshot: () => Promise getDesktopSources: () => Promise<{ id: string; name: string }[]> getCaptureBackendSupport: () => Promise getAstraConfig: () => Promise diff --git a/src/renderer/stores/performanceStore.ts b/src/renderer/stores/performanceStore.ts index 92cb727..ee27ce7 100644 --- a/src/renderer/stores/performanceStore.ts +++ b/src/renderer/stores/performanceStore.ts @@ -1,5 +1,9 @@ import { create } from 'zustand' -import { isVisualizerFrameTarget, type VisualizerFrameTarget } from '../../types/performance' +import { + isVisualizerFrameTarget, + type PerformanceMemorySnapshot, + type VisualizerFrameTarget, +} from '../../types/performance' const STORAGE_KEY = 'prism:performance' const SYNC_CHANNEL_NAME = 'prism:performance' @@ -11,8 +15,14 @@ interface PersistedPerformanceState { interface PerformanceState { frameTarget: VisualizerFrameTarget dockedRenderFps: number + memorySample: PerformanceMemorySnapshot | null + memoryHistory: PerformanceMemorySnapshot[] + rendererMemoryDeltaMb: number + appMemoryDeltaMb: number setFrameTarget: (target: VisualizerFrameTarget) => void setDockedRenderFps: (fps: number) => void + startMemoryMonitoring: () => void + stopMemoryMonitoring: () => void } interface StorageLike { @@ -66,10 +76,110 @@ function persistPerformancePreferences(target: VisualizerFrameTarget, storage = } const storedPreferences = loadPerformancePreferences() +const MEMORY_SAMPLE_INTERVAL_MS = 5000 +const MEMORY_HISTORY_LIMIT = 720 + +interface ChromiumPerformanceMemory { + jsHeapSizeLimit: number + totalJSHeapSize: number + usedJSHeapSize: number +} + +let memoryMonitorTimer: ReturnType | null = null +let memoryMonitorRefCount = 0 +let memoryMonitorInFlight = false + +function isMainWindowContext(): boolean { + if (typeof window === 'undefined') { + return false + } + + const params = new URLSearchParams(window.location.search) + return params.get('window') !== 'scope-popout' +} + +function bytesToMegabytes(value: number | undefined): number { + return Math.round((((value ?? 0) / (1024 * 1024)) * 10)) / 10 +} + +async function collectMemorySnapshot(): Promise { + if (typeof window === 'undefined' || typeof window.electronAPI?.getPerformanceMemorySnapshot !== 'function') { + return null + } + + const snapshot = await window.electronAPI.getPerformanceMemorySnapshot() + const performanceWithMemory = performance as Performance & { memory?: ChromiumPerformanceMemory } + const heap = performanceWithMemory.memory + return { + ...snapshot, + jsHeapUsedMb: heap ? bytesToMegabytes(heap.usedJSHeapSize) : null, + jsHeapLimitMb: heap ? bytesToMegabytes(heap.jsHeapSizeLimit) : null, + } +} + +async function sampleMemory(): Promise { + if (memoryMonitorInFlight) { + return + } + + memoryMonitorInFlight = true + try { + const snapshot = await collectMemorySnapshot() + if (!snapshot) { + return + } + + usePerformanceStore.setState((state) => { + const baseline = state.memoryHistory[0] ?? snapshot + const nextHistory = [...state.memoryHistory, snapshot] + if (nextHistory.length > MEMORY_HISTORY_LIMIT) { + nextHistory.splice(0, nextHistory.length - MEMORY_HISTORY_LIMIT) + } + + const baselineRendererMb = baseline.rendererPrivateMb ?? baseline.rendererMb + const currentRendererMb = snapshot.rendererPrivateMb ?? snapshot.rendererMb + + return { + memorySample: snapshot, + memoryHistory: nextHistory, + rendererMemoryDeltaMb: Math.round((currentRendererMb - baselineRendererMb) * 10) / 10, + appMemoryDeltaMb: Math.round((snapshot.appMb - baseline.appMb) * 10) / 10, + } + }) + } finally { + memoryMonitorInFlight = false + } +} + +function ensureMemoryMonitor(): void { + if (memoryMonitorTimer || !isMainWindowContext()) { + return + } + + void sampleMemory() + memoryMonitorTimer = setInterval(() => { + void sampleMemory() + }, MEMORY_SAMPLE_INTERVAL_MS) +} + +function releaseMemoryMonitor(): void { + if (memoryMonitorRefCount > 0) { + return + } + + if (memoryMonitorTimer) { + clearInterval(memoryMonitorTimer) + memoryMonitorTimer = null + } +} export const usePerformanceStore = create((set) => ({ frameTarget: storedPreferences.frameTarget, dockedRenderFps: 0, + memorySample: null, + memoryHistory: [], + rendererMemoryDeltaMb: 0, + appMemoryDeltaMb: 0, setFrameTarget: (target: VisualizerFrameTarget) => { persistPerformancePreferences(target) @@ -87,6 +197,16 @@ export const usePerformanceStore = create((set) => ({ return { ...state, dockedRenderFps: nextFps } }) }, + + startMemoryMonitoring: () => { + memoryMonitorRefCount += 1 + ensureMemoryMonitor() + }, + + stopMemoryMonitoring: () => { + memoryMonitorRefCount = Math.max(0, memoryMonitorRefCount - 1) + releaseMemoryMonitor() + }, })) let syncChannel: BroadcastChannel | null = null diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index 59297cd..a60262c 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -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; @@ -1483,6 +1489,13 @@ select { letter-spacing: 0.08em; } +.bottom-bar__memory-pill { + justify-content: center; + color: var(--text-primary); + font-family: 'JetBrains Mono', monospace; + letter-spacing: 0.06em; +} + .bottom-bar__error-text { margin-top: 0; max-width: 360px; diff --git a/src/types/performance.ts b/src/types/performance.ts index 3d85d18..9001fd7 100644 --- a/src/types/performance.ts +++ b/src/types/performance.ts @@ -2,6 +2,18 @@ export const VISUALIZER_FRAME_TARGETS = [10, 30, 60, 120, 144, 'display-sync'] a export type VisualizerFrameTarget = typeof VISUALIZER_FRAME_TARGETS[number] +export interface PerformanceMemorySnapshot { + capturedAt: number + appMb: number + mainMb: number + rendererMb: number + rendererPrivateMb: number | null + gpuMb: number + utilityMb: number + jsHeapUsedMb: number | null + jsHeapLimitMb: number | null +} + export function isVisualizerFrameTarget(value: unknown): value is VisualizerFrameTarget { return VISUALIZER_FRAME_TARGETS.includes(value as VisualizerFrameTarget) } From c1f1b6566c79a462d0d93fc98e4917f97eb88572 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:46:11 -0400 Subject: [PATCH 3/5] logging --- src/main/index.ts | 118 +++++++++++++++++++++++- src/preload/index.ts | 7 +- src/renderer/components/BottomBar.tsx | 32 +++++++ src/renderer/env.d.ts | 5 +- src/renderer/stores/performanceStore.ts | 73 ++++++++++++++- src/types/performance.ts | 12 +++ 6 files changed, 242 insertions(+), 5 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 314360d..fea68fe 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,5 +1,6 @@ import { app, BrowserWindow, desktopCapturer, dialog, ipcMain, Menu, screen, session, shell } from 'electron' import type { BrowserWindowConstructorOptions, MenuItemConstructorOptions, OpenDialogOptions, WebContents } from 'electron' +import { appendFile, mkdir, writeFile } from 'node:fs/promises' import { extname, join, resolve } from 'path' import type { AstraControlCommand, @@ -22,7 +23,7 @@ import type { ThemeLibrarySnapshot, } from '../types/theme' import { RESIZE_DIRECTIONS, type ResizeDirection } from '../types/windowResize' -import type { PerformanceMemorySnapshot } from '../types/performance' +import type { PerformanceMemoryLogRecord, PerformanceMemorySnapshot } from '../types/performance' import { normalizeProfile } from '../shared/profileState' import { calculateResizedWindowBounds } from '../shared/windowResize' import { FileBackedProfileLibrary } from './profileLibrary' @@ -70,6 +71,12 @@ const POPOUT_DEFAULTS = { minWidth: 220, minHeight: 160, } +const MEMORY_LOG_DIRECTORY_NAME = 'Prism Logs' +const MEMORY_LOG_FILE_NAME = 'memory-trace-latest.csv' +const memoryLogSessionStartedAt = Date.now() +let memoryLogInitialized = false +let memoryLogInitialization: Promise | null = null +let memoryLogWriteQueue: Promise = Promise.resolve() function kilobytesToMegabytes(value: number | undefined): number { return Math.round((((value ?? 0) / 1024) * 10)) / 10 @@ -90,6 +97,101 @@ function sumWorkingSet(metrics: Electron.ProcessMetric[], predicate: (metric: El return kilobytesToMegabytes(totalKilobytes) } +function getMemoryLogDirectory(): string { + return join(app.getPath('documents'), MEMORY_LOG_DIRECTORY_NAME) +} + +function getMemoryLogPath(): string { + return join(getMemoryLogDirectory(), MEMORY_LOG_FILE_NAME) +} + +function escapeCsvField(value: string | number | boolean | null): string { + if (value === null) { + return '' + } + + const text = String(value) + if (!/[",\n]/.test(text)) { + return text + } + + return `"${text.replace(/"/g, '""')}"` +} + +async function ensureMemoryLogFile(): Promise { + if (memoryLogInitialized) { + return + } + + if (!memoryLogInitialization) { + memoryLogInitialization = (async () => { + await mkdir(getMemoryLogDirectory(), { recursive: true }) + const header = [ + 'session_started_at_iso', + 'captured_at_iso', + 'elapsed_s', + 'app_mb', + 'main_mb', + 'renderer_mb', + 'renderer_private_mb', + 'gpu_mb', + 'utility_mb', + 'js_heap_used_mb', + 'js_heap_limit_mb', + 'renderer_delta_mb', + 'app_delta_mb', + 'frame_target', + 'docked_render_fps', + 'is_capturing', + 'capture_status', + 'visible_scopes', + 'popped_out_scopes', + ].join(',') + await writeFile(getMemoryLogPath(), `${header}\n`, 'utf8') + memoryLogInitialized = true + })().finally(() => { + memoryLogInitialization = null + }) + } + + await memoryLogInitialization +} + +function queueMemoryLogWrite(record: PerformanceMemoryLogRecord): void { + const line = [ + new Date(memoryLogSessionStartedAt).toISOString(), + new Date(record.capturedAt).toISOString(), + record.elapsedSeconds.toFixed(1), + record.appMb.toFixed(1), + record.mainMb.toFixed(1), + record.rendererMb.toFixed(1), + record.rendererPrivateMb === null ? '' : record.rendererPrivateMb.toFixed(1), + record.gpuMb.toFixed(1), + record.utilityMb.toFixed(1), + record.jsHeapUsedMb === null ? '' : record.jsHeapUsedMb.toFixed(1), + record.jsHeapLimitMb === null ? '' : record.jsHeapLimitMb.toFixed(1), + record.rendererDeltaMb.toFixed(1), + record.appDeltaMb.toFixed(1), + record.frameTarget, + Math.round(record.dockedRenderFps), + record.isCapturing, + record.captureStatus, + record.visibleScopes.join('|'), + record.poppedOutScopes.join('|'), + ] + .map((field) => escapeCsvField(field)) + .join(',') + + memoryLogWriteQueue = memoryLogWriteQueue + .then(async () => { + await ensureMemoryLogFile() + await appendFile(getMemoryLogPath(), `${line}\n`, 'utf8') + }) + .catch((error: unknown) => { + console.error('Failed to write memory trace log:', error) + }) +} + function getProfileLibrary(): FileBackedProfileLibrary { if (!profileLibrary) { profileLibrary = new FileBackedProfileLibrary( @@ -923,6 +1025,16 @@ function setupIPC(): void { return getWindowFromSender(event.sender)?.isAlwaysOnTop() ?? true }) + ipcMain.handle('performance:get-memory-log-path', async () => { + await ensureMemoryLogFile() + return getMemoryLogPath() + }) + + ipcMain.handle('performance:reveal-memory-log', async () => { + await ensureMemoryLogFile() + shell.showItemInFolder(getMemoryLogPath()) + }) + ipcMain.handle('performance:get-memory-snapshot', async (event): Promise => { const metrics = app.getAppMetrics() const senderPid = event.sender.getOSProcessId() @@ -942,6 +1054,10 @@ function setupIPC(): void { } }) + ipcMain.handle('performance:append-memory-log', async (_event, record: PerformanceMemoryLogRecord) => { + queueMemoryLogWrite(record) + }) + ipcMain.handle('audio:get-desktop-sources', async () => { const sources = await desktopCapturer.getSources({ types: ['screen'] }) return sources.map((s) => ({ id: s.id, name: s.name })) diff --git a/src/preload/index.ts b/src/preload/index.ts index 64b7be6..766bc2a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -28,7 +28,7 @@ import type { } from '../types/theme' import type { ResizeDirection } from '../types/windowResize' import type { VisualizerDSP } from '../renderer/audio/native/visualizer-dsp' -import type { PerformanceMemorySnapshot } from '../types/performance' +import type { PerformanceMemoryLogRecord, PerformanceMemorySnapshot } from '../types/performance' type NativeAddonModule = VisualizerDSP & NativeCaptureAPI @@ -50,6 +50,11 @@ contextBridge.exposeInMainWorld('electronAPI', { repositionWindow: (position: 'top' | 'bottom') => ipcRenderer.send('window:reposition', position), toggleAlwaysOnTop: () => ipcRenderer.send('window:toggle-always-on-top'), isAlwaysOnTop: () => ipcRenderer.invoke('window:is-always-on-top'), + getPerformanceMemoryLogPath: () => ipcRenderer.invoke('performance:get-memory-log-path') as Promise, + revealPerformanceMemoryLog: () => ipcRenderer.invoke('performance:reveal-memory-log') as Promise, + appendPerformanceMemoryLog: (record: PerformanceMemoryLogRecord) => { + return ipcRenderer.invoke('performance:append-memory-log', record) as Promise + }, getPerformanceMemorySnapshot: async () => { const snapshot = await ipcRenderer.invoke('performance:get-memory-snapshot') as PerformanceMemorySnapshot try { diff --git a/src/renderer/components/BottomBar.tsx b/src/renderer/components/BottomBar.tsx index 27d7561..98c16c6 100644 --- a/src/renderer/components/BottomBar.tsx +++ b/src/renderer/components/BottomBar.tsx @@ -40,6 +40,7 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): const rootRef = useRef(null) const [astraBaseUrlInput, setAstraBaseUrlInput] = useState('') const [astraTokenInput, setAstraTokenInput] = useState('') + const [memoryLogPath, setMemoryLogPath] = useState('') const hiddenScopes = useSettingsStore((s) => s.hiddenScopes) const scopeOrder = useSettingsStore((s) => s.scopeOrder) @@ -95,6 +96,26 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): setAstraTokenInput(astraState.config.token) }, [astraState.config.baseUrl, astraState.config.token]) + useEffect(() => { + let disposed = false + void (async () => { + try { + const path = await window.electronAPI.getPerformanceMemoryLogPath() + if (!disposed) { + setMemoryLogPath(path) + } + } catch { + if (!disposed) { + setMemoryLogPath('') + } + } + })() + + return () => { + disposed = true + } + }, []) + useLayoutEffect(() => { if (!onHeightChange || !rootRef.current) return @@ -440,6 +461,17 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
{roundedDockedRenderFps} FPS
+ + {memorySample ? ( diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index fbcebaa..90246e1 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -29,7 +29,7 @@ import type { ThemeLibrarySnapshot, } from '../types/theme' import type { ResizeDirection } from '../types/windowResize' -import type { PerformanceMemorySnapshot } from '../types/performance' +import type { PerformanceMemoryLogRecord, PerformanceMemorySnapshot } from '../types/performance' declare global { interface Window { @@ -48,6 +48,9 @@ declare global { repositionWindow: (position: 'top' | 'bottom') => void toggleAlwaysOnTop: () => void isAlwaysOnTop: () => Promise + getPerformanceMemoryLogPath: () => Promise + revealPerformanceMemoryLog: () => Promise + appendPerformanceMemoryLog: (record: PerformanceMemoryLogRecord) => Promise getPerformanceMemorySnapshot: () => Promise getDesktopSources: () => Promise<{ id: string; name: string }[]> getCaptureBackendSupport: () => Promise diff --git a/src/renderer/stores/performanceStore.ts b/src/renderer/stores/performanceStore.ts index ee27ce7..79ed706 100644 --- a/src/renderer/stores/performanceStore.ts +++ b/src/renderer/stores/performanceStore.ts @@ -1,6 +1,7 @@ import { create } from 'zustand' import { isVisualizerFrameTarget, + type PerformanceMemoryLogRecord, type PerformanceMemorySnapshot, type VisualizerFrameTarget, } from '../../types/performance' @@ -88,6 +89,17 @@ interface ChromiumPerformanceMemory { let memoryMonitorTimer: ReturnType | null = null let memoryMonitorRefCount = 0 let memoryMonitorInFlight = false +let contextStoreAccessorsPromise: Promise<{ + getAudioState: () => { + isCapturing: boolean + captureStatus: 'idle' | 'connecting' | 'capturing' | 'error' + } + getSettingsState: () => { + scopeOrder: string[] + hiddenScopes: Set + scopePopouts: Record + } +}> | null = null function isMainWindowContext(): boolean { if (typeof window === 'undefined') { @@ -117,6 +129,34 @@ async function collectMemorySnapshot(): Promise { + isCapturing: boolean + captureStatus: 'idle' | 'connecting' | 'capturing' | 'error' + } + getSettingsState: () => { + scopeOrder: string[] + hiddenScopes: Set + scopePopouts: Record + } +} | null> { + if (typeof window === 'undefined') { + return null + } + + if (!contextStoreAccessorsPromise) { + contextStoreAccessorsPromise = Promise.all([ + import('./audioStore'), + import('./settingsStore'), + ]).then(([audioStoreModule, settingsStoreModule]) => ({ + getAudioState: () => audioStoreModule.useAudioStore.getState(), + getSettingsState: () => settingsStoreModule.useSettingsStore.getState(), + })) + } + + return contextStoreAccessorsPromise +} + async function sampleMemory(): Promise { if (memoryMonitorInFlight) { return @@ -129,6 +169,8 @@ async function sampleMemory(): Promise { return } + const contextStoreAccessors = await getContextStoreAccessors() + let logRecord: PerformanceMemoryLogRecord | null = null usePerformanceStore.setState((state) => { const baseline = state.memoryHistory[0] ?? snapshot const nextHistory = [...state.memoryHistory, snapshot] @@ -138,14 +180,41 @@ async function sampleMemory(): Promise { const baselineRendererMb = baseline.rendererPrivateMb ?? baseline.rendererMb const currentRendererMb = snapshot.rendererPrivateMb ?? snapshot.rendererMb + const rendererDeltaMb = Math.round((currentRendererMb - baselineRendererMb) * 10) / 10 + const appDeltaMb = Math.round((snapshot.appMb - baseline.appMb) * 10) / 10 + const settingsState = contextStoreAccessors?.getSettingsState() + const audioState = contextStoreAccessors?.getAudioState() + const visibleScopes = settingsState + ? settingsState.scopeOrder.filter((kind) => !settingsState.hiddenScopes.has(kind)) + : [] + const poppedOutScopes = settingsState + ? settingsState.scopeOrder.filter((kind) => settingsState.scopePopouts[kind]?.poppedOut) + : [] + + logRecord = { + ...snapshot, + elapsedSeconds: Math.round(((snapshot.capturedAt - baseline.capturedAt) / 100)) / 10, + rendererDeltaMb, + appDeltaMb, + frameTarget: state.frameTarget, + dockedRenderFps: state.dockedRenderFps, + isCapturing: audioState?.isCapturing ?? false, + captureStatus: audioState?.captureStatus ?? 'idle', + visibleScopes, + poppedOutScopes, + } return { memorySample: snapshot, memoryHistory: nextHistory, - rendererMemoryDeltaMb: Math.round((currentRendererMb - baselineRendererMb) * 10) / 10, - appMemoryDeltaMb: Math.round((snapshot.appMb - baseline.appMb) * 10) / 10, + rendererMemoryDeltaMb: rendererDeltaMb, + appMemoryDeltaMb: appDeltaMb, } }) + + if (logRecord && typeof window !== 'undefined' && typeof window.electronAPI?.appendPerformanceMemoryLog === 'function') { + void window.electronAPI.appendPerformanceMemoryLog(logRecord) + } } finally { memoryMonitorInFlight = false } diff --git a/src/types/performance.ts b/src/types/performance.ts index 9001fd7..b03f8f7 100644 --- a/src/types/performance.ts +++ b/src/types/performance.ts @@ -14,6 +14,18 @@ export interface PerformanceMemorySnapshot { jsHeapLimitMb: number | null } +export interface PerformanceMemoryLogRecord extends PerformanceMemorySnapshot { + elapsedSeconds: number + rendererDeltaMb: number + appDeltaMb: number + frameTarget: VisualizerFrameTarget + dockedRenderFps: number + isCapturing: boolean + captureStatus: 'idle' | 'connecting' | 'capturing' | 'error' + visibleScopes: string[] + poppedOutScopes: string[] +} + export function isVisualizerFrameTarget(value: unknown): value is VisualizerFrameTarget { return VISUALIZER_FRAME_TARGETS.includes(value as VisualizerFrameTarget) } From c9e225f6ab54fc03a97f0d783b6d25151bfd9ffa Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:32:28 -0400 Subject: [PATCH 4/5] update csv logging --- src/main/index.ts | 72 ++++++++++++++++++++++++- src/renderer/stores/performanceStore.ts | 2 + src/types/performance.ts | 10 ++++ 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index fea68fe..7119ecd 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -23,7 +23,11 @@ import type { ThemeLibrarySnapshot, } from '../types/theme' import { RESIZE_DIRECTIONS, type ResizeDirection } from '../types/windowResize' -import type { PerformanceMemoryLogRecord, PerformanceMemorySnapshot } from '../types/performance' +import type { + PerformanceMemoryLogRecord, + PerformanceMemorySnapshot, + PerformanceRendererProcessSnapshot, +} from '../types/performance' import { normalizeProfile } from '../shared/profileState' import { calculateResizedWindowBounds } from '../shared/windowResize' import { FileBackedProfileLibrary } from './profileLibrary' @@ -97,6 +101,56 @@ function sumWorkingSet(metrics: Electron.ProcessMetric[], predicate: (metric: El return kilobytesToMegabytes(totalKilobytes) } +function getRendererProcessSnapshots(metrics: Electron.ProcessMetric[]): PerformanceRendererProcessSnapshot[] { + const rendererMetrics = metrics.filter((metric) => metric.type === 'Tab') + const metricsByPid = new Map(rendererMetrics.map((metric) => [metric.pid, metric])) + const snapshots: PerformanceRendererProcessSnapshot[] = [] + const seenPids = new Set() + + const pushSnapshot = (label: string, webContents: WebContents | null | undefined): void => { + if (!webContents || webContents.isDestroyed()) { + return + } + + const pid = webContents.getOSProcessId() + if (!pid || seenPids.has(pid)) { + return + } + + const metric = metricsByPid.get(pid) + if (!metric) { + return + } + + snapshots.push({ + pid, + label, + workingSetMb: workingSetForMetric(metric), + }) + seenPids.add(pid) + } + + pushSnapshot('main', mainWindow?.webContents ?? null) + for (const [kind, window] of scopePopoutWindows) { + pushSnapshot(`popout:${kind}`, window.webContents) + } + + for (const metric of rendererMetrics) { + if (seenPids.has(metric.pid)) { + continue + } + + snapshots.push({ + pid: metric.pid, + label: metric.name ? `renderer:${metric.name}` : `renderer:${metric.pid}`, + workingSetMb: workingSetForMetric(metric), + }) + } + + snapshots.sort((left, right) => left.label.localeCompare(right.label) || left.pid - right.pid) + return snapshots +} + function getMemoryLogDirectory(): string { return join(app.getPath('documents'), MEMORY_LOG_DIRECTORY_NAME) } @@ -134,11 +188,15 @@ async function ensureMemoryLogFile(): Promise { 'main_mb', 'renderer_mb', 'renderer_private_mb', + 'renderer_total_mb', + 'renderer_process_count', + 'renderer_processes', 'gpu_mb', 'utility_mb', 'js_heap_used_mb', 'js_heap_limit_mb', 'renderer_delta_mb', + 'renderer_total_delta_mb', 'app_delta_mb', 'frame_target', 'docked_render_fps', @@ -166,11 +224,17 @@ function queueMemoryLogWrite(record: PerformanceMemoryLogRecord): void { record.mainMb.toFixed(1), record.rendererMb.toFixed(1), record.rendererPrivateMb === null ? '' : record.rendererPrivateMb.toFixed(1), + record.rendererTotalMb.toFixed(1), + Math.round(record.rendererProcessCount), + record.rendererProcesses + .map((process) => `${process.label}@${process.pid}=${process.workingSetMb.toFixed(1)}`) + .join('|'), record.gpuMb.toFixed(1), record.utilityMb.toFixed(1), record.jsHeapUsedMb === null ? '' : record.jsHeapUsedMb.toFixed(1), record.jsHeapLimitMb === null ? '' : record.jsHeapLimitMb.toFixed(1), record.rendererDeltaMb.toFixed(1), + record.rendererTotalDeltaMb.toFixed(1), record.appDeltaMb.toFixed(1), record.frameTarget, Math.round(record.dockedRenderFps), @@ -1039,7 +1103,8 @@ function setupIPC(): void { const metrics = app.getAppMetrics() const senderPid = event.sender.getOSProcessId() const browserMetric = metrics.find((metric) => metric.type === 'Browser') - const rendererMetric = metrics.find((metric) => metric.pid === senderPid) + const rendererMetric = metrics.find((metric) => metric.type === 'Tab' && metric.pid === senderPid) + const rendererProcesses = getRendererProcessSnapshots(metrics) return { capturedAt: Date.now(), @@ -1047,6 +1112,9 @@ function setupIPC(): void { mainMb: workingSetForMetric(browserMetric), rendererMb: workingSetForMetric(rendererMetric), rendererPrivateMb: null, + rendererTotalMb: sumWorkingSet(metrics, (metric) => metric.type === 'Tab'), + rendererProcessCount: rendererProcesses.length, + rendererProcesses, gpuMb: sumWorkingSet(metrics, (metric) => metric.type === 'GPU'), utilityMb: sumWorkingSet(metrics, (metric) => metric.type === 'Utility'), jsHeapUsedMb: null, diff --git a/src/renderer/stores/performanceStore.ts b/src/renderer/stores/performanceStore.ts index 79ed706..e9827a4 100644 --- a/src/renderer/stores/performanceStore.ts +++ b/src/renderer/stores/performanceStore.ts @@ -181,6 +181,7 @@ async function sampleMemory(): Promise { const baselineRendererMb = baseline.rendererPrivateMb ?? baseline.rendererMb const currentRendererMb = snapshot.rendererPrivateMb ?? snapshot.rendererMb const rendererDeltaMb = Math.round((currentRendererMb - baselineRendererMb) * 10) / 10 + const rendererTotalDeltaMb = Math.round((snapshot.rendererTotalMb - baseline.rendererTotalMb) * 10) / 10 const appDeltaMb = Math.round((snapshot.appMb - baseline.appMb) * 10) / 10 const settingsState = contextStoreAccessors?.getSettingsState() const audioState = contextStoreAccessors?.getAudioState() @@ -195,6 +196,7 @@ async function sampleMemory(): Promise { ...snapshot, elapsedSeconds: Math.round(((snapshot.capturedAt - baseline.capturedAt) / 100)) / 10, rendererDeltaMb, + rendererTotalDeltaMb, appDeltaMb, frameTarget: state.frameTarget, dockedRenderFps: state.dockedRenderFps, diff --git a/src/types/performance.ts b/src/types/performance.ts index b03f8f7..d93b6e8 100644 --- a/src/types/performance.ts +++ b/src/types/performance.ts @@ -2,12 +2,21 @@ export const VISUALIZER_FRAME_TARGETS = [10, 30, 60, 120, 144, 'display-sync'] a export type VisualizerFrameTarget = typeof VISUALIZER_FRAME_TARGETS[number] +export interface PerformanceRendererProcessSnapshot { + pid: number + label: string + workingSetMb: number +} + export interface PerformanceMemorySnapshot { capturedAt: number appMb: number mainMb: number rendererMb: number rendererPrivateMb: number | null + rendererTotalMb: number + rendererProcessCount: number + rendererProcesses: PerformanceRendererProcessSnapshot[] gpuMb: number utilityMb: number jsHeapUsedMb: number | null @@ -17,6 +26,7 @@ export interface PerformanceMemorySnapshot { export interface PerformanceMemoryLogRecord extends PerformanceMemorySnapshot { elapsedSeconds: number rendererDeltaMb: number + rendererTotalDeltaMb: number appDeltaMb: number frameTarget: VisualizerFrameTarget dockedRenderFps: number From b37ed7fc112d3732de429c92af5083cbda29a28e Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Mon, 30 Mar 2026 23:34:03 -0400 Subject: [PATCH 5/5] remove logging --- src/main/index.ts | 223 ------------------------ src/preload/index.ts | 22 --- src/renderer/App.tsx | 10 -- src/renderer/components/BottomBar.tsx | 60 ------- src/renderer/env.d.ts | 5 - src/renderer/stores/performanceStore.ts | 188 -------------------- src/renderer/styles/globals.css | 7 - src/types/performance.ts | 34 ---- 8 files changed, 549 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 7119ecd..c45876e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,6 +1,5 @@ import { app, BrowserWindow, desktopCapturer, dialog, ipcMain, Menu, screen, session, shell } from 'electron' import type { BrowserWindowConstructorOptions, MenuItemConstructorOptions, OpenDialogOptions, WebContents } from 'electron' -import { appendFile, mkdir, writeFile } from 'node:fs/promises' import { extname, join, resolve } from 'path' import type { AstraControlCommand, @@ -23,11 +22,6 @@ import type { ThemeLibrarySnapshot, } from '../types/theme' import { RESIZE_DIRECTIONS, type ResizeDirection } from '../types/windowResize' -import type { - PerformanceMemoryLogRecord, - PerformanceMemorySnapshot, - PerformanceRendererProcessSnapshot, -} from '../types/performance' import { normalizeProfile } from '../shared/profileState' import { calculateResizedWindowBounds } from '../shared/windowResize' import { FileBackedProfileLibrary } from './profileLibrary' @@ -75,186 +69,6 @@ const POPOUT_DEFAULTS = { minWidth: 220, minHeight: 160, } -const MEMORY_LOG_DIRECTORY_NAME = 'Prism Logs' -const MEMORY_LOG_FILE_NAME = 'memory-trace-latest.csv' -const memoryLogSessionStartedAt = Date.now() -let memoryLogInitialized = false -let memoryLogInitialization: Promise | null = null -let memoryLogWriteQueue: Promise = Promise.resolve() - -function kilobytesToMegabytes(value: number | undefined): number { - return Math.round((((value ?? 0) / 1024) * 10)) / 10 -} - -function workingSetForMetric(metric: Electron.ProcessMetric | undefined): number { - return kilobytesToMegabytes(metric?.memory.workingSetSize) -} - -function sumWorkingSet(metrics: Electron.ProcessMetric[], predicate: (metric: Electron.ProcessMetric) => boolean): number { - let totalKilobytes = 0 - for (const metric of metrics) { - if (!predicate(metric)) { - continue - } - totalKilobytes += metric.memory.workingSetSize - } - return kilobytesToMegabytes(totalKilobytes) -} - -function getRendererProcessSnapshots(metrics: Electron.ProcessMetric[]): PerformanceRendererProcessSnapshot[] { - const rendererMetrics = metrics.filter((metric) => metric.type === 'Tab') - const metricsByPid = new Map(rendererMetrics.map((metric) => [metric.pid, metric])) - const snapshots: PerformanceRendererProcessSnapshot[] = [] - const seenPids = new Set() - - const pushSnapshot = (label: string, webContents: WebContents | null | undefined): void => { - if (!webContents || webContents.isDestroyed()) { - return - } - - const pid = webContents.getOSProcessId() - if (!pid || seenPids.has(pid)) { - return - } - - const metric = metricsByPid.get(pid) - if (!metric) { - return - } - - snapshots.push({ - pid, - label, - workingSetMb: workingSetForMetric(metric), - }) - seenPids.add(pid) - } - - pushSnapshot('main', mainWindow?.webContents ?? null) - for (const [kind, window] of scopePopoutWindows) { - pushSnapshot(`popout:${kind}`, window.webContents) - } - - for (const metric of rendererMetrics) { - if (seenPids.has(metric.pid)) { - continue - } - - snapshots.push({ - pid: metric.pid, - label: metric.name ? `renderer:${metric.name}` : `renderer:${metric.pid}`, - workingSetMb: workingSetForMetric(metric), - }) - } - - snapshots.sort((left, right) => left.label.localeCompare(right.label) || left.pid - right.pid) - return snapshots -} - -function getMemoryLogDirectory(): string { - return join(app.getPath('documents'), MEMORY_LOG_DIRECTORY_NAME) -} - -function getMemoryLogPath(): string { - return join(getMemoryLogDirectory(), MEMORY_LOG_FILE_NAME) -} - -function escapeCsvField(value: string | number | boolean | null): string { - if (value === null) { - return '' - } - - const text = String(value) - if (!/[",\n]/.test(text)) { - return text - } - - return `"${text.replace(/"/g, '""')}"` -} - -async function ensureMemoryLogFile(): Promise { - if (memoryLogInitialized) { - return - } - - if (!memoryLogInitialization) { - memoryLogInitialization = (async () => { - await mkdir(getMemoryLogDirectory(), { recursive: true }) - const header = [ - 'session_started_at_iso', - 'captured_at_iso', - 'elapsed_s', - 'app_mb', - 'main_mb', - 'renderer_mb', - 'renderer_private_mb', - 'renderer_total_mb', - 'renderer_process_count', - 'renderer_processes', - 'gpu_mb', - 'utility_mb', - 'js_heap_used_mb', - 'js_heap_limit_mb', - 'renderer_delta_mb', - 'renderer_total_delta_mb', - 'app_delta_mb', - 'frame_target', - 'docked_render_fps', - 'is_capturing', - 'capture_status', - 'visible_scopes', - 'popped_out_scopes', - ].join(',') - await writeFile(getMemoryLogPath(), `${header}\n`, 'utf8') - memoryLogInitialized = true - })().finally(() => { - memoryLogInitialization = null - }) - } - - await memoryLogInitialization -} - -function queueMemoryLogWrite(record: PerformanceMemoryLogRecord): void { - const line = [ - new Date(memoryLogSessionStartedAt).toISOString(), - new Date(record.capturedAt).toISOString(), - record.elapsedSeconds.toFixed(1), - record.appMb.toFixed(1), - record.mainMb.toFixed(1), - record.rendererMb.toFixed(1), - record.rendererPrivateMb === null ? '' : record.rendererPrivateMb.toFixed(1), - record.rendererTotalMb.toFixed(1), - Math.round(record.rendererProcessCount), - record.rendererProcesses - .map((process) => `${process.label}@${process.pid}=${process.workingSetMb.toFixed(1)}`) - .join('|'), - record.gpuMb.toFixed(1), - record.utilityMb.toFixed(1), - record.jsHeapUsedMb === null ? '' : record.jsHeapUsedMb.toFixed(1), - record.jsHeapLimitMb === null ? '' : record.jsHeapLimitMb.toFixed(1), - record.rendererDeltaMb.toFixed(1), - record.rendererTotalDeltaMb.toFixed(1), - record.appDeltaMb.toFixed(1), - record.frameTarget, - Math.round(record.dockedRenderFps), - record.isCapturing, - record.captureStatus, - record.visibleScopes.join('|'), - record.poppedOutScopes.join('|'), - ] - .map((field) => escapeCsvField(field)) - .join(',') - - memoryLogWriteQueue = memoryLogWriteQueue - .then(async () => { - await ensureMemoryLogFile() - await appendFile(getMemoryLogPath(), `${line}\n`, 'utf8') - }) - .catch((error: unknown) => { - console.error('Failed to write memory trace log:', error) - }) -} function getProfileLibrary(): FileBackedProfileLibrary { if (!profileLibrary) { @@ -1089,43 +903,6 @@ function setupIPC(): void { return getWindowFromSender(event.sender)?.isAlwaysOnTop() ?? true }) - ipcMain.handle('performance:get-memory-log-path', async () => { - await ensureMemoryLogFile() - return getMemoryLogPath() - }) - - ipcMain.handle('performance:reveal-memory-log', async () => { - await ensureMemoryLogFile() - shell.showItemInFolder(getMemoryLogPath()) - }) - - ipcMain.handle('performance:get-memory-snapshot', async (event): Promise => { - const metrics = app.getAppMetrics() - const senderPid = event.sender.getOSProcessId() - const browserMetric = metrics.find((metric) => metric.type === 'Browser') - const rendererMetric = metrics.find((metric) => metric.type === 'Tab' && metric.pid === senderPid) - const rendererProcesses = getRendererProcessSnapshots(metrics) - - return { - capturedAt: Date.now(), - appMb: sumWorkingSet(metrics, () => true), - mainMb: workingSetForMetric(browserMetric), - rendererMb: workingSetForMetric(rendererMetric), - rendererPrivateMb: null, - rendererTotalMb: sumWorkingSet(metrics, (metric) => metric.type === 'Tab'), - rendererProcessCount: rendererProcesses.length, - rendererProcesses, - gpuMb: sumWorkingSet(metrics, (metric) => metric.type === 'GPU'), - utilityMb: sumWorkingSet(metrics, (metric) => metric.type === 'Utility'), - jsHeapUsedMb: null, - jsHeapLimitMb: null, - } - }) - - ipcMain.handle('performance:append-memory-log', async (_event, record: PerformanceMemoryLogRecord) => { - queueMemoryLogWrite(record) - }) - ipcMain.handle('audio:get-desktop-sources', async () => { const sources = await desktopCapturer.getSources({ types: ['screen'] }) return sources.map((s) => ({ id: s.id, name: s.name })) diff --git a/src/preload/index.ts b/src/preload/index.ts index 766bc2a..a3d6620 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -28,14 +28,9 @@ import type { } from '../types/theme' import type { ResizeDirection } from '../types/windowResize' import type { VisualizerDSP } from '../renderer/audio/native/visualizer-dsp' -import type { PerformanceMemoryLogRecord, PerformanceMemorySnapshot } from '../types/performance' type NativeAddonModule = VisualizerDSP & NativeCaptureAPI -function kilobytesToMegabytes(value: number | undefined): number { - return Math.round((((value ?? 0) / 1024) * 10)) / 10 -} - // Expose Electron API to renderer contextBridge.exposeInMainWorld('electronAPI', { platform: process.platform, @@ -50,23 +45,6 @@ contextBridge.exposeInMainWorld('electronAPI', { repositionWindow: (position: 'top' | 'bottom') => ipcRenderer.send('window:reposition', position), toggleAlwaysOnTop: () => ipcRenderer.send('window:toggle-always-on-top'), isAlwaysOnTop: () => ipcRenderer.invoke('window:is-always-on-top'), - getPerformanceMemoryLogPath: () => ipcRenderer.invoke('performance:get-memory-log-path') as Promise, - revealPerformanceMemoryLog: () => ipcRenderer.invoke('performance:reveal-memory-log') as Promise, - appendPerformanceMemoryLog: (record: PerformanceMemoryLogRecord) => { - return ipcRenderer.invoke('performance:append-memory-log', record) as Promise - }, - getPerformanceMemorySnapshot: async () => { - const snapshot = await ipcRenderer.invoke('performance:get-memory-snapshot') as PerformanceMemorySnapshot - try { - const rendererMemory = await process.getProcessMemoryInfo() - return { - ...snapshot, - rendererPrivateMb: kilobytesToMegabytes(rendererMemory.private), - } satisfies PerformanceMemorySnapshot - } catch { - return snapshot - } - }, getDesktopSources: () => ipcRenderer.invoke('audio:get-desktop-sources') as Promise<{ id: string; name: string }[]>, getCaptureBackendSupport: async () => { const support = await ipcRenderer.invoke('capture:get-backend-support') as CaptureBackendSupport diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 1c4fa1d..f0fdcd6 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -8,7 +8,6 @@ import WindowResizeOverlay from './components/WindowResizeOverlay' import { useSettingsStore } from './stores/settingsStore' import { useAstraStore } from './stores/astraStore' import { useAudioStore } from './stores/audioStore' -import { usePerformanceStore } from './stores/performanceStore' import { useThemeStore } from './stores/themeStore' import { SCOPE_KINDS } from '../types/scope' @@ -28,8 +27,6 @@ export default function App(): JSX.Element { const guardProfileTransition = useSettingsStore((s) => s.guardProfileTransition) const importProfileFromPath = useSettingsStore((s) => s.importProfileFromPath) const updateMainWindowBounds = useSettingsStore((s) => s.updateMainWindowBounds) - const startMemoryMonitoring = usePerformanceStore((s) => s.startMemoryMonitoring) - const stopMemoryMonitoring = usePerformanceStore((s) => s.stopMemoryMonitoring) const initializeThemes = useThemeStore((s) => s.initializeThemes) const applyExternalThemeSnapshot = useThemeStore((s) => s.applyExternalThemeSnapshot) const initializeAstra = useAstraStore((s) => s.initialize) @@ -105,13 +102,6 @@ export default function App(): JSX.Element { updateMainWindowBounds, ]) - useEffect(() => { - startMemoryMonitoring() - return () => { - stopMemoryMonitoring() - } - }, [startMemoryMonitoring, stopMemoryMonitoring]) - const measuredSettingsHeight = settingsPanelHeight > 0 && bottomBarHeight > 0 ? settingsPanelHeight + bottomBarHeight : DEFAULT_SETTINGS_HEIGHT diff --git a/src/renderer/components/BottomBar.tsx b/src/renderer/components/BottomBar.tsx index 98c16c6..6892ea3 100644 --- a/src/renderer/components/BottomBar.tsx +++ b/src/renderer/components/BottomBar.tsx @@ -40,16 +40,12 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): const rootRef = useRef(null) const [astraBaseUrlInput, setAstraBaseUrlInput] = useState('') const [astraTokenInput, setAstraTokenInput] = useState('') - const [memoryLogPath, setMemoryLogPath] = useState('') const hiddenScopes = useSettingsStore((s) => s.hiddenScopes) const scopeOrder = useSettingsStore((s) => s.scopeOrder) const toggleScope = useSettingsStore((s) => s.toggleScope) const frameTarget = usePerformanceStore((s) => s.frameTarget) const dockedRenderFps = usePerformanceStore((s) => s.dockedRenderFps) - const memorySample = usePerformanceStore((s) => s.memorySample) - const rendererMemoryDeltaMb = usePerformanceStore((s) => s.rendererMemoryDeltaMb) - const appMemoryDeltaMb = usePerformanceStore((s) => s.appMemoryDeltaMb) const setFrameTarget = usePerformanceStore((s) => s.setFrameTarget) const themeId = useSettingsStore((s) => s.themeId) const setThemeId = useSettingsStore((s) => s.setThemeId) @@ -96,26 +92,6 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): setAstraTokenInput(astraState.config.token) }, [astraState.config.baseUrl, astraState.config.token]) - useEffect(() => { - let disposed = false - void (async () => { - try { - const path = await window.electronAPI.getPerformanceMemoryLogPath() - if (!disposed) { - setMemoryLogPath(path) - } - } catch { - if (!disposed) { - setMemoryLogPath('') - } - } - })() - - return () => { - disposed = true - } - }, []) - useLayoutEffect(() => { if (!onHeightChange || !rootRef.current) return @@ -179,9 +155,6 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): const trimPercent = Math.min(100, Math.max(0, ((inputGainDb + 12) / 24) * 100)) const roundedDockedRenderFps = Math.max(0, Math.round(dockedRenderFps)) const themeEntries = Object.entries(themes) - const rendererMemoryMb = memorySample?.rendererPrivateMb ?? memorySample?.rendererMb ?? 0 - const jsHeapUsedMb = memorySample?.jsHeapUsedMb ?? null - const jsHeapLimitMb = memorySample?.jsHeapLimitMb ?? null const handleThemeChange = async (value: string): Promise => { await loadTheme(value) @@ -461,40 +434,7 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
{roundedDockedRenderFps} FPS
- - - - {memorySample ? ( -
-
- App {memorySample.appMb.toFixed(1)} MB -
-
- Renderer {rendererMemoryMb.toFixed(1)} MB -
-
- ΔR {rendererMemoryDeltaMb >= 0 ? '+' : ''}{rendererMemoryDeltaMb.toFixed(1)} MB -
-
- ΔA {appMemoryDeltaMb >= 0 ? '+' : ''}{appMemoryDeltaMb.toFixed(1)} MB -
- {jsHeapUsedMb !== null ? ( -
- JS {jsHeapUsedMb.toFixed(1)} MB -
- ) : null} -
- ) : null} diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index 90246e1..a1b9465 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -29,7 +29,6 @@ import type { ThemeLibrarySnapshot, } from '../types/theme' import type { ResizeDirection } from '../types/windowResize' -import type { PerformanceMemoryLogRecord, PerformanceMemorySnapshot } from '../types/performance' declare global { interface Window { @@ -48,10 +47,6 @@ declare global { repositionWindow: (position: 'top' | 'bottom') => void toggleAlwaysOnTop: () => void isAlwaysOnTop: () => Promise - getPerformanceMemoryLogPath: () => Promise - revealPerformanceMemoryLog: () => Promise - appendPerformanceMemoryLog: (record: PerformanceMemoryLogRecord) => Promise - getPerformanceMemorySnapshot: () => Promise getDesktopSources: () => Promise<{ id: string; name: string }[]> getCaptureBackendSupport: () => Promise getAstraConfig: () => Promise diff --git a/src/renderer/stores/performanceStore.ts b/src/renderer/stores/performanceStore.ts index e9827a4..d3c0cf8 100644 --- a/src/renderer/stores/performanceStore.ts +++ b/src/renderer/stores/performanceStore.ts @@ -1,8 +1,6 @@ import { create } from 'zustand' import { isVisualizerFrameTarget, - type PerformanceMemoryLogRecord, - type PerformanceMemorySnapshot, type VisualizerFrameTarget, } from '../../types/performance' @@ -16,14 +14,8 @@ interface PersistedPerformanceState { interface PerformanceState { frameTarget: VisualizerFrameTarget dockedRenderFps: number - memorySample: PerformanceMemorySnapshot | null - memoryHistory: PerformanceMemorySnapshot[] - rendererMemoryDeltaMb: number - appMemoryDeltaMb: number setFrameTarget: (target: VisualizerFrameTarget) => void setDockedRenderFps: (fps: number) => void - startMemoryMonitoring: () => void - stopMemoryMonitoring: () => void } interface StorageLike { @@ -77,180 +69,10 @@ function persistPerformancePreferences(target: VisualizerFrameTarget, storage = } const storedPreferences = loadPerformancePreferences() -const MEMORY_SAMPLE_INTERVAL_MS = 5000 -const MEMORY_HISTORY_LIMIT = 720 - -interface ChromiumPerformanceMemory { - jsHeapSizeLimit: number - totalJSHeapSize: number - usedJSHeapSize: number -} - -let memoryMonitorTimer: ReturnType | null = null -let memoryMonitorRefCount = 0 -let memoryMonitorInFlight = false -let contextStoreAccessorsPromise: Promise<{ - getAudioState: () => { - isCapturing: boolean - captureStatus: 'idle' | 'connecting' | 'capturing' | 'error' - } - getSettingsState: () => { - scopeOrder: string[] - hiddenScopes: Set - scopePopouts: Record - } -}> | null = null - -function isMainWindowContext(): boolean { - if (typeof window === 'undefined') { - return false - } - - const params = new URLSearchParams(window.location.search) - return params.get('window') !== 'scope-popout' -} - -function bytesToMegabytes(value: number | undefined): number { - return Math.round((((value ?? 0) / (1024 * 1024)) * 10)) / 10 -} - -async function collectMemorySnapshot(): Promise { - if (typeof window === 'undefined' || typeof window.electronAPI?.getPerformanceMemorySnapshot !== 'function') { - return null - } - - const snapshot = await window.electronAPI.getPerformanceMemorySnapshot() - const performanceWithMemory = performance as Performance & { memory?: ChromiumPerformanceMemory } - const heap = performanceWithMemory.memory - return { - ...snapshot, - jsHeapUsedMb: heap ? bytesToMegabytes(heap.usedJSHeapSize) : null, - jsHeapLimitMb: heap ? bytesToMegabytes(heap.jsHeapSizeLimit) : null, - } -} - -async function getContextStoreAccessors(): Promise<{ - getAudioState: () => { - isCapturing: boolean - captureStatus: 'idle' | 'connecting' | 'capturing' | 'error' - } - getSettingsState: () => { - scopeOrder: string[] - hiddenScopes: Set - scopePopouts: Record - } -} | null> { - if (typeof window === 'undefined') { - return null - } - - if (!contextStoreAccessorsPromise) { - contextStoreAccessorsPromise = Promise.all([ - import('./audioStore'), - import('./settingsStore'), - ]).then(([audioStoreModule, settingsStoreModule]) => ({ - getAudioState: () => audioStoreModule.useAudioStore.getState(), - getSettingsState: () => settingsStoreModule.useSettingsStore.getState(), - })) - } - - return contextStoreAccessorsPromise -} - -async function sampleMemory(): Promise { - if (memoryMonitorInFlight) { - return - } - - memoryMonitorInFlight = true - try { - const snapshot = await collectMemorySnapshot() - if (!snapshot) { - return - } - - const contextStoreAccessors = await getContextStoreAccessors() - let logRecord: PerformanceMemoryLogRecord | null = null - usePerformanceStore.setState((state) => { - const baseline = state.memoryHistory[0] ?? snapshot - const nextHistory = [...state.memoryHistory, snapshot] - if (nextHistory.length > MEMORY_HISTORY_LIMIT) { - nextHistory.splice(0, nextHistory.length - MEMORY_HISTORY_LIMIT) - } - - const baselineRendererMb = baseline.rendererPrivateMb ?? baseline.rendererMb - const currentRendererMb = snapshot.rendererPrivateMb ?? snapshot.rendererMb - const rendererDeltaMb = Math.round((currentRendererMb - baselineRendererMb) * 10) / 10 - const rendererTotalDeltaMb = Math.round((snapshot.rendererTotalMb - baseline.rendererTotalMb) * 10) / 10 - const appDeltaMb = Math.round((snapshot.appMb - baseline.appMb) * 10) / 10 - const settingsState = contextStoreAccessors?.getSettingsState() - const audioState = contextStoreAccessors?.getAudioState() - const visibleScopes = settingsState - ? settingsState.scopeOrder.filter((kind) => !settingsState.hiddenScopes.has(kind)) - : [] - const poppedOutScopes = settingsState - ? settingsState.scopeOrder.filter((kind) => settingsState.scopePopouts[kind]?.poppedOut) - : [] - - logRecord = { - ...snapshot, - elapsedSeconds: Math.round(((snapshot.capturedAt - baseline.capturedAt) / 100)) / 10, - rendererDeltaMb, - rendererTotalDeltaMb, - appDeltaMb, - frameTarget: state.frameTarget, - dockedRenderFps: state.dockedRenderFps, - isCapturing: audioState?.isCapturing ?? false, - captureStatus: audioState?.captureStatus ?? 'idle', - visibleScopes, - poppedOutScopes, - } - - return { - memorySample: snapshot, - memoryHistory: nextHistory, - rendererMemoryDeltaMb: rendererDeltaMb, - appMemoryDeltaMb: appDeltaMb, - } - }) - - if (logRecord && typeof window !== 'undefined' && typeof window.electronAPI?.appendPerformanceMemoryLog === 'function') { - void window.electronAPI.appendPerformanceMemoryLog(logRecord) - } - } finally { - memoryMonitorInFlight = false - } -} - -function ensureMemoryMonitor(): void { - if (memoryMonitorTimer || !isMainWindowContext()) { - return - } - - void sampleMemory() - memoryMonitorTimer = setInterval(() => { - void sampleMemory() - }, MEMORY_SAMPLE_INTERVAL_MS) -} - -function releaseMemoryMonitor(): void { - if (memoryMonitorRefCount > 0) { - return - } - - if (memoryMonitorTimer) { - clearInterval(memoryMonitorTimer) - memoryMonitorTimer = null - } -} export const usePerformanceStore = create((set) => ({ frameTarget: storedPreferences.frameTarget, dockedRenderFps: 0, - memorySample: null, - memoryHistory: [], - rendererMemoryDeltaMb: 0, - appMemoryDeltaMb: 0, setFrameTarget: (target: VisualizerFrameTarget) => { persistPerformancePreferences(target) @@ -268,16 +90,6 @@ export const usePerformanceStore = create((set) => ({ return { ...state, dockedRenderFps: nextFps } }) }, - - startMemoryMonitoring: () => { - memoryMonitorRefCount += 1 - ensureMemoryMonitor() - }, - - stopMemoryMonitoring: () => { - memoryMonitorRefCount = Math.max(0, memoryMonitorRefCount - 1) - releaseMemoryMonitor() - }, })) let syncChannel: BroadcastChannel | null = null diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index a60262c..43bbdf7 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -1489,13 +1489,6 @@ select { letter-spacing: 0.08em; } -.bottom-bar__memory-pill { - justify-content: center; - color: var(--text-primary); - font-family: 'JetBrains Mono', monospace; - letter-spacing: 0.06em; -} - .bottom-bar__error-text { margin-top: 0; max-width: 360px; diff --git a/src/types/performance.ts b/src/types/performance.ts index d93b6e8..3d85d18 100644 --- a/src/types/performance.ts +++ b/src/types/performance.ts @@ -2,40 +2,6 @@ export const VISUALIZER_FRAME_TARGETS = [10, 30, 60, 120, 144, 'display-sync'] a export type VisualizerFrameTarget = typeof VISUALIZER_FRAME_TARGETS[number] -export interface PerformanceRendererProcessSnapshot { - pid: number - label: string - workingSetMb: number -} - -export interface PerformanceMemorySnapshot { - capturedAt: number - appMb: number - mainMb: number - rendererMb: number - rendererPrivateMb: number | null - rendererTotalMb: number - rendererProcessCount: number - rendererProcesses: PerformanceRendererProcessSnapshot[] - gpuMb: number - utilityMb: number - jsHeapUsedMb: number | null - jsHeapLimitMb: number | null -} - -export interface PerformanceMemoryLogRecord extends PerformanceMemorySnapshot { - elapsedSeconds: number - rendererDeltaMb: number - rendererTotalDeltaMb: number - appDeltaMb: number - frameTarget: VisualizerFrameTarget - dockedRenderFps: number - isCapturing: boolean - captureStatus: 'idle' | 'connecting' | 'capturing' | 'error' - visibleScopes: string[] - poppedOutScopes: string[] -} - export function isVisualizerFrameTarget(value: unknown): value is VisualizerFrameTarget { return VISUALIZER_FRAME_TARGETS.includes(value as VisualizerFrameTarget) }