Files
prism/src/renderer/visualizers/SpectrumAnalyzer.ts
T
2026-07-10 21:53:47 -04:00

1257 lines
41 KiB
TypeScript

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<Omit<SpectrumAnalyzerOptions, 'dataSource' | 'frameScheduler' | 'nativeAnalyzer'>>
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<SpectrumAnalyzerOptions>): 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)
}
}