diff --git a/native/src/main.cpp b/native/src/main.cpp index acf5d7c..6f3dcb0 100644 --- a/native/src/main.cpp +++ b/native/src/main.cpp @@ -171,6 +171,30 @@ Napi::Value SpectrumGetMagnitudes(const Napi::CallbackInfo& info) { return result; } +Napi::Value SpectrumGetRawMagnitudes(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + const auto& magnitudes = spectrum.getRawMagnitudes(); + Napi::Float32Array result = Napi::Float32Array::New(env, magnitudes.size()); + memcpy(result.Data(), magnitudes.data(), magnitudes.size() * sizeof(float)); + return result; +} + +Napi::Value SpectrumFillRawMagnitudes(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 1 || !info[0].IsTypedArray()) { + Napi::TypeError::New(env, "Expected output Float32Array").ThrowAsJavaScriptException(); + return env.Null(); + } + + Napi::Float32Array output = info[0].As(); + const auto& magnitudes = spectrum.getRawMagnitudes(); + const size_t count = std::min(output.ElementLength(), magnitudes.size()); + if (count > 0) { + memcpy(output.Data(), magnitudes.data(), count * sizeof(float)); + } + return Napi::Number::New(env, static_cast(count)); +} + Napi::Value SpectrumFillMagnitudes(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); if (info.Length() < 1 || !info[0].IsTypedArray()) { @@ -344,7 +368,9 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) { specExports.Set("setSampleRate", Napi::Function::New(env, SpectrumSetSampleRate)); specExports.Set("setSmoothing", Napi::Function::New(env, SpectrumSetSmoothing)); specExports.Set("pushSamples", Napi::Function::New(env, SpectrumPushSamples)); + specExports.Set("fillRawMagnitudes", Napi::Function::New(env, SpectrumFillRawMagnitudes)); specExports.Set("fillMagnitudes", Napi::Function::New(env, SpectrumFillMagnitudes)); + specExports.Set("getRawMagnitudes", Napi::Function::New(env, SpectrumGetRawMagnitudes)); specExports.Set("getMagnitudes", Napi::Function::New(env, SpectrumGetMagnitudes)); specExports.Set("process", Napi::Function::New(env, SpectrumProcess)); specExports.Set("binToFrequency", Napi::Function::New(env, SpectrumBinToFrequency)); diff --git a/native/src/spectrum.cpp b/native/src/spectrum.cpp index c86a1f4..ae0d437 100644 --- a/native/src/spectrum.cpp +++ b/native/src/spectrum.cpp @@ -15,6 +15,7 @@ Spectrum::Spectrum(size_t fftSize) historyBuffer_.resize(fftSize, 0.0f); windowedInput_.resize(fftSize); magnitudes_.resize(fftSize / 2); + rawMagnitudes_.resize(fftSize / 2, -100.0f); // Initialize to silence (-100.0f dB) smoothedMagnitudes_.resize(fftSize / 2, -100.0f); } @@ -26,6 +27,7 @@ void Spectrum::setFFTSize(size_t size) { historyBuffer_.assign(size, 0.0f); windowedInput_.resize(size); magnitudes_.resize(size / 2); + rawMagnitudes_.resize(size / 2, -100.0f); // Initialize to silence (-100.0f dB) smoothedMagnitudes_.resize(size / 2, -100.0f); bufferedSamples_ = 0; @@ -97,6 +99,7 @@ void Spectrum::updateMagnitudes() { // Clamp to a stable display range. db = std::clamp(db, -120.0f, 12.0f); + rawMagnitudes_[i] = db; if (bufferedSamples_ < fftSize_) { smoothedMagnitudes_[i] = db; @@ -136,6 +139,7 @@ float Spectrum::binToFrequency(int bin) const { void Spectrum::reset() { std::fill(historyBuffer_.begin(), historyBuffer_.end(), 0.0f); + std::fill(rawMagnitudes_.begin(), rawMagnitudes_.end(), -100.0f); std::fill(smoothedMagnitudes_.begin(), smoothedMagnitudes_.end(), -100.0f); bufferedSamples_ = 0; } diff --git a/native/src/spectrum.h b/native/src/spectrum.h index edb8911..20b94f1 100644 --- a/native/src/spectrum.h +++ b/native/src/spectrum.h @@ -19,6 +19,9 @@ public: // Feed new samples into the rolling history and update the latest magnitudes. void pushSamples(const float* input, size_t length); + // Read the latest raw clamped dB magnitudes without mutating analyzer state. + const std::vector& getRawMagnitudes() const { return rawMagnitudes_; } + // Read the latest smoothed magnitudes without mutating analyzer state. const std::vector& getMagnitudes() const { return smoothedMagnitudes_; } @@ -41,6 +44,7 @@ private: std::vector historyBuffer_; std::vector windowedInput_; std::vector magnitudes_; + std::vector rawMagnitudes_; std::vector smoothedMagnitudes_; size_t bufferedSamples_; diff --git a/src/renderer/audio/NativeVisualizerTransport.ts b/src/renderer/audio/NativeVisualizerTransport.ts index 94aedf5..09ed069 100644 --- a/src/renderer/audio/NativeVisualizerTransport.ts +++ b/src/renderer/audio/NativeVisualizerTransport.ts @@ -74,6 +74,8 @@ export class NativeVisualizerTransport { private readonly bridge: NativeVisualizerTransportBridge private demand: Required = { ...EMPTY_DEMAND } private sampleRate = 48000 + private activeSessionId: number | null = null + private capturing = false constructor(bridge: NativeVisualizerTransportBridge = defaultBridge) { this.bridge = bridge @@ -119,6 +121,8 @@ export class NativeVisualizerTransport { reset(sessionState: NativeVisualizerTransportSessionState): void { this.sampleRate = Math.max(1, Math.floor(sessionState.sampleRate) || 1) + this.activeSessionId = sessionState.sessionId + this.capturing = sessionState.capturing if (!this.bridge.isAvailable()) { return @@ -142,6 +146,51 @@ export class NativeVisualizerTransport { this.bridge.vectorscope.setSampleRate(this.sampleRate) } } + + handleChunk( + left: Float32Array, + right: Float32Array, + sessionState: Pick, + ): void { + if (!this.bridge.isAvailable() || !this.capturing) { + return + } + if (this.activeSessionId === null || sessionState.sessionId !== this.activeSessionId) { + return + } + + if (this.demand.oscilloscope) { + this.bridge.oscilloscope.pushSamples(left) + } + + if (this.demand.spectrum) { + this.bridge.spectrum.pushSamples(this.downmixToMono(left, right, sessionState.channelCount)) + } + + if (this.demand.vectorscope) { + this.bridge.vectorscope.pushSamples(left, right) + } + } + + fillLatestSpectrumMagnitudes(output: Float32Array): number { + if (!this.bridge.isAvailable()) { + return 0 + } + return this.bridge.spectrum.fillMagnitudes(output) + } + + private downmixToMono(left: Float32Array, right: Float32Array, channelCount: number): Float32Array { + if (channelCount <= 1) { + return left + } + + const count = Math.min(left.length, right.length) + const mono = new Float32Array(count) + for (let index = 0; index < count; index += 1) { + mono[index] = (left[index] + right[index]) * 0.5 + } + return mono + } } export const nativeVisualizerTransport = new NativeVisualizerTransport() diff --git a/src/renderer/audio/native/index.ts b/src/renderer/audio/native/index.ts index 27acfa2..e877bda 100644 --- a/src/renderer/audio/native/index.ts +++ b/src/renderer/audio/native/index.ts @@ -109,6 +109,16 @@ export const spectrum = { nativeModule?.spectrum.pushSamples(audioData) }, + fillRawMagnitudes: (output: Float32Array): number => { + if (!nativeModule) return 0 + const magnitudes = nativeModule.spectrum.getRawMagnitudes() + const count = Math.min(output.length, magnitudes.length) + if (count > 0) { + output.set(magnitudes.subarray(0, count), 0) + } + return count + }, + fillMagnitudes: (output: Float32Array): number => { if (!nativeModule) return 0 const magnitudes = nativeModule.spectrum.getMagnitudes() @@ -124,6 +134,11 @@ export const spectrum = { return nativeModule.spectrum.getMagnitudes() }, + getRawMagnitudes: (): Float32Array | null => { + if (!nativeModule) return null + return nativeModule.spectrum.getRawMagnitudes() + }, + process: (audioData: Float32Array): Float32Array | null => { if (!nativeModule) return null return nativeModule.spectrum.process(audioData) diff --git a/src/renderer/audio/native/visualizer-dsp.d.ts b/src/renderer/audio/native/visualizer-dsp.d.ts index 90d7ace..0f7cb50 100644 --- a/src/renderer/audio/native/visualizer-dsp.d.ts +++ b/src/renderer/audio/native/visualizer-dsp.d.ts @@ -53,7 +53,9 @@ export interface SpectrumModule { setSampleRate(sampleRate: number): void; setSmoothing(smoothing: number): void; pushSamples(audioData: Float32Array): void; + fillRawMagnitudes(output: Float32Array): number; fillMagnitudes(output: Float32Array): number; + getRawMagnitudes(): Float32Array; getMagnitudes(): Float32Array; process(audioData: Float32Array): Float32Array; binToFrequency(bin: number): number; diff --git a/src/renderer/components/ScopeModule.tsx b/src/renderer/components/ScopeModule.tsx index 3dc7fb7..bc1e60f 100644 --- a/src/renderer/components/ScopeModule.tsx +++ b/src/renderer/components/ScopeModule.tsx @@ -83,6 +83,7 @@ export function scopeSettingsToOptions( tiltDbPerOctave: s.tiltDbPerOctave, heatmapFill: s.heatmap, heatmapTiltDbPerOctave: s.heatmapTiltDbPerOctave, + heatmapSmoothing: s.heatmapSmoothing, showGrid: s.showGrid, fillGradient: s.fillGradient, smoothing: s.smoothing, diff --git a/src/renderer/components/ScopeSettingsSection.tsx b/src/renderer/components/ScopeSettingsSection.tsx index fb9287d..223690c 100644 --- a/src/renderer/components/ScopeSettingsSection.tsx +++ b/src/renderer/components/ScopeSettingsSection.tsx @@ -264,6 +264,18 @@ export default function ScopeSettingsSection({ onChange={(value) => onUpdate('spectrum', { heatmapTiltDbPerOctave: value })} /> + onUpdate('spectrum', { heatmapSmoothing: value })} + /> + () function getHannWindow(size: number): Float32Array { @@ -197,6 +202,7 @@ const defaultOptions: ResolvedSpectrumAnalyzerOptions = { 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', @@ -239,6 +245,8 @@ export class SpectrumAnalyzer { private jsMidHistory = new Float32Array(defaultOptions.fftSize) private jsSideHistory = new Float32Array(defaultOptions.fftSize) + private jsMidRawMagnitudes = new Float32Array(defaultOptions.fftSize / 2) + private jsRawScratch = new Float32Array(defaultOptions.fftSize / 2) private jsMidMagnitudes = new Float32Array(defaultOptions.fftSize / 2) private jsSideMagnitudes = new Float32Array(defaultOptions.fftSize / 2) private jsFftRe = new Float32Array(defaultOptions.fftSize) @@ -246,9 +254,14 @@ export class SpectrumAnalyzer { private jsBufferedSamples = 0 private jsHasSpectrumData = false private nativeMagnitudeBuffer = new Float32Array(0) + private nativeRawMagnitudeBuffer = new Float32Array(0) + private heatmapMagnitudeBuffer = new Float32Array(0) + private nativeBufferedSamples = 0 + private nativeHasSpectrumData = false private pushScratch = 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) @@ -320,22 +333,44 @@ export class SpectrumAnalyzer { this.jsMidHistory = new Float32Array(fftSize) this.jsSideHistory = new Float32Array(fftSize) + this.jsMidRawMagnitudes = new Float32Array(fftSize / 2) + this.jsRawScratch = new Float32Array(fftSize / 2) this.jsMidMagnitudes = new Float32Array(fftSize / 2) this.jsSideMagnitudes = new Float32Array(fftSize / 2) this.jsFftRe = new Float32Array(fftSize) this.jsFftIm = new Float32Array(fftSize) } + 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.heatmapMagnitudeBuffer.length !== length) { + this.heatmapMagnitudeBuffer = new Float32Array(length) + } + } + private resetJsState(): void { this.ensureJsStateSize() + this.ensureMagnitudeBufferSize() this.jsMidHistory.fill(0) this.jsSideHistory.fill(0) + this.jsMidRawMagnitudes.fill(FFT_SILENCE_DB) this.jsMidMagnitudes.fill(FFT_SILENCE_DB) this.jsSideMagnitudes.fill(FFT_SILENCE_DB) + this.nativeMagnitudeBuffer.fill(FFT_SILENCE_DB) + this.nativeRawMagnitudeBuffer.fill(FFT_SILENCE_DB) + this.heatmapMagnitudeBuffer.fill(FFT_SILENCE_DB) this.jsFftRe.fill(0) this.jsFftIm.fill(0) this.jsBufferedSamples = 0 this.jsHasSpectrumData = false + this.nativeBufferedSamples = 0 + this.nativeHasSpectrumData = false } private updateSampleRateIfNeeded(): void { @@ -351,9 +386,9 @@ export class SpectrumAnalyzer { } private getNativeSmoothing(): number { - const base = Math.min(0.99, Math.max(0, this.options.smoothing)) + const base = clampSmoothing(this.options.smoothing) const fftRatio = Math.max(0.5, this.options.fftSize / 2048) - return Math.min(0.99, Math.max(0, Math.pow(base, fftRatio))) + return clampSmoothing(Math.pow(base, fftRatio)) } private resetState(): void { @@ -379,6 +414,7 @@ export class SpectrumAnalyzer { const shouldResetForOptions = ( optionUpdates.fftSize !== undefined || optionUpdates.smoothing !== undefined + || optionUpdates.heatmapSmoothing !== undefined || optionUpdates.showSideLine !== undefined ) @@ -479,39 +515,40 @@ export class SpectrumAnalyzer { return db + tiltDbPerOctave * octaves } - private ensureNativeMagnitudeBuffer(): Float32Array { - const length = Math.max(1, Math.floor(this.options.fftSize / 2)) - if (this.nativeMagnitudeBuffer.length !== length) { - this.nativeMagnitudeBuffer = new Float32Array(length) - } - return this.nativeMagnitudeBuffer - } - 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) } } - private pushPendingSpectrumChunks(pendingSpectrum: Float32Array[]): void { - if (pendingSpectrum.length === 0) return + 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) { nativeSpectrum.pushSamples(pendingSpectrum[0]) + this.recordNativeBufferedSamples(pendingSpectrum[0].length) } - return + return pendingSpectrum[0].length } let totalLength = 0 for (const chunk of pendingSpectrum) { totalLength += chunk.length } - if (totalLength === 0) return + if (totalLength === 0) return 0 if (this.pushScratch.length < totalLength) { this.pushScratch = new Float32Array(totalLength) @@ -530,6 +567,8 @@ export class SpectrumAnalyzer { } nativeSpectrum.pushSamples(merged) + this.recordNativeBufferedSamples(totalLength) + return totalLength } private clearPendingSpectrumQueues(): void { @@ -563,7 +602,40 @@ export class SpectrumAnalyzer { this.jsBufferedSamples = Math.min(fftSize, this.jsBufferedSamples + length) } - private updateJsMagnitudes(history: Float32Array, smoothedMagnitudes: Float32Array): void { + 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 updateJsMagnitudes( + history: Float32Array, + smoothedMagnitudes: Float32Array, + rawMagnitudesOut: Float32Array | null = null, + ): void { const fftSize = this.options.fftSize const window = getHannWindow(fftSize) @@ -575,23 +647,22 @@ export class SpectrumAnalyzer { fft(this.jsFftRe, this.jsFftIm) const scale = 2 / fftSize - const smoothing = Math.min(0.99, Math.max(0, this.options.smoothing)) + const rawMagnitudes = rawMagnitudesOut ?? this.jsRawScratch 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 - } + rawMagnitudes[index] = db } + + this.updateSmoothedMagnitudes( + rawMagnitudes, + rawMagnitudes.length, + smoothedMagnitudes, + this.options.smoothing, + this.jsBufferedSamples < fftSize, + ) } private processJsSpectrumChunks(pendingSpectrum: SpectrumStereoChunk[]): void { @@ -610,8 +681,15 @@ export class SpectrumAnalyzer { return } - this.updateJsMagnitudes(this.jsMidHistory, this.jsMidMagnitudes) + this.updateJsMagnitudes(this.jsMidHistory, this.jsMidMagnitudes, this.jsMidRawMagnitudes) this.updateJsMagnitudes(this.jsSideHistory, this.jsSideMagnitudes) + this.updateSmoothedMagnitudes( + this.jsMidRawMagnitudes, + this.jsMidRawMagnitudes.length, + this.heatmapMagnitudeBuffer, + this.options.heatmapSmoothing, + this.jsBufferedSamples < this.options.fftSize, + ) this.jsHasSpectrumData = true } @@ -623,6 +701,7 @@ export class SpectrumAnalyzer { minFrequency: number, maxFrequency: number, nyquist: number, + tiltDbPerOctave: number, xOut: Float32Array, yOut: Float32Array, heatmapIntensityOut: Float32Array | null, @@ -649,16 +728,15 @@ export class SpectrumAnalyzer { 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 db = this.applyTilt(rawDb, centerFrequency, tiltDbPerOctave) const normalized = (db - this.options.minDecibels) / (this.options.maxDecibels - this.options.minDecibels) - const heatmapNormalized = (heatmapDb - this.options.minDecibels) / (this.options.maxDecibels - this.options.minDecibels) + const clampedNormalized = Math.max(0, Math.min(1, normalized)) xOut[index] = x - yOut[index] = height - Math.max(0, Math.min(1, normalized)) * height + yOut[index] = height - clampedNormalized * height if (heatmapIntensityOut) { - heatmapIntensityOut[index] = Math.pow(Math.max(0, Math.min(1, heatmapNormalized)), HEATMAP_GAMMA) + heatmapIntensityOut[index] = Math.pow(clampedNormalized, HEATMAP_GAMMA) } } @@ -758,15 +836,19 @@ export class SpectrumAnalyzer { } let primaryData: Float32Array | null = null + let heatmapData: Float32Array | null = null let secondaryData: Float32Array | null = null let primaryDataLength = 0 + let heatmapDataLength = 0 let secondaryDataLength = 0 if (options.showSideLine) { this.processJsSpectrumChunks(this.dataSource.getPendingSpectrumStereoSamples()) primaryData = this.jsHasSpectrumData ? this.jsMidMagnitudes : null + heatmapData = this.jsHasSpectrumData ? this.heatmapMagnitudeBuffer : null secondaryData = this.jsHasSpectrumData ? this.jsSideMagnitudes : null primaryDataLength = primaryData?.length ?? 0 + heatmapDataLength = heatmapData?.length ?? 0 secondaryDataLength = secondaryData?.length ?? 0 } else { if (!isNativeAvailable()) { @@ -776,11 +858,29 @@ export class SpectrumAnalyzer { } const pendingSpectrum = this.dataSource.getPendingSpectrumSamples() - this.pushPendingSpectrumChunks(pendingSpectrum) + const receivedNativeSamples = this.pushPendingSpectrumChunks(pendingSpectrum) - const nativeMagnitudes = this.ensureNativeMagnitudeBuffer() - primaryData = nativeMagnitudes - primaryDataLength = nativeSpectrum.fillMagnitudes(nativeMagnitudes) + this.ensureMagnitudeBufferSize() + primaryData = this.nativeMagnitudeBuffer + primaryDataLength = nativeSpectrum.fillMagnitudes(this.nativeMagnitudeBuffer) + + if (receivedNativeSamples > 0 || !this.nativeHasSpectrumData) { + heatmapDataLength = nativeSpectrum.fillRawMagnitudes(this.nativeRawMagnitudeBuffer) + 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 (!primaryData || primaryDataLength === 0) { @@ -798,10 +898,34 @@ export class SpectrumAnalyzer { minFrequency, maxFrequency, nyquist, + options.tiltDbPerOctave, this.primaryPointX, this.primaryPointY, - this.primaryPointHeatmap, + null, ) + const heatmapPointCount = heatmapData && heatmapDataLength > 0 + ? this.fillSpectrumPoints( + heatmapData, + heatmapDataLength, + width, + height, + minFrequency, + maxFrequency, + nyquist, + options.heatmapTiltDbPerOctave, + this.primaryPointX, + this.heatmapPointY, + this.primaryPointHeatmap, + ) + : 0 + + if (heatmapPointCount > 0) { + const clipCount = Math.min(primaryPointCount, heatmapPointCount) + for (let index = 0; index < clipCount; index += 1) { + this.heatmapPointY[index] = Math.max(this.heatmapPointY[index], this.primaryPointY[index]) + } + } + const secondaryPointCount = secondaryData && secondaryDataLength > 0 ? this.fillSpectrumPoints( secondaryData, @@ -811,6 +935,7 @@ export class SpectrumAnalyzer { minFrequency, maxFrequency, nyquist, + options.tiltDbPerOctave, this.secondaryPointX, this.secondaryPointY, null, @@ -819,8 +944,15 @@ export class SpectrumAnalyzer { this.renderStaticLayer(minFrequency, maxFrequency) - if (options.heatmapFill && primaryPointCount > 0) { - this.renderHeatmap(this.primaryPointX, this.primaryPointY, this.primaryPointHeatmap, primaryPointCount, width, height) + if (options.heatmapFill && heatmapPointCount > 0) { + this.renderHeatmap( + this.primaryPointX, + this.heatmapPointY, + this.primaryPointHeatmap, + heatmapPointCount, + width, + height, + ) } else if (options.fillGradient && primaryPointCount > 0) { this.renderGradientFill(this.primaryPointX, this.primaryPointY, primaryPointCount, width, height) } diff --git a/src/types/settings.ts b/src/types/settings.ts index 1251dfa..b9478ca 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -10,6 +10,7 @@ export interface ScopeSettings { tiltDbPerOctave: number heatmap: boolean heatmapTiltDbPerOctave: number + heatmapSmoothing: number showGrid: boolean smoothing: number fillGradient: boolean @@ -59,7 +60,7 @@ 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, showSideLine: false }, + spectrum: { fftSize: 2048, tiltDbPerOctave: 2.0, heatmap: false, heatmapTiltDbPerOctave: 2.0, heatmapSmoothing: 0.5, 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' }, diff --git a/test/profile-library.test.ts b/test/profile-library.test.ts index bf970b6..8011c3f 100644 --- a/test/profile-library.test.ts +++ b/test/profile-library.test.ts @@ -66,6 +66,7 @@ function createProfile(name: string): Profile { } profile.windowBounds = { x: 10, y: 20, width: 840, height: 180 } profile.scopeSettings.spectrum.showSideLine = true + profile.scopeSettings.spectrum.heatmapSmoothing = 0.67 profile.scopeSettings.spectrogram.colorScheme = 'mono' return profile } @@ -80,6 +81,7 @@ test('profile file serialization excludes geometry and round-trips with local me assert.equal(JSON.stringify(file).includes('windowBounds'), false) assert.equal(JSON.stringify(file).includes('frameTarget'), false) assert.deepEqual(file.scopePopouts.spectrum, { poppedOut: true }) + assert.equal(file.scopeSettings.spectrum.heatmapSmoothing, 0.67) assert.equal(file.scopeOrder.includes('astra'), false) assert.equal(file.hiddenScopes.includes('astra'), true) assert.equal(file.widthWeights.astra, 1) @@ -88,6 +90,7 @@ test('profile file serialization excludes geometry and round-trips with local me 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.spectrum.heatmapSmoothing, 0.67) assert.equal(restored.scopeSettings.spectrogram.colorScheme, 'mono') assert.equal(restored.scopeSettings.astra.showControls, true) }) @@ -211,6 +214,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.spectrum.heatmapSmoothing, 0.5) 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 e952351..134f0af 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -54,6 +54,7 @@ import { type NativeVisualizerTransportBridge, } from '../src/renderer/audio/NativeVisualizerTransport' import { LUFSMeter } from '../src/renderer/visualizers/LUFSMeter' +import { SpectrumAnalyzer, type SpectrumAnalyzerOptions } from '../src/renderer/visualizers/SpectrumAnalyzer' import { MultibandBuffer, MultibandSplitter, @@ -458,9 +459,12 @@ function createFakeCanvasContext(): CanvasRenderingContext2D { fillRect() {}, fillText() {}, beginPath() {}, + closePath() {}, + fill() {}, moveTo() {}, lineTo() {}, stroke() {}, + drawImage() {}, save() {}, restore() {}, translate() {}, @@ -479,6 +483,8 @@ function createFakeCanvasContext(): CanvasRenderingContext2D { font: '', textAlign: 'left', textBaseline: 'top', + lineCap: 'butt', + lineJoin: 'miter', } as unknown as CanvasRenderingContext2D } @@ -491,6 +497,104 @@ function createFakeCanvas(): HTMLCanvasElement { } as unknown as HTMLCanvasElement } +function installFakeCanvasDom(): { + restore: () => void +} { + const globalWithDom = globalThis as typeof globalThis & { window?: Window; document?: Document } + const previousWindow = globalWithDom.window + const previousDocument = globalWithDom.document + + globalWithDom.window = { + ...(previousWindow ?? globalThis), + devicePixelRatio: 1, + } as Window + + globalWithDom.document = { + createElement(tagName: string) { + if (tagName !== 'canvas') { + throw new Error(`Unsupported element in test DOM: ${tagName}`) + } + return createFakeCanvas() + }, + } as Document + + return { + restore(): void { + if (previousWindow === undefined) { + delete globalWithDom.window + } else { + globalWithDom.window = previousWindow + } + + if (previousDocument === undefined) { + delete globalWithDom.document + } else { + globalWithDom.document = previousDocument + } + }, + } +} + +function assertArraysAlmostEqual(actual: number[], expected: number[], tolerance: number, message: string): void { + assert.equal(actual.length, expected.length, `${message}: length mismatch`) + for (let index = 0; index < actual.length; index += 1) { + if (Math.abs(actual[index] - expected[index]) > tolerance) { + assert.fail(`${message}: arrays diverged at index ${index}; expected ${expected[index]}, got ${actual[index]}`) + } + } +} + +function assertArraysDiffer(actual: number[], expected: number[], tolerance: number, message: string): void { + for (let index = 0; index < actual.length; index += 1) { + if (Math.abs(actual[index] - expected[index]) > tolerance) { + return + } + } + assert.fail(message) +} + +function renderSpectrumSnapshot(options: Partial): { + primaryPointY: number[] + heatmapPointY: number[] + heatmapIntensity: number[] +} { + const dom = installFakeCanvasDom() + const { left, right } = createProgramSamples(2048) + const dataSource = { + getPendingSpectrumSamples: () => [], + getPendingSpectrumStereoSamples: () => [{ left, right }], + getSampleRate: () => 48000, + isPlaying: () => true, + subscribeToSessionChanges: () => () => {}, + } + + const analyzer = new SpectrumAnalyzer(createFakeCanvas(), { + showSideLine: true, + heatmapFill: true, + fillGradient: false, + showGrid: false, + dataSource, + ...options, + }) + + try { + ;(analyzer as unknown as { drawFrame: () => void }).drawFrame() + const state = analyzer as unknown as { + primaryPointY: Float32Array + heatmapPointY: Float32Array + primaryPointHeatmap: Float32Array + } + return { + primaryPointY: Array.from(state.primaryPointY), + heatmapPointY: Array.from(state.heatmapPointY), + heatmapIntensity: Array.from(state.primaryPointHeatmap), + } + } finally { + analyzer.dispose() + dom.restore() + } +} + test('parseColorToRgb handles hex, rgb, rgba, and percentage formats', () => { assert.deepEqual(parseColorToRgb('#38bdf8'), { r: 56, g: 189, b: 248 }) assert.deepEqual(parseColorToRgb('#3bf'), { r: 51, g: 187, b: 255 }) @@ -776,17 +880,95 @@ 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 + profile.scopeSettings.spectrum.heatmapSmoothing = 0.64 const theme = resolveTheme(createDefaultTheme()) const options = scopeSettingsToOptions('spectrum', profile.scopeSettings.spectrum, theme.spectrum) assert.equal(options.showSideLine, true) + assert.equal(options.heatmapSmoothing, 0.64) assert.equal(options.secondaryLineColor, theme.spectrum.sideLine) assert.equal(options.lineColor, theme.spectrum.line) assert.equal(options.backgroundColor, theme.spectrum.background) assert.equal(options.gridColor, theme.spectrum.guides) }) +test('SpectrumAnalyzer heatmap output does not change when only line smoothing changes', () => { + const looseLine = renderSpectrumSnapshot({ + smoothing: 0, + heatmapSmoothing: 0.5, + }) + const tightLine = renderSpectrumSnapshot({ + smoothing: 0.99, + heatmapSmoothing: 0.5, + }) + + assertArraysDiffer( + looseLine.primaryPointY, + tightLine.primaryPointY, + 1e-6, + 'line path should respond to the line smoothing control', + ) + assertArraysAlmostEqual( + looseLine.heatmapIntensity, + tightLine.heatmapIntensity, + 1e-6, + 'heatmap intensity should ignore the line smoothing control', + ) + assert.equal( + looseLine.heatmapPointY.every((value, index) => value >= looseLine.primaryPointY[index]), + true, + ) + assert.equal( + tightLine.heatmapPointY.every((value, index) => value >= tightLine.primaryPointY[index]), + true, + ) +}) + +test('SpectrumAnalyzer line output does not change when only heatmap smoothing changes', () => { + const looseHeat = renderSpectrumSnapshot({ + smoothing: 0.9, + heatmapSmoothing: 0, + }) + const tightHeat = renderSpectrumSnapshot({ + smoothing: 0.9, + heatmapSmoothing: 0.99, + }) + + assertArraysAlmostEqual( + looseHeat.primaryPointY, + tightHeat.primaryPointY, + 1e-6, + 'line path should ignore the heatmap smoothing control', + ) + assertArraysDiffer( + looseHeat.heatmapIntensity, + tightHeat.heatmapIntensity, + 1e-6, + 'heatmap intensity should respond to the heatmap smoothing control', + ) + assert.equal( + looseHeat.heatmapPointY.every((value, index) => value >= looseHeat.primaryPointY[index]), + true, + ) + assert.equal( + tightHeat.heatmapPointY.every((value, index) => value >= tightHeat.primaryPointY[index]), + true, + ) +}) + +test('SpectrumAnalyzer clips heatmap fill to the spectrum line', () => { + const snapshot = renderSpectrumSnapshot({ + smoothing: 0.99, + heatmapSmoothing: 0, + }) + + assert.equal( + snapshot.heatmapPointY.every((value, index) => value >= snapshot.primaryPointY[index]), + true, + ) +}) + test('astra playback progress advances from updatedAt while playing', () => { const progress = getAstraPlaybackProgress({ playbackState: 'playing',