fix spectrum sideline being JS not native

This commit is contained in:
Boof2015
2026-05-19 19:18:26 -04:00
parent d9cf263856
commit 056d46e95d
7 changed files with 619 additions and 285 deletions
+40
View File
@@ -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>();
Napi::Float32Array rightData = info[1].As<Napi::Float32Array>();
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<double>(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<Napi::Float32Array>();
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<double>(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));
+97 -23
View File
@@ -13,11 +13,14 @@ Spectrum::Spectrum(size_t fftSize)
, bufferedSamples_(0) {
fft_ = std::make_unique<DSP::FFT>(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<DSP::FFT>(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<float>& 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<float>& 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<float>& history,
std::vector<float>& rawMagnitudes,
std::vector<float>& 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<float>& 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;
}
+13 -1
View File
@@ -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<float>& getRawMagnitudes() const { return rawMagnitudes_; }
// Read the latest smoothed magnitudes without mutating analyzer state.
const std::vector<float>& getMagnitudes() const { return smoothedMagnitudes_; }
const std::vector<float>& getSideMagnitudes() const { return sideSmoothedMagnitudes_; }
// Process audio and get spectrum data
// Returns magnitude data (size = fftSize / 2)
@@ -42,14 +44,24 @@ private:
std::unique_ptr<DSP::FFT> fft_;
std::vector<float> historyBuffer_;
std::vector<float> sideHistoryBuffer_;
std::vector<float> windowedInput_;
std::vector<float> magnitudes_;
std::vector<float> rawMagnitudes_;
std::vector<float> smoothedMagnitudes_;
std::vector<float> sideRawMagnitudes_;
std::vector<float> 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<float>& history, const float* input, size_t length);
void pushZeroHistory(std::vector<float>& history, size_t length);
void updateMagnitudesForHistory(
const std::vector<float>& history,
std::vector<float>& rawMagnitudes,
std::vector<float>& smoothedMagnitudes
);
void updateSilentSideMagnitudes();
void updateMagnitudes();
};
+43 -1
View File
@@ -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)
+3
View File
@@ -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;
+134 -260
View File
@@ -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<Omit<SpectrumAnalyzerOptions, 'dataSource' | 'frameScheduler'>>
type ResolvedSpectrumAnalyzerOptions = Required<Omit<SpectrumAnalyzerOptions, 'dataSource' | 'frameScheduler' | 'nativeAnalyzer'>>
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<number, Float32Array>()
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<SpectrumAnalyzerOptions>): 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)
}
+289
View File
@@ -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<SpectrumAnalyzerOptions>): {
primaryPointY: number[]
heatmapPointY: number[]
@@ -795,6 +1032,7 @@ function renderSpectrumSnapshot(options: Partial<SpectrumAnalyzerOptions>): {
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<SpectrumAnalyzerOptions>, 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