mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
change spectrum db to dbFS
This commit is contained in:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface SpectrumPitchInfo {
|
||||
}
|
||||
|
||||
export interface SpectrumPeakInfo {
|
||||
db: number
|
||||
dbfs: number
|
||||
frequencyHz: number
|
||||
normalizedX: number
|
||||
normalizedY: number
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
Reference in New Issue
Block a user