diff --git a/native/src/main.cpp b/native/src/main.cpp index 093cd05..e96cc55 100644 --- a/native/src/main.cpp +++ b/native/src/main.cpp @@ -174,6 +174,19 @@ Napi::Value SpectrumPushSamples(const Napi::CallbackInfo& info) { return env.Undefined(); } +Napi::Value SpectrumPushStereoSamples(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 2 || !info[0].IsTypedArray() || !info[1].IsTypedArray()) { + Napi::TypeError::New(env, "Expected left and right Float32Array").ThrowAsJavaScriptException(); + return env.Null(); + } + Napi::Float32Array leftData = info[0].As(); + Napi::Float32Array rightData = info[1].As(); + const size_t length = std::min(leftData.ElementLength(), rightData.ElementLength()); + spectrum.pushStereoSamples(leftData.Data(), rightData.Data(), length); + return env.Undefined(); +} + Napi::Value SpectrumGetMagnitudes(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); const auto& magnitudes = spectrum.getMagnitudes(); @@ -190,6 +203,14 @@ Napi::Value SpectrumGetRawMagnitudes(const Napi::CallbackInfo& info) { return result; } +Napi::Value SpectrumGetSideMagnitudes(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + const auto& magnitudes = spectrum.getSideMagnitudes(); + 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()) { @@ -222,6 +243,22 @@ Napi::Value SpectrumFillMagnitudes(const Napi::CallbackInfo& info) { return Napi::Number::New(env, static_cast(count)); } +Napi::Value SpectrumFillSideMagnitudes(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.getSideMagnitudes(); + 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()) { @@ -644,10 +681,13 @@ 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("pushStereoSamples", Napi::Function::New(env, SpectrumPushStereoSamples)); 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("getRawMagnitudes", Napi::Function::New(env, SpectrumGetRawMagnitudes)); specExports.Set("getMagnitudes", Napi::Function::New(env, SpectrumGetMagnitudes)); + specExports.Set("getSideMagnitudes", Napi::Function::New(env, SpectrumGetSideMagnitudes)); 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 ae0d437..8725f9f 100644 --- a/native/src/spectrum.cpp +++ b/native/src/spectrum.cpp @@ -13,11 +13,14 @@ Spectrum::Spectrum(size_t fftSize) , bufferedSamples_(0) { fft_ = std::make_unique(fftSize); historyBuffer_.resize(fftSize, 0.0f); + sideHistoryBuffer_.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); + sideRawMagnitudes_.resize(fftSize / 2, -100.0f); + sideSmoothedMagnitudes_.resize(fftSize / 2, -100.0f); } void Spectrum::setFFTSize(size_t size) { @@ -25,11 +28,14 @@ void Spectrum::setFFTSize(size_t size) { fftSize_ = size; fft_ = std::make_unique(size); historyBuffer_.assign(size, 0.0f); + sideHistoryBuffer_.assign(size, 0.0f); windowedInput_.resize(size); magnitudes_.resize(size / 2); - rawMagnitudes_.resize(size / 2, -100.0f); + rawMagnitudes_.assign(size / 2, -100.0f); // Initialize to silence (-100.0f dB) - smoothedMagnitudes_.resize(size / 2, -100.0f); + smoothedMagnitudes_.assign(size / 2, -100.0f); + sideRawMagnitudes_.assign(size / 2, -100.0f); + sideSmoothedMagnitudes_.assign(size / 2, -100.0f); bufferedSamples_ = 0; } } @@ -57,31 +63,48 @@ void Spectrum::applyWindow(const float* input, float* output, size_t length) { } } -void Spectrum::pushHistory(const float* input, size_t length) { +void Spectrum::pushHistory(std::vector& history, const float* input, size_t length) { if (length == 0 || fftSize_ == 0) { return; } // Keep only the most recent fftSize_ samples. if (length >= fftSize_) { - std::memcpy(historyBuffer_.data(), input + (length - fftSize_), fftSize_ * sizeof(float)); - bufferedSamples_ = fftSize_; + std::memcpy(history.data(), input + (length - fftSize_), fftSize_ * sizeof(float)); return; } const size_t keep = fftSize_ - length; - std::move(historyBuffer_.begin() + length, historyBuffer_.end(), historyBuffer_.begin()); - std::memcpy(historyBuffer_.data() + keep, input, length * sizeof(float)); - bufferedSamples_ = std::min(fftSize_, bufferedSamples_ + length); + std::move(history.begin() + length, history.end(), history.begin()); + std::memcpy(history.data() + keep, input, length * sizeof(float)); } -void Spectrum::updateMagnitudes() { - if (historyBuffer_.empty() || magnitudes_.empty()) { +void Spectrum::pushZeroHistory(std::vector& history, size_t length) { + if (length == 0 || fftSize_ == 0) { + return; + } + + if (length >= fftSize_) { + std::fill(history.begin(), history.end(), 0.0f); + return; + } + + const size_t keep = fftSize_ - length; + std::move(history.begin() + length, history.end(), history.begin()); + 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; } // Always analyze a full FFT frame from the rolling buffer. - applyWindow(historyBuffer_.data(), windowedInput_.data(), fftSize_); + applyWindow(history.data(), windowedInput_.data(), fftSize_); // Perform FFT fft_->forward(windowedInput_.data(), magnitudes_.data()); @@ -99,37 +122,85 @@ void Spectrum::updateMagnitudes() { // Clamp to a stable display range. db = std::clamp(db, -120.0f, 12.0f); - rawMagnitudes_[i] = db; + rawMagnitudes[i] = db; if (bufferedSamples_ < fftSize_) { - smoothedMagnitudes_[i] = db; + smoothedMagnitudes[i] = db; continue; } // Apply temporal smoothing only (no bin-to-bin averaging). - smoothedMagnitudes_[i] = smoothing_ * smoothedMagnitudes_[i] + (1.0f - smoothing_) * db; + smoothedMagnitudes[i] = smoothing_ * smoothedMagnitudes[i] + (1.0f - smoothing_) * db; // Safety check - if (!std::isfinite(smoothedMagnitudes_[i])) { - smoothedMagnitudes_[i] = -100.0f; + if (!std::isfinite(smoothedMagnitudes[i])) { + smoothedMagnitudes[i] = -100.0f; + } + } +} + +void Spectrum::updateMagnitudes() { + updateMagnitudesForHistory(historyBuffer_, rawMagnitudes_, smoothedMagnitudes_); + updateMagnitudesForHistory(sideHistoryBuffer_, sideRawMagnitudes_, sideSmoothedMagnitudes_); +} + +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; + } + + sideSmoothedMagnitudes_[i] = smoothing_ * sideSmoothedMagnitudes_[i] + (1.0f - smoothing_) * silentDb; + if (!std::isfinite(sideSmoothedMagnitudes_[i])) { + sideSmoothedMagnitudes_[i] = -100.0f; } } } void Spectrum::pushSamples(const float* input, size_t length) { if (input != nullptr && length > 0) { - pushHistory(input, length); + pushHistory(historyBuffer_, input, length); + pushZeroHistory(sideHistoryBuffer_, length); + bufferedSamples_ = length >= fftSize_ ? fftSize_ : std::min(fftSize_, bufferedSamples_ + length); + updateMagnitudesForHistory(historyBuffer_, rawMagnitudes_, smoothedMagnitudes_); + updateSilentSideMagnitudes(); + return; + } + updateMagnitudes(); +} + +void Spectrum::pushStereoSamples(const float* left, const float* right, size_t length) { + if (left != nullptr && right != nullptr && length > 0 && fftSize_ > 0) { + if (length >= fftSize_) { + const size_t start = length - fftSize_; + for (size_t i = 0; i < fftSize_; i++) { + const float leftValue = left[start + i]; + const float rightValue = right[start + i]; + historyBuffer_[i] = (leftValue + rightValue) * 0.5f; + sideHistoryBuffer_[i] = (leftValue - rightValue) * 0.5f; + } + bufferedSamples_ = fftSize_; + } else { + const size_t keep = fftSize_ - length; + std::move(historyBuffer_.begin() + length, historyBuffer_.end(), historyBuffer_.begin()); + std::move(sideHistoryBuffer_.begin() + length, sideHistoryBuffer_.end(), sideHistoryBuffer_.begin()); + for (size_t i = 0; i < length; i++) { + const float leftValue = left[i]; + const float rightValue = right[i]; + historyBuffer_[keep + i] = (leftValue + rightValue) * 0.5f; + sideHistoryBuffer_[keep + i] = (leftValue - rightValue) * 0.5f; + } + bufferedSamples_ = std::min(fftSize_, bufferedSamples_ + length); + } } updateMagnitudes(); } const std::vector& Spectrum::process(const float* audioData, size_t length) { - if (audioData != nullptr && length > 0) { - pushHistory(audioData, length); - } - - updateMagnitudes(); - + pushSamples(audioData, length); return smoothedMagnitudes_; } @@ -139,8 +210,11 @@ float Spectrum::binToFrequency(int bin) const { void Spectrum::reset() { std::fill(historyBuffer_.begin(), historyBuffer_.end(), 0.0f); + std::fill(sideHistoryBuffer_.begin(), sideHistoryBuffer_.end(), 0.0f); std::fill(rawMagnitudes_.begin(), rawMagnitudes_.end(), -100.0f); std::fill(smoothedMagnitudes_.begin(), smoothedMagnitudes_.end(), -100.0f); + std::fill(sideRawMagnitudes_.begin(), sideRawMagnitudes_.end(), -100.0f); + std::fill(sideSmoothedMagnitudes_.begin(), sideSmoothedMagnitudes_.end(), -100.0f); bufferedSamples_ = 0; } diff --git a/native/src/spectrum.h b/native/src/spectrum.h index 20b94f1..ed700cf 100644 --- a/native/src/spectrum.h +++ b/native/src/spectrum.h @@ -18,12 +18,14 @@ public: // Feed new samples into the rolling history and update the latest magnitudes. void pushSamples(const float* input, size_t length); + void pushStereoSamples(const float* left, const float* right, 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_; } + const std::vector& getSideMagnitudes() const { return sideSmoothedMagnitudes_; } // Process audio and get spectrum data // Returns magnitude data (size = fftSize / 2) @@ -42,14 +44,24 @@ private: std::unique_ptr fft_; std::vector historyBuffer_; + std::vector sideHistoryBuffer_; std::vector windowedInput_; std::vector magnitudes_; std::vector rawMagnitudes_; std::vector smoothedMagnitudes_; + std::vector sideRawMagnitudes_; + std::vector sideSmoothedMagnitudes_; size_t bufferedSamples_; void applyWindow(const float* input, float* output, size_t length); - void pushHistory(const float* input, 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(); void updateMagnitudes(); }; diff --git a/src/renderer/audio/native/index.ts b/src/renderer/audio/native/index.ts index 51892b4..07195c1 100644 --- a/src/renderer/audio/native/index.ts +++ b/src/renderer/audio/native/index.ts @@ -39,6 +39,25 @@ export function getNativeLoadError(): Error | null { // Circular buffer size (must match native code) export const OSCILLOSCOPE_BUFFER_SIZE = 32768 +export interface SpectrumNativeAnalyzer { + setFFTSize(size: number): void + getFFTSize(): number + setSampleRate(sampleRate: number): void + setSmoothing(smoothing: number): void + pushSamples(audioData: Float32Array): void + pushStereoSamples(leftChannel: Float32Array, rightChannel: Float32Array): void + fillRawMagnitudes(output: Float32Array): number + fillMagnitudes(output: Float32Array): number + fillSideMagnitudes(output: Float32Array): number + getRawMagnitudes(): Float32Array | null + getMagnitudes(): Float32Array | null + getSideMagnitudes(): Float32Array | null + process(audioData: Float32Array): Float32Array | null + binToFrequency(bin: number): number + reset(): void + isAvailable?: () => boolean +} + // Export the native module functions with type safety export const oscilloscope = { setSampleRate: (sampleRate: number): void => { @@ -98,7 +117,11 @@ export const oscilloscope = { } } -export const spectrum = { +export const spectrum: SpectrumNativeAnalyzer = { + isAvailable: (): boolean => { + return Boolean(nativeModule?.spectrum) + }, + setFFTSize: (size: number): void => { nativeModule?.spectrum.setFFTSize(size) }, @@ -119,6 +142,10 @@ export const spectrum = { nativeModule?.spectrum.pushSamples(audioData) }, + pushStereoSamples: (leftChannel: Float32Array, rightChannel: Float32Array): void => { + nativeModule?.spectrum.pushStereoSamples(leftChannel, rightChannel) + }, + fillRawMagnitudes: (output: Float32Array): number => { if (!nativeModule) return 0 const magnitudes = nativeModule.spectrum.getRawMagnitudes() @@ -139,6 +166,16 @@ export const spectrum = { return count }, + fillSideMagnitudes: (output: Float32Array): number => { + if (!nativeModule) return 0 + const magnitudes = nativeModule.spectrum.getSideMagnitudes() + 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() @@ -149,6 +186,11 @@ export const spectrum = { return nativeModule.spectrum.getRawMagnitudes() }, + getSideMagnitudes: (): Float32Array | null => { + if (!nativeModule) return null + return nativeModule.spectrum.getSideMagnitudes() + }, + 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 3abb137..6275306 100644 --- a/src/renderer/audio/native/visualizer-dsp.d.ts +++ b/src/renderer/audio/native/visualizer-dsp.d.ts @@ -104,10 +104,13 @@ export interface SpectrumModule { setSampleRate(sampleRate: number): void; setSmoothing(smoothing: number): void; pushSamples(audioData: Float32Array): void; + pushStereoSamples(leftChannel: Float32Array, rightChannel: Float32Array): void; fillRawMagnitudes(output: Float32Array): number; fillMagnitudes(output: Float32Array): number; + fillSideMagnitudes(output: Float32Array): number; getRawMagnitudes(): Float32Array; getMagnitudes(): Float32Array; + getSideMagnitudes(): Float32Array; process(audioData: Float32Array): Float32Array; binToFrequency(bin: number): number; reset(): void; diff --git a/src/renderer/visualizers/SpectrumAnalyzer.ts b/src/renderer/visualizers/SpectrumAnalyzer.ts index 2e7d320..d90494a 100644 --- a/src/renderer/visualizers/SpectrumAnalyzer.ts +++ b/src/renderer/visualizers/SpectrumAnalyzer.ts @@ -1,5 +1,5 @@ import { audioRouter } from '../audio/AudioRouter' -import { spectrum as nativeSpectrum, isNativeAvailable } from '../audio/native' +import { spectrum as defaultNativeSpectrum, type SpectrumNativeAnalyzer } from '../audio/native' import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource' import { FrameScheduler } from './frameScheduler' import { VisualizerFrameLoop } from './visualizerFrameLoop' @@ -57,9 +57,10 @@ export interface SpectrumAnalyzerOptions { onPeakInfo?: (peakInfo: SpectrumPeakInfo | null) => void dataSource?: SpectrumAnalyzerDataSource frameScheduler?: FrameScheduler + nativeAnalyzer?: SpectrumNativeAnalyzer | null } -type ResolvedSpectrumAnalyzerOptions = Required> +type ResolvedSpectrumAnalyzerOptions = Required> type SpectrumPointFillResult = { pointCount: number peakInfo: SpectrumPeakInfo | null @@ -81,8 +82,6 @@ const LEGACY_DEFAULT_HEAT_COLORS: [string, string, string] = [ 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 PEAK_SELECTION_MAX_DISTANCE_OCTAVES = 0.5 const PEAK_SELECTION_SWITCH_THRESHOLD_DB = 4 @@ -94,74 +93,6 @@ function clampSmoothing(value: number): number { return Math.min(0.99, Math.max(0, value)) } -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 = parseColorToRgba(color) @@ -285,6 +216,7 @@ export class SpectrumAnalyzer { private ctx: CanvasRenderingContext2D private options: ResolvedSpectrumAnalyzerOptions private dataSource: SpectrumAnalyzerDataSource + private nativeAnalyzer: SpectrumNativeAnalyzer | null private frameLoop: VisualizerFrameLoop private nativeInitialized = false private sampleRate = 48000 @@ -295,22 +227,14 @@ export class SpectrumAnalyzer { private staticLayerKey = '' private unsubscribeSessionChange: (() => void) | null = null - 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) - private jsFftIm = new Float32Array(defaultOptions.fftSize) - private jsBufferedSamples = 0 - private jsHasSpectrumData = false private nativeMagnitudeBuffer = new Float32Array(0) private nativeRawMagnitudeBuffer = new Float32Array(0) + private nativeSideMagnitudeBuffer = new Float32Array(0) private heatmapMagnitudeBuffer = new Float32Array(0) private nativeBufferedSamples = 0 private nativeHasSpectrumData = false private pushScratch = new Float32Array(0) + private pushScratchRight = new Float32Array(0) private primaryPointX = new Float32Array(0) private primaryPointY = new Float32Array(0) private heatmapPointY = new Float32Array(0) @@ -327,7 +251,7 @@ export class SpectrumAnalyzer { if (!ctx) throw new Error('Could not get 2D context') this.ctx = ctx - const { dataSource, frameScheduler, ...optionOverrides } = options + const { dataSource, frameScheduler, nativeAnalyzer, ...optionOverrides } = options this.options = { ...defaultOptions, ...optionOverrides, @@ -339,6 +263,7 @@ export class SpectrumAnalyzer { ), } this.dataSource = dataSource ?? defaultSpectrumDataSource + this.nativeAnalyzer = nativeAnalyzer === undefined ? defaultNativeSpectrum : nativeAnalyzer this.heatLut = buildHeatLUT(this.options.heatColors) this.frameLoop = new VisualizerFrameLoop({ frameScheduler, @@ -350,7 +275,7 @@ export class SpectrumAnalyzer { if (!staticLayerCtx) throw new Error('Could not get offscreen 2D context') this.staticLayerCtx = staticLayerCtx - this.resetJsState() + this.resetAnalyzerBuffers() this.initNative() this.subscribeToSessionChanges() } @@ -369,33 +294,17 @@ export class SpectrumAnalyzer { this.sampleRate = Math.max(1, this.dataSource.getSampleRate()) this.lastSampleRate = 0 - if (isNativeAvailable() && !this.nativeInitialized) { - nativeSpectrum.setFFTSize(this.options.fftSize) - nativeSpectrum.setSampleRate(this.sampleRate) - nativeSpectrum.setSmoothing(this.getNativeSmoothing()) + if (this.isNativeAvailable() && !this.nativeInitialized) { + this.nativeAnalyzer?.setFFTSize(this.options.fftSize) + this.nativeAnalyzer?.setSampleRate(this.sampleRate) + this.nativeAnalyzer?.setSmoothing(this.getNativeSmoothing()) this.nativeInitialized = true console.log(`SpectrumAnalyzer: Using native DSP (${this.sampleRate}Hz)`) - } else if (!isNativeAvailable() && !this.options.showSideLine) { + } else if (!this.isNativeAvailable()) { 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.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) { @@ -404,38 +313,36 @@ export class SpectrumAnalyzer { if (this.nativeRawMagnitudeBuffer.length !== length) { this.nativeRawMagnitudeBuffer = new Float32Array(length) } + if (this.nativeSideMagnitudeBuffer.length !== length) { + this.nativeSideMagnitudeBuffer = new Float32Array(length) + } if (this.heatmapMagnitudeBuffer.length !== length) { this.heatmapMagnitudeBuffer = new Float32Array(length) } } - private resetJsState(): void { - this.ensureJsStateSize() + private resetAnalyzerBuffers(): void { 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.nativeSideMagnitudeBuffer.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 this.lastSelectedPeakInfo = null } + private isNativeAvailable(): boolean { + return Boolean(this.nativeAnalyzer) && this.nativeAnalyzer?.isAvailable?.() !== false + } + private updateSampleRateIfNeeded(): void { const currentRate = Math.max(1, this.dataSource.getSampleRate()) if (currentRate !== this.lastSampleRate && currentRate > 0) { this.sampleRate = currentRate this.lastSampleRate = currentRate - if (isNativeAvailable()) { - nativeSpectrum.setSampleRate(currentRate) + if (this.isNativeAvailable()) { + this.nativeAnalyzer?.setSampleRate(currentRate) } console.log(`SpectrumAnalyzer: Sample rate updated to ${currentRate}Hz`) } @@ -448,17 +355,17 @@ export class SpectrumAnalyzer { } private resetState(): void { - if (isNativeAvailable()) { - nativeSpectrum.reset() + if (this.isNativeAvailable()) { + this.nativeAnalyzer?.reset() } - this.resetJsState() + this.resetAnalyzerBuffers() this.sampleRate = Math.max(1, this.dataSource.getSampleRate()) this.lastSampleRate = 0 this.invalidate() } setOptions(options: Partial): void { - const { dataSource, frameScheduler: _frameScheduler, ...optionUpdates } = options + const { dataSource, frameScheduler: _frameScheduler, nativeAnalyzer, ...optionUpdates } = options const nextOptions = { ...this.options, ...optionUpdates } if (optionUpdates.tiltDbPerOctave !== undefined) { nextOptions.tiltDbPerOctave = clampSpectrumTiltDbPerOctave(optionUpdates.tiltDbPerOctave) @@ -478,6 +385,14 @@ export class SpectrumAnalyzer { this.heatLut = buildHeatLUT(this.options.heatColors) let didReset = false + if (nativeAnalyzer !== undefined && nativeAnalyzer !== this.nativeAnalyzer) { + this.nativeAnalyzer = nativeAnalyzer + this.nativeInitialized = false + this.initNative() + this.resetState() + didReset = true + } + if (dataSource && dataSource !== this.dataSource) { this.dataSource = dataSource this.subscribeToSessionChanges() @@ -485,12 +400,12 @@ export class SpectrumAnalyzer { didReset = true } - if (isNativeAvailable()) { + if (this.isNativeAvailable()) { if (options.fftSize !== undefined) { - nativeSpectrum.setFFTSize(options.fftSize) + this.nativeAnalyzer?.setFFTSize(options.fftSize) } if (options.smoothing !== undefined || options.fftSize !== undefined) { - nativeSpectrum.setSmoothing(this.getNativeSmoothing()) + this.nativeAnalyzer?.setSmoothing(this.getNativeSmoothing()) } } @@ -623,7 +538,7 @@ export class SpectrumAnalyzer { if (pendingSpectrum.length === 1) { if (pendingSpectrum[0].length > 0) { - nativeSpectrum.pushSamples(pendingSpectrum[0]) + this.nativeAnalyzer?.pushSamples(pendingSpectrum[0]) this.recordNativeBufferedSamples(pendingSpectrum[0].length) } return pendingSpectrum[0].length @@ -651,7 +566,57 @@ export class SpectrumAnalyzer { } } - nativeSpectrum.pushSamples(merged) + this.nativeAnalyzer?.pushSamples(merged) + this.recordNativeBufferedSamples(totalLength) + return totalLength + } + + private pushPendingSpectrumStereoChunks(pendingSpectrum: SpectrumStereoChunk[]): number { + if (pendingSpectrum.length === 0) return 0 + + if (pendingSpectrum.length === 1) { + const chunk = pendingSpectrum[0] + const length = Math.min(chunk.left.length, chunk.right.length) + if (length > 0) { + const left = chunk.left.length === length ? chunk.left : chunk.left.subarray(0, length) + const right = chunk.right.length === length ? chunk.right : chunk.right.subarray(0, length) + this.nativeAnalyzer?.pushStereoSamples(left, right) + this.recordNativeBufferedSamples(length) + } + return length + } + + let totalLength = 0 + for (const chunk of pendingSpectrum) { + totalLength += Math.min(chunk.left.length, chunk.right.length) + } + if (totalLength === 0) return 0 + + if (this.pushScratch.length < totalLength) { + this.pushScratch = new Float32Array(totalLength) + } + if (this.pushScratchRight.length < totalLength) { + this.pushScratchRight = new Float32Array(totalLength) + } + + const mergedLeft = this.pushScratch.length === totalLength + ? this.pushScratch + : this.pushScratch.subarray(0, totalLength) + const mergedRight = this.pushScratchRight.length === totalLength + ? this.pushScratchRight + : this.pushScratchRight.subarray(0, totalLength) + + let offset = 0 + for (const chunk of pendingSpectrum) { + const length = Math.min(chunk.left.length, chunk.right.length) + if (length > 0) { + mergedLeft.set(chunk.left.subarray(0, length), offset) + mergedRight.set(chunk.right.subarray(0, length), offset) + offset += length + } + } + + this.nativeAnalyzer?.pushStereoSamples(mergedLeft, mergedRight) this.recordNativeBufferedSamples(totalLength) return totalLength } @@ -661,32 +626,6 @@ export class SpectrumAnalyzer { 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 updateSmoothedMagnitudes( rawMagnitudes: Float32Array, dataLength: number, @@ -716,68 +655,6 @@ export class SpectrumAnalyzer { return count } - private updateJsMagnitudes( - history: Float32Array, - smoothedMagnitudes: Float32Array, - rawMagnitudesOut: Float32Array | null = null, - ): 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 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)) - rawMagnitudes[index] = db - } - - this.updateSmoothedMagnitudes( - rawMagnitudes, - rawMagnitudes.length, - smoothedMagnitudes, - this.options.smoothing, - this.jsBufferedSamples < fftSize, - ) - } - - 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.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 - } - private fillSpectrumPoints( frequencyData: Float32Array, dataLength: number, @@ -1098,10 +975,10 @@ export class SpectrumAnalyzer { if (!this.dataSource.isPlaying()) { this.clearPendingSpectrumQueues() - if (isNativeAvailable()) { - nativeSpectrum.reset() + if (this.isNativeAvailable()) { + this.nativeAnalyzer?.reset() } - this.resetJsState() + this.resetAnalyzerBuffers() this.renderStaticLayer(minFrequency, maxFrequency) this.emitPeakInfo(null) return @@ -1114,46 +991,43 @@ export class SpectrumAnalyzer { let heatmapDataLength = 0 let secondaryDataLength = 0 + if (!this.isNativeAvailable()) { + this.clearPendingSpectrumQueues() + console.error('SpectrumAnalyzer: Native DSP required') + this.renderStaticLayer(minFrequency, maxFrequency) + this.emitPeakInfo(null) + return + } + + const receivedNativeSamples = options.showSideLine + ? this.pushPendingSpectrumStereoChunks(this.dataSource.getPendingSpectrumStereoSamples()) + : this.pushPendingSpectrumChunks(this.dataSource.getPendingSpectrumSamples()) + + this.ensureMagnitudeBufferSize() + primaryData = this.nativeMagnitudeBuffer + primaryDataLength = this.nativeAnalyzer?.fillMagnitudes(this.nativeMagnitudeBuffer) ?? 0 + + if (receivedNativeSamples > 0 || !this.nativeHasSpectrumData) { + heatmapDataLength = this.nativeAnalyzer?.fillRawMagnitudes(this.nativeRawMagnitudeBuffer) ?? 0 + if (heatmapDataLength > 0) { + this.updateSmoothedMagnitudes( + this.nativeRawMagnitudeBuffer, + heatmapDataLength, + this.heatmapMagnitudeBuffer, + options.heatmapSmoothing, + this.nativeBufferedSamples < options.fftSize, + ) + this.nativeHasSpectrumData = true + } + } else if (this.nativeHasSpectrumData) { + heatmapDataLength = this.heatmapMagnitudeBuffer.length + } + + heatmapData = this.nativeHasSpectrumData ? this.heatmapMagnitudeBuffer : null + if (options.showSideLine) { - 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()) { - console.error('SpectrumAnalyzer: Native DSP required') - this.renderStaticLayer(minFrequency, maxFrequency) - this.emitPeakInfo(null) - return - } - - const pendingSpectrum = this.dataSource.getPendingSpectrumSamples() - const receivedNativeSamples = this.pushPendingSpectrumChunks(pendingSpectrum) - - 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 + secondaryData = this.nativeSideMagnitudeBuffer + secondaryDataLength = this.nativeAnalyzer?.fillSideMagnitudes(this.nativeSideMagnitudeBuffer) ?? 0 } if (!primaryData || primaryDataLength === 0) { @@ -1336,10 +1210,10 @@ export class SpectrumAnalyzer { this.unsubscribeSessionChange = null } - if (isNativeAvailable()) { - nativeSpectrum.reset() + if (this.isNativeAvailable()) { + this.nativeAnalyzer?.reset() } - this.resetJsState() + this.resetAnalyzerBuffers() this.lastSampleRate = 0 this.emitPeakInfo(null) } diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index a943e27..5d1dc31 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -81,6 +81,7 @@ import type { SpectrogramNativeAnalyzer, SpectrogramNativeOptions, SpectrogramNativeResult, + SpectrumNativeAnalyzer, VectorscopeNativeAnalyzer, VUMeterNativeAnalyzer, VUMeterNativeSnapshot, @@ -773,6 +774,242 @@ function assertArraysDiffer(actual: number[], expected: number[], tolerance: num assert.fail(message) } +interface FakeSpectrumNativeAnalyzer extends SpectrumNativeAnalyzer { + calls: { + monoPushes: Float32Array[] + stereoPushes: Array<{ left: Float32Array; right: Float32Array }> + fillMagnitudes: number + fillRawMagnitudes: number + fillSideMagnitudes: number + resets: number + } +} + +function runFft(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 createFakeSpectrumNativeAnalyzer(): FakeSpectrumNativeAnalyzer { + let fftSize = 2048 + let sampleRate = 48000 + let smoothing = 0.9 + let bufferedSamples = 0 + let history = new Float32Array(fftSize) + let sideHistory = new Float32Array(fftSize) + let rawMagnitudes = new Float32Array(fftSize / 2) + let magnitudes = new Float32Array(fftSize / 2) + let sideMagnitudes = new Float32Array(fftSize / 2) + let re = new Float32Array(fftSize) + let im = new Float32Array(fftSize) + rawMagnitudes.fill(-100) + magnitudes.fill(-100) + sideMagnitudes.fill(-100) + + const calls: FakeSpectrumNativeAnalyzer['calls'] = { + monoPushes: [], + stereoPushes: [], + fillMagnitudes: 0, + fillRawMagnitudes: 0, + fillSideMagnitudes: 0, + resets: 0, + } + + const resize = (size: number): void => { + fftSize = size + bufferedSamples = 0 + history = new Float32Array(fftSize) + sideHistory = new Float32Array(fftSize) + rawMagnitudes = new Float32Array(fftSize / 2) + magnitudes = new Float32Array(fftSize / 2) + sideMagnitudes = new Float32Array(fftSize / 2) + re = new Float32Array(fftSize) + im = new Float32Array(fftSize) + rawMagnitudes.fill(-100) + magnitudes.fill(-100) + sideMagnitudes.fill(-100) + } + + const updateMagnitudes = (source: Float32Array, output: Float32Array, rawOutput: Float32Array | null): void => { + for (let index = 0; index < fftSize; index += 1) { + const window = fftSize <= 1 ? 1 : 0.5 * (1 - Math.cos((2 * Math.PI * index) / (fftSize - 1))) + re[index] = source[index] * window + im[index] = 0 + } + runFft(re, im) + + 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 + db = Math.min(12, Math.max(-120, db)) + if (rawOutput) { + rawOutput[index] = db + } + output[index] = bufferedSamples < fftSize + ? db + : smoothing * output[index] + (1 - smoothing) * db + } + } + + const pushMonoHistory = (samples: Float32Array): void => { + const length = samples.length + if (length >= fftSize) { + history.set(samples.subarray(length - fftSize)) + sideHistory.fill(0) + bufferedSamples = fftSize + return + } + + history.copyWithin(0, length) + sideHistory.copyWithin(0, length) + history.set(samples, fftSize - length) + sideHistory.fill(0, fftSize - length) + bufferedSamples = Math.min(fftSize, bufferedSamples + length) + } + + const pushStereoHistory = (left: Float32Array, right: Float32Array): void => { + const length = Math.min(left.length, right.length) + if (length >= fftSize) { + const start = length - fftSize + for (let index = 0; index < fftSize; index += 1) { + const leftValue = left[start + index] + const rightValue = right[start + index] + history[index] = (leftValue + rightValue) * 0.5 + sideHistory[index] = (leftValue - rightValue) * 0.5 + } + bufferedSamples = fftSize + return + } + + history.copyWithin(0, length) + sideHistory.copyWithin(0, length) + const writeStart = fftSize - length + for (let index = 0; index < length; index += 1) { + history[writeStart + index] = (left[index] + right[index]) * 0.5 + sideHistory[writeStart + index] = (left[index] - right[index]) * 0.5 + } + bufferedSamples = Math.min(fftSize, bufferedSamples + length) + } + + const copyInto = (source: Float32Array, output: Float32Array): number => { + const count = Math.min(source.length, output.length) + output.set(source.subarray(0, count), 0) + return count + } + + const analyzer: FakeSpectrumNativeAnalyzer = { + calls, + isAvailable: () => true, + setFFTSize: (size) => { + if (size !== fftSize) { + resize(size) + } + }, + getFFTSize: () => fftSize, + setSampleRate: (nextSampleRate) => { + sampleRate = nextSampleRate + }, + setSmoothing: (nextSmoothing) => { + smoothing = Math.min(0.99, Math.max(0, nextSmoothing)) + }, + pushSamples: (audioData) => { + calls.monoPushes.push(new Float32Array(audioData)) + pushMonoHistory(audioData) + updateMagnitudes(history, magnitudes, rawMagnitudes) + updateMagnitudes(sideHistory, sideMagnitudes, null) + }, + pushStereoSamples: (leftChannel, rightChannel) => { + calls.stereoPushes.push({ + left: new Float32Array(leftChannel), + right: new Float32Array(rightChannel), + }) + pushStereoHistory(leftChannel, rightChannel) + updateMagnitudes(history, magnitudes, rawMagnitudes) + updateMagnitudes(sideHistory, sideMagnitudes, null) + }, + fillRawMagnitudes: (output) => { + calls.fillRawMagnitudes += 1 + return copyInto(rawMagnitudes, output) + }, + fillMagnitudes: (output) => { + calls.fillMagnitudes += 1 + return copyInto(magnitudes, output) + }, + fillSideMagnitudes: (output) => { + calls.fillSideMagnitudes += 1 + return copyInto(sideMagnitudes, output) + }, + getRawMagnitudes: () => rawMagnitudes, + getMagnitudes: () => magnitudes, + getSideMagnitudes: () => sideMagnitudes, + process: (audioData) => { + analyzer.pushSamples(audioData) + return magnitudes + }, + binToFrequency: (bin) => bin * sampleRate / fftSize, + reset: () => { + calls.resets += 1 + history.fill(0) + sideHistory.fill(0) + rawMagnitudes.fill(-100) + magnitudes.fill(-100) + sideMagnitudes.fill(-100) + bufferedSamples = 0 + }, + } + + return analyzer +} + function renderSpectrumSnapshot(options: Partial): { primaryPointY: number[] heatmapPointY: number[] @@ -795,6 +1032,7 @@ function renderSpectrumSnapshot(options: Partial): { fillGradient: false, showGrid: false, dataSource, + nativeAnalyzer: createFakeSpectrumNativeAnalyzer(), ...options, }) @@ -862,6 +1100,7 @@ function renderSpectrumHeatmap( fillGradient: false, showGrid: false, dataSource, + nativeAnalyzer: createFakeSpectrumNativeAnalyzer(), ...options, }) @@ -907,6 +1146,7 @@ function projectSpectrumDb(options: Partial, db: number showSideLine: true, showGrid: false, dataSource, + nativeAnalyzer: createFakeSpectrumNativeAnalyzer(), ...options, }) @@ -1333,6 +1573,52 @@ test('scopeSettingsToOptions wires spectrum side overlay settings into analyzer assert.equal(options.gridColor, theme.spectrum.guides) }) +test('SpectrumAnalyzer side line uses native stereo spectrum without draining mono samples', () => { + const dom = installFakeCanvasDom() + const nativeAnalyzer = createFakeSpectrumNativeAnalyzer() + let monoDrains = 0 + let stereoDrains = 0 + const left = new Float32Array([1, 0.5, -0.5, -1]) + const right = new Float32Array([-1, -0.5, 0.5, 1]) + const dataSource = { + getPendingSpectrumSamples: () => { + monoDrains += 1 + return [new Float32Array([0.25, 0.25, 0.25, 0.25])] + }, + getPendingSpectrumStereoSamples: () => { + stereoDrains += 1 + return [{ left, right }] + }, + getSampleRate: () => 48000, + isPlaying: () => true, + subscribeToSessionChanges: () => () => {}, + } + + const analyzer = new SpectrumAnalyzer(createFakeCanvas(), { + showSideLine: true, + showGrid: false, + fillGradient: false, + dataSource, + nativeAnalyzer, + }) + + try { + const state = analyzer as unknown as { drawFrame: () => void } + state.drawFrame() + + assert.equal(monoDrains, 0) + assert.equal(stereoDrains, 1) + assert.equal(nativeAnalyzer.calls.monoPushes.length, 0) + assert.equal(nativeAnalyzer.calls.stereoPushes.length, 1) + assert.deepEqual(Array.from(nativeAnalyzer.calls.stereoPushes[0]?.left ?? []), Array.from(left)) + assert.deepEqual(Array.from(nativeAnalyzer.calls.stereoPushes[0]?.right ?? []), Array.from(right)) + assert.equal(nativeAnalyzer.calls.fillSideMagnitudes, 1) + } finally { + analyzer.dispose() + dom.restore() + } +}) + test('default profile starts with spectrum peak info disabled', () => { const profile = createDefaultProfile('Default') @@ -1835,6 +2121,7 @@ test('SpectrumAnalyzer reports peak info from the visible spectrum curve', () => tiltDbPerOctave: 0, fftSize: 4096, dataSource, + nativeAnalyzer: createFakeSpectrumNativeAnalyzer(), capturePeakInfo: true, onPeakInfo: (nextPeakInfo) => { peakInfo = nextPeakInfo @@ -1919,6 +2206,7 @@ test('SpectrumAnalyzer smooths peak selection without smoothing the reported pos tiltDbPerOctave: 0, fftSize: 4096, dataSource, + nativeAnalyzer: createFakeSpectrumNativeAnalyzer(), capturePeakInfo: true, onPeakInfo: (nextPeakInfo) => { if (nextPeakInfo) { @@ -1968,6 +2256,7 @@ test('SpectrumAnalyzer does not over-bias toward an octave-lower peak', () => { tiltDbPerOctave: 0, fftSize: 4096, dataSource, + nativeAnalyzer: createFakeSpectrumNativeAnalyzer(), capturePeakInfo: true, onPeakInfo: (nextPeakInfo) => { peakInfo = nextPeakInfo