mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
fix spectrum sideline being JS not native
This commit is contained in:
@@ -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
@@ -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
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user