diff --git a/package.json b/package.json index c0d0479..ebd0431 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "preview": "electron-vite preview", "typecheck": "tsc --noEmit", "test:audio-router": "node scripts/run-audio-router-tests.mjs", + "test:renderer-helpers": "node scripts/run-renderer-helper-tests.mjs", "build:native": "cd native && node-gyp rebuild", "rebuild:native": "node -e \"const e=require('electron/package.json').version;const a=process.arch;const{execSync}=require('child_process');execSync('node-gyp rebuild --target='+e+' --arch='+a+' --dist-url=https://electronjs.org/headers',{stdio:'inherit',cwd:'native'})\"", "postinstall": "npm run rebuild:native || echo 'Native build failed, will use JS fallback'", diff --git a/scripts/run-renderer-helper-tests.mjs b/scripts/run-renderer-helper-tests.mjs new file mode 100644 index 0000000..341a4a0 --- /dev/null +++ b/scripts/run-renderer-helper-tests.mjs @@ -0,0 +1,44 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawn } from 'node:child_process' +import { build } from 'esbuild' + +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))) +const tempDir = await mkdtemp(join(tmpdir(), 'prism-renderer-helper-tests-')) +const bundledTestPath = join(tempDir, 'renderer-helpers.test.mjs') +const entryPoint = join(rootDir, 'test', 'renderer-helpers.test.ts') + +let exitCode = 1 + +try { + await build({ + entryPoints: [entryPoint], + outfile: bundledTestPath, + bundle: true, + platform: 'node', + format: 'esm', + target: 'node23', + sourcemap: 'inline', + }) + + exitCode = await new Promise((resolve) => { + const child = spawn(process.execPath, ['--test', bundledTestPath], { + stdio: 'inherit', + cwd: rootDir, + }) + + child.on('exit', (code) => { + resolve(code ?? 1) + }) + + child.on('error', () => { + resolve(1) + }) + }) +} finally { + await rm(tempDir, { recursive: true, force: true }) +} + +process.exit(exitCode) diff --git a/src/renderer/components/ScopeModule.tsx b/src/renderer/components/ScopeModule.tsx index e678814..9185b50 100644 --- a/src/renderer/components/ScopeModule.tsx +++ b/src/renderer/components/ScopeModule.tsx @@ -9,11 +9,13 @@ import { Spectrogram, type SpectrogramDataSource } from '../visualizers/Spectrog import { VUMeter, type VUMeterDataSource } from '../visualizers/VUMeter' import { LUFSMeter, type LUFSMeterDataSource } from '../visualizers/LUFSMeter' import { Waveform, type WaveformDataSource } from '../visualizers/Waveform' +import type { FrameScheduler } from '../visualizers/frameScheduler' interface ScopeModuleProps { scopeKind: ScopeKind lineColor?: string settings?: ScopeSettings[ScopeKind] + frameScheduler?: FrameScheduler dataSource?: | SpectrumAnalyzerDataSource | OscilloscopeDataSource @@ -90,9 +92,10 @@ function createVisualizer( canvas: HTMLCanvasElement, mySettings: ScopeSettings[ScopeKind], lineColor: string, + frameScheduler?: FrameScheduler, dataSource?: ScopeModuleProps['dataSource'], ): Visualizer | null { - const opts = scopeSettingsToOptions(scopeKind, mySettings, lineColor) + const opts = { ...scopeSettingsToOptions(scopeKind, mySettings, lineColor), frameScheduler } switch (scopeKind) { case 'spectrum': return new SpectrumAnalyzer(canvas, { @@ -138,6 +141,7 @@ export default function ScopeModule({ scopeKind, lineColor = '#38bdf8', settings, + frameScheduler, dataSource, }: ScopeModuleProps): JSX.Element { const containerRef = useRef(null) @@ -154,7 +158,7 @@ export default function ScopeModule({ if (!canvas) return initializedRef.current = false - const viz = createVisualizer(scopeKind, canvas, mySettings, lineColor, dataSource) + const viz = createVisualizer(scopeKind, canvas, mySettings, lineColor, frameScheduler, dataSource) if (!viz) return visualizerRef.current = viz @@ -168,14 +172,18 @@ export default function ScopeModule({ visualizerRef.current = null initializedRef.current = false } - }, [dataSource, scopeKind]) + }, [dataSource, frameScheduler, scopeKind]) // Push settings + lineColor changes to live visualizer (skip initial — constructor already handled it) useEffect(() => { if (!visualizerRef.current || !initializedRef.current) return - const opts = { ...scopeSettingsToOptions(scopeKind, mySettings, lineColor), ...(dataSource ? { dataSource } : {}) } + const opts = { + ...scopeSettingsToOptions(scopeKind, mySettings, lineColor), + frameScheduler, + ...(dataSource ? { dataSource } : {}), + } visualizerRef.current.setOptions(opts) - }, [dataSource, lineColor, mySettings, scopeKind]) + }, [dataSource, frameScheduler, lineColor, mySettings, scopeKind]) // ResizeObserver for DPI-aware canvas sizing useEffect(() => { diff --git a/src/renderer/components/ScopePopoutBridge.tsx b/src/renderer/components/ScopePopoutBridge.tsx index 6e902c9..195e3a2 100644 --- a/src/renderer/components/ScopePopoutBridge.tsx +++ b/src/renderer/components/ScopePopoutBridge.tsx @@ -65,6 +65,7 @@ export default function ScopePopoutBridge(): null { [hiddenScopes, scopePopouts], ) const activePopoutKindsRef = useRef(activePopoutKinds) + const sessionStateRef = useRef(audioRouter.getSessionState()) useEffect(() => { activePopoutKindsRef.current = activePopoutKinds @@ -155,23 +156,48 @@ export default function ScopePopoutBridge(): null { let frameId = 0 const flushFrame = (): void => { + frameId = 0 + if (!sessionStateRef.current.capturing || activePopoutKindsRef.current.length === 0) { + return + } + for (const kind of activePopoutKindsRef.current) { const batch = flushScopeAudioBatch(kind) if (batch.length > 0) { window.electronAPI.sendScopePopoutAudio(kind, batch) } } + frameId = window.requestAnimationFrame(flushFrame) } - if (activePopoutKinds.length > 0) { - frameId = window.requestAnimationFrame(flushFrame) + const syncFlushLoop = (): void => { + const shouldRun = sessionStateRef.current.capturing && activePopoutKindsRef.current.length > 0 + if (!shouldRun) { + if (frameId) { + window.cancelAnimationFrame(frameId) + frameId = 0 + } + return + } + + if (!frameId) { + frameId = window.requestAnimationFrame(flushFrame) + } } + sessionStateRef.current = audioRouter.getSessionState() + const unsubscribeSession = audioRouter.subscribeToSessionChanges((state) => { + sessionStateRef.current = state + syncFlushLoop() + }) + syncFlushLoop() + return () => { if (frameId) { window.cancelAnimationFrame(frameId) } + unsubscribeSession() } }, [activePopoutKinds]) diff --git a/src/renderer/components/Strip.tsx b/src/renderer/components/Strip.tsx index 48018fa..d068e88 100644 --- a/src/renderer/components/Strip.tsx +++ b/src/renderer/components/Strip.tsx @@ -6,6 +6,7 @@ import type { WindowBounds } from '../../types/popout' import ScopeModule from './ScopeModule' import { buildAnalyzerGridTemplateColumns } from '../analyzerLayout' import { audioRouter } from '../audio/AudioRouter' +import { FrameScheduler } from '../visualizers/frameScheduler' export default function Strip(): JSX.Element { const scopeOrder = useSettingsStore((s) => s.scopeOrder) @@ -15,6 +16,7 @@ export default function Strip(): JSX.Element { const setScopeWidthWeight = useSettingsStore((s) => s.setScopeWidthWeight) const popOutScope = useSettingsStore((s) => s.popOutScope) const accent = useThemeStore((s) => s.accent) + const frameScheduler = useMemo(() => new FrameScheduler(), []) const stripRef = useRef(null) const gridRef = useRef(null) const scopeRefs = useRef>>({}) @@ -214,6 +216,7 @@ export default function Strip(): JSX.Element { ))} diff --git a/src/renderer/popouts/ScopePopoutWindow.tsx b/src/renderer/popouts/ScopePopoutWindow.tsx index c1ffb75..b87374d 100644 --- a/src/renderer/popouts/ScopePopoutWindow.tsx +++ b/src/renderer/popouts/ScopePopoutWindow.tsx @@ -6,6 +6,7 @@ import ScopeModule from '../components/ScopeModule' import ScopeSettingsSection from '../components/ScopeSettingsSection' import { applyAccentToDOM } from '../stores/themeStore' import { ScopePopoutDataSource } from './ScopePopoutDataSource' +import { FrameScheduler } from '../visualizers/frameScheduler' function PopInIcon(): JSX.Element { return ( @@ -47,6 +48,7 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps) const [snapshot, setSnapshot] = useState | null>(null) const [miniSettingsOpen, setMiniSettingsOpen] = useState(false) const prevMiniSettingsOpenRef = useRef(false) + const frameScheduler = useMemo(() => new FrameScheduler(), []) const dataSource = useMemo(() => new ScopePopoutDataSource(scopeKind), [scopeKind]) useEffect(() => { @@ -209,6 +211,7 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps) scopeKind={scopeKind} lineColor={effectiveAccent} settings={effectiveSettings} + frameScheduler={frameScheduler} dataSource={dataSource} /> diff --git a/src/renderer/utils/color.ts b/src/renderer/utils/color.ts new file mode 100644 index 0000000..66fc2af --- /dev/null +++ b/src/renderer/utils/color.ts @@ -0,0 +1,82 @@ +export interface RgbColor { + r: number + g: number + b: number +} + +export const DEFAULT_VISUALIZER_TINT: RgbColor = { r: 56, g: 189, b: 248 } + +function clampByte(value: number): number { + return Math.max(0, Math.min(255, value)) +} + +function parseRgbToken(value: string): number | null { + const token = value.trim() + if (!token) return null + + if (token.endsWith('%')) { + const percent = Number.parseFloat(token.slice(0, -1)) + if (!Number.isFinite(percent)) return null + return clampByte((percent / 100) * 255) + } + + const numeric = Number.parseFloat(token) + if (!Number.isFinite(numeric)) return null + return clampByte(numeric) +} + +export function parseColorToRgb(color: string): RgbColor | null { + const normalizedColor = color.trim() + + if (normalizedColor.startsWith('#')) { + const hex = normalizedColor.slice(1) + const normalizedHex = hex.length === 3 + ? hex.split('').map((ch) => `${ch}${ch}`).join('') + : hex + + if (normalizedHex.length === 6) { + const r = Number.parseInt(normalizedHex.slice(0, 2), 16) + const g = Number.parseInt(normalizedHex.slice(2, 4), 16) + const b = Number.parseInt(normalizedHex.slice(4, 6), 16) + if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null + return { r, g, b } + } + } + + const rgbMatch = /^rgba?\((.*)\)$/i.exec(normalizedColor) + if (!rgbMatch) return null + + const rawBody = rgbMatch[1]?.trim() + if (!rawBody) return null + + const body = rawBody.includes('/') + ? rawBody.split('/')[0]?.trim() ?? '' + : rawBody + if (!body) return null + + const tokens = body.includes(',') + ? body.split(',').map((token) => token.trim()) + : body.split(/\s+/).filter(Boolean) + if (tokens.length < 3) return null + + const r = parseRgbToken(tokens[0]) + const g = parseRgbToken(tokens[1]) + const b = parseRgbToken(tokens[2]) + if (r === null || g === null || b === null) return null + + return { + r: Math.round(r), + g: Math.round(g), + b: Math.round(b), + } +} + +export function colorToRgbChannels(color: string): string | null { + const rgb = parseColorToRgb(color) + if (!rgb) return null + return `${rgb.r}, ${rgb.g}, ${rgb.b}` +} + +export function resolveColorToRgb(color: string, fallback: RgbColor = DEFAULT_VISUALIZER_TINT): RgbColor { + return parseColorToRgb(color) ?? fallback +} diff --git a/src/renderer/visualizers/LUFSMeter.ts b/src/renderer/visualizers/LUFSMeter.ts index d42a0ec..652ce3b 100644 --- a/src/renderer/visualizers/LUFSMeter.ts +++ b/src/renderer/visualizers/LUFSMeter.ts @@ -1,6 +1,9 @@ import { audioRouter } from '../audio/AudioRouter' import type { LUFSMeterMode } from '../../types/lufsmeter' +import { resolveColorToRgb } from '../utils/color' import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource' +import { FrameScheduler } from './frameScheduler' +import { VisualizerFrameLoop } from './visualizerFrameLoop' export interface LUFSMeterDataSource extends VisualizerSessionSource { getPendingLUFSMeterSamples: () => Array<{ left: Float32Array; right: Float32Array }> @@ -10,9 +13,10 @@ export interface LUFSMeterOptions { mode?: LUFSMeterMode lineColor?: string dataSource?: LUFSMeterDataSource + frameScheduler?: FrameScheduler } -type ResolvedLUFSMeterOptions = Required> +type ResolvedLUFSMeterOptions = Required> const defaultOptions: ResolvedLUFSMeterOptions = { mode: 'bar', @@ -97,17 +101,6 @@ function applyBiquad(coeffs: BiquadCoeffs, state: BiquadState, input: number): n return output } -// ---- Color utilities ---- - -function parseHexColor(hex: string): [number, number, number] { - const h = hex.replace('#', '') - return [ - parseInt(h.substring(0, 2), 16) || 56, - parseInt(h.substring(2, 4), 16) || 189, - parseInt(h.substring(4, 6), 16) || 248, - ] -} - // ---- LUFS Meter class ---- export class LUFSMeter { @@ -115,8 +108,7 @@ export class LUFSMeter { private ctx: CanvasRenderingContext2D private options: ResolvedLUFSMeterOptions private dataSource: LUFSMeterDataSource - private animationId: number | null = null - private isRunning = false + private frameLoop: VisualizerFrameLoop // K-weighting filter state (per channel, two stages) private preFilterL = createBiquadState() @@ -143,6 +135,7 @@ export class LUFSMeter { private momentaryLUFS = METER_MIN_LUFS private shortTermLUFS = METER_MIN_LUFS private integratedLUFS = METER_MIN_LUFS + private unsubscribeSessionChange: (() => void) | null = null constructor(canvas: HTMLCanvasElement, options: LUFSMeterOptions = {}) { this.canvas = canvas @@ -150,11 +143,26 @@ export class LUFSMeter { if (!ctx) throw new Error('Could not get 2D context') this.ctx = ctx - const { dataSource, ...optionOverrides } = options + const { dataSource, frameScheduler, ...optionOverrides } = options this.options = { ...defaultOptions, ...optionOverrides } this.dataSource = dataSource ?? defaultLUFSMeterDataSource + this.frameLoop = new VisualizerFrameLoop({ + frameScheduler, + shouldRun: () => this.dataSource.isPlaying(), + onFrame: this.drawFrame, + }) this.initRingBuffer(this.dataSource.getSampleRate()) + this.subscribeToSessionChanges() + } + + private subscribeToSessionChanges(): void { + if (this.unsubscribeSessionChange) { + this.unsubscribeSessionChange() + } + this.unsubscribeSessionChange = this.dataSource.subscribeToSessionChanges(() => { + this.resetMeters() + }) } private initRingBuffer(sampleRate: number): void { @@ -184,32 +192,36 @@ export class LUFSMeter { this.preFilterR = createBiquadState() this.rlbFilterL = createBiquadState() this.rlbFilterR = createBiquadState() + this.invalidate() } setOptions(options: Partial): void { - const { dataSource, ...optionUpdates } = options + const { dataSource, frameScheduler: _frameScheduler, ...optionUpdates } = options this.options = { ...this.options, ...optionUpdates } - if (dataSource) { + if (dataSource && dataSource !== this.dataSource) { this.dataSource = dataSource + this.subscribeToSessionChanges() + this.initRingBuffer(this.dataSource.getSampleRate()) + this.resetMeters() } + this.invalidate() } start(): void { - if (this.isRunning) return - this.isRunning = true - this.draw() + this.frameLoop.start() } stop(): void { - this.isRunning = false - if (this.animationId !== null) { - cancelAnimationFrame(this.animationId) - this.animationId = null - } + this.frameLoop.stop() + } + + invalidate(): void { + this.frameLoop.invalidate() } resize(): void { // Canvas resize handled externally + this.invalidate() } private processAudio(): void { @@ -341,30 +353,25 @@ export class LUFSMeter { return Math.max(METER_MIN_LUFS, 10 * Math.log10(finalSum / afterRelative.length)) } - private draw = (): void => { - if (!this.isRunning) return - - this.processAudio() - + private drawFrame = (): void => { const { canvas, ctx } = this const width = canvas.width const height = canvas.height if (width <= 0 || height <= 0) { - this.animationId = requestAnimationFrame(this.draw) return } + this.processAudio() + ctx.clearRect(0, 0, width, height) this.drawBars(width, height) - - this.animationId = requestAnimationFrame(this.draw) } private drawBars(width: number, height: number): void { const ctx = this.ctx - const [tintR, tintG, tintB] = parseHexColor(this.options.lineColor) + const { r: tintR, g: tintG, b: tintB } = resolveColorToRgb(this.options.lineColor) const dpr = window.devicePixelRatio || 1 const padding = Math.round(8 * dpr) @@ -461,5 +468,10 @@ export class LUFSMeter { dispose(): void { this.stop() + this.frameLoop.dispose() + if (this.unsubscribeSessionChange) { + this.unsubscribeSessionChange() + this.unsubscribeSessionChange = null + } } } diff --git a/src/renderer/visualizers/Oscilloscope.ts b/src/renderer/visualizers/Oscilloscope.ts index 025fbd5..79b42a2 100644 --- a/src/renderer/visualizers/Oscilloscope.ts +++ b/src/renderer/visualizers/Oscilloscope.ts @@ -5,7 +5,10 @@ import { isNativeAvailable } from '../audio/native' import { getNormalizedOscilloscopeDisplaySamples } from '../audio/native/oscilloscopeDisplaySamples' +import { colorToRgbChannels } from '../utils/color' import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource' +import { FrameScheduler } from './frameScheduler' +import { VisualizerFrameLoop } from './visualizerFrameLoop' export interface OscilloscopeDataSource extends VisualizerSessionSource { getPendingOscilloscopeSamples: () => Float32Array[] @@ -20,9 +23,10 @@ export interface OscilloscopeOptions { pitchLock?: boolean underfillEnabled?: boolean dataSource?: OscilloscopeDataSource + frameScheduler?: FrameScheduler } -type ResolvedOscilloscopeOptions = Required> +type ResolvedOscilloscopeOptions = Required> const defaultOptions: ResolvedOscilloscopeOptions = { lineColor: '#00ffff', @@ -31,7 +35,7 @@ const defaultOptions: ResolvedOscilloscopeOptions = { showGrid: true, gridColor: 'rgba(255, 255, 255, 0.1)', pitchLock: true, - underfillEnabled: false + underfillEnabled: false, } const defaultOscilloscopeDataSource: OscilloscopeDataSource = { @@ -39,45 +43,9 @@ const defaultOscilloscopeDataSource: OscilloscopeDataSource = { ...defaultVisualizerSessionSource, } -function parseRgbChannels(color: string): string | null { - const normalized = color.trim() - - if (normalized.startsWith('#')) { - const hex = normalized.slice(1) - const expanded = hex.length === 3 - ? hex.split('').map((ch) => `${ch}${ch}`).join('') - : hex - - if (expanded.length === 6) { - const r = Number.parseInt(expanded.slice(0, 2), 16) - const g = Number.parseInt(expanded.slice(2, 4), 16) - const b = Number.parseInt(expanded.slice(4, 6), 16) - if (!Number.isNaN(r) && !Number.isNaN(g) && !Number.isNaN(b)) { - return `${r}, ${g}, ${b}` - } - } - } - - const rgbMatch = /^rgba?\((.*)\)$/i.exec(normalized) - if (!rgbMatch) return null - - const tokens = rgbMatch[1] - ?.split(',') - .map((token) => token.trim()) - .filter(Boolean) ?? [] - if (tokens.length < 3) return null - - const r = Number.parseFloat(tokens[0]) - const g = Number.parseFloat(tokens[1]) - const b = Number.parseFloat(tokens[2]) - if (!Number.isFinite(r) || !Number.isFinite(g) || !Number.isFinite(b)) return null - - return `${Math.max(0, Math.min(255, Math.round(r)))}, ${Math.max(0, Math.min(255, Math.round(g)))}, ${Math.max(0, Math.min(255, Math.round(b)))}` -} - function highContrastUnderfillColor(accentColor: string, alpha: number): string { const safeAlpha = Math.max(0, Math.min(1, alpha)) - const channels = parseRgbChannels(accentColor) + const channels = colorToRgbChannels(accentColor) const nearWhite = { r: 245, g: 248, b: 252 } const tintAmount = 0.18 @@ -105,25 +73,43 @@ export class Oscilloscope { private ctx: CanvasRenderingContext2D private options: ResolvedOscilloscopeOptions private dataSource: OscilloscopeDataSource - private animationId: number | null = null - private isRunning: boolean = false - private nativeInitialized: boolean = false - private samplesReceived: number = 0 - private lastSampleRate: number = 0 + private frameLoop: VisualizerFrameLoop + private nativeInitialized = false + private samplesReceived = 0 + private lastSampleRate = 0 private unsubscribeSessionChange: (() => void) | null = null - private static readonly WARMUP_SAMPLES = 4096 // Need ~4K samples before pitch detection is reliable + private staticLayerCanvas: HTMLCanvasElement + private staticLayerCtx: CanvasRenderingContext2D + private staticLayerKey = '' + private static readonly WARMUP_SAMPLES = 4096 constructor(canvas: HTMLCanvasElement, options: OscilloscopeOptions = {}) { this.canvas = canvas const ctx = canvas.getContext('2d') if (!ctx) throw new Error('Could not get 2D context') this.ctx = ctx - const { dataSource, ...optionOverrides } = options + + const { dataSource, frameScheduler, ...optionOverrides } = options this.options = { ...defaultOptions, ...optionOverrides } this.dataSource = dataSource ?? defaultOscilloscopeDataSource + this.frameLoop = new VisualizerFrameLoop({ + frameScheduler, + shouldRun: () => this.dataSource.isPlaying(), + onFrame: this.drawFrame, + }) + this.staticLayerCanvas = document.createElement('canvas') + const staticLayerCtx = this.staticLayerCanvas.getContext('2d') + if (!staticLayerCtx) throw new Error('Could not get offscreen 2D context') + this.staticLayerCtx = staticLayerCtx - // Initialize native module this.initNative() + this.subscribeToSessionChanges() + } + + private subscribeToSessionChanges(): void { + if (this.unsubscribeSessionChange) { + this.unsubscribeSessionChange() + } this.unsubscribeSessionChange = this.dataSource.subscribeToSessionChanges(() => { this.reset() }) @@ -131,9 +117,6 @@ export class Oscilloscope { private initNative(): void { if (isNativeAvailable() && !this.nativeInitialized) { - // Initialize with current sample rate, but set lastSampleRate to 0 so - // updateSampleRateIfNeeded() always fires once the real capture rate is known. - // This prevents stale-rate issues when capture starts after initialization. const sampleRate = this.dataSource.getSampleRate() this.lastSampleRate = 0 nativeOscilloscope.setSampleRate(sampleRate) @@ -146,7 +129,6 @@ export class Oscilloscope { } } - // Update sample rate if AudioContext changes (called from draw loop) private updateSampleRateIfNeeded(): void { if (!isNativeAvailable()) return const currentRate = this.dataSource.getSampleRate() @@ -159,123 +141,102 @@ export class Oscilloscope { } setOptions(options: Partial): void { - const { dataSource, ...optionUpdates } = options + const { dataSource, frameScheduler: _frameScheduler, ...optionUpdates } = options this.options = { ...this.options, ...optionUpdates } - if (dataSource) { + if (dataSource && dataSource !== this.dataSource) { this.dataSource = dataSource + this.subscribeToSessionChanges() + this.reset() } - // Update native module settings if (isNativeAvailable() && options.pitchLock !== undefined) { nativeOscilloscope.setPitchLock(options.pitchLock) } + + this.staticLayerKey = '' + this.invalidate() } start(): void { - if (this.isRunning) return - this.isRunning = true - this.draw() + this.frameLoop.start() } stop(): void { - this.isRunning = false - if (this.animationId !== null) { - cancelAnimationFrame(this.animationId) - this.animationId = null - } + this.frameLoop.stop() } - resize(): void { } + invalidate(): void { + this.frameLoop.invalidate() + } - private draw = (): void => { - if (!this.isRunning) return + resize(): void { + this.staticLayerKey = '' + this.invalidate() + } + private drawFrame = (): void => { const { canvas, ctx, options } = this const width = canvas.width const height = canvas.height const dpr = window.devicePixelRatio || 1 - ctx.clearRect(0, 0, width, height) + if (width <= 0 || height <= 0) return - if (options.backgroundColor !== 'transparent') { - ctx.fillStyle = options.backgroundColor - ctx.fillRect(0, 0, width, height) - } + this.renderStaticLayer() - if (options.showGrid) { - this.drawGrid() - } - - // Native C++ is being fed continuously by AudioWorklet via AudioEngine if (!isNativeAvailable()) { console.error('Oscilloscope: Native DSP required') - this.animationId = requestAnimationFrame(this.draw) return } - // Check if sample rate needs updating (AudioContext may have initialized after us) this.updateSampleRateIfNeeded() if (!this.dataSource.isPlaying()) { - this.animationId = requestAnimationFrame(this.draw) return } - // Flush ALL pending samples to native C++ (prevents sample loss) const pendingSamples = this.dataSource.getPendingOscilloscopeSamples() for (const chunk of pendingSamples) { nativeOscilloscope.pushSamples(chunk) this.samplesReceived += chunk.length } - // Skip pitch-locked processing during warmup period. - // Bypass mode (pitchLock=false) should render immediately using a moving window. if (options.pitchLock && this.samplesReceived < Oscilloscope.WARMUP_SAMPLES) { - // During warmup, just show a static waveform or grid - this.animationId = requestAnimationFrame(this.draw) return } - // Process using circular buffer - searches backwards from writePos const result = nativeOscilloscope.processContinuous() if (!result) { - this.animationId = requestAnimationFrame(this.draw) return } const samplesToShow = result.samplesToShow let triggerIndex = result.triggerIndex - // In bypass mode, ignore trigger locking and follow the live write head. - // This produces free-running oscilloscope motion without touching pitch-lock behavior. if (!options.pitchLock) { const writePos = result.writePos triggerIndex = writePos - samplesToShow while (triggerIndex < 0) triggerIndex += OSCILLOSCOPE_BUFFER_SIZE } - // Get samples from circular buffer for rendering const renderData = nativeOscilloscope.getSamples(Math.floor(triggerIndex), samplesToShow) if (!renderData || renderData.length === 0) { - this.animationId = requestAnimationFrame(this.draw) return } - // Draw waveform (data already starts at trigger point) const sliceWidth = width / samplesToShow const centerY = height / 2 - const VISUAL_GAIN = 1.8 + 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 * VISUAL_GAIN) / 2) * height + const y = ((1 - sample * visualGain) / 2) * height const x = i * sliceWidth points.push({ x, y }) } if (points.length < 2) { - this.animationId = requestAnimationFrame(this.draw) return } @@ -312,11 +273,46 @@ export class Oscilloscope { ctx.lineTo(points[i].x, points[i].y) } ctx.stroke() - this.animationId = requestAnimationFrame(this.draw) } - private drawGrid(): void { - const { ctx, canvas, options } = this + private renderStaticLayer(): void { + this.ensureStaticLayer() + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height) + this.ctx.drawImage(this.staticLayerCanvas, 0, 0) + } + + private ensureStaticLayer(): void { + const { canvas, options } = this + const key = [ + canvas.width, + canvas.height, + options.backgroundColor, + options.showGrid, + options.gridColor, + ].join(':') + + if (this.staticLayerKey === key) { + return + } + + this.staticLayerCanvas.width = canvas.width + this.staticLayerCanvas.height = canvas.height + this.staticLayerCtx.clearRect(0, 0, canvas.width, canvas.height) + + if (options.backgroundColor !== 'transparent') { + this.staticLayerCtx.fillStyle = options.backgroundColor + this.staticLayerCtx.fillRect(0, 0, canvas.width, canvas.height) + } + + if (options.showGrid) { + this.drawGrid(this.staticLayerCtx) + } + + this.staticLayerKey = key + } + + private drawGrid(ctx: CanvasRenderingContext2D): void { + const { canvas, options } = this const width = canvas.width const height = canvas.height const dpr = window.devicePixelRatio || 1 @@ -348,31 +344,29 @@ export class Oscilloscope { } } - // Reset state for new track (call on track change to re-enable fast pitch convergence) reset(): void { - // Reset JS warmup state this.samplesReceived = 0 - // Reset native state (clears buffers, resets pitch tracking, re-enables fast smoothing) if (isNativeAvailable()) { nativeOscilloscope.reset() } + + this.invalidate() } dispose(): void { this.stop() + this.frameLoop.dispose() if (this.unsubscribeSessionChange) { this.unsubscribeSessionChange() this.unsubscribeSessionChange = null } - // Reset native module state if (isNativeAvailable()) { nativeOscilloscope.reset() } - // Reset warmup state this.samplesReceived = 0 this.lastSampleRate = 0 } diff --git a/src/renderer/visualizers/Spectrogram.ts b/src/renderer/visualizers/Spectrogram.ts index a712007..99a8f4e 100644 --- a/src/renderer/visualizers/Spectrogram.ts +++ b/src/renderer/visualizers/Spectrogram.ts @@ -1,5 +1,8 @@ import { audioRouter } from '../audio/AudioRouter' +import { resolveColorToRgb } from '../utils/color' import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource' +import { FrameScheduler } from './frameScheduler' +import { VisualizerFrameLoop } from './visualizerFrameLoop' import { DEFAULT_SPECTROGRAM_CLARITY_MODE, DEFAULT_SPECTROGRAM_SCALE_MODE, @@ -27,9 +30,10 @@ export interface SpectrogramOptions { colorScheme?: 'heat' | 'mono' lineColor?: string dataSource?: SpectrogramDataSource + frameScheduler?: FrameScheduler } -type ResolvedSpectrogramOptions = Required> +type ResolvedSpectrogramOptions = Required> interface SpectrogramClarityProfile { gamma: number // contrast curve exponent @@ -251,15 +255,6 @@ function buildHeatLUT(): Uint8Array { const HEAT_LUT = buildHeatLUT() -function parseHexColor(hex: string): [number, number, number] { - const normalized = hex.replace('#', '') - return [ - Number.parseInt(normalized.substring(0, 2), 16) || 56, - Number.parseInt(normalized.substring(2, 4), 16) || 189, - Number.parseInt(normalized.substring(4, 6), 16) || 248, - ] -} - // Zero-pad FFT for finer frequency resolution (visual interpolation) const FFT_PAD_FACTOR = 4 @@ -268,8 +263,7 @@ export class Spectrogram { private ctx: CanvasRenderingContext2D private options: ResolvedSpectrogramOptions private dataSource: SpectrogramDataSource - private animationId: number | null = null - private isRunning = false + private frameLoop: VisualizerFrameLoop private fftRe: Float32Array private fftIm: Float32Array @@ -293,6 +287,7 @@ export class Spectrogram { private lastMinFrequency = 0 private lastMaxFrequency = 0 private lastScaleMode: SpectrogramScaleMode | null = null + private unsubscribeSessionChange: (() => void) | null = null constructor(canvas: HTMLCanvasElement, options: SpectrogramOptions = {}) { this.canvas = canvas @@ -300,9 +295,14 @@ export class Spectrogram { if (!ctx) throw new Error('Could not get 2D context') this.ctx = ctx - const { dataSource, ...optionOverrides } = options + const { dataSource, frameScheduler, ...optionOverrides } = options this.options = resolveOptions(defaultOptions, optionOverrides) this.dataSource = dataSource ?? defaultSpectrogramDataSource + this.frameLoop = new VisualizerFrameLoop({ + frameScheduler, + shouldRun: () => this.dataSource.isPlaying(), + onFrame: this.drawFrame, + }) const windowSize = this.options.fftSize const paddedSize = windowSize * FFT_PAD_FACTOR @@ -319,20 +319,34 @@ export class Spectrogram { this.ctx.imageSmoothingEnabled = false this.waterfallCtx.imageSmoothingEnabled = false + + this.subscribeToSessionChanges() + } + + private subscribeToSessionChanges(): void { + if (this.unsubscribeSessionChange) { + this.unsubscribeSessionChange() + } + this.unsubscribeSessionChange = this.dataSource.subscribeToSessionChanges(() => { + this.resetDisplay() + }) } private resetDisplay(): void { this.sampleBufferPos = 0 this.waterfallCtx.clearRect(0, 0, this.waterfallCanvas.width, this.waterfallCanvas.height) + this.invalidate() } setOptions(options: Partial): void { - const { dataSource, ...optionUpdates } = options + const { dataSource, frameScheduler: _frameScheduler, ...optionUpdates } = options const previousOptions = this.options this.options = resolveOptions(previousOptions, optionUpdates) - if (dataSource) { + if (dataSource && dataSource !== this.dataSource) { this.dataSource = dataSource + this.subscribeToSessionChanges() + this.resetDisplay() } if (this.options.fftSize !== previousOptions.fftSize) { @@ -347,25 +361,26 @@ export class Spectrogram { } else if (this.options.scaleMode !== previousOptions.scaleMode) { this.resetDisplay() } + + this.invalidate() } start(): void { - if (this.isRunning) return - this.isRunning = true - this.draw() + this.frameLoop.start() } stop(): void { - this.isRunning = false - if (this.animationId !== null) { - cancelAnimationFrame(this.animationId) - this.animationId = null - } + this.frameLoop.stop() + } + + invalidate(): void { + this.frameLoop.invalidate() } resize(): void { this.lastWidth = 0 this.lastHeight = 0 + this.invalidate() } private ensureColumnBuffers(height: number): void { @@ -500,9 +515,9 @@ export class Spectrogram { if (!this.columnImageData) return const imageData = this.columnImageData.data - const [tintR, tintG, tintB] = this.options.colorScheme === 'mono' - ? parseHexColor(this.options.lineColor) - : [0, 0, 0] + const { r: tintR, g: tintG, b: tintB } = this.options.colorScheme === 'mono' + ? resolveColorToRgb(this.options.lineColor) + : { r: 0, g: 0, b: 0 } for (let row = 0; row < values.length; row += 1) { const intensity = Math.max(0, Math.min(1, values[row])) @@ -600,13 +615,10 @@ export class Spectrogram { return values } - private draw = (): void => { - if (!this.isRunning) return - + private drawFrame = (): void => { const width = this.canvas.width const height = this.canvas.height if (width <= 0 || height <= 0) { - this.animationId = requestAnimationFrame(this.draw) return } @@ -648,7 +660,6 @@ export class Spectrogram { // Freeze waterfall in place instead of blanking this.ctx.clearRect(0, 0, width, height) this.ctx.drawImage(this.waterfallCanvas, 0, 0) - this.animationId = requestAnimationFrame(this.draw) return } @@ -680,10 +691,14 @@ export class Spectrogram { this.ctx.clearRect(0, 0, width, height) this.ctx.drawImage(this.waterfallCanvas, 0, 0) - this.animationId = requestAnimationFrame(this.draw) } dispose(): void { this.stop() + this.frameLoop.dispose() + if (this.unsubscribeSessionChange) { + this.unsubscribeSessionChange() + this.unsubscribeSessionChange = null + } } } diff --git a/src/renderer/visualizers/SpectrumAnalyzer.ts b/src/renderer/visualizers/SpectrumAnalyzer.ts index c6957de..1a56319 100644 --- a/src/renderer/visualizers/SpectrumAnalyzer.ts +++ b/src/renderer/visualizers/SpectrumAnalyzer.ts @@ -1,6 +1,8 @@ import { audioRouter } from '../audio/AudioRouter' import { spectrum as nativeSpectrum, isNativeAvailable } from '../audio/native' import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource' +import { FrameScheduler } from './frameScheduler' +import { VisualizerFrameLoop } from './visualizerFrameLoop' import { DEFAULT_SPECTRUM_TILT_DB_PER_OCTAVE, DEFAULT_SPECTRUM_HEATMAP_TILT_DB_PER_OCTAVE, @@ -17,7 +19,7 @@ export interface SpectrumAnalyzerOptions { lineWidth?: number fillGradient?: boolean heatmapFill?: boolean - gradientColors?: string[] // Bottom to top + gradientColors?: string[] backgroundColor?: string showGrid?: boolean gridColor?: string @@ -32,11 +34,11 @@ export interface SpectrumAnalyzerOptions { tiltReferenceHz?: number fftSize?: number dataSource?: SpectrumAnalyzerDataSource + frameScheduler?: FrameScheduler } -type ResolvedSpectrumAnalyzerOptions = Required> +type ResolvedSpectrumAnalyzerOptions = Required> -// ---- Heat LUT for heatmap fill (same palette as Spectrogram) ---- type HeatStop = { at: number; color: [number, number, number] } const HEAT_STOPS: readonly HeatStop[] = [ { at: 0, color: [0, 0, 0] }, @@ -52,9 +54,14 @@ function buildHeatLUT(): Uint8Array { const lut = new Uint8Array(256 * 3) for (let i = 0; i < 256; i++) { const t = i / 255 - let s = HEAT_STOPS[0], e = HEAT_STOPS[HEAT_STOPS.length - 1] + let s = HEAT_STOPS[0] + let e = HEAT_STOPS[HEAT_STOPS.length - 1] for (let si = 0; si < HEAT_STOPS.length - 1; si++) { - if (t <= HEAT_STOPS[si + 1].at) { s = HEAT_STOPS[si]; e = HEAT_STOPS[si + 1]; break } + if (t <= HEAT_STOPS[si + 1].at) { + s = HEAT_STOPS[si] + e = HEAT_STOPS[si + 1] + break + } } const a = Math.max(0, Math.min(1, (t - s.at) / Math.max(1e-6, e.at - s.at))) lut[i * 3] = Math.round(s.color[0] + (e.color[0] - s.color[0]) * a) @@ -63,6 +70,7 @@ function buildHeatLUT(): Uint8Array { } return lut } + const HEAT_LUT = buildHeatLUT() const HEATMAP_GAMMA = 1.4 @@ -84,7 +92,7 @@ const defaultOptions: ResolvedSpectrumAnalyzerOptions = { tiltDbPerOctave: DEFAULT_SPECTRUM_TILT_DB_PER_OCTAVE, heatmapTiltDbPerOctave: DEFAULT_SPECTRUM_HEATMAP_TILT_DB_PER_OCTAVE, tiltReferenceHz: 1000, - fftSize: 2048 + fftSize: 2048, } const defaultSpectrumDataSource: SpectrumAnalyzerDataSource = { @@ -97,12 +105,13 @@ export class SpectrumAnalyzer { private ctx: CanvasRenderingContext2D private options: ResolvedSpectrumAnalyzerOptions private dataSource: SpectrumAnalyzerDataSource - private animationId: number | null = null - private isRunning: boolean = false - private nativeInitialized: boolean = false - private sampleRate: number = 48000 - private lastSampleRate: number = 0 - private lastFrequencyData: Float32Array | null = null + private frameLoop: VisualizerFrameLoop + private nativeInitialized = false + private sampleRate = 48000 + private lastSampleRate = 0 + private staticLayerCanvas: HTMLCanvasElement + private staticLayerCtx: CanvasRenderingContext2D + private staticLayerKey = '' private unsubscribeSessionChange: (() => void) | null = null constructor(canvas: HTMLCanvasElement, options: SpectrumAnalyzerOptions = {}) { @@ -110,7 +119,8 @@ export class SpectrumAnalyzer { const ctx = canvas.getContext('2d') if (!ctx) throw new Error('Could not get 2D context') this.ctx = ctx - const { dataSource, ...optionOverrides } = options + + const { dataSource, frameScheduler, ...optionOverrides } = options this.options = { ...defaultOptions, ...optionOverrides, @@ -122,9 +132,24 @@ export class SpectrumAnalyzer { ), } this.dataSource = dataSource ?? defaultSpectrumDataSource + this.frameLoop = new VisualizerFrameLoop({ + frameScheduler, + shouldRun: () => this.dataSource.isPlaying(), + onFrame: this.drawFrame, + }) + this.staticLayerCanvas = document.createElement('canvas') + const staticLayerCtx = this.staticLayerCanvas.getContext('2d') + if (!staticLayerCtx) throw new Error('Could not get offscreen 2D context') + this.staticLayerCtx = staticLayerCtx - // Initialize native module this.initNative() + this.subscribeToSessionChanges() + } + + private subscribeToSessionChanges(): void { + if (this.unsubscribeSessionChange) { + this.unsubscribeSessionChange() + } this.unsubscribeSessionChange = this.dataSource.subscribeToSessionChanges(() => { this.resetState() }) @@ -133,7 +158,7 @@ export class SpectrumAnalyzer { private initNative(): void { if (isNativeAvailable() && !this.nativeInitialized) { this.sampleRate = Math.max(1, this.dataSource.getSampleRate()) - this.lastSampleRate = 0 // Force updateSampleRateIfNeeded() to fire once real rate is known + this.lastSampleRate = 0 nativeSpectrum.setFFTSize(this.options.fftSize) nativeSpectrum.setSampleRate(this.sampleRate) nativeSpectrum.setSmoothing(this.getNativeSmoothing()) @@ -167,12 +192,11 @@ export class SpectrumAnalyzer { } this.sampleRate = Math.max(1, this.dataSource.getSampleRate()) this.lastSampleRate = 0 - this.lastFrequencyData = null + this.invalidate() } setOptions(options: Partial): void { - const { dataSource, ...optionUpdates } = options - const prevFftSize = this.options.fftSize + const { dataSource, frameScheduler: _frameScheduler, ...optionUpdates } = options const nextOptions = { ...this.options, ...optionUpdates } if (optionUpdates.tiltDbPerOctave !== undefined) { nextOptions.tiltDbPerOctave = clampSpectrumTiltDbPerOctave(optionUpdates.tiltDbPerOctave) @@ -181,45 +205,46 @@ export class SpectrumAnalyzer { nextOptions.heatmapTiltDbPerOctave = clampSpectrumHeatmapTiltDbPerOctave(optionUpdates.heatmapTiltDbPerOctave) } this.options = nextOptions - if (dataSource) { + if (dataSource && dataSource !== this.dataSource) { this.dataSource = dataSource + this.subscribeToSessionChanges() + this.resetState() } - // Update native module settings — only when values actually change to avoid buffer resets if (isNativeAvailable()) { - if (options.fftSize !== undefined && options.fftSize !== prevFftSize) { + if (options.fftSize !== undefined) { nativeSpectrum.setFFTSize(options.fftSize) } - if (options.smoothing !== undefined || (options.fftSize !== undefined && options.fftSize !== prevFftSize)) { + if (options.smoothing !== undefined || options.fftSize !== undefined) { nativeSpectrum.setSmoothing(this.getNativeSmoothing()) } } + + this.staticLayerKey = '' + this.invalidate() } start(): void { - if (this.isRunning) return - this.isRunning = true - this.draw() + this.frameLoop.start() } stop(): void { - this.isRunning = false - if (this.animationId !== null) { - cancelAnimationFrame(this.animationId) - this.animationId = null - } + this.frameLoop.stop() + } + + invalidate(): void { + this.frameLoop.invalidate() } resize(): void { - // Canvas resize is handled externally + this.staticLayerKey = '' + this.invalidate() } - // Linear interpolation helper private lerp(a: number, b: number, t: number): number { return a + (b - a) * t } - // Get interpolated value from frequency data private getInterpolatedValue(data: Float32Array, index: number): number { const i0 = Math.floor(index) const i1 = Math.min(i0 + 1, data.length - 1) @@ -282,83 +307,52 @@ export class SpectrumAnalyzer { return monoData } - private draw = (): void => { - if (!this.isRunning) return - + private drawFrame = (): void => { const { canvas, ctx, options } = this const width = canvas.width const height = canvas.height const dpr = window.devicePixelRatio || 1 if (width <= 0 || height <= 0) { - this.animationId = requestAnimationFrame(this.draw) return } - // Get frequency data from native FFT if (!isNativeAvailable()) { console.error('SpectrumAnalyzer: Native DSP required') - this.animationId = requestAnimationFrame(this.draw) return } this.updateSampleRateIfNeeded() - // Clear canvas - ctx.clearRect(0, 0, width, height) - - // Draw background if not transparent - if (options.backgroundColor !== 'transparent') { - ctx.fillStyle = options.backgroundColor - ctx.fillRect(0, 0, width, height) - } - - // Draw grid const nyquist = this.sampleRate / 2 const minFrequency = Math.max(1, Math.min(options.minFrequency, nyquist)) const maxFrequency = Math.max(minFrequency + 1, Math.min(options.maxFrequency, nyquist)) - if (options.showGrid) { - this.drawGrid(minFrequency, maxFrequency) - } if (!this.dataSource.isPlaying()) { this.dataSource.getPendingSpectrumSamples() nativeSpectrum.reset() - this.lastFrequencyData = null - this.animationId = requestAnimationFrame(this.draw) + this.renderStaticLayer(minFrequency, maxFrequency) return } const pendingSpectrum = this.dataSource.getPendingSpectrumSamples() const monoData = this.mergePendingSpectrumChunks(pendingSpectrum) - let frequencyData = this.lastFrequencyData - - if (monoData) { - const nativeResult = nativeSpectrum.process(monoData) - if (!nativeResult) { - this.animationId = requestAnimationFrame(this.draw) - return - } - - frequencyData = nativeResult - this.lastFrequencyData = nativeResult + if (!monoData) { + return } + const frequencyData = nativeSpectrum.process(monoData) if (!frequencyData) { - this.animationId = requestAnimationFrame(this.draw) return } const bufferLength = frequencyData.length - if (bufferLength === 0) { - this.animationId = requestAnimationFrame(this.draw) return } - // Calculate frequency mapping - const binWidth = nyquist / bufferLength + this.renderStaticLayer(minFrequency, maxFrequency) - // Build one point per horizontal pixel and preserve local peaks. + const binWidth = nyquist / bufferLength const points: { x: number; y: number; heatmapIntensity: number }[] = [] const numPoints = Math.max(2, Math.floor(width)) @@ -375,16 +369,12 @@ export class SpectrumAnalyzer { const centerBin = (bin0 + bin1) * 0.5 const binSpan = Math.abs(bin1 - bin0) - - // Low frequencies can look stepped because each pixel maps to <1 FFT bin. - // Use sub-bin interpolation there, and keep peak-hold for wider spans. const rawDb = binSpan <= 1 ? this.getInterpolatedValue(frequencyData, Math.min(centerBin, bufferLength - 1)) : this.getPeakInRange(frequencyData, bin0, bin1) const db = this.applyTilt(rawDb, centerFrequency) const heatmapDb = this.applyTilt(rawDb, centerFrequency, options.heatmapTiltDbPerOctave) - // Normalize to 0-1 range const normalized = (db - options.minDecibels) / (options.maxDecibels - options.minDecibels) const heatmapNormalized = (heatmapDb - options.minDecibels) / (options.maxDecibels - options.minDecibels) const y = height - Math.max(0, Math.min(1, normalized)) * height @@ -393,9 +383,7 @@ export class SpectrumAnalyzer { points.push({ x, y, heatmapIntensity }) } - // Draw filled area if (options.heatmapFill && points.length > 0) { - // Per-column heat-colored fill — each frequency colored by its intensity for (let i = 0; i < points.length; i++) { const x = Math.floor(points[i].x) const y = points[i].y @@ -421,12 +409,10 @@ export class SpectrumAnalyzer { ctx.lineTo(points[i].x, points[i].y) } - // Complete path for fill ctx.lineTo(width, height) ctx.lineTo(0, height) ctx.closePath() - // Create gradient const gradient = ctx.createLinearGradient(0, height, 0, 0) const colors = options.gradientColors for (let i = 0; i < colors.length; i++) { @@ -437,7 +423,6 @@ export class SpectrumAnalyzer { ctx.fill() } - // Draw the line on top ctx.beginPath() ctx.moveTo(points[0].x, points[0].y) @@ -450,12 +435,51 @@ export class SpectrumAnalyzer { ctx.lineCap = 'round' ctx.lineJoin = 'round' ctx.stroke() - - this.animationId = requestAnimationFrame(this.draw) } - private drawGrid(minFrequency: number, maxFrequency: number): void { - const { ctx, canvas, options } = this + private renderStaticLayer(minFrequency: number, maxFrequency: number): void { + this.ensureStaticLayer(minFrequency, maxFrequency) + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height) + this.ctx.drawImage(this.staticLayerCanvas, 0, 0) + } + + private ensureStaticLayer(minFrequency: number, maxFrequency: number): void { + const { canvas, options } = this + const key = [ + canvas.width, + canvas.height, + options.backgroundColor, + options.showGrid, + options.gridColor, + options.scaleType, + options.minDecibels, + options.maxDecibels, + minFrequency, + maxFrequency, + ].join(':') + + if (this.staticLayerKey === key) { + return + } + + this.staticLayerCanvas.width = canvas.width + this.staticLayerCanvas.height = canvas.height + this.staticLayerCtx.clearRect(0, 0, canvas.width, canvas.height) + + if (options.backgroundColor !== 'transparent') { + this.staticLayerCtx.fillStyle = options.backgroundColor + this.staticLayerCtx.fillRect(0, 0, canvas.width, canvas.height) + } + + if (options.showGrid) { + this.drawGrid(this.staticLayerCtx, minFrequency, maxFrequency) + } + + this.staticLayerKey = key + } + + private drawGrid(ctx: CanvasRenderingContext2D, minFrequency: number, maxFrequency: number): void { + const { canvas, options } = this const width = canvas.width const height = canvas.height const dpr = window.devicePixelRatio || 1 @@ -463,7 +487,6 @@ export class SpectrumAnalyzer { ctx.strokeStyle = options.gridColor ctx.lineWidth = dpr - // Horizontal dB lines const dbSteps = [-80, -60, -40, -20, 0] ctx.fillStyle = options.gridColor ctx.font = `${10 * dpr}px monospace` @@ -481,7 +504,6 @@ export class SpectrumAnalyzer { ctx.fillText(`${db}dB`, 4 * dpr, y - 2 * dpr) } - // Vertical frequency lines (log scale) const freqSteps = [50, 100, 200, 500, 1000, 2000, 5000, 10000] ctx.textAlign = 'center' @@ -510,17 +532,15 @@ export class SpectrumAnalyzer { dispose(): void { this.stop() - + this.frameLoop.dispose() if (this.unsubscribeSessionChange) { this.unsubscribeSessionChange() this.unsubscribeSessionChange = null } - // Reset native module state if (isNativeAvailable()) { nativeSpectrum.reset() } this.lastSampleRate = 0 - this.lastFrequencyData = null } } diff --git a/src/renderer/visualizers/VUMeter.ts b/src/renderer/visualizers/VUMeter.ts index 55b664c..3511f0c 100644 --- a/src/renderer/visualizers/VUMeter.ts +++ b/src/renderer/visualizers/VUMeter.ts @@ -1,5 +1,8 @@ import { audioRouter } from '../audio/AudioRouter' +import { resolveColorToRgb } from '../utils/color' import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource' +import { FrameScheduler } from './frameScheduler' +import { VisualizerFrameLoop } from './visualizerFrameLoop' import { DEFAULT_VU_METER_ORIENTATION, type VUMeterMode, @@ -15,9 +18,10 @@ export interface VUMeterOptions { orientation?: VUMeterOrientation lineColor?: string dataSource?: VUMeterDataSource + frameScheduler?: FrameScheduler } -type ResolvedVUMeterOptions = Required> +type ResolvedVUMeterOptions = Required> const defaultOptions: ResolvedVUMeterOptions = { mode: 'bar', @@ -39,17 +43,6 @@ const PEAK_DECAY_DB_PER_FRAME = 0.3 const RMS_SMOOTHING = 0.85 // exponential smoothing factor const CORRELATION_SMOOTHING = 0.88 -// ---- Color utilities ---- - -function parseHexColor(hex: string): [number, number, number] { - const h = hex.replace('#', '') - return [ - parseInt(h.substring(0, 2), 16) || 56, - parseInt(h.substring(2, 4), 16) || 189, - parseInt(h.substring(4, 6), 16) || 248, - ] -} - function colorWithAlpha(r: number, g: number, b: number, a: number): string { return `rgba(${r}, ${g}, ${b}, ${a})` } @@ -61,8 +54,7 @@ export class VUMeter { private ctx: CanvasRenderingContext2D private options: ResolvedVUMeterOptions private dataSource: VUMeterDataSource - private animationId: number | null = null - private isRunning = false + private frameLoop: VisualizerFrameLoop private unsubscribeSessionChange: (() => void) | null = null // Meter state @@ -80,9 +72,21 @@ export class VUMeter { if (!ctx) throw new Error('Could not get 2D context') this.ctx = ctx - const { dataSource, ...optionOverrides } = options + const { dataSource, frameScheduler, ...optionOverrides } = options this.options = { ...defaultOptions, ...optionOverrides } this.dataSource = dataSource ?? defaultVUMeterDataSource + this.frameLoop = new VisualizerFrameLoop({ + frameScheduler, + shouldRun: () => this.dataSource.isPlaying(), + onFrame: this.drawFrame, + }) + this.subscribeToSessionChanges() + } + + private subscribeToSessionChanges(): void { + if (this.unsubscribeSessionChange) { + this.unsubscribeSessionChange() + } this.unsubscribeSessionChange = this.dataSource.subscribeToSessionChanges(() => { this.resetMeters() }) @@ -96,32 +100,35 @@ export class VUMeter { this.peakHoldL = 0 this.peakHoldR = 0 this.correlation = 0 + this.invalidate() } setOptions(options: Partial): void { - const { dataSource, ...optionUpdates } = options + const { dataSource, frameScheduler: _frameScheduler, ...optionUpdates } = options this.options = { ...this.options, ...optionUpdates } - if (dataSource) { + if (dataSource && dataSource !== this.dataSource) { this.dataSource = dataSource + this.subscribeToSessionChanges() + this.resetMeters() } + this.invalidate() } start(): void { - if (this.isRunning) return - this.isRunning = true - this.draw() + this.frameLoop.start() } stop(): void { - this.isRunning = false - if (this.animationId !== null) { - cancelAnimationFrame(this.animationId) - this.animationId = null - } + this.frameLoop.stop() + } + + invalidate(): void { + this.frameLoop.invalidate() } resize(): void { // Canvas resize handled externally + this.invalidate() } private processAudio(): void { @@ -210,7 +217,7 @@ export class VUMeter { private drawHorizontalBarMode(width: number, height: number): void { const ctx = this.ctx - const [cr, cg, cb] = parseHexColor(this.options.lineColor) + const { r: cr, g: cg, b: cb } = resolveColorToRgb(this.options.lineColor) const meterHeight = Math.max(1, Math.floor(height * 0.28)) const corrHeight = Math.max(1, Math.floor(height * 0.16)) @@ -244,7 +251,7 @@ export class VUMeter { private drawVerticalBarMode(width: number, height: number): void { const ctx = this.ctx - const [cr, cg, cb] = parseHexColor(this.options.lineColor) + const { r: cr, g: cg, b: cb } = resolveColorToRgb(this.options.lineColor) const sidePadding = Math.max(4, Math.floor(width * 0.08)) const channelGap = Math.max(4, Math.floor(width * 0.08)) @@ -464,7 +471,7 @@ export class VUMeter { private drawNeedleMode(width: number, height: number): void { const ctx = this.ctx - const [cr, cg, cb] = parseHexColor(this.options.lineColor) + const { r: cr, g: cg, b: cb } = resolveColorToRgb(this.options.lineColor) // Layout: two meters side by side, correlation bar below const corrHeight = Math.max(1, Math.floor(height * 0.12)) @@ -581,15 +588,12 @@ export class VUMeter { ctx.fillText(dbText, centerX, y + h - 2) } - private draw = (): void => { - if (!this.isRunning) return - + private drawFrame = (): void => { const { canvas, ctx, options } = this const width = canvas.width const height = canvas.height if (width <= 0 || height <= 0) { - this.animationId = requestAnimationFrame(this.draw) return } @@ -602,12 +606,11 @@ export class VUMeter { } else { this.drawBarMode(width, height) } - - this.animationId = requestAnimationFrame(this.draw) } dispose(): void { this.stop() + this.frameLoop.dispose() if (this.unsubscribeSessionChange) { this.unsubscribeSessionChange() this.unsubscribeSessionChange = null diff --git a/src/renderer/visualizers/Vectorscope.ts b/src/renderer/visualizers/Vectorscope.ts index 32426d7..3880e4a 100644 --- a/src/renderer/visualizers/Vectorscope.ts +++ b/src/renderer/visualizers/Vectorscope.ts @@ -3,6 +3,8 @@ import { vectorscope as nativeVectorscope, isNativeAvailable } from '../audio/na import { transformPoint, drawVectorscopeGridForMode, getVectorscopeLayout } from './vectorscopeGrids' import { MultibandSplitter, MultibandBuffer, BAND_COLORS } from './multibandSplitter' import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource' +import { FrameScheduler } from './frameScheduler' +import { VisualizerFrameLoop } from './visualizerFrameLoop' export type VectorscopeMode = 'lissajous' | 'polar-unipolar' | 'polar-bipolar' | 'linear-unipolar' | 'linear-bipolar' @@ -16,14 +18,15 @@ export interface VectorscopeOptions { backgroundColor?: string showGrid?: boolean gridColor?: string - persistence?: number // 0.0 (no trail) to 1.0 (infinite trail), default 0.10 - displayPoints?: number // how many points to request from native, default 4096 + persistence?: number + displayPoints?: number mode?: VectorscopeMode multiband?: boolean dataSource?: VectorscopeDataSource + frameScheduler?: FrameScheduler } -type ResolvedVectorscopeOptions = Required> +type ResolvedVectorscopeOptions = Required> const defaultOptions: ResolvedVectorscopeOptions = { lineColor: '#00ffff', @@ -49,35 +52,52 @@ export class Vectorscope { private ctx: CanvasRenderingContext2D private offscreenCanvas: HTMLCanvasElement private offscreenCtx: CanvasRenderingContext2D + private staticLayerCanvas: HTMLCanvasElement + private staticLayerCtx: CanvasRenderingContext2D private options: ResolvedVectorscopeOptions private dataSource: VectorscopeDataSource - private animationId: number | null = null - private isRunning: boolean = false - private nativeInitialized: boolean = false - private lastSampleRate: number = 0 + private frameLoop: VisualizerFrameLoop + private nativeInitialized = false + private lastSampleRate = 0 private unsubscribeSessionChange: (() => void) | null = null private splitter: MultibandSplitter = new MultibandSplitter() private multibandBuffer: MultibandBuffer = new MultibandBuffer() + private staticLayerKey = '' constructor(canvas: HTMLCanvasElement, options: VectorscopeOptions = {}) { this.canvas = canvas const ctx = canvas.getContext('2d') if (!ctx) throw new Error('Could not get 2D context') this.ctx = ctx - const { dataSource, ...optionOverrides } = options + + const { dataSource, frameScheduler, ...optionOverrides } = options this.options = { ...defaultOptions, ...optionOverrides } this.dataSource = dataSource ?? defaultVectorscopeDataSource + this.frameLoop = new VisualizerFrameLoop({ + frameScheduler, + shouldRun: () => this.dataSource.isPlaying(), + onFrame: this.drawFrame, + }) - // Create offscreen canvas for persistence/fade this.offscreenCanvas = document.createElement('canvas') this.offscreenCanvas.width = canvas.width this.offscreenCanvas.height = canvas.height const offCtx = this.offscreenCanvas.getContext('2d') if (!offCtx) throw new Error('Could not get offscreen 2D context') this.offscreenCtx = offCtx + this.staticLayerCanvas = document.createElement('canvas') + const staticLayerCtx = this.staticLayerCanvas.getContext('2d') + if (!staticLayerCtx) throw new Error('Could not get static offscreen 2D context') + this.staticLayerCtx = staticLayerCtx - // Initialize native module if available this.initNative() + this.subscribeToSessionChanges() + } + + private subscribeToSessionChanges(): void { + if (this.unsubscribeSessionChange) { + this.unsubscribeSessionChange() + } this.unsubscribeSessionChange = this.dataSource.subscribeToSessionChanges(() => { this.resetDisplay() }) @@ -107,125 +127,131 @@ export class Vectorscope { } private resetDisplay(): void { - // Clear the offscreen canvas and reset native state if (isNativeAvailable()) { nativeVectorscope.reset() } this.splitter.reset() this.multibandBuffer.reset() this.offscreenCtx.clearRect(0, 0, this.offscreenCanvas.width, this.offscreenCanvas.height) + this.invalidate() } setOptions(options: Partial): void { - const { dataSource, ...optionUpdates } = options + const { dataSource, frameScheduler: _frameScheduler, ...optionUpdates } = options this.options = { ...this.options, ...optionUpdates } - if (dataSource) { + if (dataSource && dataSource !== this.dataSource) { this.dataSource = dataSource + this.subscribeToSessionChanges() + this.resetDisplay() } + this.staticLayerKey = '' + this.invalidate() } start(): void { - if (this.isRunning) return - this.isRunning = true - this.draw() + this.frameLoop.start() } stop(): void { - this.isRunning = false - if (this.animationId !== null) { - cancelAnimationFrame(this.animationId) - this.animationId = null - } + this.frameLoop.stop() + } + + invalidate(): void { + this.frameLoop.invalidate() } resize(): void { - // Canvas resize is handled externally; offscreen will sync in draw() + this.staticLayerKey = '' + this.invalidate() } - private draw = (): void => { - if (!this.isRunning) return - + private drawFrame = (): void => { const { canvas, ctx, offscreenCanvas, offscreenCtx, options } = this const width = canvas.width const height = canvas.height + if (width <= 0 || height <= 0) return + const isPolar = options.mode === 'polar-unipolar' || options.mode === 'polar-bipolar' - const VISUAL_GAIN = isPolar ? 1.2 : 1.5 + const visualGain = isPolar ? 1.2 : 1.5 const layout = getVectorscopeLayout(width, height, options.mode) const centerX = layout.centerX const centerY = layout.centerY - const scale = layout.radius * VISUAL_GAIN + const scale = layout.radius * visualGain - // Sync offscreen canvas size if (offscreenCanvas.width !== width || offscreenCanvas.height !== height) { offscreenCanvas.width = width offscreenCanvas.height = height } - // Update sample rate if changed this.updateSampleRateIfNeeded() if (!this.dataSource.isPlaying()) { - ctx.clearRect(0, 0, width, height) - if (options.backgroundColor !== 'transparent') { - ctx.fillStyle = options.backgroundColor - ctx.fillRect(0, 0, width, height) - } - if (options.showGrid) { - const dpr = window.devicePixelRatio || 1 - drawVectorscopeGridForMode(ctx, width, height, options.gridColor, options.mode, dpr) - } - this.animationId = requestAnimationFrame(this.draw) + this.renderStaticLayer() return } - // ---- PERSISTENCE FADE ---- offscreenCtx.globalCompositeOperation = 'destination-in' offscreenCtx.fillStyle = `rgba(255, 255, 255, ${options.persistence})` offscreenCtx.fillRect(0, 0, width, height) offscreenCtx.globalCompositeOperation = 'source-over' - // ---- FLUSH SAMPLES ---- const pendingSamples = this.dataSource.getPendingVectorscopeSamples() if (options.multiband) { - // Multiband path: split into 3 bands, render each with its own color this.drawMultibandPoints(offscreenCtx, pendingSamples, centerX, centerY, scale) } else if (isNativeAvailable()) { - // Push all accumulated stereo chunks to native circular buffer for (const chunk of pendingSamples) { nativeVectorscope.pushSamples(chunk.left, chunk.right) } - // Get filtered points from native circular buffer const pointsResult = nativeVectorscope.getPoints(options.displayPoints) - if (pointsResult && pointsResult.count > 0) { this.drawPoints(offscreenCtx, pointsResult.x, pointsResult.y, pointsResult.count, centerX, centerY, scale) } } else { - // JavaScript fallback: draw raw samples from pending chunks this.drawFallbackPoints(offscreenCtx, pendingSamples, centerX, centerY, scale) } - // ---- COMPOSITE TO VISIBLE CANVAS ---- - ctx.clearRect(0, 0, width, height) + this.renderStaticLayer() + ctx.drawImage(offscreenCanvas, 0, 0) + } - // Draw background - if (options.backgroundColor !== 'transparent') { - ctx.fillStyle = options.backgroundColor - ctx.fillRect(0, 0, width, height) + private renderStaticLayer(): void { + this.ensureStaticLayer() + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height) + this.ctx.drawImage(this.staticLayerCanvas, 0, 0) + } + + private ensureStaticLayer(): void { + const { canvas, options } = this + const key = [ + canvas.width, + canvas.height, + options.backgroundColor, + options.showGrid, + options.gridColor, + options.mode, + ].join(':') + + if (this.staticLayerKey === key) { + return + } + + this.staticLayerCanvas.width = canvas.width + this.staticLayerCanvas.height = canvas.height + this.staticLayerCtx.clearRect(0, 0, canvas.width, canvas.height) + + if (options.backgroundColor !== 'transparent') { + this.staticLayerCtx.fillStyle = options.backgroundColor + this.staticLayerCtx.fillRect(0, 0, canvas.width, canvas.height) } - // Draw grid underneath if (options.showGrid) { const dpr = window.devicePixelRatio || 1 - drawVectorscopeGridForMode(ctx, width, height, options.gridColor, options.mode, dpr) + drawVectorscopeGridForMode(this.staticLayerCtx, canvas.width, canvas.height, options.gridColor, options.mode, dpr) } - // Draw the accumulated vectorscope image on top - ctx.drawImage(offscreenCanvas, 0, 0) - - this.animationId = requestAnimationFrame(this.draw) + this.staticLayerKey = key } private drawPoints( @@ -242,7 +268,6 @@ export class Vectorscope { const dpr = window.devicePixelRatio || 1 const dotSize = options.lineWidth * dpr - // Draw dots with age-based opacity: oldest dimmer, newest brighter const segments = 8 const pointsPerSegment = Math.ceil(count / segments) @@ -251,14 +276,12 @@ export class Vectorscope { const endIdx = Math.min((seg + 1) * pointsPerSegment, count) if (startIdx >= count) break - // Older segments (lower seg) are dimmer const alpha = 0.15 + 0.85 * (seg / Math.max(segments - 1, 1)) ctx.fillStyle = options.lineColor ctx.globalAlpha = alpha for (let i = startIdx; i < endIdx; i++) { - // Native returns x=Right, y=Left const point = transformPoint(y[i], x[i], mode) if (!point) continue @@ -312,26 +335,22 @@ export class Vectorscope { const dpr = window.devicePixelRatio || 1 const dotSize = options.lineWidth * dpr - // Ensure splitter is configured const sampleRate = this.dataSource.getSampleRate() if (sampleRate > 0) { this.splitter.configure(sampleRate) } - // Also push to native so switching back to single-color is seamless if (isNativeAvailable()) { for (const chunk of pendingSamples) { nativeVectorscope.pushSamples(chunk.left, chunk.right) } } - // Split new samples into bands and push into circular buffer for (const chunk of pendingSamples) { const bands = this.splitter.split(chunk.left, chunk.right) this.multibandBuffer.push(bands) } - // Read all buffered points and draw with age-based opacity (same as native path) const result = this.multibandBuffer.getPoints(options.displayPoints) if (result.count === 0) return @@ -365,13 +384,13 @@ export class Vectorscope { dispose(): void { this.stop() + this.frameLoop.dispose() if (this.unsubscribeSessionChange) { this.unsubscribeSessionChange() this.unsubscribeSessionChange = null } - // Reset native module state if (isNativeAvailable()) { nativeVectorscope.reset() } diff --git a/src/renderer/visualizers/Waveform.ts b/src/renderer/visualizers/Waveform.ts index 10296a6..216386e 100644 --- a/src/renderer/visualizers/Waveform.ts +++ b/src/renderer/visualizers/Waveform.ts @@ -1,5 +1,8 @@ import { audioRouter } from '../audio/AudioRouter' +import { resolveColorToRgb } from '../utils/color' import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource' +import { FrameScheduler } from './frameScheduler' +import { VisualizerFrameLoop } from './visualizerFrameLoop' import { DEFAULT_WAVEFORM_GAIN_DB, DEFAULT_WAVEFORM_SCROLL_SPEED, @@ -18,9 +21,10 @@ export interface WaveformOptions { gainDb?: number multiband?: boolean dataSource?: WaveformDataSource + frameScheduler?: FrameScheduler } -type ResolvedWaveformOptions = Required> +type ResolvedWaveformOptions = Required> const defaultOptions: ResolvedWaveformOptions = { lineColor: '#38bdf8', @@ -48,26 +52,19 @@ const defaultWaveformDataSource: WaveformDataSource = { // while keeping scroll speed independent from panel width. const BASE_PIXELS_PER_SECOND = 64 -function parseHexColor(hex: string): [number, number, number] { - const h = hex.replace('#', '') - return [ - parseInt(h.substring(0, 2), 16) || 56, - parseInt(h.substring(2, 4), 16) || 189, - parseInt(h.substring(4, 6), 16) || 248, - ] -} - export class Waveform { private canvas: HTMLCanvasElement private ctx: CanvasRenderingContext2D private options: ResolvedWaveformOptions private dataSource: WaveformDataSource - private animationId: number | null = null - private isRunning = false + private frameLoop: VisualizerFrameLoop // Offscreen canvas for scrolling content private waterfallCanvas: HTMLCanvasElement private waterfallCtx: CanvasRenderingContext2D + private staticLayerCanvas: HTMLCanvasElement + private staticLayerCtx: CanvasRenderingContext2D + private staticLayerKey = '' // Sample accumulator for current pixel column private columnAccumulator: Float32Array = new Float32Array(0) @@ -80,6 +77,7 @@ export class Waveform { private bandLowAcc: Float32Array = new Float32Array(0) private bandMidAcc: Float32Array = new Float32Array(0) private bandHighAcc: Float32Array = new Float32Array(0) + private unsubscribeSessionChange: (() => void) | null = null constructor(canvas: HTMLCanvasElement, options: WaveformOptions = {}) { this.canvas = canvas @@ -88,7 +86,7 @@ export class Waveform { this.ctx = ctx this.ctx.imageSmoothingEnabled = false - const { dataSource, ...optionOverrides } = options + const { dataSource, frameScheduler, ...optionOverrides } = options this.options = { ...defaultOptions, ...optionOverrides, @@ -97,6 +95,11 @@ export class Waveform { multiband: optionOverrides.multiband ?? defaultOptions.multiband, } this.dataSource = dataSource ?? defaultWaveformDataSource + this.frameLoop = new VisualizerFrameLoop({ + frameScheduler, + shouldRun: () => this.dataSource.isPlaying(), + onFrame: this.drawFrame, + }) this.waterfallCanvas = document.createElement('canvas') this.waterfallCanvas.width = canvas.width @@ -105,14 +108,29 @@ export class Waveform { if (!waterfallCtx) throw new Error('Could not get waterfall 2D context') this.waterfallCtx = waterfallCtx this.waterfallCtx.imageSmoothingEnabled = false + this.staticLayerCanvas = document.createElement('canvas') + const staticLayerCtx = this.staticLayerCanvas.getContext('2d') + if (!staticLayerCtx) throw new Error('Could not get static 2D context') + this.staticLayerCtx = staticLayerCtx this.recomputeSamplesPerColumn() + this.subscribeToSessionChanges() + } + + private subscribeToSessionChanges(): void { + if (this.unsubscribeSessionChange) { + this.unsubscribeSessionChange() + } + this.unsubscribeSessionChange = this.dataSource.subscribeToSessionChanges(() => { + this.resetDisplay() + }) } private resetDisplay(): void { this.waterfallCtx.clearRect(0, 0, this.waterfallCanvas.width, this.waterfallCanvas.height) this.columnAccumulatorPos = 0 this.splitter.reset() + this.invalidate() } private recomputeSamplesPerColumn(): void { @@ -132,7 +150,7 @@ export class Waveform { } setOptions(options: Partial): void { - const { dataSource, ...optionUpdates } = options + const { dataSource, frameScheduler: _frameScheduler, ...optionUpdates } = options const nextOptions: ResolvedWaveformOptions = { ...this.options, ...optionUpdates, @@ -145,8 +163,11 @@ export class Waveform { const multibandChanged = nextOptions.multiband !== this.options.multiband this.options = nextOptions - if (dataSource) { + if (dataSource && dataSource !== this.dataSource) { this.dataSource = dataSource + this.subscribeToSessionChanges() + this.recomputeSamplesPerColumn() + this.resetDisplay() } if (speedChanged) { this.recomputeSamplesPerColumn() @@ -156,24 +177,26 @@ export class Waveform { this.splitter.reset() this.resetDisplay() } + + this.invalidate() } start(): void { - if (this.isRunning) return - this.isRunning = true - this.draw() + this.frameLoop.start() } stop(): void { - this.isRunning = false - if (this.animationId !== null) { - cancelAnimationFrame(this.animationId) - this.animationId = null - } + this.frameLoop.stop() + } + + invalidate(): void { + this.frameLoop.invalidate() } resize(): void { // Resize handled in draw loop + this.staticLayerKey = '' + this.invalidate() } private computeMinMax(): { min: number; max: number } { @@ -269,7 +292,10 @@ export class Waveform { if (this.options.multiband) { ;[r, g, b] = this.computeBandColor() } else { - ;[r, g, b] = parseHexColor(this.options.lineColor) + const lineColor = resolveColorToRgb(this.options.lineColor) + r = lineColor.r + g = lineColor.g + b = lineColor.b } const fillAlpha = this.options.multiband ? MULTIBAND_FILL_ALPHA : 0.55 @@ -287,8 +313,26 @@ export class Waveform { } } - private drawGrid(width: number, height: number): void { - const ctx = this.ctx + private renderStaticLayer(width: number, height: number): void { + this.ensureStaticLayer(width, height) + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height) + this.ctx.drawImage(this.staticLayerCanvas, 0, 0) + } + + private ensureStaticLayer(width: number, height: number): void { + const key = `${width}:${height}` + if (this.staticLayerKey === key) { + return + } + + this.staticLayerCanvas.width = this.canvas.width + this.staticLayerCanvas.height = this.canvas.height + this.staticLayerCtx.clearRect(0, 0, this.canvas.width, this.canvas.height) + this.drawGrid(this.staticLayerCtx, width, height) + this.staticLayerKey = key + } + + private drawGrid(ctx: CanvasRenderingContext2D, width: number, height: number): void { const centerY = height / 2 // Center line (zero crossing) @@ -310,14 +354,11 @@ export class Waveform { ctx.stroke() } - private draw = (): void => { - if (!this.isRunning) return - + private drawFrame = (): void => { const width = this.canvas.width const height = this.canvas.height if (width <= 0 || height <= 0) { - this.animationId = requestAnimationFrame(this.draw) return } @@ -349,6 +390,7 @@ export class Waveform { } this.recomputeSamplesPerColumn() + this.staticLayerKey = '' } // Handle sample rate changes @@ -360,10 +402,8 @@ export class Waveform { if (!this.dataSource.isPlaying()) { this.dataSource.getPendingWaveformSamples() // drain // Freeze display — show last waveform - this.ctx.clearRect(0, 0, width, height) - this.drawGrid(width, height) + this.renderStaticLayer(width, height) this.ctx.drawImage(this.waterfallCanvas, 0, 0) - this.animationId = requestAnimationFrame(this.draw) return } @@ -402,13 +442,16 @@ export class Waveform { } } - this.ctx.clearRect(0, 0, width, height) - this.drawGrid(width, height) + this.renderStaticLayer(width, height) this.ctx.drawImage(this.waterfallCanvas, 0, 0) - this.animationId = requestAnimationFrame(this.draw) } dispose(): void { this.stop() + this.frameLoop.dispose() + if (this.unsubscribeSessionChange) { + this.unsubscribeSessionChange() + this.unsubscribeSessionChange = null + } } } diff --git a/src/renderer/visualizers/frameScheduler.ts b/src/renderer/visualizers/frameScheduler.ts new file mode 100644 index 0000000..589da13 --- /dev/null +++ b/src/renderer/visualizers/frameScheduler.ts @@ -0,0 +1,46 @@ +export type FrameSchedulerCallback = () => void + +export class FrameScheduler { + private callbacks = new Set() + private frameId: number | null = null + + subscribe(callback: FrameSchedulerCallback): () => void { + this.callbacks.add(callback) + this.start() + + return () => { + this.callbacks.delete(callback) + if (this.callbacks.size === 0) { + this.stop() + } + } + } + + private start(): void { + if (this.frameId !== null || this.callbacks.size === 0) { + return + } + + this.frameId = window.requestAnimationFrame(this.tick) + } + + private stop(): void { + if (this.frameId !== null) { + window.cancelAnimationFrame(this.frameId) + this.frameId = null + } + } + + private tick = (): void => { + this.frameId = null + if (this.callbacks.size === 0) { + return + } + + for (const callback of [...this.callbacks]) { + callback() + } + + this.start() + } +} diff --git a/src/renderer/visualizers/visualizerFrameLoop.ts b/src/renderer/visualizers/visualizerFrameLoop.ts new file mode 100644 index 0000000..aa7f1a1 --- /dev/null +++ b/src/renderer/visualizers/visualizerFrameLoop.ts @@ -0,0 +1,73 @@ +import { FrameScheduler } from './frameScheduler' + +interface VisualizerFrameLoopOptions { + frameScheduler?: FrameScheduler + shouldRun: () => boolean + onFrame: () => void +} + +export class VisualizerFrameLoop { + private readonly frameScheduler: FrameScheduler + private readonly shouldRun: () => boolean + private readonly onFrame: () => void + private unsubscribeFrame: (() => void) | null = null + private isStarted = false + private isInvalidated = false + + constructor({ frameScheduler, shouldRun, onFrame }: VisualizerFrameLoopOptions) { + this.frameScheduler = frameScheduler ?? new FrameScheduler() + this.shouldRun = shouldRun + this.onFrame = onFrame + } + + start(): void { + if (this.isStarted) return + this.isStarted = true + this.invalidate() + } + + stop(): void { + this.isStarted = false + this.detach() + } + + invalidate(): void { + if (!this.isStarted) return + this.isInvalidated = true + this.sync() + } + + dispose(): void { + this.stop() + } + + private sync(): void { + if (!this.isStarted) { + this.detach() + return + } + + if (this.isInvalidated || this.shouldRun()) { + if (this.unsubscribeFrame === null) { + this.unsubscribeFrame = this.frameScheduler.subscribe(this.tick) + } + return + } + + this.detach() + } + + private detach(): void { + if (this.unsubscribeFrame) { + this.unsubscribeFrame() + this.unsubscribeFrame = null + } + } + + private tick = (): void => { + if (!this.isStarted) return + this.isInvalidated = false + this.onFrame() + this.sync() + } +} diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts new file mode 100644 index 0000000..a9346e5 --- /dev/null +++ b/test/renderer-helpers.test.ts @@ -0,0 +1,153 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + DEFAULT_VISUALIZER_TINT, + colorToRgbChannels, + parseColorToRgb, + resolveColorToRgb, +} from '../src/renderer/utils/color' +import { VisualizerFrameLoop } from '../src/renderer/visualizers/visualizerFrameLoop' + +type WindowWithRaf = typeof globalThis & Pick + +function installFakeAnimationFrame(): { + pendingCount: () => number + runFrame: (timestamp?: number) => void + restore: () => void +} { + let nextFrameId = 1 + const callbacks = new Map() + const globalWithWindow = globalThis as typeof globalThis & { window?: WindowWithRaf } + const previousWindow = globalWithWindow.window + + globalWithWindow.window = { + ...globalThis, + requestAnimationFrame(callback: FrameRequestCallback): number { + const frameId = nextFrameId + nextFrameId += 1 + callbacks.set(frameId, callback) + return frameId + }, + cancelAnimationFrame(frameId: number): void { + callbacks.delete(frameId) + }, + } as WindowWithRaf + + return { + pendingCount: () => callbacks.size, + runFrame(timestamp = 0): void { + const frameCallbacks = [...callbacks.values()] + callbacks.clear() + for (const callback of frameCallbacks) { + callback(timestamp) + } + }, + restore(): void { + if (previousWindow === undefined) { + delete globalWithWindow.window + return + } + globalWithWindow.window = previousWindow + }, + } +} + +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 }) + assert.deepEqual(parseColorToRgb('rgb(10, 20, 30)'), { r: 10, g: 20, b: 30 }) + assert.deepEqual(parseColorToRgb('rgba(10 20 30 / 0.5)'), { r: 10, g: 20, b: 30 }) + assert.deepEqual(parseColorToRgb('rgb(10%, 20%, 30%)'), { r: 26, g: 51, b: 77 }) +}) + +test('color helpers fall back predictably for invalid values', () => { + assert.equal(colorToRgbChannels('rgba(1, 2, 3, 0.5)'), '1, 2, 3') + assert.equal(colorToRgbChannels('nope'), null) + assert.deepEqual(resolveColorToRgb('still-nope'), DEFAULT_VISUALIZER_TINT) + assert.deepEqual(resolveColorToRgb('rgb(4, 5, 6)'), { r: 4, g: 5, b: 6 }) +}) + +test('VisualizerFrameLoop renders one invalidated frame while idle', () => { + const raf = installFakeAnimationFrame() + + try { + let frameCount = 0 + const loop = new VisualizerFrameLoop({ + shouldRun: () => false, + onFrame: () => { + frameCount += 1 + }, + }) + + loop.start() + assert.equal(raf.pendingCount(), 1) + + raf.runFrame() + + assert.equal(frameCount, 1) + assert.equal(raf.pendingCount(), 0) + + loop.dispose() + } finally { + raf.restore() + } +}) + +test('VisualizerFrameLoop stays subscribed while running and detaches when playback stops', () => { + const raf = installFakeAnimationFrame() + + try { + let frameCount = 0 + let running = true + const loop = new VisualizerFrameLoop({ + shouldRun: () => running, + onFrame: () => { + frameCount += 1 + }, + }) + + loop.start() + raf.runFrame() + assert.equal(frameCount, 1) + assert.equal(raf.pendingCount(), 1) + + running = false + raf.runFrame() + assert.equal(frameCount, 2) + assert.equal(raf.pendingCount(), 0) + + loop.dispose() + } finally { + raf.restore() + } +}) + +test('VisualizerFrameLoop invalidate and stop manage subscriptions correctly', () => { + const raf = installFakeAnimationFrame() + + try { + let frameCount = 0 + const loop = new VisualizerFrameLoop({ + shouldRun: () => false, + onFrame: () => { + frameCount += 1 + }, + }) + + loop.start() + raf.runFrame() + assert.equal(frameCount, 1) + assert.equal(raf.pendingCount(), 0) + + loop.invalidate() + assert.equal(raf.pendingCount(), 1) + + loop.stop() + assert.equal(raf.pendingCount(), 0) + + raf.runFrame() + assert.equal(frameCount, 1) + } finally { + raf.restore() + } +})