change spectrum db to dbFS

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