diff --git a/src/renderer/audio/AudioRouter.ts b/src/renderer/audio/AudioRouter.ts index b62f119..b50b33e 100644 --- a/src/renderer/audio/AudioRouter.ts +++ b/src/renderer/audio/AudioRouter.ts @@ -90,13 +90,13 @@ interface ScopeLatencyTracker { } type ScopeRingMap = { - spectrum: FixedChunkRing + spectrum: FixedChunkRing oscilloscope: FixedChunkRing vectorscope: FixedChunkRing spectrogram: FixedChunkRing vumeter: FixedChunkRing lufsmeter: FixedChunkRing - waveform: FixedChunkRing + waveform: FixedChunkRing } class FixedChunkRing { @@ -212,13 +212,13 @@ function createScopeLatencyTracker(): ScopeLatencyTracker { export class AudioRouter { private readonly rings: ScopeRingMap = { - spectrum: new FixedChunkRing(SCOPE_RING_CAPACITY.spectrum), + spectrum: new FixedChunkRing(SCOPE_RING_CAPACITY.spectrum), oscilloscope: new FixedChunkRing(SCOPE_RING_CAPACITY.oscilloscope), vectorscope: new FixedChunkRing(SCOPE_RING_CAPACITY.vectorscope), spectrogram: new FixedChunkRing(SCOPE_RING_CAPACITY.spectrogram), vumeter: new FixedChunkRing(SCOPE_RING_CAPACITY.vumeter), lufsmeter: new FixedChunkRing(SCOPE_RING_CAPACITY.lufsmeter), - waveform: new FixedChunkRing(SCOPE_RING_CAPACITY.waveform), + waveform: new FixedChunkRing(SCOPE_RING_CAPACITY.waveform), } private readonly scopeLatency: Record = { @@ -362,11 +362,12 @@ export class AudioRouter { if (len === 0) return const activeDemand = this.getActiveDemand() - const needsMono = Boolean(activeDemand.spectrum || activeDemand.spectrogram) - const needsStereo = Boolean(activeDemand.vectorscope || activeDemand.vumeter || activeDemand.lufsmeter) - const needsLeft = Boolean(activeDemand.oscilloscope || activeDemand.waveform) + const needsSpectrum = Boolean(activeDemand.spectrum) + const needsMono = Boolean(activeDemand.spectrogram) + const needsStereo = Boolean(activeDemand.vectorscope || activeDemand.vumeter || activeDemand.lufsmeter || activeDemand.waveform) + const needsLeft = Boolean(activeDemand.oscilloscope) - if (!needsMono && !needsStereo && !needsLeft) { + if (!needsSpectrum && !needsMono && !needsStereo && !needsLeft) { this.undemandedChunks += 1 return } @@ -388,8 +389,8 @@ export class AudioRouter { this.rings.oscilloscope.push({ samples: leftSamples, capturedAt, sequence }) } - if (activeDemand.spectrum && mono) { - this.rings.spectrum.push({ samples: mono, capturedAt, sequence }) + if (activeDemand.spectrum) { + this.rings.spectrum.push({ left: leftSamples, right: rightSamples, capturedAt, sequence }) } if (activeDemand.spectrogram && mono) { @@ -409,7 +410,7 @@ export class AudioRouter { } if (activeDemand.waveform) { - this.rings.waveform.push({ samples: leftSamples, capturedAt, sequence }) + this.rings.waveform.push({ left: leftSamples, right: rightSamples, capturedAt, sequence }) } } @@ -422,7 +423,20 @@ export class AudioRouter { flushPendingSpectrumSamples(): Float32Array[] { const records = this.rings.spectrum.drain() this.recordScopeDrain('spectrum', records) - return records.map((record) => record.samples) + return records.map((record) => { + const length = Math.min(record.left.length, record.right.length) + const mono = new Float32Array(length) + for (let index = 0; index < length; index += 1) { + mono[index] = (record.left[index] + record.right[index]) * 0.5 + } + return mono + }) + } + + flushPendingSpectrumStereoSamples(): { left: Float32Array; right: Float32Array }[] { + const records = this.rings.spectrum.drain() + this.recordScopeDrain('spectrum', records) + return records.map((record) => ({ left: record.left, right: record.right })) } flushPendingSpectrogramSamples(): Float32Array[] { @@ -452,7 +466,13 @@ export class AudioRouter { flushPendingWaveformSamples(): Float32Array[] { const records = this.rings.waveform.drain() this.recordScopeDrain('waveform', records) - return records.map((record) => record.samples) + return records.map((record) => record.left) + } + + flushPendingWaveformStereoSamples(): { left: Float32Array; right: Float32Array }[] { + const records = this.rings.waveform.drain() + this.recordScopeDrain('waveform', records) + return records.map((record) => ({ left: record.left, right: record.right })) } getDiagnosticsSnapshot(): AudioRouterDiagnostics { diff --git a/src/renderer/components/ScopeModule.tsx b/src/renderer/components/ScopeModule.tsx index 741a716..9286a6b 100644 --- a/src/renderer/components/ScopeModule.tsx +++ b/src/renderer/components/ScopeModule.tsx @@ -58,7 +58,7 @@ function getScopeTheme(theme: PrismResolvedTheme, kind: ScopeKind): ScopeModuleT return theme[kind] as ScopeModuleTheme } -function scopeSettingsToOptions( +export function scopeSettingsToOptions( kind: ScopeKind, settings: ScopeSettings[ScopeKind], theme: ScopeModuleTheme, @@ -69,6 +69,7 @@ function scopeSettingsToOptions( const t = theme as ResolvedSpectrumTheme return { lineColor: t.primary, + secondaryLineColor: t.secondary, gradientColors: t.fillGradient, heatColors: t.heatColors, backgroundColor: t.background, @@ -80,6 +81,7 @@ function scopeSettingsToOptions( showGrid: s.showGrid, fillGradient: s.fillGradient, smoothing: s.smoothing, + showSideLine: s.showSideLine, } } case 'oscilloscope': { @@ -164,6 +166,7 @@ function scopeSettingsToOptions( mid: t.midBand, high: t.highBand, }, + mode: s.mode, scrollSpeed: s.scrollSpeed, gainDb: s.gainDb, multiband: s.multiband, diff --git a/src/renderer/components/ScopePopoutBridge.tsx b/src/renderer/components/ScopePopoutBridge.tsx index 42413b5..15db3e2 100644 --- a/src/renderer/components/ScopePopoutBridge.tsx +++ b/src/renderer/components/ScopePopoutBridge.tsx @@ -20,10 +20,12 @@ function buildConsumerDemand(kind: ScopeKind): Record { }, {} as Record) } -function flushScopeAudioBatch(kind: ScopeKind): ScopePopoutAudioBatch { +function flushScopeAudioBatch(kind: ScopeKind, scopeSettings: ScopeSettings): ScopePopoutAudioBatch { switch (kind) { case 'spectrum': - return audioRouter.flushPendingSpectrumSamples() + return scopeSettings.spectrum.showSideLine + ? audioRouter.flushPendingSpectrumStereoSamples() + : audioRouter.flushPendingSpectrumSamples() case 'oscilloscope': return audioRouter.flushPendingOscilloscopeSamples() case 'vectorscope': @@ -35,7 +37,9 @@ function flushScopeAudioBatch(kind: ScopeKind): ScopePopoutAudioBatch { case 'lufsmeter': return audioRouter.flushPendingLUFSMeterSamples() case 'waveform': - return audioRouter.flushPendingWaveformSamples() + return scopeSettings.waveform.mode === 'stereo' + ? audioRouter.flushPendingWaveformStereoSamples() + : audioRouter.flushPendingWaveformSamples() } } @@ -171,7 +175,7 @@ export default function ScopePopoutBridge(): null { } for (const kind of activePopoutKindsRef.current) { - const batch = flushScopeAudioBatch(kind) + const batch = flushScopeAudioBatch(kind, useSettingsStore.getState().scopeSettings) if (batch.length > 0) { window.electronAPI.sendScopePopoutAudio(kind, batch) } diff --git a/src/renderer/components/ScopeSettingsSection.tsx b/src/renderer/components/ScopeSettingsSection.tsx index 5949d28..4f26767 100644 --- a/src/renderer/components/ScopeSettingsSection.tsx +++ b/src/renderer/components/ScopeSettingsSection.tsx @@ -22,7 +22,8 @@ export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind] switch (kind) { case 'spectrum': { const scopeSettings = settings as ScopeSettings['spectrum'] - return `${scopeSettings.heatmap ? 'Heat' : 'Fill'} · FFT ${scopeSettings.fftSize}` + const summary = `${scopeSettings.heatmap ? 'Heat' : 'Fill'} · FFT ${scopeSettings.fftSize}` + return scopeSettings.showSideLine ? `${summary} · Side` : summary } case 'oscilloscope': { const scopeSettings = settings as ScopeSettings['oscilloscope'] @@ -47,9 +48,14 @@ export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind] return 'Bar Meter' case 'waveform': { const scopeSettings = settings as ScopeSettings['waveform'] - return scopeSettings.multiband - ? `${scopeSettings.gainDb > 0 ? '+' : ''}${scopeSettings.gainDb} dB · RGB` - : `${scopeSettings.gainDb > 0 ? '+' : ''}${scopeSettings.gainDb} dB` + const summary = [`${scopeSettings.gainDb > 0 ? '+' : ''}${scopeSettings.gainDb} dB`] + if (scopeSettings.mode === 'stereo') { + summary.push('Stereo') + } + if (scopeSettings.multiband) { + summary.push('RGB') + } + return summary.join(' · ') } } } @@ -213,6 +219,11 @@ export default function ScopeSettingsSection({ active={current.showGrid} onClick={() => onUpdate('spectrum', { showGrid: !current.showGrid })} /> + onUpdate('spectrum', { showSideLine: !current.showSideLine })} + /> + + onUpdate('waveform', { mode: 'mono' })} + /> + onUpdate('waveform', { mode: 'stereo' })} + /> + + { + const left = resolveColorToRgb(color) + const right = resolveColorToRgb(LEGACY_DEFAULT_HEAT_COLORS[index]) + return left.r === right.r && left.g === right.g && left.b === right.b + }) +} + function buildHeatStops(colors: [string, string, string]): ColorStop[] { + if (isLegacyDefaultHeatColors(colors)) { + return [ + { at: 0, color: [0, 0, 0] }, + { at: 0.14, color: [15, 7, 33] }, + { at: 0.32, color: [61, 11, 94] }, + { at: 0.54, color: [163, 26, 121] }, + { at: 0.74, color: [255, 82, 87] }, + { at: 0.9, color: [255, 166, 63] }, + { at: 1, color: [255, 241, 209] }, + ] + } + const low = resolveColorToRgb(colors[0]) const mid = resolveColorToRgb(colors[1]) const high = resolveColorToRgb(colors[2]) diff --git a/src/renderer/visualizers/SpectrumAnalyzer.ts b/src/renderer/visualizers/SpectrumAnalyzer.ts index 341926e..3f35f76 100644 --- a/src/renderer/visualizers/SpectrumAnalyzer.ts +++ b/src/renderer/visualizers/SpectrumAnalyzer.ts @@ -11,12 +11,25 @@ import { clampSpectrumHeatmapTiltDbPerOctave, } from '../../types/spectrum' +type SpectrumStereoChunk = { + left: Float32Array + right: Float32Array +} + +type SpectrumPoint = { + x: number + y: number + heatmapIntensity: number +} + export interface SpectrumAnalyzerDataSource extends VisualizerSessionSource { getPendingSpectrumSamples: () => Float32Array[] + getPendingSpectrumStereoSamples: () => SpectrumStereoChunk[] } export interface SpectrumAnalyzerOptions { lineColor?: string + secondaryLineColor?: string lineWidth?: number fillGradient?: boolean heatmapFill?: boolean @@ -35,6 +48,7 @@ export interface SpectrumAnalyzerOptions { heatmapTiltDbPerOctave?: number tiltReferenceHz?: number fftSize?: number + showSideLine?: boolean dataSource?: SpectrumAnalyzerDataSource frameScheduler?: FrameScheduler } @@ -42,12 +56,87 @@ export interface SpectrumAnalyzerOptions { type ResolvedSpectrumAnalyzerOptions = Required> type HeatStop = { at: number; color: [number, number, number] } + 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 SPECTRUM_DB_FLOOR = -120 +const SPECTRUM_DB_CEILING = 12 +const SIDE_LINE_WIDTH_RATIO = 0.75 + +const hannWindowCache = new Map() + +function getHannWindow(size: number): Float32Array { + let window = hannWindowCache.get(size) + if (window) return window + + window = new Float32Array(size) + for (let index = 0; index < size; index += 1) { + window[index] = 0.5 * (1 - Math.cos((2 * Math.PI * index) / (size - 1))) + } + + hannWindowCache.set(size, window) + return window +} + +function fft(re: Float32Array, im: Float32Array): void { + const size = re.length + if (size <= 1) return + + let j = 0 + for (let i = 1; i < size; i += 1) { + let bit = size >> 1 + while (j & bit) { + j ^= bit + bit >>= 1 + } + j ^= bit + + if (i < j) { + let tmp = re[i] + re[i] = re[j] + re[j] = tmp + tmp = im[i] + im[i] = im[j] + im[j] = tmp + } + } + + for (let len = 2; len <= size; len <<= 1) { + const halfLen = len >> 1 + const angle = -2 * Math.PI / len + const wRe = Math.cos(angle) + const wIm = Math.sin(angle) + + for (let i = 0; i < size; i += len) { + let curRe = 1 + let curIm = 0 + + for (let k = 0; k < halfLen; k += 1) { + const evenIndex = i + k + const oddIndex = i + k + halfLen + + const tRe = curRe * re[oddIndex] - curIm * im[oddIndex] + const tIm = curRe * im[oddIndex] + curIm * re[oddIndex] + + re[oddIndex] = re[evenIndex] - tRe + im[oddIndex] = im[evenIndex] - tIm + re[evenIndex] += tRe + im[evenIndex] += tIm + + const nextRe = curRe * wRe - curIm * wIm + curIm = curRe * wIm + curIm * wRe + curRe = nextRe + } + } + } +} + function isLegacyDefaultHeatColors(colors: [string, string, string]): boolean { return colors.every((color, index) => { const left = resolveColorToRgb(color) @@ -87,28 +176,29 @@ function buildHeatStops(colors: [string, string, string]): HeatStop[] { function buildHeatLUT(colors: [string, string, string]): Uint8Array { const heatStops = buildHeatStops(colors) const lut = new Uint8Array(256 * 3) - for (let i = 0; i < 256; i++) { + for (let i = 0; i < 256; i += 1) { const t = i / 255 - let s = heatStops[0] - let e = heatStops[heatStops.length - 1] - for (let si = 0; si < heatStops.length - 1; si++) { - if (t <= heatStops[si + 1].at) { - s = heatStops[si] - e = heatStops[si + 1] + 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 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) - lut[i * 3 + 1] = Math.round(s.color[1] + (e.color[1] - s.color[1]) * a) - lut[i * 3 + 2] = Math.round(s.color[2] + (e.color[2] - s.color[2]) * a) + + const amount = Math.max(0, Math.min(1, (t - start.at) / Math.max(1e-6, end.at - start.at))) + lut[i * 3] = Math.round(start.color[0] + (end.color[0] - start.color[0]) * amount) + lut[i * 3 + 1] = Math.round(start.color[1] + (end.color[1] - start.color[1]) * amount) + lut[i * 3 + 2] = Math.round(start.color[2] + (end.color[2] - start.color[2]) * amount) } return lut } -const HEATMAP_GAMMA = 1.4 const defaultOptions: ResolvedSpectrumAnalyzerOptions = { lineColor: '#00ffff', + secondaryLineColor: 'rgba(0, 255, 255, 0.5)', lineWidth: 2, fillGradient: true, heatmapFill: false, @@ -127,10 +217,12 @@ const defaultOptions: ResolvedSpectrumAnalyzerOptions = { heatmapTiltDbPerOctave: DEFAULT_SPECTRUM_HEATMAP_TILT_DB_PER_OCTAVE, tiltReferenceHz: 1000, fftSize: 2048, + showSideLine: false, } const defaultSpectrumDataSource: SpectrumAnalyzerDataSource = { getPendingSpectrumSamples: () => audioRouter.flushPendingSpectrumSamples(), + getPendingSpectrumStereoSamples: () => audioRouter.flushPendingSpectrumStereoSamples(), ...defaultVisualizerSessionSource, } @@ -149,6 +241,15 @@ export class SpectrumAnalyzer { private staticLayerKey = '' private unsubscribeSessionChange: (() => void) | null = null + private jsMidHistory = new Float32Array(defaultOptions.fftSize) + private jsSideHistory = new Float32Array(defaultOptions.fftSize) + private jsMidMagnitudes = new Float32Array(defaultOptions.fftSize / 2) + private jsSideMagnitudes = new Float32Array(defaultOptions.fftSize / 2) + private jsFftRe = new Float32Array(defaultOptions.fftSize) + private jsFftIm = new Float32Array(defaultOptions.fftSize) + private jsBufferedSamples = 0 + private jsHasSpectrumData = false + constructor(canvas: HTMLCanvasElement, options: SpectrumAnalyzerOptions = {}) { this.canvas = canvas const ctx = canvas.getContext('2d') @@ -178,6 +279,7 @@ export class SpectrumAnalyzer { if (!staticLayerCtx) throw new Error('Could not get offscreen 2D context') this.staticLayerCtx = staticLayerCtx + this.resetJsState() this.initNative() this.subscribeToSessionChanges() } @@ -186,32 +288,61 @@ export class SpectrumAnalyzer { 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 (isNativeAvailable() && !this.nativeInitialized) { - this.sampleRate = Math.max(1, this.dataSource.getSampleRate()) - this.lastSampleRate = 0 nativeSpectrum.setFFTSize(this.options.fftSize) nativeSpectrum.setSampleRate(this.sampleRate) nativeSpectrum.setSmoothing(this.getNativeSmoothing()) this.nativeInitialized = true console.log(`SpectrumAnalyzer: Using native DSP (${this.sampleRate}Hz)`) - } else if (!isNativeAvailable()) { + } else if (!isNativeAvailable() && !this.options.showSideLine) { console.error('SpectrumAnalyzer: Native DSP not available!') } } + private ensureJsStateSize(): void { + const { fftSize } = this.options + if (this.jsMidHistory.length === fftSize) { + return + } + + this.jsMidHistory = new Float32Array(fftSize) + this.jsSideHistory = new Float32Array(fftSize) + this.jsMidMagnitudes = new Float32Array(fftSize / 2) + this.jsSideMagnitudes = new Float32Array(fftSize / 2) + this.jsFftRe = new Float32Array(fftSize) + this.jsFftIm = new Float32Array(fftSize) + } + + private resetJsState(): void { + this.ensureJsStateSize() + this.jsMidHistory.fill(0) + this.jsSideHistory.fill(0) + this.jsMidMagnitudes.fill(FFT_SILENCE_DB) + this.jsSideMagnitudes.fill(FFT_SILENCE_DB) + this.jsFftRe.fill(0) + this.jsFftIm.fill(0) + this.jsBufferedSamples = 0 + this.jsHasSpectrumData = false + } + private updateSampleRateIfNeeded(): void { - if (!isNativeAvailable()) return const currentRate = Math.max(1, this.dataSource.getSampleRate()) if (currentRate !== this.lastSampleRate && currentRate > 0) { this.sampleRate = currentRate this.lastSampleRate = currentRate - nativeSpectrum.setSampleRate(currentRate) + if (isNativeAvailable()) { + nativeSpectrum.setSampleRate(currentRate) + } console.log(`SpectrumAnalyzer: Sample rate updated to ${currentRate}Hz`) } } @@ -226,6 +357,7 @@ export class SpectrumAnalyzer { if (isNativeAvailable()) { nativeSpectrum.reset() } + this.resetJsState() this.sampleRate = Math.max(1, this.dataSource.getSampleRate()) this.lastSampleRate = 0 this.invalidate() @@ -240,12 +372,22 @@ export class SpectrumAnalyzer { if (optionUpdates.heatmapTiltDbPerOctave !== undefined) { nextOptions.heatmapTiltDbPerOctave = clampSpectrumHeatmapTiltDbPerOctave(optionUpdates.heatmapTiltDbPerOctave) } + + const shouldResetForOptions = ( + optionUpdates.fftSize !== undefined + || optionUpdates.smoothing !== undefined + || optionUpdates.showSideLine !== undefined + ) + this.options = nextOptions this.heatLut = buildHeatLUT(this.options.heatColors) + let didReset = false + if (dataSource && dataSource !== this.dataSource) { this.dataSource = dataSource this.subscribeToSessionChanges() this.resetState() + didReset = true } if (isNativeAvailable()) { @@ -257,8 +399,15 @@ export class SpectrumAnalyzer { } } + if (shouldResetForOptions && !didReset) { + this.resetState() + didReset = true + } + this.staticLayerKey = '' - this.invalidate() + if (!didReset) { + this.invalidate() + } } start(): void { @@ -309,7 +458,7 @@ export class SpectrumAnalyzer { } let peak = -Infinity - for (let i = lo; i <= hi; i++) { + for (let i = lo; i <= hi; i += 1) { peak = Math.max(peak, data[i]) } @@ -332,7 +481,9 @@ export class SpectrumAnalyzer { if (pendingSpectrum.length === 1) return pendingSpectrum[0] let totalLength = 0 - for (const chunk of pendingSpectrum) totalLength += chunk.length + for (const chunk of pendingSpectrum) { + totalLength += chunk.length + } const monoData = new Float32Array(totalLength) let offset = 0 @@ -344,8 +495,196 @@ export class SpectrumAnalyzer { return monoData } + private clearPendingSpectrumQueues(): void { + this.dataSource.getPendingSpectrumSamples() + this.dataSource.getPendingSpectrumStereoSamples() + } + + private pushJsSpectrumHistory(left: Float32Array, right: Float32Array, length: number): void { + const fftSize = this.options.fftSize + if (length >= fftSize) { + const start = length - fftSize + for (let index = 0; index < fftSize; index += 1) { + const leftValue = left[start + index] ?? 0 + const rightValue = right[start + index] ?? leftValue + this.jsMidHistory[index] = (leftValue + rightValue) * 0.5 + this.jsSideHistory[index] = (leftValue - rightValue) * 0.5 + } + this.jsBufferedSamples = fftSize + return + } + + this.jsMidHistory.copyWithin(0, length) + this.jsSideHistory.copyWithin(0, length) + const writeStart = fftSize - length + for (let index = 0; index < length; index += 1) { + const leftValue = left[index] ?? 0 + const rightValue = right[index] ?? leftValue + this.jsMidHistory[writeStart + index] = (leftValue + rightValue) * 0.5 + this.jsSideHistory[writeStart + index] = (leftValue - rightValue) * 0.5 + } + this.jsBufferedSamples = Math.min(fftSize, this.jsBufferedSamples + length) + } + + private updateJsMagnitudes(history: Float32Array, smoothedMagnitudes: Float32Array): void { + const fftSize = this.options.fftSize + const window = getHannWindow(fftSize) + + for (let index = 0; index < fftSize; index += 1) { + this.jsFftRe[index] = history[index] * window[index] + this.jsFftIm[index] = 0 + } + + fft(this.jsFftRe, this.jsFftIm) + + const scale = 2 / fftSize + const smoothing = Math.min(0.99, Math.max(0, this.options.smoothing)) + for (let index = 0; index < smoothedMagnitudes.length; index += 1) { + const magnitude = Math.hypot(this.jsFftRe[index], this.jsFftIm[index]) * scale + let db = 20 * Math.log10(Math.max(magnitude, 1e-10)) + db += 6 + db = Math.min(SPECTRUM_DB_CEILING, Math.max(SPECTRUM_DB_FLOOR, db)) + + if (this.jsBufferedSamples < fftSize) { + smoothedMagnitudes[index] = db + continue + } + + smoothedMagnitudes[index] = smoothing * smoothedMagnitudes[index] + (1 - smoothing) * db + if (!Number.isFinite(smoothedMagnitudes[index])) { + smoothedMagnitudes[index] = FFT_SILENCE_DB + } + } + } + + private processJsSpectrumChunks(pendingSpectrum: SpectrumStereoChunk[]): void { + let didReceiveAudio = false + for (const chunk of pendingSpectrum) { + const length = Math.min(chunk.left.length, chunk.right.length) + if (length <= 0) { + continue + } + + this.pushJsSpectrumHistory(chunk.left, chunk.right, length) + didReceiveAudio = true + } + + if (!didReceiveAudio) { + return + } + + this.updateJsMagnitudes(this.jsMidHistory, this.jsMidMagnitudes) + this.updateJsMagnitudes(this.jsSideHistory, this.jsSideMagnitudes) + this.jsHasSpectrumData = true + } + + private buildSpectrumPoints( + frequencyData: Float32Array, + width: number, + height: number, + minFrequency: number, + maxFrequency: number, + nyquist: number, + ): SpectrumPoint[] { + const bufferLength = frequencyData.length + const binWidth = nyquist / bufferLength + const points: SpectrumPoint[] = [] + 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 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, this.options.heatmapTiltDbPerOctave) + + const normalized = (db - this.options.minDecibels) / (this.options.maxDecibels - this.options.minDecibels) + const heatmapNormalized = (heatmapDb - this.options.minDecibels) / (this.options.maxDecibels - this.options.minDecibels) + + points.push({ + x, + y: height - Math.max(0, Math.min(1, normalized)) * height, + heatmapIntensity: Math.pow(Math.max(0, Math.min(1, heatmapNormalized)), HEATMAP_GAMMA), + }) + } + + return points + } + + private renderHeatmap(points: SpectrumPoint[], width: number, height: number): void { + for (let index = 0; index < points.length; index += 1) { + const x = Math.floor(points[index].x) + const y = points[index].y + const nextX = index < points.length - 1 ? Math.floor(points[index + 1].x) : width + const columnWidth = Math.max(1, nextX - x) + const fillHeight = height - y + if (fillHeight <= 0) { + continue + } + + const lutIndex = Math.round(points[index].heatmapIntensity * 255) + const r = this.heatLut[lutIndex * 3] + const g = this.heatLut[lutIndex * 3 + 1] + const b = this.heatLut[lutIndex * 3 + 2] + + this.ctx.fillStyle = `rgba(${r}, ${g}, ${b}, 0.85)` + this.ctx.fillRect(x, Math.floor(y), columnWidth, Math.ceil(fillHeight)) + } + } + + private renderGradientFill(points: SpectrumPoint[], width: number, height: number): void { + this.ctx.beginPath() + this.ctx.moveTo(points[0].x, points[0].y) + + for (let index = 1; index < points.length; index += 1) { + this.ctx.lineTo(points[index].x, points[index].y) + } + + 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(points: SpectrumPoint[], color: string, lineWidth: number): void { + if (points.length === 0) { + return + } + + this.ctx.beginPath() + this.ctx.moveTo(points[0].x, points[0].y) + for (let index = 1; index < points.length; index += 1) { + this.ctx.lineTo(points[index].x, points[index].y) + } + + this.ctx.lineWidth = lineWidth + this.ctx.strokeStyle = color + this.ctx.lineCap = 'round' + this.ctx.lineJoin = 'round' + this.ctx.stroke() + } + private drawFrame = (): void => { - const { canvas, ctx, options } = this + const { canvas, options } = this const width = canvas.width const height = canvas.height const dpr = window.devicePixelRatio || 1 @@ -353,11 +692,6 @@ export class SpectrumAnalyzer { return } - if (!isNativeAvailable()) { - console.error('SpectrumAnalyzer: Native DSP required') - return - } - this.updateSampleRateIfNeeded() const nyquist = this.sampleRate / 2 @@ -365,119 +699,66 @@ export class SpectrumAnalyzer { const maxFrequency = Math.max(minFrequency + 1, Math.min(options.maxFrequency, nyquist)) if (!this.dataSource.isPlaying()) { - this.dataSource.getPendingSpectrumSamples() - nativeSpectrum.reset() - this.renderStaticLayer(minFrequency, maxFrequency) - return - } - - const nativeTransport = this.dataSource.getNativeVisualizerTransport?.() ?? null - const pendingSpectrum = this.dataSource.getPendingSpectrumSamples() - if (!nativeTransport) { - const monoData = this.mergePendingSpectrumChunks(pendingSpectrum) - if (monoData) { - nativeSpectrum.pushSamples(monoData) + this.clearPendingSpectrumQueues() + if (isNativeAvailable()) { + nativeSpectrum.reset() } - } - - const frequencyData = nativeTransport - ? nativeTransport.getLatestSpectrumMagnitudes() - : nativeSpectrum.getMagnitudes() - if (!frequencyData) { + this.resetJsState() this.renderStaticLayer(minFrequency, maxFrequency) return } - const bufferLength = frequencyData.length - if (bufferLength === 0) { + let primaryData: Float32Array | null = null + let secondaryData: Float32Array | null = null + + if (options.showSideLine) { + this.processJsSpectrumChunks(this.dataSource.getPendingSpectrumStereoSamples()) + primaryData = this.jsHasSpectrumData ? this.jsMidMagnitudes : null + secondaryData = this.jsHasSpectrumData ? this.jsSideMagnitudes : null + } else { + if (!isNativeAvailable()) { + console.error('SpectrumAnalyzer: Native DSP required') + this.renderStaticLayer(minFrequency, maxFrequency) + return + } + + const nativeTransport = this.dataSource.getNativeVisualizerTransport?.() ?? null + const pendingSpectrum = this.dataSource.getPendingSpectrumSamples() + if (!nativeTransport) { + const monoData = this.mergePendingSpectrumChunks(pendingSpectrum) + if (monoData) { + nativeSpectrum.pushSamples(monoData) + } + } + + primaryData = nativeTransport + ? nativeTransport.getLatestSpectrumMagnitudes() + : nativeSpectrum.getMagnitudes() + } + + if (!primaryData || primaryData.length === 0) { + this.renderStaticLayer(minFrequency, maxFrequency) return } + const primaryPoints = this.buildSpectrumPoints(primaryData, width, height, minFrequency, maxFrequency, nyquist) + const secondaryPoints = secondaryData && secondaryData.length > 0 + ? this.buildSpectrumPoints(secondaryData, width, height, minFrequency, maxFrequency, nyquist) + : null + this.renderStaticLayer(minFrequency, maxFrequency) - const binWidth = nyquist / bufferLength - const points: { x: number; y: number; heatmapIntensity: number }[] = [] - const numPoints = Math.max(2, Math.floor(width)) - - for (let i = 0; i < numPoints; i++) { - const t0 = i / (numPoints - 1) - const t1 = Math.min(1, (i + 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 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) - - 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 - const heatmapIntensity = Math.pow(Math.max(0, Math.min(1, heatmapNormalized)), HEATMAP_GAMMA) - - points.push({ x, y, heatmapIntensity }) + if (options.heatmapFill && primaryPoints.length > 0) { + this.renderHeatmap(primaryPoints, width, height) + } else if (options.fillGradient && primaryPoints.length > 0) { + this.renderGradientFill(primaryPoints, width, height) } - if (options.heatmapFill && points.length > 0) { - for (let i = 0; i < points.length; i++) { - const x = Math.floor(points[i].x) - const y = points[i].y - const nextX = i < points.length - 1 ? Math.floor(points[i + 1].x) : width - const colWidth = Math.max(1, nextX - x) - const fillHeight = height - y - if (fillHeight <= 0) continue - - const intensity = points[i].heatmapIntensity - const li = Math.round(intensity * 255) - const r = this.heatLut[li * 3] - const g = this.heatLut[li * 3 + 1] - const b = this.heatLut[li * 3 + 2] - - ctx.fillStyle = `rgba(${r}, ${g}, ${b}, 0.85)` - ctx.fillRect(x, Math.floor(y), colWidth, Math.ceil(fillHeight)) - } - } else if (options.fillGradient && points.length > 0) { - ctx.beginPath() - ctx.moveTo(points[0].x, points[0].y) - - for (let i = 1; i < points.length; i++) { - ctx.lineTo(points[i].x, points[i].y) - } - - ctx.lineTo(width, height) - ctx.lineTo(0, height) - ctx.closePath() - - const gradient = ctx.createLinearGradient(0, height, 0, 0) - const colors = options.gradientColors - for (let i = 0; i < colors.length; i++) { - gradient.addColorStop(i / (colors.length - 1), colors[i]) - } - - ctx.fillStyle = gradient - ctx.fill() + this.renderStroke(primaryPoints, options.lineColor, options.lineWidth * dpr) + if (secondaryPoints && secondaryPoints.length > 0) { + const secondaryLineWidth = Math.max(dpr, options.lineWidth * SIDE_LINE_WIDTH_RATIO * dpr) + this.renderStroke(secondaryPoints, options.secondaryLineColor, secondaryLineWidth) } - - ctx.beginPath() - ctx.moveTo(points[0].x, points[0].y) - - for (let i = 1; i < points.length; i++) { - ctx.lineTo(points[i].x, points[i].y) - } - - ctx.lineWidth = options.lineWidth * dpr - ctx.strokeStyle = options.lineColor - ctx.lineCap = 'round' - ctx.lineJoin = 'round' - ctx.stroke() } private renderStaticLayer(minFrequency: number, maxFrequency: number): void { @@ -584,6 +865,7 @@ export class SpectrumAnalyzer { if (isNativeAvailable()) { nativeSpectrum.reset() } + this.resetJsState() this.lastSampleRate = 0 } } diff --git a/src/renderer/visualizers/Waveform.ts b/src/renderer/visualizers/Waveform.ts index 802183b..7c6692a 100644 --- a/src/renderer/visualizers/Waveform.ts +++ b/src/renderer/visualizers/Waveform.ts @@ -5,14 +5,22 @@ import { FrameScheduler } from './frameScheduler' import { VisualizerFrameLoop } from './visualizerFrameLoop' import { DEFAULT_WAVEFORM_GAIN_DB, + DEFAULT_WAVEFORM_MODE, DEFAULT_WAVEFORM_SCROLL_SPEED, clampWaveformGainDb, clampWaveformScrollSpeed, + type WaveformMode, } from '../../types/waveform' import { MultibandSplitter } from './multibandSplitter' +export interface WaveformStereoChunk { + left: Float32Array + right: Float32Array +} + export interface WaveformDataSource extends VisualizerSessionSource { getPendingWaveformSamples: () => Float32Array[] + getPendingWaveformStereoSamples: () => WaveformStereoChunk[] } export interface WaveformOptions { @@ -24,6 +32,7 @@ export interface WaveformOptions { mid: string high: string } + mode?: WaveformMode scrollSpeed?: number gainDb?: number multiband?: boolean @@ -42,6 +51,7 @@ const defaultOptions: ResolvedWaveformOptions = { mid: '#44dd44', high: '#4488ff', }, + mode: DEFAULT_WAVEFORM_MODE, scrollSpeed: DEFAULT_WAVEFORM_SCROLL_SPEED, gainDb: DEFAULT_WAVEFORM_GAIN_DB, multiband: false, @@ -52,16 +62,15 @@ const MULTIBAND_DOMINANCE_SENSITIVITY = 5 const MULTIBAND_FOCUSED_BLEND = 0.68 const MULTIBAND_FILL_ALPHA = 0.72 const MULTIBAND_EDGE_ALPHA = 1.0 +const BASE_PIXELS_PER_SECOND = 64 +const DISPLAY_MARGIN = 0.95 const defaultWaveformDataSource: WaveformDataSource = { getPendingWaveformSamples: () => audioRouter.flushPendingWaveformSamples(), + getPendingWaveformStereoSamples: () => audioRouter.flushPendingWaveformStereoSamples(), ...defaultVisualizerSessionSource, } -// Calibrate 1.0x to the prior 8s window at roughly 512px wide, -// while keeping scroll speed independent from panel width. -const BASE_PIXELS_PER_SECOND = 64 - export class Waveform { private canvas: HTMLCanvasElement private ctx: CanvasRenderingContext2D @@ -69,24 +78,25 @@ export class Waveform { private dataSource: WaveformDataSource 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) + private leftColumnAccumulator: Float32Array = new Float32Array(0) + private rightColumnAccumulator: Float32Array = new Float32Array(0) private columnAccumulatorPos = 0 private samplesPerColumn = 0 private lastSampleRate = 0 - // Multiband analysis private splitter = new MultibandSplitter() - private bandLowAcc: Float32Array = new Float32Array(0) - private bandMidAcc: Float32Array = new Float32Array(0) - private bandHighAcc: Float32Array = new Float32Array(0) + private leftBandLowAcc: Float32Array = new Float32Array(0) + private leftBandMidAcc: Float32Array = new Float32Array(0) + private leftBandHighAcc: Float32Array = new Float32Array(0) + private rightBandLowAcc: Float32Array = new Float32Array(0) + private rightBandMidAcc: Float32Array = new Float32Array(0) + private rightBandHighAcc: Float32Array = new Float32Array(0) private unsubscribeSessionChange: (() => void) | null = null constructor(canvas: HTMLCanvasElement, options: WaveformOptions = {}) { @@ -100,6 +110,7 @@ export class Waveform { this.options = { ...defaultOptions, ...optionOverrides, + mode: optionOverrides.mode ?? defaultOptions.mode, scrollSpeed: clampWaveformScrollSpeed(optionOverrides.scrollSpeed ?? defaultOptions.scrollSpeed), gainDb: clampWaveformGainDb(optionOverrides.gainDb ?? defaultOptions.gainDb), multiband: optionOverrides.multiband ?? defaultOptions.multiband, @@ -118,6 +129,7 @@ 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') @@ -149,10 +161,14 @@ export class Waveform { const next = Math.max(1, Math.round(sampleRate / pixelsPerSecond)) if (next !== this.samplesPerColumn) { this.samplesPerColumn = next - this.columnAccumulator = new Float32Array(next) - this.bandLowAcc = new Float32Array(next) - this.bandMidAcc = new Float32Array(next) - this.bandHighAcc = new Float32Array(next) + this.leftColumnAccumulator = new Float32Array(next) + this.rightColumnAccumulator = new Float32Array(next) + this.leftBandLowAcc = new Float32Array(next) + this.leftBandMidAcc = new Float32Array(next) + this.leftBandHighAcc = new Float32Array(next) + this.rightBandLowAcc = new Float32Array(next) + this.rightBandMidAcc = new Float32Array(next) + this.rightBandHighAcc = new Float32Array(next) this.columnAccumulatorPos = 0 } this.lastSampleRate = sampleRate @@ -164,6 +180,7 @@ export class Waveform { const nextOptions: ResolvedWaveformOptions = { ...this.options, ...optionUpdates, + mode: optionUpdates.mode ?? this.options.mode, lineColor: optionUpdates.lineColor ?? this.options.lineColor, scrollSpeed: clampWaveformScrollSpeed(optionUpdates.scrollSpeed ?? this.options.scrollSpeed), gainDb: clampWaveformGainDb(optionUpdates.gainDb ?? this.options.gainDb), @@ -171,20 +188,26 @@ export class Waveform { } const speedChanged = nextOptions.scrollSpeed !== this.options.scrollSpeed const multibandChanged = nextOptions.multiband !== this.options.multiband + const modeChanged = nextOptions.mode !== this.options.mode + const dataSourceChanged = Boolean(dataSource && dataSource !== this.dataSource) this.options = nextOptions - if (dataSource && dataSource !== this.dataSource) { + + if (dataSourceChanged && dataSource) { this.dataSource = dataSource this.subscribeToSessionChanges() - this.recomputeSamplesPerColumn() - this.resetDisplay() } - if (speedChanged) { + + if (dataSourceChanged || speedChanged) { this.recomputeSamplesPerColumn() - this.resetDisplay() } - if (multibandChanged) { + + if (multibandChanged || modeChanged) { this.splitter.reset() + } + + this.staticLayerKey = '' + if (dataSourceChanged || speedChanged || multibandChanged || modeChanged) { this.resetDisplay() } @@ -204,40 +227,46 @@ export class Waveform { } resize(): void { - // Resize handled in draw loop this.staticLayerKey = '' this.invalidate() } - private computeMinMax(): { min: number; max: number } { - let min = this.columnAccumulator[0] - let max = this.columnAccumulator[0] + private computeMinMax(samples: Float32Array): { min: number; max: number } { + if (this.columnAccumulatorPos === 0) { + return { min: 0, max: 0 } + } + + let min = samples[0] + let max = samples[0] for (let i = 1; i < this.columnAccumulatorPos; i++) { - const s = this.columnAccumulator[i] - if (s < min) min = s - if (s > max) max = s + const sample = samples[i] + if (sample < min) min = sample + if (sample > max) max = sample } return { min, max } } - private computeBandColor(): [number, number, number] { + private computeBandColor( + lowBandSamples: Float32Array, + midBandSamples: Float32Array, + highBandSamples: Float32Array, + ): [number, number, number] { const lowBand = this.toBandColorTuple(this.options.bandColors.low) const midBand = this.toBandColorTuple(this.options.bandColors.mid) const highBand = this.toBandColorTuple(this.options.bandColors.high) const n = this.columnAccumulatorPos if (n === 0) return midBand - // Compute RMS energy for each band let lowSum = 0 let midSum = 0 let highSum = 0 for (let i = 0; i < n; i++) { - const l = this.bandLowAcc[i] - const m = this.bandMidAcc[i] - const h = this.bandHighAcc[i] - lowSum += l * l - midSum += m * m - highSum += h * h + const low = lowBandSamples[i] + const mid = midBandSamples[i] + const high = highBandSamples[i] + lowSum += low * low + midSum += mid * mid + highSum += high * high } const lowRms = Math.sqrt(lowSum / n) @@ -290,40 +319,49 @@ export class Waveform { return [r, g, b] } - private shiftAndPaintColumn(min: number, max: number, width: number, height: number): void { - // Shift existing content left by 1 pixel — use 'copy' to avoid - // alpha accumulation from source-over compositing on semi-transparent pixels + private resolveColumnColor( + lowBandSamples: Float32Array, + midBandSamples: Float32Array, + highBandSamples: Float32Array, + ): [number, number, number] { + if (this.options.multiband) { + return this.computeBandColor(lowBandSamples, midBandSamples, highBandSamples) + } + + const lineColor = resolveColorToRgb(this.options.lineColor) + return [lineColor.r, lineColor.g, lineColor.b] + } + + private shiftWaterfall(): void { this.waterfallCtx.globalCompositeOperation = 'copy' this.waterfallCtx.drawImage(this.waterfallCanvas, -1, 0) this.waterfallCtx.globalCompositeOperation = 'source-over' + } - const centerY = height / 2 + private paintColumn( + min: number, + max: number, + width: number, + laneTop: number, + laneHeight: number, + color: [number, number, number], + ): void { const amplitudeGain = Math.pow(10, this.options.gainDb / 20) const scaledMin = Math.max(-1, Math.min(1, min * amplitudeGain)) const scaledMax = Math.max(-1, Math.min(1, max * amplitudeGain)) - const displayMargin = 0.95 // slight margin so full-scale doesn't clip at edge - const yTop = Math.round(centerY - scaledMax * centerY * displayMargin) - const yBottom = Math.round(centerY - scaledMin * centerY * displayMargin) + const centerY = laneTop + (laneHeight / 2) + const displayHalfHeight = (laneHeight / 2) * DISPLAY_MARGIN + const yTop = Math.round(centerY - scaledMax * displayHalfHeight) + const yBottom = Math.round(centerY - scaledMin * displayHalfHeight) const lineHeight = Math.max(1, yBottom - yTop) - let r: number, g: number, b: number - if (this.options.multiband) { - ;[r, g, b] = this.computeBandColor() - } else { - 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 const edgeAlpha = this.options.multiband ? MULTIBAND_EDGE_ALPHA : 0.9 + const [r, g, b] = color - // Draw the amplitude column — brighter at the edges, dimmer in the middle this.waterfallCtx.fillStyle = `rgba(${r}, ${g}, ${b}, ${fillAlpha})` this.waterfallCtx.fillRect(width - 1, yTop, 1, lineHeight) - // Bright edge pixels at min/max this.waterfallCtx.fillStyle = `rgba(${r}, ${g}, ${b}, ${edgeAlpha})` this.waterfallCtx.fillRect(width - 1, yTop, 1, 1) if (lineHeight > 1) { @@ -338,7 +376,7 @@ export class Waveform { } private ensureStaticLayer(width: number, height: number): void { - const key = `${width}:${height}` + const key = `${width}:${height}:${this.options.mode}` if (this.staticLayerKey === key) { return } @@ -351,9 +389,17 @@ export class Waveform { } private drawGrid(ctx: CanvasRenderingContext2D, width: number, height: number): void { + if (this.options.mode === 'stereo') { + this.drawStereoGrid(ctx, width, height) + return + } + + this.drawMonoGrid(ctx, width, height) + } + + private drawMonoGrid(ctx: CanvasRenderingContext2D, width: number, height: number): void { const centerY = height / 2 - // Center line (zero crossing) ctx.strokeStyle = this.options.gridMajorColor ctx.lineWidth = 1 ctx.beginPath() @@ -361,7 +407,6 @@ export class Waveform { ctx.lineTo(width, centerY) ctx.stroke() - // ±0.5 guide lines ctx.strokeStyle = this.options.gridMinorColor const quarterY = centerY * 0.5 ctx.beginPath() @@ -372,6 +417,124 @@ export class Waveform { ctx.stroke() } + private drawStereoGrid(ctx: CanvasRenderingContext2D, width: number, height: number): void { + const laneHeight = height / 2 + + ctx.strokeStyle = this.options.gridMajorColor + ctx.lineWidth = 1 + ctx.beginPath() + ctx.moveTo(0, laneHeight * 0.5) + ctx.lineTo(width, laneHeight * 0.5) + ctx.moveTo(0, laneHeight) + ctx.lineTo(width, laneHeight) + ctx.moveTo(0, laneHeight * 1.5) + ctx.lineTo(width, laneHeight * 1.5) + ctx.stroke() + + ctx.strokeStyle = this.options.gridMinorColor + ctx.beginPath() + ctx.moveTo(0, laneHeight * 0.25) + ctx.lineTo(width, laneHeight * 0.25) + ctx.moveTo(0, laneHeight * 0.75) + ctx.lineTo(width, laneHeight * 0.75) + ctx.moveTo(0, laneHeight * 1.25) + ctx.lineTo(width, laneHeight * 1.25) + ctx.moveTo(0, laneHeight * 1.75) + ctx.lineTo(width, laneHeight * 1.75) + ctx.stroke() + } + + private drainPendingSamples(): void { + if (this.options.mode === 'stereo') { + this.dataSource.getPendingWaveformStereoSamples() + return + } + + this.dataSource.getPendingWaveformSamples() + } + + private processMonoChunk(chunk: Float32Array, width: number, height: number): void { + let lowBand: Float32Array | null = null + let midBand: Float32Array | null = null + let highBand: Float32Array | null = null + if (this.options.multiband) { + const bands = this.splitter.split(chunk, chunk) + lowBand = bands.low.left + midBand = bands.mid.left + highBand = bands.high.left + } + + for (let i = 0; i < chunk.length; i++) { + this.leftColumnAccumulator[this.columnAccumulatorPos] = chunk[i] + if (lowBand && midBand && highBand) { + this.leftBandLowAcc[this.columnAccumulatorPos] = lowBand[i] + this.leftBandMidAcc[this.columnAccumulatorPos] = midBand[i] + this.leftBandHighAcc[this.columnAccumulatorPos] = highBand[i] + } + this.columnAccumulatorPos += 1 + + if (this.columnAccumulatorPos >= this.samplesPerColumn) { + const { min, max } = this.computeMinMax(this.leftColumnAccumulator) + const color = this.resolveColumnColor(this.leftBandLowAcc, this.leftBandMidAcc, this.leftBandHighAcc) + this.shiftWaterfall() + this.paintColumn(min, max, width, 0, height, color) + this.columnAccumulatorPos = 0 + } + } + } + + private processStereoChunk(chunk: WaveformStereoChunk, width: number, height: number): void { + const length = Math.min(chunk.left.length, chunk.right.length) + if (length === 0) { + return + } + + const leftSamples = chunk.left.length === length ? chunk.left : chunk.left.subarray(0, length) + const rightSamples = chunk.right.length === length ? chunk.right : chunk.right.subarray(0, length) + + let lowLeft: Float32Array | null = null + let midLeft: Float32Array | null = null + let highLeft: Float32Array | null = null + let lowRight: Float32Array | null = null + let midRight: Float32Array | null = null + let highRight: Float32Array | null = null + if (this.options.multiband) { + const bands = this.splitter.split(leftSamples, rightSamples) + lowLeft = bands.low.left + midLeft = bands.mid.left + highLeft = bands.high.left + lowRight = bands.low.right + midRight = bands.mid.right + highRight = bands.high.right + } + + const laneHeight = height / 2 + for (let i = 0; i < length; i++) { + this.leftColumnAccumulator[this.columnAccumulatorPos] = leftSamples[i] + this.rightColumnAccumulator[this.columnAccumulatorPos] = rightSamples[i] + if (lowLeft && midLeft && highLeft && lowRight && midRight && highRight) { + this.leftBandLowAcc[this.columnAccumulatorPos] = lowLeft[i] + this.leftBandMidAcc[this.columnAccumulatorPos] = midLeft[i] + this.leftBandHighAcc[this.columnAccumulatorPos] = highLeft[i] + this.rightBandLowAcc[this.columnAccumulatorPos] = lowRight[i] + this.rightBandMidAcc[this.columnAccumulatorPos] = midRight[i] + this.rightBandHighAcc[this.columnAccumulatorPos] = highRight[i] + } + this.columnAccumulatorPos += 1 + + if (this.columnAccumulatorPos >= this.samplesPerColumn) { + const leftMinMax = this.computeMinMax(this.leftColumnAccumulator) + const rightMinMax = this.computeMinMax(this.rightColumnAccumulator) + const leftColor = this.resolveColumnColor(this.leftBandLowAcc, this.leftBandMidAcc, this.leftBandHighAcc) + const rightColor = this.resolveColumnColor(this.rightBandLowAcc, this.rightBandMidAcc, this.rightBandHighAcc) + this.shiftWaterfall() + this.paintColumn(leftMinMax.min, leftMinMax.max, width, 0, laneHeight, leftColor) + this.paintColumn(rightMinMax.min, rightMinMax.max, width, laneHeight, laneHeight, rightColor) + this.columnAccumulatorPos = 0 + } + } + } + private drawFrame = (): void => { const width = this.canvas.width const height = this.canvas.height @@ -382,7 +545,6 @@ export class Waveform { this.ctx.imageSmoothingEnabled = false - // Handle resize: preserve existing content anchored to right edge if (this.waterfallCanvas.width !== width || this.waterfallCanvas.height !== height) { const previousCanvas = document.createElement('canvas') previousCanvas.width = this.waterfallCanvas.width @@ -403,7 +565,7 @@ export class Waveform { this.waterfallCtx.drawImage( previousCanvas, srcX, 0, srcW, previousCanvas.height, - dstX, 0, srcW, height + dstX, 0, srcW, height, ) } @@ -411,52 +573,27 @@ export class Waveform { this.staticLayerKey = '' } - // Handle sample rate changes const sampleRate = this.dataSource.getSampleRate() if (Math.abs(sampleRate - this.lastSampleRate) > 100) { this.recomputeSamplesPerColumn() } if (!this.dataSource.isPlaying()) { - this.dataSource.getPendingWaveformSamples() // drain - // Freeze display — show last waveform + this.drainPendingSamples() this.renderStaticLayer(width, height) this.ctx.drawImage(this.waterfallCanvas, 0, 0) return } - const pending = this.dataSource.getPendingWaveformSamples() - const samplesPerCol = this.samplesPerColumn - const multiband = this.options.multiband - - if (samplesPerCol > 0) { + if (this.options.mode === 'stereo') { + const pending = this.dataSource.getPendingWaveformStereoSamples() for (const chunk of pending) { - // When multiband is enabled, split each chunk through the crossover filters - let lowBand: Float32Array | null = null - let midBand: Float32Array | null = null - let highBand: Float32Array | null = null - if (multiband) { - const bands = this.splitter.split(chunk, chunk) - lowBand = bands.low.left - midBand = bands.mid.left - highBand = bands.high.left - } - - for (let i = 0; i < chunk.length; i++) { - this.columnAccumulator[this.columnAccumulatorPos] = chunk[i] - if (multiband && lowBand && midBand && highBand) { - this.bandLowAcc[this.columnAccumulatorPos] = lowBand[i] - this.bandMidAcc[this.columnAccumulatorPos] = midBand[i] - this.bandHighAcc[this.columnAccumulatorPos] = highBand[i] - } - this.columnAccumulatorPos++ - - if (this.columnAccumulatorPos >= samplesPerCol) { - const { min, max } = this.computeMinMax() - this.shiftAndPaintColumn(min, max, width, height) - this.columnAccumulatorPos = 0 - } - } + this.processStereoChunk(chunk, width, height) + } + } else { + const pending = this.dataSource.getPendingWaveformSamples() + for (const chunk of pending) { + this.processMonoChunk(chunk, width, height) } } diff --git a/src/types/settings.ts b/src/types/settings.ts index f35826a..e8af65c 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -2,6 +2,7 @@ import type { VectorscopeMode } from '../renderer/visualizers/Vectorscope' import type { SpectrogramClarityMode, SpectrogramScaleMode } from './spectrogram' import type { VUMeterMode, VUMeterOrientation } from './vumeter' import type { LUFSMeterMode } from './lufsmeter' +import { DEFAULT_WAVEFORM_MODE, type WaveformMode } from './waveform' export interface ScopeSettings { spectrum: { @@ -12,6 +13,7 @@ export interface ScopeSettings { showGrid: boolean smoothing: number fillGradient: boolean + showSideLine: boolean } oscilloscope: { pitchLock: boolean @@ -41,6 +43,7 @@ export interface ScopeSettings { mode: LUFSMeterMode } waveform: { + mode: WaveformMode scrollSpeed: number gainDb: number multiband: boolean @@ -48,11 +51,11 @@ export interface ScopeSettings { } export const DEFAULT_SCOPE_SETTINGS: ScopeSettings = { - spectrum: { fftSize: 2048, tiltDbPerOctave: 2.0, heatmap: false, heatmapTiltDbPerOctave: 2.0, showGrid: true, smoothing: 0.9, fillGradient: true }, + spectrum: { fftSize: 2048, tiltDbPerOctave: 2.0, heatmap: false, heatmapTiltDbPerOctave: 2.0, showGrid: true, smoothing: 0.9, fillGradient: true, showSideLine: false }, oscilloscope: { pitchLock: true, underfillEnabled: false, showGrid: true, lineWidth: 2 }, vectorscope: { mode: 'lissajous', multiband: false, showGrid: true, persistence: 0.10, lineWidth: 1.5 }, spectrogram: { fftSize: 2048, scrollSpeed: 2, clarityMode: 'sharper', scaleMode: 'log', colorScheme: 'heat' }, vumeter: { mode: 'bar', orientation: 'horizontal' }, lufsmeter: { mode: 'bar' }, - waveform: { scrollSpeed: 1, gainDb: 0, multiband: false }, + waveform: { mode: DEFAULT_WAVEFORM_MODE, scrollSpeed: 1, gainDb: 0, multiband: false }, } diff --git a/src/types/waveform.ts b/src/types/waveform.ts index 252832b..944149b 100644 --- a/src/types/waveform.ts +++ b/src/types/waveform.ts @@ -1,3 +1,5 @@ +export type WaveformMode = 'mono' | 'stereo' + export const MIN_WAVEFORM_SCROLL_SPEED = 0.5 export const MAX_WAVEFORM_SCROLL_SPEED = 8 export const WAVEFORM_SCROLL_SPEED_STEP = 0.5 @@ -6,6 +8,7 @@ export const MIN_WAVEFORM_GAIN_DB = -12 export const MAX_WAVEFORM_GAIN_DB = 18 export const WAVEFORM_GAIN_DB_STEP = 0.5 export const DEFAULT_WAVEFORM_GAIN_DB = 0 +export const DEFAULT_WAVEFORM_MODE: WaveformMode = 'mono' export function clampWaveformScrollSpeed(value: unknown): number { const numeric = Number(value) diff --git a/test/audio-router.test.ts b/test/audio-router.test.ts index c4a4468..8690eab 100644 --- a/test/audio-router.test.ts +++ b/test/audio-router.test.ts @@ -47,6 +47,69 @@ test('routes chunks only to demanded scopes and prunes queues when demand is rem assert.equal(router.flushPendingSpectrumSamples().length, 0) assert.equal(router.flushPendingWaveformSamples().length, 0) + assert.equal(router.flushPendingWaveformStereoSamples().length, 0) +}) + +test('spectrum keeps stereo chunks for the side overlay path and still exposes mono downmixes', () => { + const router = new AudioRouter() + const sessionId = router.beginSession(48000, 2, 'electron-system') + router.setVisualizerConsumerDemand('test-consumer', { spectrum: true }) + + router.ingestChunk(createChunk(2), createChunk(4), { + sessionId, + channelCount: 2, + sequence: 1, + capturedAt: performance.now() - 5, + }) + + const stereoChunks = router.flushPendingSpectrumStereoSamples() + assert.equal(stereoChunks.length, 1) + assert.deepEqual(Array.from(stereoChunks[0]?.left ?? []), [2, 2, 2, 2]) + assert.deepEqual(Array.from(stereoChunks[0]?.right ?? []), [4, 4, 4, 4]) + assert.equal(router.flushPendingSpectrumSamples().length, 0) + + router.ingestChunk(createChunk(6), createChunk(10), { + sessionId, + channelCount: 2, + sequence: 2, + capturedAt: performance.now() - 5, + }) + + const monoChunks = router.flushPendingSpectrumSamples() + assert.equal(monoChunks.length, 1) + assert.deepEqual(Array.from(monoChunks[0] ?? []), [8, 8, 8, 8]) + assert.equal(router.flushPendingSpectrumStereoSamples().length, 0) +}) + +test('waveform keeps stereo chunks for stereo mode while mono flushes still expose the left channel', () => { + const router = new AudioRouter() + const sessionId = router.beginSession(48000, 2, 'electron-system') + router.setVisualizerConsumerDemand('test-consumer', { waveform: true }) + + router.ingestChunk(createChunk(2), createChunk(4), { + sessionId, + channelCount: 2, + sequence: 1, + capturedAt: performance.now() - 5, + }) + + const stereoChunks = router.flushPendingWaveformStereoSamples() + assert.equal(stereoChunks.length, 1) + assert.deepEqual(Array.from(stereoChunks[0]?.left ?? []), [2, 2, 2, 2]) + assert.deepEqual(Array.from(stereoChunks[0]?.right ?? []), [4, 4, 4, 4]) + assert.equal(router.flushPendingWaveformSamples().length, 0) + + router.ingestChunk(createChunk(6), createChunk(10), { + sessionId, + channelCount: 2, + sequence: 2, + capturedAt: performance.now() - 5, + }) + + const monoChunks = router.flushPendingWaveformSamples() + assert.equal(monoChunks.length, 1) + assert.deepEqual(Array.from(monoChunks[0] ?? []), [6, 6, 6, 6]) + assert.equal(router.flushPendingWaveformStereoSamples().length, 0) }) test('keeps the newest chunks when a fixed-capacity ring overflows', () => { diff --git a/test/profile-library.test.ts b/test/profile-library.test.ts index 317901e..ea24cb1 100644 --- a/test/profile-library.test.ts +++ b/test/profile-library.test.ts @@ -64,6 +64,7 @@ function createProfile(name: string): Profile { windowBounds: { x: 120, y: 40, width: 420, height: 240 }, } profile.windowBounds = { x: 10, y: 20, width: 840, height: 180 } + profile.scopeSettings.spectrum.showSideLine = true profile.scopeSettings.spectrogram.colorScheme = 'mono' return profile } @@ -82,6 +83,7 @@ test('profile file serialization excludes geometry and round-trips with local me const restored = profileFileToProfile(file, extractLocalProfileMetadata(profile)) assert.deepEqual(restored.windowBounds, profile.windowBounds) assert.deepEqual(restored.scopePopouts.spectrum.windowBounds, profile.scopePopouts.spectrum.windowBounds) + assert.equal(restored.scopeSettings.spectrum.showSideLine, true) assert.equal(restored.scopeSettings.spectrogram.colorScheme, 'mono') }) @@ -195,6 +197,7 @@ test('partial files normalize, unsupported versions fail, and import does not ch const partialSnapshot = await harness.library.importProfileFromPath(partialPath) assert.equal(partialSnapshot.activeProfileId, 'profile_partial') + assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.spectrum.showSideLine, false) assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.spectrogram.colorScheme, 'heat') assert.equal(partialSnapshot.profiles.profile_partial.scopePopouts.spectrogram.poppedOut, true) assert.equal(partialSnapshot.profiles.profile_partial.widthWeights.spectrum, 1) diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index 0659c7e..bb3c4af 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -9,17 +9,21 @@ import { import { createDefaultProfile, } from '../src/shared/profileState' +import { createDefaultTheme, resolveTheme } from '../src/shared/themeState' import { usePerformanceStore } from '../src/renderer/stores/performanceStore' import { moveDockedScopeOrder, useSettingsStore, } from '../src/renderer/stores/settingsStore' +import { scopeSettingsToOptions } from '../src/renderer/components/ScopeModule' +import { scopeSummary } from '../src/renderer/components/ScopeSettingsSection' import { applyInputGainToStereoSamples, inputGainDbToLinear, } from '../src/renderer/audio/inputGain' import { SCOPE_KINDS, type ScopeKind } from '../src/types/scope' import type { ScopePopoutStateMap } from '../src/types/popout' +import { ScopePopoutDataSource } from '../src/renderer/popouts/ScopePopoutDataSource' import { VUMeterBallistics, VU_INTEGRATION_WINDOW_MS, @@ -499,6 +503,67 @@ test('moveDockedScopeOrder swaps a middle docked scope with its adjacent docked ]) }) +test('scopeSettingsToOptions wires spectrum side overlay settings into analyzer options', () => { + const profile = createDefaultProfile('Default') + profile.scopeSettings.spectrum.showSideLine = true + const theme = resolveTheme(createDefaultTheme()) + + const options = scopeSettingsToOptions('spectrum', profile.scopeSettings.spectrum, theme.spectrum) + + assert.equal(options.showSideLine, true) + assert.equal(options.secondaryLineColor, theme.spectrum.secondary) + assert.equal(options.lineColor, theme.spectrum.primary) +}) + +test('scopeSettingsToOptions wires waveform stereo mode into analyzer options', () => { + const profile = createDefaultProfile('Default') + profile.scopeSettings.waveform.mode = 'stereo' + profile.scopeSettings.waveform.multiband = true + const theme = resolveTheme(createDefaultTheme()) + + const options = scopeSettingsToOptions('waveform', profile.scopeSettings.waveform, theme.waveform) + + assert.equal(options.mode, 'stereo') + assert.equal(options.multiband, true) + assert.equal(options.lineColor, theme.waveform.primary) +}) + +test('scopeSummary includes Stereo for waveform only when stereo mode is enabled', () => { + const profile = createDefaultProfile('Default') + profile.scopeSettings.waveform.gainDb = 6 + + assert.equal(scopeSummary('waveform', profile.scopeSettings.waveform), '+6 dB') + + profile.scopeSettings.waveform.mode = 'stereo' + assert.equal(scopeSummary('waveform', profile.scopeSettings.waveform), '+6 dB · Stereo') + + profile.scopeSettings.waveform.multiband = true + assert.equal(scopeSummary('waveform', profile.scopeSettings.waveform), '+6 dB · Stereo · RGB') +}) + +test('ScopePopoutDataSource switches waveform batches between mono and stereo queues', () => { + const dataSource = new ScopePopoutDataSource('waveform') + const monoChunk = new Float32Array([0.1, 0.2, 0.3]) + const stereoLeft = new Float32Array([0.4, 0.5]) + const stereoRight = new Float32Array([0.6, 0.7]) + const nextMonoChunk = new Float32Array([0.8]) + + dataSource.pushAudioBatch([monoChunk]) + assert.equal(dataSource.getPendingWaveformSamples()[0], monoChunk) + assert.equal(dataSource.getPendingWaveformStereoSamples().length, 0) + + dataSource.pushAudioBatch([{ left: stereoLeft, right: stereoRight }]) + assert.equal(dataSource.getPendingWaveformSamples().length, 0) + const stereoBatch = dataSource.getPendingWaveformStereoSamples() + assert.equal(stereoBatch.length, 1) + assert.equal(stereoBatch[0]?.left, stereoLeft) + assert.equal(stereoBatch[0]?.right, stereoRight) + + dataSource.pushAudioBatch([nextMonoChunk]) + assert.equal(dataSource.getPendingWaveformStereoSamples().length, 0) + assert.equal(dataSource.getPendingWaveformSamples()[0], nextMonoChunk) +}) + test('applying a profile snapshot does not change the machine-local frame target', () => { const previousPerformanceState = usePerformanceStore.getState() const previousSettingsState = useSettingsStore.getState()