mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
fix VU, fix window parenting
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user