import { audioRouter } from '../audio/AudioRouter' import { spectrum as defaultNativeSpectrum, type SpectrumNativeAnalyzer } from '../audio/native' import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource' import { FrameScheduler } from './frameScheduler' import { VisualizerFrameLoop } from './visualizerFrameLoop' import { parseColorToRgba } from '../utils/color' import { DEFAULT_SPECTRUM_TILT_DB_PER_OCTAVE, DEFAULT_SPECTRUM_HEATMAP_TILT_DB_PER_OCTAVE, clampSpectrumTiltDbPerOctave, clampSpectrumHeatmapTiltDbPerOctave, formatSpectrumPitchInfo, resolveSpectrumPitchInfo, type SpectrumPeakInfo, } from '../../types/spectrum' import { HEAT_LOW_DB, HEAT_MID_DB, normalizeHeatDb, } from './heatScale' type SpectrumStereoChunk = { left: Float32Array right: Float32Array } export interface SpectrumAnalyzerDataSource extends VisualizerSessionSource { getPendingSpectrumSamples: () => Float32Array[] getPendingSpectrumStereoSamples: () => SpectrumStereoChunk[] } export interface SpectrumAnalyzerOptions { lineColor?: string secondaryLineColor?: string lineWidth?: number fillGradient?: boolean heatmapFill?: boolean heatmapSmoothing?: number gradientColors?: string[] heatColors?: [string, string, string] heatBaseColor?: string backgroundColor?: string showGrid?: boolean gridColor?: string scaleType?: 'linear' | 'log' smoothing?: number minDecibels?: number maxDecibels?: number minFrequency?: number maxFrequency?: number tiltDbPerOctave?: number heatmapTiltDbPerOctave?: number tiltReferenceHz?: number fftSize?: number showSideLine?: boolean capturePeakInfo?: boolean onPeakInfo?: (peakInfo: SpectrumPeakInfo | null) => void dataSource?: SpectrumAnalyzerDataSource frameScheduler?: FrameScheduler nativeAnalyzer?: SpectrumNativeAnalyzer | null } type ResolvedSpectrumAnalyzerOptions = Required> type SpectrumPointFillResult = { pointCount: number peakInfo: SpectrumPeakInfo | null } type SpectrumRangePeak = { rawDb: number frequencyHz: number } type HeatColor = [number, number, number, number] type HeatStop = { at: number; color: HeatColor } const LEGACY_DEFAULT_HEAT_COLORS: [string, string, string] = [ 'rgb(15, 7, 33)', 'rgb(163, 26, 121)', 'rgb(255, 241, 209)', ] const HEATMAP_GAMMA = 1.4 const FFT_SILENCE_DB = -100 const SIDE_LINE_WIDTH_RATIO = 0.75 const PEAK_SELECTION_MAX_DISTANCE_OCTAVES = 0.5 const PEAK_SELECTION_SWITCH_THRESHOLD_DB = 4 const PEAK_SELECTION_LOW_FREQUENCY_BIAS_DB_PER_OCTAVE = 0.75 const PEAK_SELECTION_UPWARD_SWITCH_THRESHOLD_DB = 2 const NOOP_SPECTRUM_PEAK_INFO_CALLBACK = (_peakInfo: SpectrumPeakInfo | null): void => {} function clampSmoothing(value: number): number { return Math.min(0.99, Math.max(0, value)) } function isLegacyDefaultHeatColors(colors: [string, string, string]): boolean { return colors.every((color, index) => { const left = parseColorToRgba(color) const right = parseColorToRgba(LEGACY_DEFAULT_HEAT_COLORS[index]) return !!left && !!right && left.r === right.r && left.g === right.g && left.b === right.b && Math.round(left.a * 255) === Math.round(right.a * 255) }) } function resolveHeatColor(color: string, fallback: string): HeatColor { const parsed = parseColorToRgba(color) ?? parseColorToRgba(fallback) if (!parsed) { return [0, 0, 0, 255] } return [parsed.r, parsed.g, parsed.b, Math.round(parsed.a * 255)] } function scaleHeatColor(color: HeatColor, factor: number): HeatColor { return [ Math.round(color[0] * factor), Math.round(color[1] * factor), Math.round(color[2] * factor), Math.round(color[3] * factor), ] } function lerpChannel(start: number, end: number, amount: number): number { return Math.round(start + ((end - start) * amount)) } function buildHeatStops(colors: [string, string, string]): HeatStop[] { if (isLegacyDefaultHeatColors(colors)) { return [ { at: 0, color: [0, 0, 0, 0] }, { at: normalizeHeatDb(-80), color: [15, 7, 33, 255] }, { at: normalizeHeatDb(-70), color: [61, 11, 94, 255] }, { at: normalizeHeatDb(-60), color: [163, 26, 121, 255] }, { at: normalizeHeatDb(-45), color: [255, 82, 87, 255] }, { at: normalizeHeatDb(-35), color: [255, 166, 63, 255] }, { at: 1, color: [255, 241, 209, 255] }, ] } const low = resolveHeatColor(colors[0], LEGACY_DEFAULT_HEAT_COLORS[0]) const mid = resolveHeatColor(colors[1], LEGACY_DEFAULT_HEAT_COLORS[1]) const high = resolveHeatColor(colors[2], LEGACY_DEFAULT_HEAT_COLORS[2]) return [ { at: 0, color: [0, 0, 0, 0] }, { at: normalizeHeatDb(-90), color: scaleHeatColor(low, 0.5) }, { at: normalizeHeatDb(HEAT_LOW_DB), color: low }, { at: normalizeHeatDb(HEAT_MID_DB), color: mid }, { at: 1, color: high }, ] } function buildHeatLUT(colors: [string, string, string]): Uint8ClampedArray { const heatStops = buildHeatStops(colors) const lut = new Uint8ClampedArray(256 * 4) for (let i = 0; i < 256; i += 1) { const t = i / 255 let start = heatStops[0] let end = heatStops[heatStops.length - 1] for (let stopIndex = 0; stopIndex < heatStops.length - 1; stopIndex += 1) { if (t <= heatStops[stopIndex + 1].at) { start = heatStops[stopIndex] end = heatStops[stopIndex + 1] break } } const amount = Math.max(0, Math.min(1, (t - start.at) / Math.max(1e-6, end.at - start.at))) lut[i * 4] = lerpChannel(start.color[0], end.color[0], amount) lut[i * 4 + 1] = lerpChannel(start.color[1], end.color[1], amount) lut[i * 4 + 2] = lerpChannel(start.color[2], end.color[2], amount) lut[i * 4 + 3] = lerpChannel(start.color[3], end.color[3], amount) } return lut } const defaultOptions: ResolvedSpectrumAnalyzerOptions = { lineColor: '#00ffff', secondaryLineColor: 'rgba(0, 255, 255, 0.5)', lineWidth: 2, fillGradient: true, heatmapFill: false, heatmapSmoothing: 0.5, gradientColors: ['rgba(0, 255, 255, 0)', 'rgba(0, 255, 255, 0.3)', 'rgba(138, 43, 226, 0.5)'], heatColors: [...LEGACY_DEFAULT_HEAT_COLORS], heatBaseColor: 'transparent', backgroundColor: 'transparent', showGrid: true, gridColor: 'rgba(255, 255, 255, 0.1)', scaleType: 'log', smoothing: 0.9, minDecibels: -90, maxDecibels: -10, minFrequency: 20, maxFrequency: 20000, tiltDbPerOctave: DEFAULT_SPECTRUM_TILT_DB_PER_OCTAVE, heatmapTiltDbPerOctave: DEFAULT_SPECTRUM_HEATMAP_TILT_DB_PER_OCTAVE, tiltReferenceHz: 1000, fftSize: 2048, showSideLine: false, capturePeakInfo: false, onPeakInfo: NOOP_SPECTRUM_PEAK_INFO_CALLBACK, } const defaultSpectrumDataSource: SpectrumAnalyzerDataSource = { getPendingSpectrumSamples: () => audioRouter.flushPendingSpectrumSamples(), getPendingSpectrumStereoSamples: () => audioRouter.flushPendingSpectrumStereoSamples(), ...defaultVisualizerSessionSource, } export class SpectrumAnalyzer { private canvas: HTMLCanvasElement private ctx: CanvasRenderingContext2D private options: ResolvedSpectrumAnalyzerOptions private dataSource: SpectrumAnalyzerDataSource private nativeAnalyzer: SpectrumNativeAnalyzer | null private frameLoop: VisualizerFrameLoop private nativeInitialized = false private sampleRate = 48000 private lastSampleRate = 0 private heatLut: Uint8ClampedArray private staticLayerCanvas: HTMLCanvasElement private staticLayerCtx: CanvasRenderingContext2D private staticLayerKey = '' private unsubscribeSessionChange: (() => void) | null = null private nativeMagnitudeBuffer = new Float32Array(0) private nativeRawMagnitudeBuffer = new Float32Array(0) private nativeSideMagnitudeBuffer = new Float32Array(0) private nativeChannelMaxMagnitudeBuffer = new Float32Array(0) private heatmapMagnitudeBuffer = new Float32Array(0) private nativeBufferedSamples = 0 private nativeHasSpectrumData = false private pushScratch = new Float32Array(0) private pushScratchRight = new Float32Array(0) private primaryPointX = new Float32Array(0) private primaryPointY = new Float32Array(0) private heatmapPointY = new Float32Array(0) private primaryPointHeatmap = new Float32Array(0) private secondaryPointX = new Float32Array(0) private secondaryPointY = new Float32Array(0) private primaryPointDb = new Float32Array(0) private primaryPointDbfs = new Float32Array(0) private primaryPointFrequency = new Float32Array(0) private lastSelectedPeakInfo: SpectrumPeakInfo | null = null constructor(canvas: HTMLCanvasElement, options: SpectrumAnalyzerOptions = {}) { this.canvas = canvas const ctx = canvas.getContext('2d') if (!ctx) throw new Error('Could not get 2D context') this.ctx = ctx const { dataSource, frameScheduler, nativeAnalyzer, ...optionOverrides } = options this.options = { ...defaultOptions, ...optionOverrides, tiltDbPerOctave: clampSpectrumTiltDbPerOctave( optionOverrides.tiltDbPerOctave ?? defaultOptions.tiltDbPerOctave ), heatmapTiltDbPerOctave: clampSpectrumHeatmapTiltDbPerOctave( optionOverrides.heatmapTiltDbPerOctave ?? defaultOptions.heatmapTiltDbPerOctave ), } this.dataSource = dataSource ?? defaultSpectrumDataSource this.nativeAnalyzer = nativeAnalyzer === undefined ? defaultNativeSpectrum : nativeAnalyzer this.heatLut = buildHeatLUT(this.options.heatColors) 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 this.resetAnalyzerBuffers() this.initNative() this.subscribeToSessionChanges() } private subscribeToSessionChanges(): void { if (this.unsubscribeSessionChange) { this.unsubscribeSessionChange() } this.unsubscribeSessionChange = this.dataSource.subscribeToSessionChanges(() => { this.resetState() }) } private initNative(): void { this.sampleRate = Math.max(1, this.dataSource.getSampleRate()) this.lastSampleRate = 0 if (this.isNativeAvailable() && !this.nativeInitialized) { this.nativeAnalyzer?.setFFTSize(this.options.fftSize) this.nativeAnalyzer?.setSampleRate(this.sampleRate) this.nativeAnalyzer?.setSmoothing(this.getNativeSmoothing()) this.nativeInitialized = true console.log(`SpectrumAnalyzer: Using native DSP (${this.sampleRate}Hz)`) } else if (!this.isNativeAvailable()) { console.error('SpectrumAnalyzer: Native DSP not available!') } } private ensureMagnitudeBufferSize(): void { const length = Math.max(1, Math.floor(this.options.fftSize / 2)) if (this.nativeMagnitudeBuffer.length !== length) { this.nativeMagnitudeBuffer = new Float32Array(length) } if (this.nativeRawMagnitudeBuffer.length !== length) { this.nativeRawMagnitudeBuffer = new Float32Array(length) } if (this.nativeSideMagnitudeBuffer.length !== length) { this.nativeSideMagnitudeBuffer = new Float32Array(length) } if (this.nativeChannelMaxMagnitudeBuffer.length !== length) { this.nativeChannelMaxMagnitudeBuffer = new Float32Array(length) } if (this.heatmapMagnitudeBuffer.length !== length) { this.heatmapMagnitudeBuffer = new Float32Array(length) } } private resetAnalyzerBuffers(): void { this.ensureMagnitudeBufferSize() this.nativeMagnitudeBuffer.fill(FFT_SILENCE_DB) this.nativeRawMagnitudeBuffer.fill(FFT_SILENCE_DB) this.nativeSideMagnitudeBuffer.fill(FFT_SILENCE_DB) this.nativeChannelMaxMagnitudeBuffer.fill(FFT_SILENCE_DB) this.heatmapMagnitudeBuffer.fill(FFT_SILENCE_DB) this.nativeBufferedSamples = 0 this.nativeHasSpectrumData = false this.lastSelectedPeakInfo = null } private isNativeAvailable(): boolean { return Boolean(this.nativeAnalyzer) && this.nativeAnalyzer?.isAvailable?.() !== false } private updateSampleRateIfNeeded(): void { const currentRate = Math.max(1, this.dataSource.getSampleRate()) if (currentRate !== this.lastSampleRate && currentRate > 0) { this.sampleRate = currentRate this.lastSampleRate = currentRate if (this.isNativeAvailable()) { this.nativeAnalyzer?.setSampleRate(currentRate) } console.log(`SpectrumAnalyzer: Sample rate updated to ${currentRate}Hz`) } } private getNativeSmoothing(): number { const base = clampSmoothing(this.options.smoothing) const fftRatio = Math.max(0.5, this.options.fftSize / 2048) return clampSmoothing(Math.pow(base, fftRatio)) } private resetState(): void { if (this.isNativeAvailable()) { this.nativeAnalyzer?.reset() } this.resetAnalyzerBuffers() this.sampleRate = Math.max(1, this.dataSource.getSampleRate()) this.lastSampleRate = 0 this.invalidate() } setOptions(options: Partial): void { const { dataSource, frameScheduler: _frameScheduler, nativeAnalyzer, ...optionUpdates } = options const nextOptions = { ...this.options, ...optionUpdates } if (optionUpdates.tiltDbPerOctave !== undefined) { nextOptions.tiltDbPerOctave = clampSpectrumTiltDbPerOctave(optionUpdates.tiltDbPerOctave) } if (optionUpdates.heatmapTiltDbPerOctave !== undefined) { nextOptions.heatmapTiltDbPerOctave = clampSpectrumHeatmapTiltDbPerOctave(optionUpdates.heatmapTiltDbPerOctave) } const shouldResetForOptions = ( optionUpdates.fftSize !== undefined || optionUpdates.smoothing !== undefined || optionUpdates.heatmapSmoothing !== undefined || optionUpdates.showSideLine !== undefined || optionUpdates.capturePeakInfo !== undefined ) this.options = nextOptions this.heatLut = buildHeatLUT(this.options.heatColors) let didReset = false if (nativeAnalyzer !== undefined && nativeAnalyzer !== this.nativeAnalyzer) { this.nativeAnalyzer = nativeAnalyzer this.nativeInitialized = false this.initNative() this.resetState() didReset = true } if (dataSource && dataSource !== this.dataSource) { this.dataSource = dataSource this.subscribeToSessionChanges() this.resetState() didReset = true } if (this.isNativeAvailable()) { if (options.fftSize !== undefined) { this.nativeAnalyzer?.setFFTSize(options.fftSize) } if (options.smoothing !== undefined || options.fftSize !== undefined) { this.nativeAnalyzer?.setSmoothing(this.getNativeSmoothing()) } } if (shouldResetForOptions && !didReset) { this.resetState() didReset = true } this.staticLayerKey = '' if (!didReset) { this.invalidate() } } start(): void { this.frameLoop.start() } stop(): void { this.frameLoop.stop() } invalidate(): void { this.frameLoop.invalidate() } resize(): void { this.staticLayerKey = '' this.invalidate() } private lerp(a: number, b: number, t: number): number { return a + (b - a) * t } private getInterpolatedValue(data: Float32Array, index: number): number { const i0 = Math.floor(index) const i1 = Math.min(i0 + 1, data.length - 1) const t = index - i0 return this.lerp(data[i0], data[i1], t) } private getQuadraticInterpolatedValue(data: Float32Array, index: number): number { const center = Math.round(index) if (center <= 0 || center >= data.length - 1) { return this.getInterpolatedValue(data, index) } const offset = index - center const previous = data[center - 1] const current = data[center] const next = data[center + 1] return current + 0.5 * offset * ( next - previous + offset * (previous - (2 * current) + next) ) } private frequencyAtPosition(t: number, minFrequency: number, maxFrequency: number): number { if (this.options.scaleType === 'log') { const logMin = Math.log10(minFrequency) const logMax = Math.log10(maxFrequency) return Math.pow(10, logMin + t * (logMax - logMin)) } return minFrequency + t * (maxFrequency - minFrequency) } private resolvePeakInRange( data: Float32Array, startIndex: number, endIndex: number, binWidth: number, ): SpectrumRangePeak { const clampedStart = Math.max(0, Math.min(data.length - 1, startIndex)) const clampedEnd = Math.max(0, Math.min(data.length - 1, endIndex)) const lo = Math.floor(Math.min(clampedStart, clampedEnd)) const hi = Math.ceil(Math.max(clampedStart, clampedEnd)) if (hi <= lo) { const rawDb = this.getInterpolatedValue(data, clampedStart) return { rawDb, frequencyHz: Math.max(0, clampedStart * binWidth), } } let peakBin = lo let peakDb = Number.NEGATIVE_INFINITY for (let i = lo; i <= hi; i += 1) { if (data[i] > peakDb) { peakDb = data[i] peakBin = i } } if (peakBin > 0 && peakBin < data.length - 1) { const y1 = data[peakBin - 1] const y2 = data[peakBin] const y3 = data[peakBin + 1] const denominator = y1 - (2 * y2) + y3 if (Math.abs(denominator) > 1e-9) { const offset = Math.max(-0.5, Math.min(0.5, 0.5 * (y1 - y3) / denominator)) const interpolatedDb = y2 - (0.25 * (y1 - y3) * offset) return { rawDb: interpolatedDb, frequencyHz: Math.max(0, (peakBin + offset) * binWidth), } } } return { rawDb: peakDb, frequencyHz: Math.max(0, peakBin * binWidth), } } private applyTilt(db: number, frequency: number, tiltDbPerOctave = this.options.tiltDbPerOctave): number { const safeFreq = Math.max(1, frequency) const reference = Math.max(1, this.options.tiltReferenceHz) const octaves = Math.log2(safeFreq / reference) return db + tiltDbPerOctave * octaves } private ensurePointBuffers(pointCount: number): void { if (this.primaryPointX.length !== pointCount) { this.primaryPointX = new Float32Array(pointCount) this.primaryPointY = new Float32Array(pointCount) this.heatmapPointY = new Float32Array(pointCount) this.primaryPointHeatmap = new Float32Array(pointCount) this.secondaryPointX = new Float32Array(pointCount) this.secondaryPointY = new Float32Array(pointCount) this.primaryPointDb = new Float32Array(pointCount) this.primaryPointDbfs = new Float32Array(pointCount) this.primaryPointFrequency = new Float32Array(pointCount) } } private recordNativeBufferedSamples(length: number): void { if (length <= 0) { return } this.nativeBufferedSamples = Math.min(this.options.fftSize, this.nativeBufferedSamples + length) } private pushPendingSpectrumChunks(pendingSpectrum: Float32Array[]): number { if (pendingSpectrum.length === 0) return 0 if (pendingSpectrum.length === 1) { if (pendingSpectrum[0].length > 0) { this.nativeAnalyzer?.pushSamples(pendingSpectrum[0]) this.recordNativeBufferedSamples(pendingSpectrum[0].length) } return pendingSpectrum[0].length } let totalLength = 0 for (const chunk of pendingSpectrum) { totalLength += chunk.length } if (totalLength === 0) return 0 if (this.pushScratch.length < totalLength) { this.pushScratch = new Float32Array(totalLength) } const merged = this.pushScratch.length === totalLength ? this.pushScratch : this.pushScratch.subarray(0, totalLength) let offset = 0 for (const chunk of pendingSpectrum) { if (chunk.length > 0) { merged.set(chunk, offset) offset += chunk.length } } this.nativeAnalyzer?.pushSamples(merged) this.recordNativeBufferedSamples(totalLength) return totalLength } private pushPendingSpectrumStereoChunks(pendingSpectrum: SpectrumStereoChunk[]): number { if (pendingSpectrum.length === 0) return 0 if (pendingSpectrum.length === 1) { const chunk = pendingSpectrum[0] const length = Math.min(chunk.left.length, chunk.right.length) if (length > 0) { const left = chunk.left.length === length ? chunk.left : chunk.left.subarray(0, length) const right = chunk.right.length === length ? chunk.right : chunk.right.subarray(0, length) this.nativeAnalyzer?.pushStereoSamples(left, right) this.recordNativeBufferedSamples(length) } return length } let totalLength = 0 for (const chunk of pendingSpectrum) { totalLength += Math.min(chunk.left.length, chunk.right.length) } if (totalLength === 0) return 0 if (this.pushScratch.length < totalLength) { this.pushScratch = new Float32Array(totalLength) } if (this.pushScratchRight.length < totalLength) { this.pushScratchRight = new Float32Array(totalLength) } const mergedLeft = this.pushScratch.length === totalLength ? this.pushScratch : this.pushScratch.subarray(0, totalLength) const mergedRight = this.pushScratchRight.length === totalLength ? this.pushScratchRight : this.pushScratchRight.subarray(0, totalLength) let offset = 0 for (const chunk of pendingSpectrum) { const length = Math.min(chunk.left.length, chunk.right.length) if (length > 0) { mergedLeft.set(chunk.left.subarray(0, length), offset) mergedRight.set(chunk.right.subarray(0, length), offset) offset += length } } this.nativeAnalyzer?.pushStereoSamples(mergedLeft, mergedRight) this.recordNativeBufferedSamples(totalLength) return totalLength } private clearPendingSpectrumQueues(): void { this.dataSource.getPendingSpectrumSamples() this.dataSource.getPendingSpectrumStereoSamples() } private updateSmoothedMagnitudes( rawMagnitudes: Float32Array, dataLength: number, smoothedMagnitudes: Float32Array, smoothing: number, bypassSmoothing: boolean, ): number { const count = Math.min(dataLength, rawMagnitudes.length, smoothedMagnitudes.length) if (count <= 0) { return 0 } const smoothingAmount = clampSmoothing(smoothing) for (let index = 0; index < count; index += 1) { const rawDb = Number.isFinite(rawMagnitudes[index]) ? rawMagnitudes[index] : FFT_SILENCE_DB if (bypassSmoothing) { smoothedMagnitudes[index] = rawDb continue } smoothedMagnitudes[index] = smoothingAmount * smoothedMagnitudes[index] + (1 - smoothingAmount) * rawDb if (!Number.isFinite(smoothedMagnitudes[index])) { smoothedMagnitudes[index] = FFT_SILENCE_DB } } return count } private fillSpectrumPoints( frequencyData: Float32Array, dataLength: number, width: number, height: number, minFrequency: number, maxFrequency: number, nyquist: number, tiltDbPerOctave: number, xOut: Float32Array, yOut: Float32Array, heatmapIntensityOut: Float32Array | null, capturePeakInfo = false, peakDbfsData: Float32Array | null = null, ): SpectrumPointFillResult { const bufferLength = Math.min(dataLength, frequencyData.length) if (bufferLength <= 0) { return { pointCount: 0, peakInfo: null } } const binWidth = nyquist / bufferLength const numPoints = Math.max(2, Math.floor(width)) for (let index = 0; index < numPoints; index += 1) { const t0 = index / (numPoints - 1) const t1 = Math.min(1, (index + 1) / (numPoints - 1)) const x = t0 * width const frequency0 = this.frequencyAtPosition(t0, minFrequency, maxFrequency) const frequency1 = this.frequencyAtPosition(t1, minFrequency, maxFrequency) const centerFrequency = (frequency0 + frequency1) * 0.5 const bin0 = frequency0 / binWidth const bin1 = frequency1 / binWidth const centerBin = (bin0 + bin1) * 0.5 const binSpan = Math.abs(bin1 - bin0) const resolvedPeak = (capturePeakInfo || binSpan > 1) ? this.resolvePeakInRange(frequencyData, bin0, bin1, binWidth) : null const rawDb = binSpan <= 1 ? this.getInterpolatedValue(frequencyData, Math.min(centerBin, bufferLength - 1)) : (resolvedPeak?.rawDb ?? this.getInterpolatedValue(frequencyData, Math.min(centerBin, bufferLength - 1))) const db = this.applyTilt(rawDb, centerFrequency, tiltDbPerOctave) const normalized = (db - this.options.minDecibels) / (this.options.maxDecibels - this.options.minDecibels) const clampedNormalized = Math.max(0, Math.min(1, normalized)) xOut[index] = x yOut[index] = height - clampedNormalized * height if (heatmapIntensityOut) { heatmapIntensityOut[index] = Math.pow(normalizeHeatDb(db), HEATMAP_GAMMA) } if (capturePeakInfo) { const peakFrequencyHz = resolvedPeak?.frequencyHz ?? centerFrequency this.primaryPointDb[index] = db this.primaryPointFrequency[index] = peakFrequencyHz const peakDbfsBin = Math.min( Math.max(0, peakFrequencyHz / binWidth), Math.max(0, (peakDbfsData?.length ?? 1) - 1), ) this.primaryPointDbfs[index] = peakDbfsData && peakDbfsData.length > 0 ? this.getQuadraticInterpolatedValue(peakDbfsData, peakDbfsBin) : rawDb } } return { pointCount: numPoints, peakInfo: capturePeakInfo ? this.selectPeakInfo(numPoints, height) : null, } } private buildPeakInfoAt(index: number, height: number): SpectrumPeakInfo | null { if ( index < 0 || index >= this.primaryPointDb.length || index >= this.primaryPointFrequency.length || index >= this.primaryPointX.length || index >= this.primaryPointY.length || index >= this.primaryPointDbfs.length ) { return null } const frequencyHz = this.primaryPointFrequency[index] const dbfs = this.primaryPointDbfs[index] return { dbfs, frequencyHz, normalizedX: this.primaryPointX[index] / Math.max(1, this.canvas.width), normalizedY: this.primaryPointY[index] / Math.max(1, height), key: formatSpectrumPitchInfo(resolveSpectrumPitchInfo(frequencyHz)), } } private isLocalPeak(index: number, pointCount: number): boolean { const currentDb = this.primaryPointDb[index] const previousDb = index > 0 ? this.primaryPointDb[index - 1] : Number.NEGATIVE_INFINITY const nextDb = index + 1 < pointCount ? this.primaryPointDb[index + 1] : Number.NEGATIVE_INFINITY return currentDb >= previousDb && currentDb >= nextDb && (currentDb > previousDb || currentDb > nextDb) } private getPeakSelectionScore(index: number): number { const frequencyHz = this.primaryPointFrequency[index] const db = this.primaryPointDb[index] if (!Number.isFinite(frequencyHz) || frequencyHz <= 0 || !Number.isFinite(db)) { return Number.NEGATIVE_INFINITY } const referenceFrequencyHz = Math.max(1, this.options.minFrequency) const octaveOffset = Math.max(0, Math.log2(frequencyHz / referenceFrequencyHz)) return db - octaveOffset * PEAK_SELECTION_LOW_FREQUENCY_BIAS_DB_PER_OCTAVE } private isBetterPeakIndex(candidateIndex: number, bestIndex: number): boolean { const candidateScore = this.getPeakSelectionScore(candidateIndex) const bestScore = this.getPeakSelectionScore(bestIndex) if (candidateScore !== bestScore) { return candidateScore > bestScore } const candidateDb = this.primaryPointDb[candidateIndex] const bestDb = this.primaryPointDb[bestIndex] if (candidateDb !== bestDb) { return candidateDb > bestDb } return this.primaryPointFrequency[candidateIndex] < this.primaryPointFrequency[bestIndex] } private findBestPeakIndex(candidateIndices: number[]): number { let bestIndex = candidateIndices[0] for (let index = 1; index < candidateIndices.length; index += 1) { const candidateIndex = candidateIndices[index] if (this.isBetterPeakIndex(candidateIndex, bestIndex)) { bestIndex = candidateIndex } } return bestIndex } private findBestPointIndex(pointCount: number): number { let bestIndex = 0 for (let index = 1; index < pointCount; index += 1) { if (this.isBetterPeakIndex(index, bestIndex)) { bestIndex = index } } return bestIndex } private findBestNearbyPeakIndex(candidateIndices: number[], targetFrequencyHz: number): number { let bestIndex = -1 for (const candidateIndex of candidateIndices) { const candidateFrequencyHz = this.primaryPointFrequency[candidateIndex] if (!Number.isFinite(candidateFrequencyHz) || candidateFrequencyHz <= 0) { continue } const octaveDistance = Math.abs(Math.log2(candidateFrequencyHz / targetFrequencyHz)) if (octaveDistance > PEAK_SELECTION_MAX_DISTANCE_OCTAVES) { continue } if (bestIndex === -1 || this.isBetterPeakIndex(candidateIndex, bestIndex)) { bestIndex = candidateIndex } } return bestIndex } private selectPeakInfo(pointCount: number, height: number): SpectrumPeakInfo | null { if (pointCount <= 0) { this.lastSelectedPeakInfo = null return null } const candidateIndices: number[] = [] for (let index = 0; index < pointCount; index += 1) { if (this.isLocalPeak(index, pointCount)) { candidateIndices.push(index) } } if (candidateIndices.length === 0) { candidateIndices.push(this.findBestPointIndex(pointCount)) } const strongestIndex = this.findBestPeakIndex(candidateIndices) const strongestScore = this.getPeakSelectionScore(strongestIndex) const previousPeak = this.lastSelectedPeakInfo let selectedIndex = strongestIndex if (previousPeak && Number.isFinite(previousPeak.frequencyHz) && previousPeak.frequencyHz > 0) { const stickyIndex = this.findBestNearbyPeakIndex(candidateIndices, previousPeak.frequencyHz) if ( stickyIndex !== -1 && strongestScore < ( this.getPeakSelectionScore(stickyIndex) + PEAK_SELECTION_SWITCH_THRESHOLD_DB + ( this.primaryPointFrequency[strongestIndex] > this.primaryPointFrequency[stickyIndex] ? PEAK_SELECTION_UPWARD_SWITCH_THRESHOLD_DB : 0 ) ) ) { selectedIndex = stickyIndex } } const peakInfo = this.buildPeakInfoAt(selectedIndex, height) this.lastSelectedPeakInfo = peakInfo return peakInfo } private emitPeakInfo(peakInfo: SpectrumPeakInfo | null): void { if (peakInfo === null) { this.lastSelectedPeakInfo = null } this.options.onPeakInfo(peakInfo) } private renderHeatmap(xPoints: Float32Array, yPoints: Float32Array, heatmapIntensity: Float32Array, pointCount: number, width: number, height: number): void { const baseColor = this.options.heatBaseColor const parsedBaseColor = baseColor ? parseColorToRgba(baseColor) : null const shouldRenderBaseColor = !!baseColor && baseColor !== 'transparent' && (!parsedBaseColor || parsedBaseColor.a > 0) for (let index = 0; index < pointCount; index += 1) { const x = Math.floor(xPoints[index]) const y = yPoints[index] const nextX = index < pointCount - 1 ? Math.floor(xPoints[index + 1]) : width const columnWidth = Math.max(1, nextX - x) const fillHeight = height - y if (fillHeight <= 0) { continue } if (shouldRenderBaseColor) { this.ctx.fillStyle = baseColor this.ctx.fillRect(x, Math.floor(y), columnWidth, Math.ceil(fillHeight)) } const lutIndex = Math.round(heatmapIntensity[index] * 255) const r = this.heatLut[lutIndex * 4] const g = this.heatLut[lutIndex * 4 + 1] const b = this.heatLut[lutIndex * 4 + 2] const a = Math.round((this.heatLut[lutIndex * 4 + 3] * heatmapIntensity[index])) if (a <= 0) { continue } this.ctx.fillStyle = a >= 255 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${Number((a / 255).toFixed(3))})` this.ctx.fillRect(x, Math.floor(y), columnWidth, Math.ceil(fillHeight)) } } private renderGradientFill(xPoints: Float32Array, yPoints: Float32Array, pointCount: number, width: number, height: number): void { this.ctx.beginPath() this.ctx.moveTo(xPoints[0], yPoints[0]) for (let index = 1; index < pointCount; index += 1) { this.ctx.lineTo(xPoints[index], yPoints[index]) } this.ctx.lineTo(width, height) this.ctx.lineTo(0, height) this.ctx.closePath() const gradient = this.ctx.createLinearGradient(0, height, 0, 0) const colors = this.options.gradientColors for (let index = 0; index < colors.length; index += 1) { gradient.addColorStop(index / (colors.length - 1), colors[index]) } this.ctx.fillStyle = gradient this.ctx.fill() } private renderStroke(xPoints: Float32Array, yPoints: Float32Array, pointCount: number, color: string, lineWidth: number): void { if (pointCount === 0) { return } this.ctx.beginPath() this.ctx.moveTo(xPoints[0], yPoints[0]) for (let index = 1; index < pointCount; index += 1) { this.ctx.lineTo(xPoints[index], yPoints[index]) } this.ctx.lineWidth = lineWidth this.ctx.strokeStyle = color this.ctx.lineCap = 'round' this.ctx.lineJoin = 'round' this.ctx.stroke() } private drawFrame = (): void => { const { canvas, options } = this const width = canvas.width const height = canvas.height const dpr = window.devicePixelRatio || 1 if (width <= 0 || height <= 0) { return } this.updateSampleRateIfNeeded() 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 (!this.dataSource.isPlaying()) { this.clearPendingSpectrumQueues() if (this.isNativeAvailable()) { this.nativeAnalyzer?.reset() } this.resetAnalyzerBuffers() this.renderStaticLayer(minFrequency, maxFrequency) this.emitPeakInfo(null) return } let primaryData: Float32Array | null = null let heatmapData: Float32Array | null = null let secondaryData: Float32Array | null = null let primaryDataLength = 0 let heatmapDataLength = 0 let secondaryDataLength = 0 let channelMaxDataLength = 0 if (!this.isNativeAvailable()) { this.clearPendingSpectrumQueues() console.error('SpectrumAnalyzer: Native DSP required') this.renderStaticLayer(minFrequency, maxFrequency) this.emitPeakInfo(null) return } const receivedNativeSamples = options.showSideLine || options.capturePeakInfo ? this.pushPendingSpectrumStereoChunks(this.dataSource.getPendingSpectrumStereoSamples()) : this.pushPendingSpectrumChunks(this.dataSource.getPendingSpectrumSamples()) this.ensureMagnitudeBufferSize() primaryData = this.nativeMagnitudeBuffer primaryDataLength = this.nativeAnalyzer?.fillMagnitudes(this.nativeMagnitudeBuffer) ?? 0 channelMaxDataLength = this.nativeAnalyzer?.fillChannelMaxMagnitudes(this.nativeChannelMaxMagnitudeBuffer) ?? 0 if (receivedNativeSamples > 0 || !this.nativeHasSpectrumData) { heatmapDataLength = this.nativeAnalyzer?.fillRawMagnitudes(this.nativeRawMagnitudeBuffer) ?? 0 if (heatmapDataLength > 0) { this.updateSmoothedMagnitudes( this.nativeRawMagnitudeBuffer, heatmapDataLength, this.heatmapMagnitudeBuffer, options.heatmapSmoothing, this.nativeBufferedSamples < options.fftSize, ) this.nativeHasSpectrumData = true } } else if (this.nativeHasSpectrumData) { heatmapDataLength = this.heatmapMagnitudeBuffer.length } heatmapData = this.nativeHasSpectrumData ? this.heatmapMagnitudeBuffer : null if (options.showSideLine) { secondaryData = this.nativeSideMagnitudeBuffer secondaryDataLength = this.nativeAnalyzer?.fillSideMagnitudes(this.nativeSideMagnitudeBuffer) ?? 0 } if (!primaryData || primaryDataLength === 0) { this.renderStaticLayer(minFrequency, maxFrequency) this.emitPeakInfo(null) return } const pointCount = Math.max(2, Math.floor(width)) this.ensurePointBuffers(pointCount) const primaryRender = this.fillSpectrumPoints( primaryData, primaryDataLength, width, height, minFrequency, maxFrequency, nyquist, options.tiltDbPerOctave, this.primaryPointX, this.primaryPointY, null, options.capturePeakInfo, channelMaxDataLength > 0 ? this.nativeChannelMaxMagnitudeBuffer : primaryData, ) const heatmapRender = heatmapData && heatmapDataLength > 0 ? this.fillSpectrumPoints( heatmapData, heatmapDataLength, width, height, minFrequency, maxFrequency, nyquist, options.heatmapTiltDbPerOctave, this.primaryPointX, this.heatmapPointY, this.primaryPointHeatmap, ) : { pointCount: 0, peakInfo: null } const secondaryRender = secondaryData && secondaryDataLength > 0 ? this.fillSpectrumPoints( secondaryData, secondaryDataLength, width, height, minFrequency, maxFrequency, nyquist, options.tiltDbPerOctave, this.secondaryPointX, this.secondaryPointY, null, ) : { pointCount: 0, peakInfo: null } this.renderStaticLayer(minFrequency, maxFrequency) if (options.heatmapFill && heatmapRender.pointCount > 0) { const renderPointCount = Math.min(primaryRender.pointCount, heatmapRender.pointCount) this.renderHeatmap( this.primaryPointX, this.primaryPointY, this.primaryPointHeatmap, renderPointCount, width, height, ) } else if (options.fillGradient && primaryRender.pointCount > 0) { this.renderGradientFill(this.primaryPointX, this.primaryPointY, primaryRender.pointCount, width, height) } this.renderStroke(this.primaryPointX, this.primaryPointY, primaryRender.pointCount, options.lineColor, options.lineWidth * dpr) if (secondaryRender.pointCount > 0) { const secondaryLineWidth = Math.max(dpr, options.lineWidth * SIDE_LINE_WIDTH_RATIO * dpr) this.renderStroke(this.secondaryPointX, this.secondaryPointY, secondaryRender.pointCount, options.secondaryLineColor, secondaryLineWidth) } this.emitPeakInfo(primaryRender.peakInfo) } 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 ctx.strokeStyle = options.gridColor ctx.lineWidth = dpr const dbSteps = [-80, -60, -40, -20, 0] ctx.fillStyle = options.gridColor ctx.font = `${10 * dpr}px monospace` ctx.textAlign = 'left' for (const db of dbSteps) { const normalized = (db - options.minDecibels) / (options.maxDecibels - options.minDecibels) const y = height - normalized * height ctx.beginPath() ctx.moveTo(0, y) ctx.lineTo(width, y) ctx.stroke() ctx.fillText(`${db}dB`, 4 * dpr, y - 2 * dpr) } const freqSteps = [50, 100, 200, 500, 1000, 2000, 5000, 10000] ctx.textAlign = 'center' for (const freq of freqSteps) { if (freq < minFrequency || freq > maxFrequency) continue let x: number if (options.scaleType === 'log') { const logMin = Math.log10(minFrequency) const logMax = Math.log10(maxFrequency) const logFreq = Math.log10(freq) x = ((logFreq - logMin) / (logMax - logMin)) * width } else { x = ((freq - minFrequency) / (maxFrequency - minFrequency)) * width } ctx.beginPath() ctx.moveTo(x, 0) ctx.lineTo(x, height) ctx.stroke() const label = freq >= 1000 ? `${freq / 1000}k` : `${freq}` ctx.fillText(label, x, height - 4 * dpr) } } dispose(): void { this.stop() this.frameLoop.dispose() if (this.unsubscribeSessionChange) { this.unsubscribeSessionChange() this.unsubscribeSessionChange = null } if (this.isNativeAvailable()) { this.nativeAnalyzer?.reset() } this.resetAnalyzerBuffers() this.lastSampleRate = 0 this.emitPeakInfo(null) } }