mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
port over astra scope fixes
This commit is contained in:
@@ -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'",
|
||||
|
||||
@@ -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)
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user