fix VU, fix window parenting

This commit is contained in:
Boof2015
2026-03-28 00:58:44 -04:00
parent fd9eb3da18
commit 5dc68a5f8f
7 changed files with 386 additions and 101 deletions
-1
View File
@@ -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: {
+9
View File
@@ -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 = [
+24 -5
View File
@@ -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<HTMLButtonElement>): void => {
if (event.button !== 0) return
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
window.electronAPI.startWindowMove()
}, [])
const handleDragEnd = useCallback((event: ReactPointerEvent<HTMLButtonElement>): void => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
window.electronAPI.stopWindowMove()
}, [])
const activeProfile = activeProfileId ? profiles[activeProfileId] : null
return (
<div className="toolbar">
<div
<button
type="button"
className="toolbar__grab"
style={{ WebkitAppRegion: 'drag' } as CSSProperties}
title="Drag to move window"
onPointerDown={handleDragStart}
onPointerUp={handleDragEnd}
onPointerCancel={handleDragEnd}
onLostPointerCapture={handleDragEnd}
title="Drag window"
aria-label="Drag window"
>
<GripIcon />
</div>
</button>
<div
className="toolbar__brand"
+4 -1
View File
@@ -105,10 +105,13 @@ select {
justify-content: center;
width: 20px;
height: 28px;
padding: 0;
border: 0;
background: transparent;
color: rgba(255, 255, 255, 0.3);
cursor: grab;
flex-shrink: 0;
-webkit-app-region: drag;
-webkit-app-region: no-drag;
}
.toolbar__grab:active {
+38 -94
View File
@@ -3,6 +3,12 @@ import { resolveColorToRgb } from '../utils/color'
import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource'
import { FrameScheduler } from './frameScheduler'
import { VisualizerFrameLoop } from './visualizerFrameLoop'
import {
VUMeterBallistics,
VU_METER_MAX_DB,
VU_METER_MIN_DB,
type VUMeterSnapshot,
} from './vuMeterBallistics'
import {
DEFAULT_VU_METER_ORIENTATION,
type VUMeterMode,
@@ -34,15 +40,6 @@ const defaultVUMeterDataSource: VUMeterDataSource = {
...defaultVisualizerSessionSource,
}
// ---- Meter constants ----
const METER_MIN_DB = -60
const METER_MAX_DB = 0
const PEAK_HOLD_FRAMES = 45 // ~0.75s at 60fps
const PEAK_DECAY_DB_PER_FRAME = 0.3
const RMS_SMOOTHING = 0.85 // exponential smoothing factor
const CORRELATION_SMOOTHING = 0.88
function colorWithAlpha(r: number, g: number, b: number, a: number): string {
return `rgba(${r}, ${g}, ${b}, ${a})`
}
@@ -55,15 +52,14 @@ export class VUMeter {
private options: ResolvedVUMeterOptions
private dataSource: VUMeterDataSource
private frameLoop: VisualizerFrameLoop
private meterBallistics: VUMeterBallistics
private unsubscribeSessionChange: (() => 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'
@@ -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
}
}
}
+105
View File
@@ -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<Window, 'requestAnimationFrame' | 'cancelAnimationFrame'>
@@ -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)
})