diff --git a/native/src/main.cpp b/native/src/main.cpp index 58ddf42..9fe602a 100644 --- a/native/src/main.cpp +++ b/native/src/main.cpp @@ -212,6 +212,14 @@ Napi::Value SpectrumGetSideMagnitudes(const Napi::CallbackInfo& info) { return result; } +Napi::Value SpectrumGetChannelMaxMagnitudes(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + const auto& magnitudes = spectrum.getChannelMaxMagnitudes(); + 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()) { @@ -260,6 +268,22 @@ Napi::Value SpectrumFillSideMagnitudes(const Napi::CallbackInfo& info) { return Napi::Number::New(env, static_cast(count)); } +Napi::Value SpectrumFillChannelMaxMagnitudes(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.getChannelMaxMagnitudes(); + 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 SpectrumProcess(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); if (info.Length() < 1 || !info[0].IsTypedArray()) { @@ -686,9 +710,11 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) { specExports.Set("fillRawMagnitudes", Napi::Function::New(env, SpectrumFillRawMagnitudes)); specExports.Set("fillMagnitudes", Napi::Function::New(env, SpectrumFillMagnitudes)); specExports.Set("fillSideMagnitudes", Napi::Function::New(env, SpectrumFillSideMagnitudes)); + specExports.Set("fillChannelMaxMagnitudes", Napi::Function::New(env, SpectrumFillChannelMaxMagnitudes)); specExports.Set("getRawMagnitudes", Napi::Function::New(env, SpectrumGetRawMagnitudes)); specExports.Set("getMagnitudes", Napi::Function::New(env, SpectrumGetMagnitudes)); specExports.Set("getSideMagnitudes", Napi::Function::New(env, SpectrumGetSideMagnitudes)); + specExports.Set("getChannelMaxMagnitudes", Napi::Function::New(env, SpectrumGetChannelMaxMagnitudes)); specExports.Set("process", Napi::Function::New(env, SpectrumProcess)); specExports.Set("binToFrequency", Napi::Function::New(env, SpectrumBinToFrequency)); specExports.Set("reset", Napi::Function::New(env, SpectrumReset)); diff --git a/native/src/spectrum.cpp b/native/src/spectrum.cpp index 8725f9f..df7f000 100644 --- a/native/src/spectrum.cpp +++ b/native/src/spectrum.cpp @@ -15,12 +15,16 @@ Spectrum::Spectrum(size_t fftSize) historyBuffer_.resize(fftSize, 0.0f); sideHistoryBuffer_.resize(fftSize, 0.0f); windowedInput_.resize(fftSize); - magnitudes_.resize(fftSize / 2); + midSpectrum_.resize(fftSize); + sideSpectrum_.resize(fftSize); rawMagnitudes_.resize(fftSize / 2, -100.0f); // Initialize to silence (-100.0f dB) smoothedMagnitudes_.resize(fftSize / 2, -100.0f); sideRawMagnitudes_.resize(fftSize / 2, -100.0f); sideSmoothedMagnitudes_.resize(fftSize / 2, -100.0f); + leftSmoothedMagnitudes_.resize(fftSize / 2, -100.0f); + rightSmoothedMagnitudes_.resize(fftSize / 2, -100.0f); + channelMaxMagnitudes_.resize(fftSize / 2, -100.0f); } void Spectrum::setFFTSize(size_t size) { @@ -30,12 +34,16 @@ void Spectrum::setFFTSize(size_t size) { historyBuffer_.assign(size, 0.0f); sideHistoryBuffer_.assign(size, 0.0f); windowedInput_.resize(size); - magnitudes_.resize(size / 2); + midSpectrum_.resize(size); + sideSpectrum_.resize(size); rawMagnitudes_.assign(size / 2, -100.0f); // Initialize to silence (-100.0f dB) smoothedMagnitudes_.assign(size / 2, -100.0f); sideRawMagnitudes_.assign(size / 2, -100.0f); sideSmoothedMagnitudes_.assign(size / 2, -100.0f); + leftSmoothedMagnitudes_.assign(size / 2, -100.0f); + rightSmoothedMagnitudes_.assign(size / 2, -100.0f); + channelMaxMagnitudes_.assign(size / 2, -100.0f); bufferedSamples_ = 0; } } @@ -94,69 +102,52 @@ void Spectrum::pushZeroHistory(std::vector& history, size_t length) { std::fill(history.begin() + keep, history.end(), 0.0f); } -void Spectrum::updateMagnitudesForHistory( - const std::vector& history, - std::vector& rawMagnitudes, - std::vector& smoothedMagnitudes -) { - if (history.empty() || magnitudes_.empty()) { - return; +float Spectrum::magnitudeToDb(float magnitude, float correctionDb) const { + const float db = 20.0f * log10f(std::max(magnitude, 1e-10f)) + correctionDb; + return std::clamp(db, -120.0f, 12.0f); +} + +void Spectrum::updateSmoothedMagnitude(float db, float& smoothedMagnitude) { + if (bufferedSamples_ < fftSize_) { + smoothedMagnitude = db; + } else { + smoothedMagnitude = smoothing_ * smoothedMagnitude + (1.0f - smoothing_) * db; } - - // Always analyze a full FFT frame from the rolling buffer. - applyWindow(history.data(), windowedInput_.data(), fftSize_); - - // Perform FFT - fft_->forward(windowedInput_.data(), magnitudes_.data()); - - // Convert to dB and apply smoothing - for (size_t i = 0; i < magnitudes_.size(); i++) { - float mag = magnitudes_[i]; - - // Convert to dB - // Add epsilon to avoid log(0) - float db = 20.0f * log10f(std::max(mag, 1e-10f)); - - // Compensate Hann window coherent gain (about -6 dB). - db += 6.0f; - - // Clamp to a stable display range. - db = std::clamp(db, -120.0f, 12.0f); - rawMagnitudes[i] = db; - - if (bufferedSamples_ < fftSize_) { - smoothedMagnitudes[i] = db; - continue; - } - - // Apply temporal smoothing only (no bin-to-bin averaging). - smoothedMagnitudes[i] = smoothing_ * smoothedMagnitudes[i] + (1.0f - smoothing_) * db; - - // Safety check - if (!std::isfinite(smoothedMagnitudes[i])) { - smoothedMagnitudes[i] = -100.0f; - } + if (!std::isfinite(smoothedMagnitude)) { + smoothedMagnitude = -100.0f; } } void Spectrum::updateMagnitudes() { - updateMagnitudesForHistory(historyBuffer_, rawMagnitudes_, smoothedMagnitudes_); - updateMagnitudesForHistory(sideHistoryBuffer_, sideRawMagnitudes_, sideSmoothedMagnitudes_); -} + if (historyBuffer_.empty() || rawMagnitudes_.empty()) { + return; + } -void Spectrum::updateSilentSideMagnitudes() { - const float silentDb = -120.0f; - for (size_t i = 0; i < sideSmoothedMagnitudes_.size(); i++) { - sideRawMagnitudes_[i] = silentDb; - if (bufferedSamples_ < fftSize_) { - sideSmoothedMagnitudes_[i] = silentDb; - continue; - } + applyWindow(historyBuffer_.data(), windowedInput_.data(), fftSize_); + fft_->forward(windowedInput_.data(), midSpectrum_.data()); + applyWindow(sideHistoryBuffer_.data(), windowedInput_.data(), fftSize_); + fft_->forward(windowedInput_.data(), sideSpectrum_.data()); - sideSmoothedMagnitudes_[i] = smoothing_ * sideSmoothedMagnitudes_[i] + (1.0f - smoothing_) * silentDb; - if (!std::isfinite(sideSmoothedMagnitudes_[i])) { - sideSmoothedMagnitudes_[i] = -100.0f; - } + const float scale = 2.0f / static_cast(fftSize_); + const float coherentGain = fftSize_ > 1 + ? static_cast(fftSize_ - 1) / (2.0f * static_cast(fftSize_)) + : 1.0f; + const float correctionDb = -20.0f * log10f(coherentGain); + for (size_t i = 0; i < rawMagnitudes_.size(); i++) { + const std::complex mid = midSpectrum_[i]; + const std::complex side = sideSpectrum_[i]; + const float midDb = magnitudeToDb(std::abs(mid) * scale, correctionDb); + const float sideDb = magnitudeToDb(std::abs(side) * scale, correctionDb); + const float leftDb = magnitudeToDb(std::abs(mid + side) * scale, correctionDb); + const float rightDb = magnitudeToDb(std::abs(mid - side) * scale, correctionDb); + + rawMagnitudes_[i] = midDb; + sideRawMagnitudes_[i] = sideDb; + updateSmoothedMagnitude(midDb, smoothedMagnitudes_[i]); + updateSmoothedMagnitude(sideDb, sideSmoothedMagnitudes_[i]); + updateSmoothedMagnitude(leftDb, leftSmoothedMagnitudes_[i]); + updateSmoothedMagnitude(rightDb, rightSmoothedMagnitudes_[i]); + channelMaxMagnitudes_[i] = std::max(leftSmoothedMagnitudes_[i], rightSmoothedMagnitudes_[i]); } } @@ -165,8 +156,7 @@ void Spectrum::pushSamples(const float* input, size_t length) { pushHistory(historyBuffer_, input, length); pushZeroHistory(sideHistoryBuffer_, length); bufferedSamples_ = length >= fftSize_ ? fftSize_ : std::min(fftSize_, bufferedSamples_ + length); - updateMagnitudesForHistory(historyBuffer_, rawMagnitudes_, smoothedMagnitudes_); - updateSilentSideMagnitudes(); + updateMagnitudes(); return; } updateMagnitudes(); @@ -215,6 +205,9 @@ void Spectrum::reset() { std::fill(smoothedMagnitudes_.begin(), smoothedMagnitudes_.end(), -100.0f); std::fill(sideRawMagnitudes_.begin(), sideRawMagnitudes_.end(), -100.0f); std::fill(sideSmoothedMagnitudes_.begin(), sideSmoothedMagnitudes_.end(), -100.0f); + std::fill(leftSmoothedMagnitudes_.begin(), leftSmoothedMagnitudes_.end(), -100.0f); + std::fill(rightSmoothedMagnitudes_.begin(), rightSmoothedMagnitudes_.end(), -100.0f); + std::fill(channelMaxMagnitudes_.begin(), channelMaxMagnitudes_.end(), -100.0f); bufferedSamples_ = 0; } diff --git a/native/src/spectrum.h b/native/src/spectrum.h index ed700cf..ca1ffca 100644 --- a/native/src/spectrum.h +++ b/native/src/spectrum.h @@ -26,6 +26,7 @@ public: // Read the latest smoothed magnitudes without mutating analyzer state. const std::vector& getMagnitudes() const { return smoothedMagnitudes_; } const std::vector& getSideMagnitudes() const { return sideSmoothedMagnitudes_; } + const std::vector& getChannelMaxMagnitudes() const { return channelMaxMagnitudes_; } // Process audio and get spectrum data // Returns magnitude data (size = fftSize / 2) @@ -46,22 +47,22 @@ private: std::vector historyBuffer_; std::vector sideHistoryBuffer_; std::vector windowedInput_; - std::vector magnitudes_; + std::vector> midSpectrum_; + std::vector> sideSpectrum_; std::vector rawMagnitudes_; std::vector smoothedMagnitudes_; std::vector sideRawMagnitudes_; std::vector sideSmoothedMagnitudes_; + std::vector leftSmoothedMagnitudes_; + std::vector rightSmoothedMagnitudes_; + std::vector channelMaxMagnitudes_; size_t bufferedSamples_; void applyWindow(const float* input, float* output, size_t length); void pushHistory(std::vector& history, const float* input, size_t length); void pushZeroHistory(std::vector& history, size_t length); - void updateMagnitudesForHistory( - const std::vector& history, - std::vector& rawMagnitudes, - std::vector& smoothedMagnitudes - ); - void updateSilentSideMagnitudes(); + float magnitudeToDb(float magnitude, float correctionDb) const; + void updateSmoothedMagnitude(float db, float& smoothedMagnitude); void updateMagnitudes(); }; diff --git a/package.json b/package.json index fbcfe80..240985d 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "test:secret-vault": "node scripts/run-secret-vault-tests.mjs", "test:window-state": "node scripts/run-window-state-tests.mjs", "test:renderer-helpers": "node scripts/run-renderer-helper-tests.mjs", + "test:spectrum-native": "node --test test/spectrum-native.test.mjs", "test:build-metadata": "node scripts/run-build-metadata-tests.mjs", "test:updates": "node scripts/run-update-tests.mjs", "plugin-ui:dev": "vite --config vite.plugin-ui.config.ts", diff --git a/plugin/Source/SpectrumEngine.h b/plugin/Source/SpectrumEngine.h index 038051d..5766cff 100644 --- a/plugin/Source/SpectrumEngine.h +++ b/plugin/Source/SpectrumEngine.h @@ -39,6 +39,7 @@ public: obj->setProperty("sampleRate", sampleRate); obj->setProperty("magnitudes", toBase64(spectrum.getMagnitudes())); obj->setProperty("side", toBase64(spectrum.getSideMagnitudes())); + obj->setProperty("channelMax", toBase64(spectrum.getChannelMaxMagnitudes())); return juce::var(obj); } diff --git a/src/plugin-ui/BridgeSpectrumAnalyzer.ts b/src/plugin-ui/BridgeSpectrumAnalyzer.ts index 672a919..e85acc9 100644 --- a/src/plugin-ui/BridgeSpectrumAnalyzer.ts +++ b/src/plugin-ui/BridgeSpectrumAnalyzer.ts @@ -20,15 +20,17 @@ export class BridgeSpectrumAnalyzer implements SpectrumNativeAnalyzer { private sampleRate = 48000 private magnitudes: Float32Array private sideMagnitudes: Float32Array + private channelMaxMagnitudes: Float32Array constructor(fftSize = 2048) { this.fftSize = fftSize this.magnitudes = new Float32Array(fftSize / 2).fill(FFT_SILENCE_DB) this.sideMagnitudes = new Float32Array(fftSize / 2).fill(FFT_SILENCE_DB) + this.channelMaxMagnitudes = new Float32Array(fftSize / 2).fill(FFT_SILENCE_DB) } /** Called by the bridge whenever the host emits a new frame. */ - setMagnitudes(magnitudes: Float32Array, side?: Float32Array): void { + setMagnitudes(magnitudes: Float32Array, side?: Float32Array, channelMax?: Float32Array): void { if (magnitudes.length !== this.magnitudes.length) { this.magnitudes = new Float32Array(magnitudes.length) } @@ -40,6 +42,12 @@ export class BridgeSpectrumAnalyzer implements SpectrumNativeAnalyzer { } this.sideMagnitudes.set(side) } + + const resolvedChannelMax = channelMax && channelMax.length > 0 ? channelMax : magnitudes + if (resolvedChannelMax.length !== this.channelMaxMagnitudes.length) { + this.channelMaxMagnitudes = new Float32Array(resolvedChannelMax.length) + } + this.channelMaxMagnitudes.set(resolvedChannelMax) } isAvailable(): boolean { @@ -51,6 +59,7 @@ export class BridgeSpectrumAnalyzer implements SpectrumNativeAnalyzer { this.fftSize = size this.magnitudes = new Float32Array(size / 2).fill(FFT_SILENCE_DB) this.sideMagnitudes = new Float32Array(size / 2).fill(FFT_SILENCE_DB) + this.channelMaxMagnitudes = new Float32Array(size / 2).fill(FFT_SILENCE_DB) } } @@ -91,6 +100,14 @@ export class BridgeSpectrumAnalyzer implements SpectrumNativeAnalyzer { return count } + fillChannelMaxMagnitudes(output: Float32Array): number { + const count = Math.min(output.length, this.channelMaxMagnitudes.length) + if (count > 0) { + output.set(this.channelMaxMagnitudes.subarray(0, count), 0) + } + return count + } + getMagnitudes(): Float32Array { return this.magnitudes } @@ -103,6 +120,10 @@ export class BridgeSpectrumAnalyzer implements SpectrumNativeAnalyzer { return this.sideMagnitudes } + getChannelMaxMagnitudes(): Float32Array { + return this.channelMaxMagnitudes + } + process(_audioData: Float32Array): Float32Array { return this.magnitudes } @@ -114,5 +135,6 @@ export class BridgeSpectrumAnalyzer implements SpectrumNativeAnalyzer { reset(): void { this.magnitudes.fill(FFT_SILENCE_DB) this.sideMagnitudes.fill(FFT_SILENCE_DB) + this.channelMaxMagnitudes.fill(FFT_SILENCE_DB) } } diff --git a/src/plugin-ui/SpectrumScope.tsx b/src/plugin-ui/SpectrumScope.tsx index fe420fd..d55c85c 100644 --- a/src/plugin-ui/SpectrumScope.tsx +++ b/src/plugin-ui/SpectrumScope.tsx @@ -9,7 +9,7 @@ import { spectrumSettingsToOptions } from './spectrumOptions' import { getScopeCanvasTransformStyle } from '../renderer/scopeCanvasTransform' import { applyPluginScopeCanvasLayout } from './scopeCanvasLayout' import { - formatSpectrumPeakDb, + formatSpectrumPeakDbfs, formatSpectrumPeakFrequency, resolveFollowingPeakOverlayStyle, type CanvasResizeState, @@ -137,7 +137,7 @@ export default function SpectrumScope({ className={['scope-module__peak-info', peakMode === 'following' ? 'is-following' : 'is-corner'].join(' ')} style={overlayStyle} > - {formatSpectrumPeakDb(peak.db)} + {formatSpectrumPeakDbfs(peak.dbfs)} / {formatSpectrumPeakFrequency(peak.frequencyHz)} / diff --git a/src/plugin-ui/juceBridge.ts b/src/plugin-ui/juceBridge.ts index a9cead8..ced0d7c 100644 --- a/src/plugin-ui/juceBridge.ts +++ b/src/plugin-ui/juceBridge.ts @@ -16,12 +16,15 @@ export interface SpectrumFrame { magnitudes: Float32Array /** Side magnitudes in dB (same length); empty if unavailable. */ side: Float32Array + /** Smoothed max(L, R) magnitudes in dBFS (same length). */ + channelMax: Float32Array } interface SpectrumFramePayload { sampleRate?: number magnitudes?: string side?: string + channelMax?: string } type JuceBackend = { @@ -100,14 +103,19 @@ export function base64ToFloat32Array(b64: string): Float32Array { return new Float32Array(bytes.buffer, 0, byteLength >> 2) } -function decodeFrame(payload: unknown): SpectrumFrame | null { +export function decodeSpectrumFrame(payload: unknown): SpectrumFrame | null { if (typeof payload !== 'object' || payload === null) return null - const { sampleRate, magnitudes, side } = payload as SpectrumFramePayload + const { sampleRate, magnitudes, side, channelMax } = payload as SpectrumFramePayload if (typeof magnitudes !== 'string' || magnitudes.length === 0) return null + const decodedMagnitudes = base64ToFloat32Array(magnitudes) + const decodedChannelMax = typeof channelMax === 'string' + ? base64ToFloat32Array(channelMax) + : new Float32Array(0) return { sampleRate: typeof sampleRate === 'number' && sampleRate > 0 ? sampleRate : 48000, - magnitudes: base64ToFloat32Array(magnitudes), + magnitudes: decodedMagnitudes, side: typeof side === 'string' ? base64ToFloat32Array(side) : new Float32Array(0), + channelMax: decodedChannelMax.length > 0 ? decodedChannelMax : decodedMagnitudes, } } @@ -128,6 +136,7 @@ export function connectSpectrumBridge(handlers: SpectrumBridgeHandlers): () => v const sampleRate = 48000 const mid = new Float32Array(binCount) const side = new Float32Array(binCount).fill(-100) + const channelMax = new Float32Array(binCount) let phase = 0 const tick = (): void => { if (disposed) return @@ -138,8 +147,9 @@ export function connectSpectrumBridge(handlers: SpectrumBridgeHandlers): () => v const peak2 = Math.exp(-Math.pow((t - 0.5) * 18, 2)) * 50 mid[i] = -100 + peak1 + peak2 + Math.random() * 6 side[i] = -100 + peak2 * 0.4 + Math.random() * 4 + channelMax[i] = mid[i] } - handlers.onFrame({ sampleRate, magnitudes: mid, side }) + handlers.onFrame({ sampleRate, magnitudes: mid, side, channelMax }) mockRaf = requestAnimationFrame(tick) } mockRaf = requestAnimationFrame(tick) @@ -149,7 +159,7 @@ export function connectSpectrumBridge(handlers: SpectrumBridgeHandlers): () => v if (disposed) return if (backend) { listenerId = backend.addEventListener('spectrumFrame', (payload) => { - const frame = decodeFrame(payload) + const frame = decodeSpectrumFrame(payload) if (frame) handlers.onFrame(frame) }) handlers.onConnected?.(false) diff --git a/src/plugin-ui/main.tsx b/src/plugin-ui/main.tsx index 4577d45..e69d163 100644 --- a/src/plugin-ui/main.tsx +++ b/src/plugin-ui/main.tsx @@ -185,7 +185,7 @@ function buildApp(): JSX.Element { const analyzer = new BridgeSpectrumAnalyzer(2048) connectSpectrumBridge({ onFrame: (frame) => { - analyzer.setMagnitudes(frame.magnitudes, frame.side) + analyzer.setMagnitudes(frame.magnitudes, frame.side, frame.channelMax) dataSource.setSampleRate(frame.sampleRate) dataSource.setPlaying(true) }, diff --git a/src/plugin-ui/peakOverlay.ts b/src/plugin-ui/peakOverlay.ts index 4e820c7..8a2ecd0 100644 --- a/src/plugin-ui/peakOverlay.ts +++ b/src/plugin-ui/peakOverlay.ts @@ -28,11 +28,11 @@ function clampNumber(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)) } -export function formatSpectrumPeakDb(value: number): string { +export function formatSpectrumPeakDbfs(value: number): string { if (!Number.isFinite(value)) { return '--' } - return `${value >= 0 ? '+' : ''}${value.toFixed(2)}dB` + return `${value >= 0 ? '+' : ''}${value.toFixed(2)}dBFS` } export function formatSpectrumPeakFrequency(value: number): string { diff --git a/src/renderer/audio/native/index.ts b/src/renderer/audio/native/index.ts index 2e0564f..6f0c909 100644 --- a/src/renderer/audio/native/index.ts +++ b/src/renderer/audio/native/index.ts @@ -49,9 +49,11 @@ export interface SpectrumNativeAnalyzer { fillRawMagnitudes(output: Float32Array): number fillMagnitudes(output: Float32Array): number fillSideMagnitudes(output: Float32Array): number + fillChannelMaxMagnitudes(output: Float32Array): number getRawMagnitudes(): Float32Array | null getMagnitudes(): Float32Array | null getSideMagnitudes(): Float32Array | null + getChannelMaxMagnitudes(): Float32Array | null process(audioData: Float32Array): Float32Array | null binToFrequency(bin: number): number reset(): void @@ -193,6 +195,19 @@ export const spectrum: SpectrumNativeAnalyzer = { return count }, + fillChannelMaxMagnitudes: (output: Float32Array): number => { + if (!nativeModule) return 0 + const getter = nativeModule.spectrum.getChannelMaxMagnitudes + const magnitudes = typeof getter === 'function' + ? nativeModule.spectrum.getChannelMaxMagnitudes() + : nativeModule.spectrum.getMagnitudes() + const count = Math.min(output.length, magnitudes.length) + if (count > 0) { + output.set(magnitudes.subarray(0, count), 0) + } + return count + }, + getMagnitudes: (): Float32Array | null => { if (!nativeModule) return null return nativeModule.spectrum.getMagnitudes() @@ -208,6 +223,14 @@ export const spectrum: SpectrumNativeAnalyzer = { return nativeModule.spectrum.getSideMagnitudes() }, + getChannelMaxMagnitudes: (): Float32Array | null => { + if (!nativeModule) return null + const getter = nativeModule.spectrum.getChannelMaxMagnitudes + return typeof getter === 'function' + ? nativeModule.spectrum.getChannelMaxMagnitudes() + : nativeModule.spectrum.getMagnitudes() + }, + 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 6275306..85c021a 100644 --- a/src/renderer/audio/native/visualizer-dsp.d.ts +++ b/src/renderer/audio/native/visualizer-dsp.d.ts @@ -108,9 +108,11 @@ export interface SpectrumModule { fillRawMagnitudes(output: Float32Array): number; fillMagnitudes(output: Float32Array): number; fillSideMagnitudes(output: Float32Array): number; + fillChannelMaxMagnitudes(output: Float32Array): number; getRawMagnitudes(): Float32Array; getMagnitudes(): Float32Array; getSideMagnitudes(): Float32Array; + getChannelMaxMagnitudes(): Float32Array; process(audioData: Float32Array): Float32Array; binToFrequency(bin: number): number; reset(): void; diff --git a/src/renderer/components/ScopeModule.tsx b/src/renderer/components/ScopeModule.tsx index 574da37..8e7ee65 100644 --- a/src/renderer/components/ScopeModule.tsx +++ b/src/renderer/components/ScopeModule.tsx @@ -84,12 +84,12 @@ function clampNumber(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)) } -function formatSpectrumPeakDb(value: number): string { +function formatSpectrumPeakDbfs(value: number): string { if (!Number.isFinite(value)) { return '--' } - return `${value >= 0 ? '+' : ''}${value.toFixed(2)}dB` + return `${value >= 0 ? '+' : ''}${value.toFixed(2)}dBFS` } function formatSpectrumPeakFrequency(value: number): string { @@ -646,7 +646,7 @@ export default function ScopeModule({ ].join(' ')} style={spectrumPeakOverlayStyle} > - {formatSpectrumPeakDb(spectrumPeakInfo.db)} + {formatSpectrumPeakDbfs(spectrumPeakInfo.dbfs)} / {formatSpectrumPeakFrequency(spectrumPeakInfo.frequencyHz)} / diff --git a/src/renderer/visualizers/SpectrumAnalyzer.ts b/src/renderer/visualizers/SpectrumAnalyzer.ts index d90494a..7394090 100644 --- a/src/renderer/visualizers/SpectrumAnalyzer.ts +++ b/src/renderer/visualizers/SpectrumAnalyzer.ts @@ -230,6 +230,7 @@ export class SpectrumAnalyzer { private nativeMagnitudeBuffer = new Float32Array(0) private nativeRawMagnitudeBuffer = new Float32Array(0) private nativeSideMagnitudeBuffer = new Float32Array(0) + private nativeChannelMaxMagnitudeBuffer = new Float32Array(0) private heatmapMagnitudeBuffer = new Float32Array(0) private nativeBufferedSamples = 0 private nativeHasSpectrumData = false @@ -242,6 +243,7 @@ export class SpectrumAnalyzer { private secondaryPointX = new Float32Array(0) private secondaryPointY = new Float32Array(0) private primaryPointDb = new Float32Array(0) + private primaryPointDbfs = new Float32Array(0) private primaryPointFrequency = new Float32Array(0) private lastSelectedPeakInfo: SpectrumPeakInfo | null = null @@ -316,6 +318,9 @@ export class SpectrumAnalyzer { if (this.nativeSideMagnitudeBuffer.length !== length) { this.nativeSideMagnitudeBuffer = new Float32Array(length) } + if (this.nativeChannelMaxMagnitudeBuffer.length !== length) { + this.nativeChannelMaxMagnitudeBuffer = new Float32Array(length) + } if (this.heatmapMagnitudeBuffer.length !== length) { this.heatmapMagnitudeBuffer = new Float32Array(length) } @@ -326,6 +331,7 @@ export class SpectrumAnalyzer { this.nativeMagnitudeBuffer.fill(FFT_SILENCE_DB) this.nativeRawMagnitudeBuffer.fill(FFT_SILENCE_DB) this.nativeSideMagnitudeBuffer.fill(FFT_SILENCE_DB) + this.nativeChannelMaxMagnitudeBuffer.fill(FFT_SILENCE_DB) this.heatmapMagnitudeBuffer.fill(FFT_SILENCE_DB) this.nativeBufferedSamples = 0 this.nativeHasSpectrumData = false @@ -379,6 +385,7 @@ export class SpectrumAnalyzer { || optionUpdates.smoothing !== undefined || optionUpdates.heatmapSmoothing !== undefined || optionUpdates.showSideLine !== undefined + || optionUpdates.capturePeakInfo !== undefined ) this.options = nextOptions @@ -448,6 +455,21 @@ export class SpectrumAnalyzer { return this.lerp(data[i0], data[i1], t) } + private getQuadraticInterpolatedValue(data: Float32Array, index: number): number { + const center = Math.round(index) + if (center <= 0 || center >= data.length - 1) { + return this.getInterpolatedValue(data, index) + } + + const offset = index - center + const previous = data[center - 1] + const current = data[center] + const next = data[center + 1] + return current + 0.5 * offset * ( + next - previous + offset * (previous - (2 * current) + next) + ) + } + private frequencyAtPosition(t: number, minFrequency: number, maxFrequency: number): number { if (this.options.scaleType === 'log') { const logMin = Math.log10(minFrequency) @@ -522,6 +544,7 @@ export class SpectrumAnalyzer { this.secondaryPointX = new Float32Array(pointCount) this.secondaryPointY = new Float32Array(pointCount) this.primaryPointDb = new Float32Array(pointCount) + this.primaryPointDbfs = new Float32Array(pointCount) this.primaryPointFrequency = new Float32Array(pointCount) } } @@ -668,6 +691,7 @@ export class SpectrumAnalyzer { yOut: Float32Array, heatmapIntensityOut: Float32Array | null, capturePeakInfo = false, + peakDbfsData: Float32Array | null = null, ): SpectrumPointFillResult { const bufferLength = Math.min(dataLength, frequencyData.length) if (bufferLength <= 0) { @@ -706,8 +730,16 @@ export class SpectrumAnalyzer { } if (capturePeakInfo) { + const peakFrequencyHz = resolvedPeak?.frequencyHz ?? centerFrequency this.primaryPointDb[index] = db - this.primaryPointFrequency[index] = resolvedPeak?.frequencyHz ?? centerFrequency + this.primaryPointFrequency[index] = peakFrequencyHz + const peakDbfsBin = Math.min( + Math.max(0, peakFrequencyHz / binWidth), + Math.max(0, (peakDbfsData?.length ?? 1) - 1), + ) + this.primaryPointDbfs[index] = peakDbfsData && peakDbfsData.length > 0 + ? this.getQuadraticInterpolatedValue(peakDbfsData, peakDbfsBin) + : rawDb } } @@ -724,14 +756,15 @@ export class SpectrumAnalyzer { || index >= this.primaryPointFrequency.length || index >= this.primaryPointX.length || index >= this.primaryPointY.length + || index >= this.primaryPointDbfs.length ) { return null } const frequencyHz = this.primaryPointFrequency[index] - const db = this.primaryPointDb[index] + const dbfs = this.primaryPointDbfs[index] return { - db, + dbfs, frequencyHz, normalizedX: this.primaryPointX[index] / Math.max(1, this.canvas.width), normalizedY: this.primaryPointY[index] / Math.max(1, height), @@ -990,6 +1023,7 @@ export class SpectrumAnalyzer { let primaryDataLength = 0 let heatmapDataLength = 0 let secondaryDataLength = 0 + let channelMaxDataLength = 0 if (!this.isNativeAvailable()) { this.clearPendingSpectrumQueues() @@ -999,13 +1033,14 @@ export class SpectrumAnalyzer { return } - const receivedNativeSamples = options.showSideLine + const receivedNativeSamples = options.showSideLine || options.capturePeakInfo ? this.pushPendingSpectrumStereoChunks(this.dataSource.getPendingSpectrumStereoSamples()) : this.pushPendingSpectrumChunks(this.dataSource.getPendingSpectrumSamples()) this.ensureMagnitudeBufferSize() primaryData = this.nativeMagnitudeBuffer primaryDataLength = this.nativeAnalyzer?.fillMagnitudes(this.nativeMagnitudeBuffer) ?? 0 + channelMaxDataLength = this.nativeAnalyzer?.fillChannelMaxMagnitudes(this.nativeChannelMaxMagnitudeBuffer) ?? 0 if (receivedNativeSamples > 0 || !this.nativeHasSpectrumData) { heatmapDataLength = this.nativeAnalyzer?.fillRawMagnitudes(this.nativeRawMagnitudeBuffer) ?? 0 @@ -1051,6 +1086,7 @@ export class SpectrumAnalyzer { this.primaryPointY, null, options.capturePeakInfo, + channelMaxDataLength > 0 ? this.nativeChannelMaxMagnitudeBuffer : primaryData, ) const heatmapRender = heatmapData && heatmapDataLength > 0 ? this.fillSpectrumPoints( diff --git a/src/types/spectrum.ts b/src/types/spectrum.ts index c1e93b7..970ab81 100644 --- a/src/types/spectrum.ts +++ b/src/types/spectrum.ts @@ -17,7 +17,7 @@ export interface SpectrumPitchInfo { } export interface SpectrumPeakInfo { - db: number + dbfs: number frequencyHz: number normalizedX: number normalizedY: number diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index 1923c03..c0e6332 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -104,6 +104,9 @@ import { import { LUFSMeter } from '../src/renderer/visualizers/LUFSMeter' import { Oscilloscope } from '../src/renderer/visualizers/Oscilloscope' import { SpectrumAnalyzer, type SpectrumAnalyzerOptions } from '../src/renderer/visualizers/SpectrumAnalyzer' +import { BridgeSpectrumAnalyzer } from '../src/plugin-ui/BridgeSpectrumAnalyzer' +import { decodeSpectrumFrame } from '../src/plugin-ui/juceBridge' +import { formatSpectrumPeakDbfs } from '../src/plugin-ui/peakOverlay' import { Spectrogram, type SpectrogramOptions } from '../src/renderer/visualizers/Spectrogram' import { Vectorscope } from '../src/renderer/visualizers/Vectorscope' import { Waveform } from '../src/renderer/visualizers/Waveform' @@ -795,6 +798,7 @@ interface FakeSpectrumNativeAnalyzer extends SpectrumNativeAnalyzer { fillMagnitudes: number fillRawMagnitudes: number fillSideMagnitudes: number + fillChannelMaxMagnitudes: number resets: number } } @@ -861,11 +865,19 @@ function createFakeSpectrumNativeAnalyzer(): FakeSpectrumNativeAnalyzer { let rawMagnitudes = new Float32Array(fftSize / 2) let magnitudes = new Float32Array(fftSize / 2) let sideMagnitudes = new Float32Array(fftSize / 2) + let leftMagnitudes = new Float32Array(fftSize / 2) + let rightMagnitudes = new Float32Array(fftSize / 2) + let channelMaxMagnitudes = new Float32Array(fftSize / 2) + let leftHistory = new Float32Array(fftSize) + let rightHistory = new Float32Array(fftSize) let re = new Float32Array(fftSize) let im = new Float32Array(fftSize) rawMagnitudes.fill(-100) magnitudes.fill(-100) sideMagnitudes.fill(-100) + leftMagnitudes.fill(-100) + rightMagnitudes.fill(-100) + channelMaxMagnitudes.fill(-100) const calls: FakeSpectrumNativeAnalyzer['calls'] = { monoPushes: [], @@ -873,6 +885,7 @@ function createFakeSpectrumNativeAnalyzer(): FakeSpectrumNativeAnalyzer { fillMagnitudes: 0, fillRawMagnitudes: 0, fillSideMagnitudes: 0, + fillChannelMaxMagnitudes: 0, resets: 0, } @@ -884,11 +897,19 @@ function createFakeSpectrumNativeAnalyzer(): FakeSpectrumNativeAnalyzer { rawMagnitudes = new Float32Array(fftSize / 2) magnitudes = new Float32Array(fftSize / 2) sideMagnitudes = new Float32Array(fftSize / 2) + leftMagnitudes = new Float32Array(fftSize / 2) + rightMagnitudes = new Float32Array(fftSize / 2) + channelMaxMagnitudes = new Float32Array(fftSize / 2) + leftHistory = new Float32Array(fftSize) + rightHistory = new Float32Array(fftSize) re = new Float32Array(fftSize) im = new Float32Array(fftSize) rawMagnitudes.fill(-100) magnitudes.fill(-100) sideMagnitudes.fill(-100) + leftMagnitudes.fill(-100) + rightMagnitudes.fill(-100) + channelMaxMagnitudes.fill(-100) } const updateMagnitudes = (source: Float32Array, output: Float32Array, rawOutput: Float32Array | null): void => { @@ -902,8 +923,8 @@ function createFakeSpectrumNativeAnalyzer(): FakeSpectrumNativeAnalyzer { const scale = 2 / fftSize for (let index = 0; index < output.length; index += 1) { const magnitude = Math.hypot(re[index], im[index]) * scale - let db = 20 * Math.log10(Math.max(magnitude, 1e-10)) - db += 6 + const coherentGain = fftSize <= 1 ? 1 : (fftSize - 1) / (2 * fftSize) + let db = 20 * Math.log10(Math.max(magnitude, 1e-10)) - 20 * Math.log10(coherentGain) db = Math.min(12, Math.max(-120, db)) if (rawOutput) { rawOutput[index] = db @@ -960,6 +981,20 @@ function createFakeSpectrumNativeAnalyzer(): FakeSpectrumNativeAnalyzer { return count } + const updateAllMagnitudes = (): void => { + updateMagnitudes(history, magnitudes, rawMagnitudes) + updateMagnitudes(sideHistory, sideMagnitudes, null) + for (let index = 0; index < fftSize; index += 1) { + leftHistory[index] = history[index] + sideHistory[index] + rightHistory[index] = history[index] - sideHistory[index] + } + updateMagnitudes(leftHistory, leftMagnitudes, null) + updateMagnitudes(rightHistory, rightMagnitudes, null) + for (let index = 0; index < channelMaxMagnitudes.length; index += 1) { + channelMaxMagnitudes[index] = Math.max(leftMagnitudes[index], rightMagnitudes[index]) + } + } + const analyzer: FakeSpectrumNativeAnalyzer = { calls, isAvailable: () => true, @@ -978,8 +1013,7 @@ function createFakeSpectrumNativeAnalyzer(): FakeSpectrumNativeAnalyzer { pushSamples: (audioData) => { calls.monoPushes.push(new Float32Array(audioData)) pushMonoHistory(audioData) - updateMagnitudes(history, magnitudes, rawMagnitudes) - updateMagnitudes(sideHistory, sideMagnitudes, null) + updateAllMagnitudes() }, pushStereoSamples: (leftChannel, rightChannel) => { calls.stereoPushes.push({ @@ -987,8 +1021,7 @@ function createFakeSpectrumNativeAnalyzer(): FakeSpectrumNativeAnalyzer { right: new Float32Array(rightChannel), }) pushStereoHistory(leftChannel, rightChannel) - updateMagnitudes(history, magnitudes, rawMagnitudes) - updateMagnitudes(sideHistory, sideMagnitudes, null) + updateAllMagnitudes() }, fillRawMagnitudes: (output) => { calls.fillRawMagnitudes += 1 @@ -1002,9 +1035,14 @@ function createFakeSpectrumNativeAnalyzer(): FakeSpectrumNativeAnalyzer { calls.fillSideMagnitudes += 1 return copyInto(sideMagnitudes, output) }, + fillChannelMaxMagnitudes: (output) => { + calls.fillChannelMaxMagnitudes += 1 + return copyInto(channelMaxMagnitudes, output) + }, getRawMagnitudes: () => rawMagnitudes, getMagnitudes: () => magnitudes, getSideMagnitudes: () => sideMagnitudes, + getChannelMaxMagnitudes: () => channelMaxMagnitudes, process: (audioData) => { analyzer.pushSamples(audioData) return magnitudes @@ -1017,6 +1055,9 @@ function createFakeSpectrumNativeAnalyzer(): FakeSpectrumNativeAnalyzer { rawMagnitudes.fill(-100) magnitudes.fill(-100) sideMagnitudes.fill(-100) + leftMagnitudes.fill(-100) + rightMagnitudes.fill(-100) + channelMaxMagnitudes.fill(-100) bufferedSamples = 0 }, } @@ -2204,7 +2245,7 @@ test('SpectrumAnalyzer reports peak info from the visible spectrum curve', () => assert.ok(peakInfo, 'expected peak info to be reported') assert.ok(peakInfo.frequencyHz > 437 && peakInfo.frequencyHz < 443, `expected peak frequency near 440 Hz, got ${peakInfo.frequencyHz}`) assert.match(peakInfo.key, /^A4 [+-]?\d+c$/) - assert.ok(peakInfo.db > -20, `expected an audible peak dB, got ${peakInfo.db}`) + assert.ok(peakInfo.dbfs > -20, `expected an audible peak dBFS, got ${peakInfo.dbfs}`) assert.ok(peakInfo.normalizedX >= 0 && peakInfo.normalizedX <= 1, 'peak x should be normalized') assert.ok(peakInfo.normalizedY >= 0 && peakInfo.normalizedY <= 1, 'peak y should be normalized') @@ -2235,6 +2276,233 @@ test('SpectrumAnalyzer reports peak info from the visible spectrum curve', () => } }) +test('SpectrumAnalyzer reports louder-channel dBFS without applying visual tilt', () => { + const dom = installFakeCanvasDom() + const sampleRate = 48000 + const fftSize = 4096 + const frequencyHz = 21 * sampleRate / fftSize + const frame = createCompositeStereoChunk([ + { frequencyHz, amplitude: 0.2 }, + ], sampleRate, fftSize) + + const renderWithTilt = (tiltDbPerOctave: number): { + peak: SpectrumPeakInfo + visibleDb: number + } => { + let peakInfo: SpectrumPeakInfo | null = null + const analyzer = new SpectrumAnalyzer(createFakeCanvas(), { + showSideLine: true, + showGrid: false, + fillGradient: false, + smoothing: 0, + tiltDbPerOctave, + fftSize, + dataSource: { + getPendingSpectrumSamples: () => [], + getPendingSpectrumStereoSamples: () => [frame], + getSampleRate: () => sampleRate, + isPlaying: () => true, + subscribeToSessionChanges: () => () => {}, + }, + nativeAnalyzer: createFakeSpectrumNativeAnalyzer(), + capturePeakInfo: true, + onPeakInfo: (nextPeakInfo) => { + peakInfo = nextPeakInfo + }, + }) + + try { + const state = analyzer as unknown as { + drawFrame: () => void + primaryPointDb: Float32Array + primaryPointFrequency: Float32Array + } + state.drawFrame() + assert.ok(peakInfo) + let closestIndex = 0 + for (let index = 1; index < state.primaryPointFrequency.length; index += 1) { + if ( + Math.abs(state.primaryPointFrequency[index] - peakInfo.frequencyHz) + < Math.abs(state.primaryPointFrequency[closestIndex] - peakInfo.frequencyHz) + ) { + closestIndex = index + } + } + return { peak: peakInfo, visibleDb: state.primaryPointDb[closestIndex] } + } finally { + analyzer.dispose() + } + } + + try { + const flat = renderWithTilt(0) + const tilted = renderWithTilt(6) + assertAlmostEqual(flat.peak.dbfs, 20 * Math.log10(0.2), 0.3, 'flat dBFS') + assertAlmostEqual(tilted.peak.dbfs, flat.peak.dbfs, 1e-5, 'tilt-independent dBFS') + assertAlmostEqual(tilted.peak.frequencyHz, flat.peak.frequencyHz, 0.1, 'visible peak frequency') + assertAlmostEqual( + tilted.visibleDb - flat.visibleDb, + 6 * Math.log2(frequencyHz / 1000), + 0.6, + 'visual curve tilt', + ) + } finally { + dom.restore() + } +}) + +test('SpectrumAnalyzer keeps a left-only Mid curve while reporting the left channel near 0 dBFS', () => { + const dom = installFakeCanvasDom() + const sampleRate = 48000 + const fftSize = 4096 + const frequencyHz = 85 * sampleRate / fftSize + const left = createCompositeStereoChunk([{ frequencyHz, amplitude: 1 }], sampleRate, fftSize).left + const right = new Float32Array(fftSize) + let peakInfo: SpectrumPeakInfo | null = null + let stereoDrains = 0 + const analyzer = new SpectrumAnalyzer(createFakeCanvas(), { + showSideLine: false, + showGrid: false, + fillGradient: false, + smoothing: 0, + tiltDbPerOctave: 0, + fftSize, + dataSource: { + getPendingSpectrumSamples: () => assert.fail('peak capture must preserve stereo channel data'), + getPendingSpectrumStereoSamples: () => { + stereoDrains += 1 + return [{ left, right }] + }, + getSampleRate: () => sampleRate, + isPlaying: () => true, + subscribeToSessionChanges: () => () => {}, + }, + nativeAnalyzer: createFakeSpectrumNativeAnalyzer(), + capturePeakInfo: true, + onPeakInfo: (nextPeakInfo) => { + peakInfo = nextPeakInfo + }, + }) + + try { + const state = analyzer as unknown as { + drawFrame: () => void + primaryPointDb: Float32Array + primaryPointFrequency: Float32Array + } + state.drawFrame() + assert.ok(peakInfo) + assert.equal(stereoDrains, 1) + assertAlmostEqual(peakInfo.dbfs, 0, 0.3, 'left-channel dBFS') + + let closestIndex = 0 + for (let index = 1; index < state.primaryPointFrequency.length; index += 1) { + if ( + Math.abs(state.primaryPointFrequency[index] - peakInfo.frequencyHz) + < Math.abs(state.primaryPointFrequency[closestIndex] - peakInfo.frequencyHz) + ) { + closestIndex = index + } + } + assertAlmostEqual(state.primaryPointDb[closestIndex], -6.0206, 0.3, 'left-only Mid curve') + } finally { + analyzer.dispose() + dom.restore() + } +}) + +test('SpectrumAnalyzer tilt can change the visible peak while each readout stays un-tilted', () => { + const dom = installFakeCanvasDom() + const sampleRate = 48000 + const fftSize = 4096 + + const captureWithTilt = (tiltDbPerOctave: number): SpectrumPeakInfo => { + const frame = createCompositeStereoChunk([ + { frequencyHz: 250, amplitude: 0.3 }, + { frequencyHz: 4000, amplitude: 0.15 }, + ], sampleRate, fftSize) + let peakInfo: SpectrumPeakInfo | null = null + const analyzer = new SpectrumAnalyzer(createFakeCanvas(), { + showSideLine: true, + showGrid: false, + fillGradient: false, + smoothing: 0, + tiltDbPerOctave, + fftSize, + dataSource: { + getPendingSpectrumSamples: () => [], + getPendingSpectrumStereoSamples: () => [frame], + getSampleRate: () => sampleRate, + isPlaying: () => true, + subscribeToSessionChanges: () => () => {}, + }, + nativeAnalyzer: createFakeSpectrumNativeAnalyzer(), + capturePeakInfo: true, + onPeakInfo: (nextPeakInfo) => { + peakInfo = nextPeakInfo + }, + }) + try { + ;(analyzer as unknown as { drawFrame: () => void }).drawFrame() + assert.ok(peakInfo) + return peakInfo + } finally { + analyzer.dispose() + } + } + + try { + const flat = captureWithTilt(0) + const tilted = captureWithTilt(6) + assert.ok(flat.frequencyHz > 240 && flat.frequencyHz < 260) + assert.ok(tilted.frequencyHz > 3900 && tilted.frequencyHz < 4100) + assertAlmostEqual(flat.dbfs, 20 * Math.log10(0.3), 0.3, 'low peak dBFS') + assertAlmostEqual(tilted.dbfs, 20 * Math.log10(0.15), 0.3, 'high peak dBFS') + } finally { + dom.restore() + } +}) + +test('spectrum plugin bridge carries channel-max data and falls back for legacy frames', () => { + const encode = (values: Float32Array): string => Buffer.from( + values.buffer, + values.byteOffset, + values.byteLength, + ).toString('base64') + const magnitudes = Float32Array.from([-30, -20, -10]) + const side = Float32Array.from([-50, -40, -30]) + const channelMax = Float32Array.from([-24, -14, -4]) + + const decoded = decodeSpectrumFrame({ + sampleRate: 96000, + magnitudes: encode(magnitudes), + side: encode(side), + channelMax: encode(channelMax), + }) + assert.ok(decoded) + assert.equal(decoded.sampleRate, 96000) + assert.deepEqual(Array.from(decoded.channelMax), Array.from(channelMax)) + + const legacy = decodeSpectrumFrame({ magnitudes: encode(magnitudes) }) + assert.ok(legacy) + assert.deepEqual(Array.from(legacy.channelMax), Array.from(magnitudes)) + + const analyzer = new BridgeSpectrumAnalyzer(6) + analyzer.setMagnitudes(magnitudes, side, channelMax) + const output = new Float32Array(3) + assert.equal(analyzer.fillChannelMaxMagnitudes(output), 3) + assert.deepEqual(Array.from(output), Array.from(channelMax)) + + analyzer.setMagnitudes(magnitudes, side) + assert.deepEqual(Array.from(analyzer.getChannelMaxMagnitudes()), Array.from(magnitudes)) + analyzer.setFFTSize(8) + assert.deepEqual(Array.from(analyzer.getChannelMaxMagnitudes()), [-100, -100, -100, -100]) + analyzer.reset() + assert.deepEqual(Array.from(analyzer.getChannelMaxMagnitudes()), [-100, -100, -100, -100]) + assert.equal(formatSpectrumPeakDbfs(-6.0206), '-6.02dBFS') + assert.equal(formatSpectrumPeakDbfs(0), '+0.00dBFS') +}) + test('SpectrumAnalyzer smooths peak selection without smoothing the reported position', () => { const dom = installFakeCanvasDom() const sampleRate = 48000 diff --git a/test/spectrum-native.test.mjs b/test/spectrum-native.test.mjs new file mode 100644 index 0000000..eba16d4 --- /dev/null +++ b/test/spectrum-native.test.mjs @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import test from 'node:test' + +const require = createRequire(import.meta.url) +const { spectrum } = require('../native/build/Release/visualizer_dsp.node') + +const SAMPLE_RATE = 48000 +const SILENCE_DB = -120 + +function createTone(frequencyHz, length, amplitude = 1) { + const samples = new Float32Array(length) + for (let index = 0; index < length; index += 1) { + samples[index] = Math.sin((2 * Math.PI * frequencyHz * index) / SAMPLE_RATE) * amplitude + } + return samples +} + +function configure(fftSize) { + spectrum.setFFTSize(fftSize) + spectrum.setSampleRate(SAMPLE_RATE) + spectrum.setSmoothing(0) + spectrum.reset() +} + +function assertAlmostEqual(actual, expected, tolerance, message) { + assert.ok( + Math.abs(actual - expected) <= tolerance, + `${message}: expected ${expected} +/- ${tolerance}, got ${actual}`, + ) +} + +function interpolatePeakDb(magnitudes) { + let peakBin = 1 + for (let index = 2; index < magnitudes.length - 1; index += 1) { + if (magnitudes[index] > magnitudes[peakBin]) peakBin = index + } + + const y1 = magnitudes[peakBin - 1] + const y2 = magnitudes[peakBin] + const y3 = magnitudes[peakBin + 1] + const denominator = y1 - (2 * y2) + y3 + if (Math.abs(denominator) <= 1e-9) return y2 + const offset = Math.max(-0.5, Math.min(0.5, 0.5 * (y1 - y3) / denominator)) + return y2 - (0.25 * (y1 - y3) * offset) +} + +test('spectrum channel-max dBFS is calibrated for bin-centered amplitudes', () => { + const fftSize = 2048 + const bin = 42 + const frequencyHz = bin * SAMPLE_RATE / fftSize + + for (const amplitude of [1, 0.5, 0.1]) { + configure(fftSize) + spectrum.pushSamples(createTone(frequencyHz, fftSize, amplitude)) + const expectedDbfs = 20 * Math.log10(amplitude) + const filledMagnitudes = new Float32Array(fftSize / 2) + assert.equal(spectrum.fillChannelMaxMagnitudes(filledMagnitudes), fftSize / 2) + assertAlmostEqual( + filledMagnitudes[bin], + expectedDbfs, + 0.05, + `amplitude ${amplitude}`, + ) + } +}) + +test('spectrum off-bin dBFS interpolation stays within 0.3 dB across FFT sizes', () => { + for (const fftSize of [1024, 2048, 4096, 8192, 16384]) { + configure(fftSize) + spectrum.pushSamples(createTone(440, fftSize)) + assertAlmostEqual( + interpolatePeakDb(spectrum.getChannelMaxMagnitudes()), + 0, + 0.3, + `FFT size ${fftSize}`, + ) + } +}) + +test('spectrum preserves Mid/Side curves while channel-max follows the louder L/R channel', () => { + const fftSize = 2048 + const bin = 42 + const frequencyHz = bin * SAMPLE_RATE / fftSize + const fullScale = createTone(frequencyHz, fftSize) + const halfScale = createTone(frequencyHz, fftSize, 0.5) + const silence = new Float32Array(fftSize) + const inverted = Float32Array.from(fullScale, (sample) => -sample) + + configure(fftSize) + spectrum.pushStereoSamples(fullScale, silence) + assertAlmostEqual(spectrum.getMagnitudes()[bin], -6.0206, 0.05, 'left-only Mid level') + assertAlmostEqual(spectrum.getSideMagnitudes()[bin], -6.0206, 0.05, 'left-only Side level') + assertAlmostEqual(spectrum.getChannelMaxMagnitudes()[bin], 0, 0.05, 'left-only channel max') + + configure(fftSize) + spectrum.pushStereoSamples(silence, fullScale) + assertAlmostEqual(spectrum.getChannelMaxMagnitudes()[bin], 0, 0.05, 'right-only channel max') + + configure(fftSize) + spectrum.pushStereoSamples(halfScale, fullScale) + assertAlmostEqual(spectrum.getChannelMaxMagnitudes()[bin], 0, 0.05, 'unequal-channel max') + + configure(fftSize) + spectrum.pushStereoSamples(fullScale, inverted) + assert.equal(spectrum.getMagnitudes()[bin], SILENCE_DB) + assertAlmostEqual(spectrum.getSideMagnitudes()[bin], 0, 0.05, 'anti-phase Side level') + assertAlmostEqual(spectrum.getChannelMaxMagnitudes()[bin], 0, 0.05, 'anti-phase channel max') +})