From 5dc68a5f8f62868e6fabb6eba09a4e5ffa28d5f7 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Sat, 28 Mar 2026 00:58:44 -0400 Subject: [PATCH] fix VU, fix window parenting --- src/main/index.ts | 1 - src/renderer/App.tsx | 9 + src/renderer/components/Toolbar.tsx | 29 ++- src/renderer/styles/globals.css | 5 +- src/renderer/visualizers/VUMeter.ts | 132 ++++------- src/renderer/visualizers/vuMeterBallistics.ts | 206 ++++++++++++++++++ test/renderer-helpers.test.ts | 105 +++++++++ 7 files changed, 386 insertions(+), 101 deletions(-) create mode 100644 src/renderer/visualizers/vuMeterBallistics.ts diff --git a/src/main/index.ts b/src/main/index.ts index 6005a6e..dec453e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -381,7 +381,6 @@ function createScopePopoutWindow(kind: ScopeKind, rawBounds?: WindowBounds): Bro skipTaskbar: true, autoHideMenuBar: true, title: `Prism ${kind}`, - parent: mainWindow, alwaysOnTop: mainWindow.isAlwaysOnTop(), show: false, webPreferences: { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 8bd993f..a0e94e2 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -84,6 +84,15 @@ export default function App(): JSX.Element { window.electronAPI.stopWindowMove() }, []) + useEffect(() => { + return () => { + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current) + } + window.electronAPI.stopWindowMove() + } + }, []) + // Keyboard shortcuts from main process useEffect(() => { const unsubs = [ diff --git a/src/renderer/components/Toolbar.tsx b/src/renderer/components/Toolbar.tsx index f87cd52..45a5e40 100644 --- a/src/renderer/components/Toolbar.tsx +++ b/src/renderer/components/Toolbar.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef, type CSSProperties, type JSX } from 'react' +import { useState, useEffect, useCallback, useRef, type CSSProperties, type JSX, type PointerEvent as ReactPointerEvent } from 'react' import { useSettingsStore } from '../stores/settingsStore' function SettingsIcon(): JSX.Element { @@ -180,17 +180,36 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps): }) }, [activeProfileId, profiles]) + const handleDragStart = useCallback((event: ReactPointerEvent): void => { + if (event.button !== 0) return + event.preventDefault() + event.currentTarget.setPointerCapture(event.pointerId) + window.electronAPI.startWindowMove() + }, []) + + const handleDragEnd = useCallback((event: ReactPointerEvent): void => { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId) + } + window.electronAPI.stopWindowMove() + }, []) + const activeProfile = activeProfileId ? profiles[activeProfileId] : null return (
-
-
+
void) | null = null // Meter state - private rmsL = METER_MIN_DB - private rmsR = METER_MIN_DB - private peakL = METER_MIN_DB - private peakR = METER_MIN_DB - private peakHoldL = 0 - private peakHoldR = 0 + private rmsL = VU_METER_MIN_DB + private rmsR = VU_METER_MIN_DB + private peakL = VU_METER_MIN_DB + private peakR = VU_METER_MIN_DB private correlation = 0 constructor(canvas: HTMLCanvasElement, options: VUMeterOptions = {}) { @@ -75,6 +71,7 @@ export class VUMeter { const { dataSource, frameScheduler, ...optionOverrides } = options this.options = { ...defaultOptions, ...optionOverrides } this.dataSource = dataSource ?? defaultVUMeterDataSource + this.meterBallistics = new VUMeterBallistics(this.dataSource.getSampleRate()) this.frameLoop = new VisualizerFrameLoop({ frameScheduler, shouldRun: () => this.dataSource.isPlaying(), @@ -93,13 +90,8 @@ export class VUMeter { } private resetMeters(): void { - this.rmsL = METER_MIN_DB - this.rmsR = METER_MIN_DB - this.peakL = METER_MIN_DB - this.peakR = METER_MIN_DB - this.peakHoldL = 0 - this.peakHoldR = 0 - this.correlation = 0 + this.meterBallistics.reinitialize(this.dataSource.getSampleRate()) + this.applySnapshot(this.meterBallistics.getSnapshot()) this.invalidate() } @@ -131,79 +123,31 @@ export class VUMeter { this.invalidate() } - private processAudio(): void { - const chunks = this.dataSource.getPendingVUMeterSamples() + private applySnapshot(snapshot: VUMeterSnapshot): void { + this.rmsL = snapshot.rmsLDb + this.rmsR = snapshot.rmsRDb + this.peakL = snapshot.peakLDb + this.peakR = snapshot.peakRDb + this.correlation = snapshot.correlation + } - if (!this.dataSource.isPlaying() || chunks.length === 0) { - // Decay toward silence - this.rmsL = this.rmsL * RMS_SMOOTHING + METER_MIN_DB * (1 - RMS_SMOOTHING) - this.rmsR = this.rmsR * RMS_SMOOTHING + METER_MIN_DB * (1 - RMS_SMOOTHING) - this.correlation = this.correlation * CORRELATION_SMOOTHING - this.updatePeaks() + private processAudio(): void { + const sampleRate = this.dataSource.getSampleRate() + if (Math.abs(sampleRate - this.meterBallistics.getSampleRate()) > 100) { + this.meterBallistics.reinitialize(sampleRate) + } + + if (!this.dataSource.isPlaying()) { + this.applySnapshot(this.meterBallistics.getSnapshot()) return } - // Compute RMS and correlation across all chunks - let sumSqL = 0 - let sumSqR = 0 - let sumLR = 0 - let totalSamples = 0 - - for (const chunk of chunks) { - const len = Math.min(chunk.left.length, chunk.right.length) - for (let i = 0; i < len; i++) { - const l = chunk.left[i] - const r = chunk.right[i] - sumSqL += l * l - sumSqR += r * r - sumLR += l * r - } - totalSamples += len - } - - if (totalSamples === 0) return - - const rawRmsL = Math.sqrt(sumSqL / totalSamples) - const rawRmsR = Math.sqrt(sumSqR / totalSamples) - const dbL = 20 * Math.log10(Math.max(rawRmsL, 1e-10)) - const dbR = 20 * Math.log10(Math.max(rawRmsR, 1e-10)) - - // Smooth RMS values - this.rmsL = this.rmsL * RMS_SMOOTHING + dbL * (1 - RMS_SMOOTHING) - this.rmsR = this.rmsR * RMS_SMOOTHING + dbR * (1 - RMS_SMOOTHING) - - // Compute correlation coefficient: sum(L*R) / sqrt(sum(L^2) * sum(R^2)) - const denominator = Math.sqrt(sumSqL * sumSqR) - const rawCorrelation = denominator > 1e-10 ? sumLR / denominator : 0 - this.correlation = this.correlation * CORRELATION_SMOOTHING + rawCorrelation * (1 - CORRELATION_SMOOTHING) - - this.updatePeaks() - } - - private updatePeaks(): void { - // Update peak hold for L - if (this.rmsL > this.peakL) { - this.peakL = this.rmsL - this.peakHoldL = PEAK_HOLD_FRAMES - } else if (this.peakHoldL > 0) { - this.peakHoldL-- - } else { - this.peakL = Math.max(this.peakL - PEAK_DECAY_DB_PER_FRAME, METER_MIN_DB) - } - - // Update peak hold for R - if (this.rmsR > this.peakR) { - this.peakR = this.rmsR - this.peakHoldR = PEAK_HOLD_FRAMES - } else if (this.peakHoldR > 0) { - this.peakHoldR-- - } else { - this.peakR = Math.max(this.peakR - PEAK_DECAY_DB_PER_FRAME, METER_MIN_DB) - } + const chunks = this.dataSource.getPendingVUMeterSamples() + this.applySnapshot(this.meterBallistics.process(chunks, performance.now())) } private dbToNormalized(db: number): number { - return Math.max(0, Math.min(1, (db - METER_MIN_DB) / (METER_MAX_DB - METER_MIN_DB))) + return Math.max(0, Math.min(1, (db - VU_METER_MIN_DB) / (VU_METER_MAX_DB - VU_METER_MIN_DB))) } private drawBarMode(width: number, height: number): void { @@ -403,8 +347,8 @@ export class VUMeter { x: number, y: number, _w: number, h: number, db: number ): void { - const displayDb = Math.max(METER_MIN_DB, Math.min(0, db)) - const text = displayDb <= METER_MIN_DB + 1 ? '-∞' : `${displayDb.toFixed(1)}` + const displayDb = Math.max(VU_METER_MIN_DB, Math.min(0, db)) + const text = displayDb <= VU_METER_MIN_DB + 1 ? '-∞' : `${displayDb.toFixed(1)}` ctx.fillStyle = 'rgba(255, 255, 255, 0.4)' ctx.font = `${Math.min(20, Math.max(9, h * 0.55))}px "JetBrains Mono", monospace` ctx.textAlign = 'left' @@ -417,8 +361,8 @@ export class VUMeter { x: number, y: number, w: number, h: number, db: number ): void { - const displayDb = Math.max(METER_MIN_DB, Math.min(0, db)) - const text = displayDb <= METER_MIN_DB + 1 ? '-∞' : `${displayDb.toFixed(1)}` + const displayDb = Math.max(VU_METER_MIN_DB, Math.min(0, db)) + const text = displayDb <= VU_METER_MIN_DB + 1 ? '-∞' : `${displayDb.toFixed(1)}` ctx.fillStyle = 'rgba(255, 255, 255, 0.4)' ctx.font = `${Math.min(16, Math.max(8, h * 0.5))}px "JetBrains Mono", monospace` ctx.textAlign = 'center' @@ -579,8 +523,8 @@ export class VUMeter { ctx.fillText(label, centerX, y + 4) // dB readout - const displayDb = Math.max(METER_MIN_DB, Math.min(0, rmsDb)) - const dbText = displayDb <= METER_MIN_DB + 1 ? '-∞ dB' : `${displayDb.toFixed(1)} dB` + const displayDb = Math.max(VU_METER_MIN_DB, Math.min(0, rmsDb)) + const dbText = displayDb <= VU_METER_MIN_DB + 1 ? '-∞ dB' : `${displayDb.toFixed(1)} dB` ctx.fillStyle = 'rgba(255, 255, 255, 0.35)' ctx.font = `${Math.max(9, fontSize - 1)}px "JetBrains Mono", monospace` ctx.textAlign = 'center' diff --git a/src/renderer/visualizers/vuMeterBallistics.ts b/src/renderer/visualizers/vuMeterBallistics.ts new file mode 100644 index 0000000..35323f5 --- /dev/null +++ b/src/renderer/visualizers/vuMeterBallistics.ts @@ -0,0 +1,206 @@ +export interface VUMeterStereoChunk { + left: Float32Array + right: Float32Array +} + +export interface VUMeterSnapshot { + rmsLDb: number + rmsRDb: number + peakLDb: number + peakRDb: number + correlation: number +} + +export const VU_METER_MIN_DB = -60 +export const VU_METER_MAX_DB = 0 +export const VU_INTEGRATION_WINDOW_MS = 300 +export const VU_PEAK_HOLD_MS = 750 +export const VU_PEAK_DECAY_DB_PER_SECOND = 18 + +const INITIAL_SNAPSHOT: VUMeterSnapshot = { + rmsLDb: VU_METER_MIN_DB, + rmsRDb: VU_METER_MIN_DB, + peakLDb: VU_METER_MIN_DB, + peakRDb: VU_METER_MIN_DB, + correlation: 0, +} + +function clampSampleRate(sampleRate: number): number { + return Math.max(1, Math.floor(sampleRate) || 1) +} + +function amplitudeToDb(amplitude: number): number { + const db = 20 * Math.log10(Math.max(amplitude, 1e-10)) + return Math.max(VU_METER_MIN_DB, Math.min(VU_METER_MAX_DB, db)) +} + +export class VUMeterBallistics { + private sampleRate = 48000 + private integrationWindowSamples = 1 + private sqL = new Float64Array(1) + private sqR = new Float64Array(1) + private cross = new Float64Array(1) + private writeIndex = 0 + private sampleCount = 0 + private sumSqL = 0 + private sumSqR = 0 + private sumCross = 0 + private peakHoldUntilL = 0 + private peakHoldUntilR = 0 + private lastPeakUpdateMs: number | null = null + private snapshot: VUMeterSnapshot = { ...INITIAL_SNAPSHOT } + + constructor(sampleRate: number) { + this.reinitialize(sampleRate) + } + + getSampleRate(): number { + return this.sampleRate + } + + reinitialize(sampleRate: number): void { + this.sampleRate = clampSampleRate(sampleRate) + this.integrationWindowSamples = Math.max( + 1, + Math.round((this.sampleRate * VU_INTEGRATION_WINDOW_MS) / 1000), + ) + this.sqL = new Float64Array(this.integrationWindowSamples) + this.sqR = new Float64Array(this.integrationWindowSamples) + this.cross = new Float64Array(this.integrationWindowSamples) + this.reset() + } + + reset(): void { + this.sqL.fill(0) + this.sqR.fill(0) + this.cross.fill(0) + this.writeIndex = 0 + this.sampleCount = 0 + this.sumSqL = 0 + this.sumSqR = 0 + this.sumCross = 0 + this.peakHoldUntilL = 0 + this.peakHoldUntilR = 0 + this.lastPeakUpdateMs = null + this.snapshot = { ...INITIAL_SNAPSHOT } + } + + process(chunks: readonly VUMeterStereoChunk[], nowMs: number): VUMeterSnapshot { + this.advancePeaks(nowMs) + + if (chunks.length === 0) { + return this.getSnapshot() + } + + let maxPeakL = 0 + let maxPeakR = 0 + + for (const chunk of chunks) { + const len = Math.min(chunk.left.length, chunk.right.length) + for (let index = 0; index < len; index += 1) { + const left = chunk.left[index] + const right = chunk.right[index] + const sqL = left * left + const sqR = right * right + const cross = left * right + + if (this.sampleCount === this.integrationWindowSamples) { + this.sumSqL -= this.sqL[this.writeIndex] + this.sumSqR -= this.sqR[this.writeIndex] + this.sumCross -= this.cross[this.writeIndex] + } else { + this.sampleCount += 1 + } + + this.sqL[this.writeIndex] = sqL + this.sqR[this.writeIndex] = sqR + this.cross[this.writeIndex] = cross + this.sumSqL += sqL + this.sumSqR += sqR + this.sumCross += cross + this.writeIndex = (this.writeIndex + 1) % this.integrationWindowSamples + + const absL = Math.abs(left) + const absR = Math.abs(right) + if (absL > maxPeakL) maxPeakL = absL + if (absR > maxPeakR) maxPeakR = absR + } + } + + this.maybeUpdatePeak(amplitudeToDb(maxPeakL), nowMs, 'left') + this.maybeUpdatePeak(amplitudeToDb(maxPeakR), nowMs, 'right') + this.recomputeSnapshot() + return this.getSnapshot() + } + + getSnapshot(): VUMeterSnapshot { + return { ...this.snapshot } + } + + private recomputeSnapshot(): void { + if (this.sampleCount <= 0) { + this.snapshot.rmsLDb = VU_METER_MIN_DB + this.snapshot.rmsRDb = VU_METER_MIN_DB + this.snapshot.correlation = 0 + return + } + + const meanSqL = this.sumSqL / this.sampleCount + const meanSqR = this.sumSqR / this.sampleCount + const denominator = Math.sqrt(this.sumSqL * this.sumSqR) + + this.snapshot.rmsLDb = amplitudeToDb(Math.sqrt(meanSqL)) + this.snapshot.rmsRDb = amplitudeToDb(Math.sqrt(meanSqR)) + this.snapshot.correlation = denominator > 1e-10 + ? Math.max(-1, Math.min(1, this.sumCross / denominator)) + : 0 + } + + private advancePeaks(nowMs: number): void { + if (!Number.isFinite(nowMs)) { + return + } + + if (this.lastPeakUpdateMs === null) { + this.lastPeakUpdateMs = nowMs + return + } + + if (nowMs <= this.lastPeakUpdateMs) { + return + } + + this.snapshot.peakLDb = this.applyPeakDecay(this.snapshot.peakLDb, this.peakHoldUntilL, nowMs) + this.snapshot.peakRDb = this.applyPeakDecay(this.snapshot.peakRDb, this.peakHoldUntilR, nowMs) + this.lastPeakUpdateMs = nowMs + } + + private applyPeakDecay(currentDb: number, holdUntilMs: number, nowMs: number): number { + if (this.lastPeakUpdateMs === null) { + return currentDb + } + + const decayStartMs = Math.max(this.lastPeakUpdateMs, holdUntilMs) + if (nowMs <= decayStartMs) { + return currentDb + } + + const decayAmount = ((nowMs - decayStartMs) / 1000) * VU_PEAK_DECAY_DB_PER_SECOND + return Math.max(VU_METER_MIN_DB, currentDb - decayAmount) + } + + private maybeUpdatePeak(peakDb: number, nowMs: number, channel: 'left' | 'right'): void { + if (channel === 'left') { + if (peakDb > this.snapshot.peakLDb) { + this.snapshot.peakLDb = peakDb + this.peakHoldUntilL = nowMs + VU_PEAK_HOLD_MS + } + return + } + + if (peakDb > this.snapshot.peakRDb) { + this.snapshot.peakRDb = peakDb + this.peakHoldUntilR = nowMs + VU_PEAK_HOLD_MS + } + } +} diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index a9346e5..8560674 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -6,6 +6,12 @@ import { parseColorToRgb, resolveColorToRgb, } from '../src/renderer/utils/color' +import { + VUMeterBallistics, + VU_INTEGRATION_WINDOW_MS, + VU_METER_MIN_DB, + VU_PEAK_HOLD_MS, +} from '../src/renderer/visualizers/vuMeterBallistics' import { VisualizerFrameLoop } from '../src/renderer/visualizers/visualizerFrameLoop' type WindowWithRaf = typeof globalThis & Pick @@ -52,6 +58,38 @@ function installFakeAnimationFrame(): { } } +function assertAlmostEqual(actual: number, expected: number, tolerance: number, message: string): void { + assert.ok( + Math.abs(actual - expected) <= tolerance, + `${message}: expected ${expected}, got ${actual}`, + ) +} + +function createFilledStereoChunk(valueL: number, valueR: number, length: number): { + left: Float32Array + right: Float32Array +} { + const left = new Float32Array(length) + const right = new Float32Array(length) + left.fill(valueL) + right.fill(valueR) + return { left, right } +} + +function createProgramSamples(length: number): { left: Float32Array; right: Float32Array } { + const left = new Float32Array(length) + const right = new Float32Array(length) + + for (let index = 0; index < length; index += 1) { + const base = Math.sin(index * 0.037) * 0.55 + const accent = Math.cos(index * 0.011) * 0.15 + left[index] = base + right[index] = base * 0.65 + accent + } + + return { left, right } +} + 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 }) @@ -151,3 +189,70 @@ test('VisualizerFrameLoop invalidate and stop manage subscriptions correctly', ( raf.restore() } }) + +test('VUMeterBallistics holds RMS and correlation steady when an active frame receives no chunks', () => { + const sampleRate = 48000 + const windowSamples = Math.round((sampleRate * VU_INTEGRATION_WINDOW_MS) / 1000) + const meter = new VUMeterBallistics(sampleRate) + const initial = meter.process([createFilledStereoChunk(0.5, 0.5, windowSamples)], 300) + const held = meter.process([], 316) + + assert.ok(initial.rmsLDb > -7 && initial.rmsLDb < -5) + assertAlmostEqual(held.rmsLDb, initial.rmsLDb, 1e-12, 'left RMS should hold across empty frames') + assertAlmostEqual(held.rmsRDb, initial.rmsRDb, 1e-12, 'right RMS should hold across empty frames') + assertAlmostEqual(held.correlation, initial.correlation, 1e-12, 'correlation should hold across empty frames') + assert.notEqual(held.rmsLDb, VU_METER_MIN_DB) +}) + +test('VUMeterBallistics produces the same RMS and correlation for contiguous and irregular chunk delivery', () => { + const sampleRate = 48000 + const windowSamples = Math.round((sampleRate * VU_INTEGRATION_WINDOW_MS) / 1000) + const program = createProgramSamples(windowSamples) + const contiguous = new VUMeterBallistics(sampleRate) + const irregular = new VUMeterBallistics(sampleRate) + const contiguousSnapshot = contiguous.process([program], (windowSamples / sampleRate) * 1000) + + const chunkSizes = [127, 509, 33, 2048, 401, 89, 3072, 17, 611, 1536] + let offset = 0 + let nowMs = 0 + let chunkIndex = 0 + + while (offset < windowSamples) { + const chunkSize = chunkSizes[chunkIndex % chunkSizes.length] ?? 1 + const nextOffset = Math.min(windowSamples, offset + chunkSize) + const left = program.left.slice(offset, nextOffset) + const right = program.right.slice(offset, nextOffset) + nowMs += ((nextOffset - offset) / sampleRate) * 1000 + irregular.process([{ left, right }], nowMs) + offset = nextOffset + chunkIndex += 1 + } + + const irregularSnapshot = irregular.getSnapshot() + assertAlmostEqual(irregularSnapshot.rmsLDb, contiguousSnapshot.rmsLDb, 1e-6, 'left RMS should be chunking-invariant') + assertAlmostEqual(irregularSnapshot.rmsRDb, contiguousSnapshot.rmsRDb, 1e-6, 'right RMS should be chunking-invariant') + assertAlmostEqual(irregularSnapshot.correlation, contiguousSnapshot.correlation, 1e-6, 'correlation should be chunking-invariant') +}) + +test('VUMeterBallistics tracks a transient peak independently of the RMS bar and decays it by elapsed time', () => { + const sampleRate = 48000 + const windowSamples = Math.round((sampleRate * VU_INTEGRATION_WINDOW_MS) / 1000) + const left = new Float32Array(windowSamples) + const right = new Float32Array(windowSamples) + left.fill(0.1) + right.fill(0.1) + left[Math.floor(windowSamples / 2)] = 1.0 + right[Math.floor(windowSamples / 2)] = 1.0 + + const meter = new VUMeterBallistics(sampleRate) + const initial = meter.process([{ left, right }], 300) + const beforeDecay = meter.process([], 300 + VU_PEAK_HOLD_MS - 10) + const afterDecay = meter.process([], 300 + VU_PEAK_HOLD_MS + 100) + + assert.ok(initial.rmsLDb < -19) + assert.equal(initial.peakLDb, 0) + assert.ok(initial.peakLDb > initial.rmsLDb + 10) + assert.equal(beforeDecay.peakLDb, 0) + assert.ok(afterDecay.peakLDb < beforeDecay.peakLDb) + assert.ok(afterDecay.peakLDb > -3) +})