port over astra scope fixes

This commit is contained in:
Boof2015
2026-03-28 00:41:49 -04:00
parent f065a75912
commit fd9eb3da18
17 changed files with 944 additions and 399 deletions
+1
View File
@@ -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'",
+44
View File
@@ -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)
+13 -5
View File
@@ -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<HTMLDivElement>(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(() => {
+28 -2
View File
@@ -65,6 +65,7 @@ export default function ScopePopoutBridge(): null {
[hiddenScopes, scopePopouts],
)
const activePopoutKindsRef = useRef<ScopeKind[]>(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])
+3
View File
@@ -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<HTMLDivElement>(null)
const gridRef = useRef<HTMLDivElement>(null)
const scopeRefs = useRef<Partial<Record<ScopeKind, HTMLDivElement | null>>>({})
@@ -214,6 +216,7 @@ export default function Strip(): JSX.Element {
<ScopeModule
scopeKind={kind}
lineColor={accent}
frameScheduler={frameScheduler}
/>
</div>
))}
@@ -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<ScopePopoutSnapshot<ScopeKind> | 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}
/>
</div>
+82
View File
@@ -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
}
+46 -34
View File
@@ -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<Omit<LUFSMeterOptions, 'dataSource'>>
type ResolvedLUFSMeterOptions = Required<Omit<LUFSMeterOptions, 'dataSource' | 'frameScheduler'>>
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<LUFSMeterOptions>): 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
}
}
}
+95 -101
View File
@@ -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<Omit<OscilloscopeOptions, 'dataSource'>>
type ResolvedOscilloscopeOptions = Required<Omit<OscilloscopeOptions, 'dataSource' | 'frameScheduler'>>
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<OscilloscopeOptions>): 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
}
+47 -32
View File
@@ -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<Omit<SpectrogramOptions, 'dataSource'>>
type ResolvedSpectrogramOptions = Required<Omit<SpectrogramOptions, 'dataSource' | 'frameScheduler'>>
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<SpectrogramOptions>): 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
}
}
}
+109 -89
View File
@@ -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<Omit<SpectrumAnalyzerOptions, 'dataSource'>>
type ResolvedSpectrumAnalyzerOptions = Required<Omit<SpectrumAnalyzerOptions, 'dataSource' | 'frameScheduler'>>
// ---- 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<SpectrumAnalyzerOptions>): 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
}
}
+37 -34
View File
@@ -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<Omit<VUMeterOptions, 'dataSource'>>
type ResolvedVUMeterOptions = Required<Omit<VUMeterOptions, 'dataSource' | 'frameScheduler'>>
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<VUMeterOptions>): 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
+85 -66
View File
@@ -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<Omit<VectorscopeOptions, 'dataSource'>>
type ResolvedVectorscopeOptions = Required<Omit<VectorscopeOptions, 'dataSource' | 'frameScheduler'>>
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<VectorscopeOptions>): 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()
}
+79 -36
View File
@@ -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<Omit<WaveformOptions, 'dataSource'>>
type ResolvedWaveformOptions = Required<Omit<WaveformOptions, 'dataSource' | 'frameScheduler'>>
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<WaveformOptions>): 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
}
}
}
@@ -0,0 +1,46 @@
export type FrameSchedulerCallback = () => void
export class FrameScheduler {
private callbacks = new Set<FrameSchedulerCallback>()
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()
}
}
@@ -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()
}
}
+153
View File
@@ -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<Window, 'requestAnimationFrame' | 'cancelAnimationFrame'>
function installFakeAnimationFrame(): {
pendingCount: () => number
runFrame: (timestamp?: number) => void
restore: () => void
} {
let nextFrameId = 1
const callbacks = new Map<number, FrameRequestCallback>()
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()
}
})