From 3b302d55266b430d5975f45731868af02baffd67 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:43:31 -0400 Subject: [PATCH] make Spectrum and Spectrogram more frequency accurate and detail preserving --- .github/workflows/release.yml | 3 + README.md | 4 +- native/src/main.cpp | 47 ++- native/src/spectrogram.cpp | 384 ++++++++++++++---- native/src/spectrogram.h | 24 +- package.json | 1 + plugin/Source/SpectrogramEngine.h | 10 +- src/plugin-ui/spectrogramOptions.ts | 7 + src/plugin-ui/spectrumOptions.ts | 5 + src/renderer/audio/AudioRouter.ts | 36 +- src/renderer/audio/native/index.ts | 8 + src/renderer/audio/native/visualizer-dsp.d.ts | 1 + src/renderer/components/ScopeModule.tsx | 11 + src/renderer/components/ScopePopoutBridge.tsx | 2 +- .../components/ScopeSettingsSection.tsx | 50 ++- src/renderer/popouts/ScopePopoutDataSource.ts | 8 +- src/renderer/scopeMeasurement.ts | 64 +-- src/renderer/visualizers/Spectrogram.ts | 100 ++++- src/renderer/visualizers/SpectrumAnalyzer.ts | 51 ++- src/shared/profileState.ts | 22 +- src/shared/themeState.ts | 6 + src/types/frequencyScale.ts | 226 +++++++++++ src/types/settings.ts | 14 +- src/types/spectrogram.ts | 24 +- src/types/theme.ts | 4 + test/audio-router.test.ts | 30 ++ test/profile-library.test.ts | 72 ++++ test/renderer-helpers.test.ts | 359 +++++++++++++++- test/spectrogram-native.test.mjs | 384 ++++++++++++++++++ 29 files changed, 1715 insertions(+), 242 deletions(-) create mode 100644 src/types/frequencyScale.ts create mode 100644 test/spectrogram-native.test.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 76438c1..10d96d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -121,6 +121,9 @@ jobs: - name: Test native spectrum and capture exports run: npm run test:spectrum-native + - name: Test native spectrogram accuracy + run: npm run test:spectrogram-native + - name: Build and test Prism TUI run: npm run test:tui diff --git a/README.md b/README.md index 591778f..76b9662 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,10 @@ Prism taps into your system audio and runs it through a rack of real-time scopes Seven visualizers driven by a native C++ analysis engine: -- **Spectrum Analyzer** — FFT frequency display with heatmap and fill modes, configurable FFT size, spectral tilt, and log or linear scaling +- **Spectrum Analyzer** — FFT frequency display with heatmap and fill modes, configurable FFT size, spectral tilt, Log/Mel/Linear scaling, and Extended (default, 10 Hz–up to 24 kHz) or Audible (20 Hz–20 kHz) ranges - **Oscilloscope** — Time-domain waveform with a pitch-lock mode that syncs the display to the fundamental frequency - **Vectorscope** — Stereo phase visualization in five display modes (Lissajous, polar, linear) with optional multiband RGB split -- **Spectrogram** — Scrolling frequency-over-time display with mel, log, and linear scale modes +- **Spectrogram** — Scrolling frequency-over-time display with Log/Mel/Linear scales, Extended (default) or Audible ranges, optional adaptive frequency guides, stereo-energy analysis, a legacy-style Focused mode, and detail-preserving frequency reassignment in Sharp/Sharper modes - **VU Meter** — Classic loudness metering in needle or bar style, horizontal or vertical - **Loudness Meter** — Compact LUFS metering following ITU-R BS.1770 with fast stereo peak activity - **Waveform** — Scrolling time-domain view with mono, stereo, and multiband modes diff --git a/native/src/main.cpp b/native/src/main.cpp index 018c288..e7370ed 100644 --- a/native/src/main.cpp +++ b/native/src/main.cpp @@ -327,6 +327,27 @@ std::string GetObjectString(const Napi::Object& obj, const char* key, const std: Napi::Value value = obj.Get(key); return value.IsString() ? value.As().Utf8Value() : fallback; } + +Napi::Object SpectrogramResultToJs( + Napi::Env env, + const Visualizer::SpectrogramProcessResult& result +) { + Napi::Float32Array display = Napi::Float32Array::New(env, result.display.size()); + Napi::Float32Array heat = Napi::Float32Array::New(env, result.heat.size()); + if (!result.display.empty()) { + memcpy(display.Data(), result.display.data(), result.display.size() * sizeof(float)); + } + if (!result.heat.empty()) { + memcpy(heat.Data(), result.heat.data(), result.heat.size() * sizeof(float)); + } + + Napi::Object obj = Napi::Object::New(env); + obj.Set("display", display); + obj.Set("heat", heat); + obj.Set("columnCount", Napi::Number::New(env, static_cast(result.columnCount))); + obj.Set("rowCount", Napi::Number::New(env, static_cast(result.rowCount))); + return obj; +} } // namespace Napi::Value SpectrogramConfigure(const Napi::CallbackInfo& info) { @@ -365,22 +386,21 @@ Napi::Value SpectrogramProcess(const Napi::CallbackInfo& info) { Napi::Float32Array audioData = info[0].As(); auto result = spectrogramAnalyzer.process(audioData.Data(), audioData.ElementLength()); + return SpectrogramResultToJs(env, result); +} - Napi::Float32Array display = Napi::Float32Array::New(env, result.display.size()); - Napi::Float32Array heat = Napi::Float32Array::New(env, result.heat.size()); - if (!result.display.empty()) { - memcpy(display.Data(), result.display.data(), result.display.size() * sizeof(float)); - } - if (!result.heat.empty()) { - memcpy(heat.Data(), result.heat.data(), result.heat.size() * sizeof(float)); +Napi::Value SpectrogramProcessStereo(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 2 || !info[0].IsTypedArray() || !info[1].IsTypedArray()) { + Napi::TypeError::New(env, "Expected left and right Float32Arrays").ThrowAsJavaScriptException(); + return env.Null(); } - Napi::Object obj = Napi::Object::New(env); - obj.Set("display", display); - obj.Set("heat", heat); - obj.Set("columnCount", Napi::Number::New(env, static_cast(result.columnCount))); - obj.Set("rowCount", Napi::Number::New(env, static_cast(result.rowCount))); - return obj; + Napi::Float32Array left = info[0].As(); + Napi::Float32Array right = info[1].As(); + const size_t length = std::min(left.ElementLength(), right.ElementLength()); + auto result = spectrogramAnalyzer.processStereo(left.Data(), right.Data(), length); + return SpectrogramResultToJs(env, result); } Napi::Value SpectrogramReset(const Napi::CallbackInfo& info) { @@ -723,6 +743,7 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) { Napi::Object spectrogramExports = Napi::Object::New(env); spectrogramExports.Set("configure", Napi::Function::New(env, SpectrogramConfigure)); spectrogramExports.Set("process", Napi::Function::New(env, SpectrogramProcess)); + spectrogramExports.Set("processStereo", Napi::Function::New(env, SpectrogramProcessStereo)); spectrogramExports.Set("reset", Napi::Function::New(env, SpectrogramReset)); exports.Set("spectrogram", spectrogramExports); diff --git a/native/src/spectrogram.cpp b/native/src/spectrogram.cpp index 0d79f6b..1ec7ee4 100644 --- a/native/src/spectrogram.cpp +++ b/native/src/spectrogram.cpp @@ -9,10 +9,10 @@ namespace Visualizer { namespace { constexpr size_t FFT_PAD_FACTOR = 4; -constexpr float DISPLAY_GAIN_DB = 2.0f; +constexpr float HANN_EQUIVALENT_NOISE_BANDWIDTH_BINS = 1.5f; +constexpr float REASSIGNED_POWER_NORMALIZATION = static_cast(FFT_PAD_FACTOR) + * HANN_EQUIVALENT_NOISE_BANDWIDTH_BINS; constexpr float SPECTROGRAM_HEAT_GAMMA = 1.45f; -constexpr float SPECTROGRAM_DISPLAY_STROKE_WEIGHT = 0.42f; -constexpr float SPECTROGRAM_HEAT_STROKE_WEIGHT = 0.32f; constexpr float TILT_REFERENCE_HZ = 1000.0f; constexpr float HEAT_MIN_DB = -100.0f; constexpr float HEAT_MAX_DB = -20.0f; @@ -64,7 +64,8 @@ SpectrogramAnalyzer::SpectrogramAnalyzer() : fftSize_(0) , paddedSize_(0) , frameFill_(0) - , haveLastPhase_(false) { + , haveLastPhase_(false) + , magnitudeScale_(1.0f) { configureFft(config_.fftSize); rebuildFrequencyMapping(); } @@ -96,7 +97,10 @@ void SpectrogramAnalyzer::configure(const SpectrogramConfig& config) { if (next.orientation != "vertical") { next.orientation = "horizontal"; } - if (next.clarityMode != "classic" && next.clarityMode != "sharp" && next.clarityMode != "sharper") { + if (next.clarityMode != "classic" + && next.clarityMode != "focused" + && next.clarityMode != "sharp" + && next.clarityMode != "sharper") { next.clarityMode = "sharper"; } @@ -128,13 +132,19 @@ void SpectrogramAnalyzer::configureFft(size_t fftSize) { paddedSize_ = fftSize_ * FFT_PAD_FACTOR; fft_ = std::make_unique(paddedSize_); frameBuffer_.assign(fftSize_, 0.0f); + rightFrameBuffer_.assign(fftSize_, 0.0f); window_.assign(fftSize_, 1.0f); windowedInput_.assign(paddedSize_, 0.0f); + rightWindowedInput_.assign(paddedSize_, 0.0f); fftOutput_.assign(paddedSize_, std::complex(0.0f, 0.0f)); + rightFftOutput_.assign(paddedSize_, std::complex(0.0f, 0.0f)); magnitudesDb_.assign(paddedSize_ / 2, -200.0f); magnitudesLinear_.assign(paddedSize_ / 2, 0.0f); phases_.assign(paddedSize_ / 2, 0.0f); lastPhases_.assign(paddedSize_ / 2, 0.0f); + rightPhases_.assign(paddedSize_ / 2, 0.0f); + rightLastPhases_.assign(paddedSize_ / 2, 0.0f); + dominantRight_.assign(paddedSize_ / 2, 0); frameFill_ = 0; haveLastPhase_ = false; @@ -145,11 +155,21 @@ void SpectrogramAnalyzer::configureFft(size_t fftSize) { for (size_t index = 0; index < fftSize_; index += 1) { window_[index] = 0.5f * (1.0f - std::cos((2.0f * static_cast(M_PI) * index) / (fftSize_ - 1))); } + + float windowSum = 0.0f; + for (const float coefficient : window_) { + windowSum += coefficient; + } + magnitudeScale_ = windowSum > std::numeric_limits::epsilon() + ? 2.0f / windowSum + : 1.0f; } void SpectrogramAnalyzer::reset() { std::fill(frameBuffer_.begin(), frameBuffer_.end(), 0.0f); + std::fill(rightFrameBuffer_.begin(), rightFrameBuffer_.end(), 0.0f); std::fill(lastPhases_.begin(), lastPhases_.end(), 0.0f); + std::fill(rightLastPhases_.begin(), rightLastPhases_.end(), 0.0f); frameFill_ = 0; haveLastPhase_ = false; } @@ -166,7 +186,7 @@ void SpectrogramAnalyzer::rebuildFrequencyMapping() { const float sampleRate = std::max(1.0f, config_.sampleRate); const float nyquist = sampleRate * 0.5f; const float minFrequency = std::max(1.0f, std::min(config_.minFrequency, nyquist)); - const float maxFrequency = std::max(minFrequency + 1.0f, std::min(config_.maxFrequency, nyquist)); + const float maxFrequency = std::max(minFrequency, std::min(config_.maxFrequency, nyquist)); config_.minFrequency = minFrequency; config_.maxFrequency = maxFrequency; @@ -177,8 +197,9 @@ void SpectrogramAnalyzer::rebuildFrequencyMapping() { standardRaw_.assign(rowCount, 0.0f); standardHeat_.assign(rowCount, 0.0f); reassignedPower_.assign(rowCount, 0.0f); - blendedRaw_.assign(rowCount, 0.0f); - blendedHeat_.assign(rowCount, 0.0f); + focusedPower_.assign(rowCount, 0.0f); + sourceRaw_.assign(rowCount, 0.0f); + sourceHeat_.assign(rowCount, 0.0f); shapedDisplay_.assign(rowCount, 0.0f); shapedHeat_.assign(rowCount, 0.0f); strokedDisplay_.assign(rowCount, 0.0f); @@ -218,7 +239,11 @@ void SpectrogramAnalyzer::rebuildFrequencyMapping() { float SpectrogramAnalyzer::frequencyFromScale(float normalizedPosition) const { const float t = clamp01(normalizedPosition); const float minFrequency = std::max(1.0f, config_.minFrequency); - const float maxFrequency = std::max(minFrequency + 1.0f, config_.maxFrequency); + const float maxFrequency = std::max(minFrequency, config_.maxFrequency); + + if (maxFrequency <= minFrequency) { + return minFrequency; + } if (config_.scaleMode == "linear") { return minFrequency + (t * (maxFrequency - minFrequency)); @@ -236,8 +261,15 @@ float SpectrogramAnalyzer::frequencyFromScale(float normalizedPosition) const { } float SpectrogramAnalyzer::frequencyToRow(float frequency) const { + if (config_.rowCount <= 1) { + return 0.0f; + } + const float minFrequency = std::max(1.0f, config_.minFrequency); - const float maxFrequency = std::max(minFrequency + 1.0f, config_.maxFrequency); + const float maxFrequency = std::max(minFrequency, config_.maxFrequency); + if (maxFrequency <= minFrequency) { + return config_.orientation == "vertical" ? 0.0f : static_cast(config_.rowCount - 1); + } const float clampedFrequency = std::clamp(frequency, minFrequency, maxFrequency); float normalized = 0.0f; @@ -262,7 +294,7 @@ float SpectrogramAnalyzer::frequencyToRow(float frequency) const { float SpectrogramAnalyzer::applyDisplayTilt(float db, float frequency) const { const float safeFrequency = std::max(1.0f, frequency); const float tiltAmount = config_.tiltDbPerOctave * std::log2(safeFrequency / TILT_REFERENCE_HZ); - return db + tiltAmount + DISPLAY_GAIN_DB; + return db + tiltAmount; } float SpectrogramAnalyzer::displayDbToIntensity(float db) const { @@ -296,10 +328,44 @@ float SpectrogramAnalyzer::sampleDbAtBin(float bin) const { ); } +float SpectrogramAnalyzer::samplePeakDbInBand(float startBin, float endBin, float& peakBin) const { + if (magnitudesDb_.empty()) { + peakBin = 0.0f; + return -200.0f; + } + + const float lastBin = static_cast(magnitudesDb_.size() - 1); + const float lo = std::clamp(std::min(startBin, endBin), 0.0f, lastBin); + const float hi = std::clamp(std::max(startBin, endBin), 0.0f, lastBin); + peakBin = 0.5f * (lo + hi); + float peakDb = sampleDbAtBin(peakBin); + + const auto consider = [&](float candidateBin) { + const float candidateDb = sampleDbAtBin(candidateBin); + if (candidateDb > peakDb) { + peakDb = candidateDb; + peakBin = candidateBin; + } + }; + + consider(lo); + consider(hi); + const size_t firstWholeBin = static_cast(std::ceil(lo)); + const size_t lastWholeBin = static_cast(std::floor(hi)); + for (size_t bin = firstWholeBin; bin <= lastWholeBin && bin < magnitudesDb_.size(); bin += 1) { + consider(static_cast(bin)); + } + + return peakDb; +} + void SpectrogramAnalyzer::computeStandardSpectrum() { const size_t rowCount = config_.rowCount; + const float binWidth = std::max(1.0f, config_.sampleRate) / static_cast(paddedSize_); for (size_t row = 0; row < rowCount; row += 1) { - const float displayDb = applyDisplayTilt(sampleDbAtBin(rowCenterBins_[row]), rowCenterFrequencies_[row]); + float peakBin = rowCenterBins_[row]; + const float rawDb = samplePeakDbInBand(rowBandStartBins_[row], rowBandEndBins_[row], peakBin); + const float displayDb = applyDisplayTilt(rawDb, peakBin * binWidth); standardRaw_[row] = displayDbToIntensity(displayDb); standardHeat_[row] = normalizeHeatDb(displayDb); } @@ -314,7 +380,11 @@ void SpectrogramAnalyzer::computeReassignedSpectrum() { const float sampleRate = std::max(1.0f, config_.sampleRate); const float binWidth = sampleRate / static_cast(paddedSize_); const float hopDt = static_cast(resolveHopSize()) / sampleRate; - const float ampThreshold = std::pow(10.0f, config_.minDecibels / 20.0f); + // Reassign every bin that can contribute to either output. Limiting this to + // local maxima throws away low-level partials and ambience — exactly the + // detail a sharpened spectrogram is meant to retain. + const float visibleFloorDb = std::min(config_.minDecibels, HEAT_MIN_DB); + const float ampThreshold = std::pow(10.0f, visibleFloorDb / 20.0f); const float twoPi = static_cast(2.0 * M_PI); for (size_t bin = 1; bin + 1 < magnitudesLinear_.size(); bin += 1) { @@ -322,9 +392,6 @@ void SpectrogramAnalyzer::computeReassignedSpectrum() { if (mag <= ampThreshold) { continue; } - if (mag < magnitudesLinear_[bin - 1] || mag < magnitudesLinear_[bin + 1]) { - continue; - } const float nominalFrequency = static_cast(bin) * binWidth; if (nominalFrequency < config_.minFrequency || nominalFrequency > config_.maxFrequency) { @@ -332,9 +399,67 @@ void SpectrogramAnalyzer::computeReassignedSpectrum() { } const float expected = twoPi * nominalFrequency * hopDt; - float correctionHz = wrapPhase(phases_[bin] - lastPhases_[bin] - expected) / (twoPi * hopDt); + const bool useRightPhase = dominantRight_[bin] != 0; + const float currentPhase = useRightPhase ? rightPhases_[bin] : phases_[bin]; + const float previousPhase = useRightPhase ? rightLastPhases_[bin] : lastPhases_[bin]; + const float correctionHz = wrapPhase(currentPhase - previousPhase - expected) / (twoPi * hopDt); + const float reassignedFrequency = nominalFrequency + correctionHz; + + if (reassignedFrequency < config_.minFrequency || reassignedFrequency > config_.maxFrequency) { + continue; + } + const float rowF = frequencyToRow(reassignedFrequency); + const size_t row0 = static_cast(std::floor(std::clamp(rowF, 0.0f, static_cast(config_.rowCount - 1)))); + const float frac = rowF - static_cast(row0); + // A coherently-normalized Hann spectrum contains 1.5 bins of equivalent + // noise bandwidth. With 4x zero padding, a bin-centered sinusoid therefore + // contributes 6x its signal power across the positive-frequency bins. + // Divide that back out so relocation conserves calibrated signal power. + const float power = (mag * mag) / REASSIGNED_POWER_NORMALIZATION; + + reassignedPower_[row0] += power * (1.0f - frac); + if (row0 + 1 < config_.rowCount) { + reassignedPower_[row0 + 1] += power * frac; + } + } +} + +void SpectrogramAnalyzer::computeFocusedSpectrum() { + std::fill(focusedPower_.begin(), focusedPower_.end(), 0.0f); + if (!haveLastPhase_ || magnitudesLinear_.size() < 3 || config_.rowCount == 0) { + return; + } + + const float sampleRate = std::max(1.0f, config_.sampleRate); + const float binWidth = sampleRate / static_cast(paddedSize_); + const float hopDt = static_cast(resolveHopSize()) / sampleRate; + const float ampThreshold = std::pow(10.0f, config_.minDecibels / 20.0f); + const float twoPi = static_cast(2.0 * M_PI); + + // Focused intentionally restores Prism's former peak-isolation aesthetic: + // only local FFT maxima are relocated, with conservative phase correction + // and a local spectral centroid. It is kept separate from Sharp/Sharper so + // their energy-preserving reassignment cannot silently lose texture. + for (size_t bin = 1; bin + 1 < magnitudesLinear_.size(); bin += 1) { + const float mag = magnitudesLinear_[bin]; + if (mag <= ampThreshold + || mag < magnitudesLinear_[bin - 1] + || mag < magnitudesLinear_[bin + 1]) { + continue; + } + + const float nominalFrequency = static_cast(bin) * binWidth; + if (nominalFrequency < config_.minFrequency || nominalFrequency > config_.maxFrequency) { + continue; + } + + const float expected = twoPi * nominalFrequency * hopDt; + const bool useRightPhase = dominantRight_[bin] != 0; + const float currentPhase = useRightPhase ? rightPhases_[bin] : phases_[bin]; + const float previousPhase = useRightPhase ? rightLastPhases_[bin] : lastPhases_[bin]; + float correctionHz = wrapPhase(currentPhase - previousPhase - expected) / (twoPi * hopDt); correctionHz = std::clamp(correctionHz, -1.5f * binWidth, 1.5f * binWidth); - float reassignedFrequency = nominalFrequency + correctionHz; + float focusedFrequency = nominalFrequency + correctionHz; const float leftWeight = magnitudesLinear_[bin - 1]; const float centerWeight = mag; @@ -346,97 +471,163 @@ void SpectrogramAnalyzer::computeReassignedSpectrum() { + (nominalFrequency * centerWeight) + (static_cast(bin + 1) * binWidth * rightWeight) ) / weightSum; - reassignedFrequency = 0.5f * reassignedFrequency + 0.5f * centroidFrequency; + focusedFrequency = 0.5f * focusedFrequency + 0.5f * centroidFrequency; } - reassignedFrequency = std::clamp(reassignedFrequency, config_.minFrequency, config_.maxFrequency); - const float rowF = frequencyToRow(reassignedFrequency); - const size_t row0 = static_cast(std::floor(std::clamp(rowF, 0.0f, static_cast(config_.rowCount - 1)))); + if (focusedFrequency < config_.minFrequency || focusedFrequency > config_.maxFrequency) { + continue; + } + const float rowF = frequencyToRow(focusedFrequency); + const size_t row0 = static_cast(std::floor( + std::clamp(rowF, 0.0f, static_cast(config_.rowCount - 1)) + )); const float frac = rowF - static_cast(row0); const float power = mag * mag; - reassignedPower_[row0] += power * (1.0f - frac); + focusedPower_[row0] += power * (1.0f - frac); if (row0 + 1 < config_.rowCount) { - reassignedPower_[row0 + 1] += power * frac; + focusedPower_[row0 + 1] += power * frac; } } } SpectrogramAnalyzer::ClarityProfile SpectrogramAnalyzer::clarityProfile(const std::string& mode) { if (mode == "classic") { - return {1.4f, 0.0f, 3.0f}; + return {1.4f, 0.42f, 0.32f, false}; } if (mode == "sharp") { - return {1.5f, 2.5f, 3.0f}; + return {1.25f, 0.22f, 0.16f, true}; } - return {2.0f, 5.0f, 2.0f}; + return {1.1f, 0.08f, 0.06f, true}; } -void SpectrogramAnalyzer::blendAndShapeColumn(std::vector& display, std::vector& heat) { +void SpectrogramAnalyzer::shapeColumn(std::vector& display, std::vector& heat) { + if (config_.clarityMode == "focused") { + shapeFocusedColumn(display, heat); + return; + } + const size_t rowCount = config_.rowCount; const ClarityProfile clarity = clarityProfile(config_.clarityMode); - const float standardWeight = config_.clarityMode == "classic" ? 0.8f : (config_.clarityMode == "sharp" ? 0.6f : 0.45f); - const float reassignedWeight = config_.clarityMode == "classic" ? 0.85f : 1.0f; + const bool useReassignedColumn = clarity.useReassignment && haveLastPhase_; for (size_t row = 0; row < rowCount; row += 1) { - float reassignedRaw = 0.0f; - float reassignedHeat = 0.0f; - if (reassignedPower_[row] > 0.0f) { + if (useReassignedColumn && reassignedPower_[row] > 0.0f) { const float reassignedMag = std::sqrt(reassignedPower_[row]); const float reassignedDb = 20.0f * std::log10(std::max(reassignedMag, 1.0e-10f)); const float displayDb = applyDisplayTilt(reassignedDb, rowCenterFrequencies_[row]); - reassignedRaw = displayDbToIntensity(displayDb); - reassignedHeat = normalizeHeatDb(displayDb); - } - - blendedRaw_[row] = std::max(standardRaw_[row] * standardWeight, reassignedRaw * reassignedWeight); - blendedHeat_[row] = std::max(standardHeat_[row] * standardWeight, reassignedHeat * reassignedWeight); - } - - if (clarity.sharpness > 0.0f) { - const std::vector peakSource = blendedRaw_; - const float mainlobePaddedBins = 4.0f * static_cast(FFT_PAD_FACTOR); - const float detailPreserve = config_.clarityMode == "sharp" ? 0.18f : 0.14f; - - for (size_t row = 0; row < rowCount; row += 1) { - const float bandWidthPerRow = std::max(0.1f, rowBandEndBins_[row] - rowBandStartBins_[row]); - const float mainlobePixels = mainlobePaddedBins / bandWidthPerRow; - const int halfWindow = std::max(2, std::min(50, static_cast(std::lround(mainlobePixels * 0.5f)))); - const float scaleFactor = std::max(1.0f, mainlobePixels / clarity.lineWidth); - const float effectiveSharpness = clarity.sharpness * scaleFactor; - - float localMax = peakSource[row]; - for (int offset = 1; offset <= halfWindow; offset += 1) { - if (row >= static_cast(offset)) { - localMax = std::max(localMax, peakSource[row - static_cast(offset)]); - } - if (row + static_cast(offset) < rowCount) { - localMax = std::max(localMax, peakSource[row + static_cast(offset)]); - } - } - - if (localMax > 1.0e-6f) { - const float ratio = blendedRaw_[row] / localMax; - const float suppression = std::pow(clamp01(ratio), effectiveSharpness); - const float rawBefore = blendedRaw_[row]; - const float heatBefore = blendedHeat_[row]; - blendedRaw_[row] = std::max(rawBefore * suppression, rawBefore * detailPreserve); - blendedHeat_[row] = std::max(heatBefore * suppression, heatBefore * detailPreserve); - } + sourceRaw_[row] = displayDbToIntensity(displayDb); + sourceHeat_[row] = normalizeHeatDb(displayDb); + } else if (!useReassignedColumn) { + // The first phase-history frame falls back to Classic. Subsequent Sharp + // and Sharper frames contain only reassigned energy — no hidden Classic + // layer and no local-contrast gate. + sourceRaw_[row] = standardRaw_[row]; + sourceHeat_[row] = standardHeat_[row]; + } else { + sourceRaw_[row] = 0.0f; + sourceHeat_[row] = 0.0f; } } const float effectiveGamma = clarity.gamma * config_.contrast; for (size_t row = 0; row < rowCount; row += 1) { - shapedDisplay_[row] = std::pow(clamp01(blendedRaw_[row]), effectiveGamma); - shapedHeat_[row] = std::pow(clamp01(blendedHeat_[row]), SPECTROGRAM_HEAT_GAMMA); + shapedDisplay_[row] = std::pow(clamp01(sourceRaw_[row]), effectiveGamma); + shapedHeat_[row] = std::pow(clamp01(sourceHeat_[row]), SPECTROGRAM_HEAT_GAMMA); strokedDisplay_[row] = shapedDisplay_[row]; strokedHeat_[row] = shapedHeat_[row]; } for (size_t row = 0; row < rowCount; row += 1) { - const float displayShoulder = shapedDisplay_[row] * SPECTROGRAM_DISPLAY_STROKE_WEIGHT; - const float heatShoulder = shapedHeat_[row] * SPECTROGRAM_HEAT_STROKE_WEIGHT; + const float displayShoulder = shapedDisplay_[row] * clarity.displayShoulder; + const float heatShoulder = shapedHeat_[row] * clarity.heatShoulder; + if (row > 0) { + strokedDisplay_[row - 1] = std::max(strokedDisplay_[row - 1], displayShoulder); + strokedHeat_[row - 1] = std::max(strokedHeat_[row - 1], heatShoulder); + } + if (row + 1 < rowCount) { + strokedDisplay_[row + 1] = std::max(strokedDisplay_[row + 1], displayShoulder); + strokedHeat_[row + 1] = std::max(strokedHeat_[row + 1], heatShoulder); + } + } + + const size_t offset = display.size(); + display.resize(offset + rowCount); + heat.resize(offset + rowCount); + for (size_t row = 0; row < rowCount; row += 1) { + display[offset + row] = strokedDisplay_[row]; + heat[offset + row] = strokedHeat_[row]; + } +} + +void SpectrogramAnalyzer::shapeFocusedColumn(std::vector& display, std::vector& heat) { + const size_t rowCount = config_.rowCount; + constexpr float standardWeight = 0.45f; + constexpr float reassignedWeight = 1.0f; + constexpr float sharpness = 5.0f; + constexpr float lineWidth = 2.0f; + constexpr float detailPreserve = 0.14f; + constexpr float gamma = 2.0f; + constexpr float displayShoulderWeight = 0.42f; + constexpr float heatShoulderWeight = 0.32f; + + for (size_t row = 0; row < rowCount; row += 1) { + float focusedRaw = 0.0f; + float focusedHeat = 0.0f; + if (focusedPower_[row] > 0.0f) { + const float focusedMag = std::sqrt(focusedPower_[row]); + const float focusedDb = 20.0f * std::log10(std::max(focusedMag, 1.0e-10f)); + const float displayDb = applyDisplayTilt(focusedDb, rowCenterFrequencies_[row]); + focusedRaw = displayDbToIntensity(displayDb); + focusedHeat = normalizeHeatDb(displayDb); + } + + sourceRaw_[row] = std::max(standardRaw_[row] * standardWeight, focusedRaw * reassignedWeight); + sourceHeat_[row] = std::max(standardHeat_[row] * standardWeight, focusedHeat * reassignedWeight); + } + + const std::vector peakSource = sourceRaw_; + const float mainlobePaddedBins = 4.0f * static_cast(FFT_PAD_FACTOR); + for (size_t row = 0; row < rowCount; row += 1) { + const float bandWidthPerRow = std::max(0.1f, rowBandEndBins_[row] - rowBandStartBins_[row]); + const float mainlobePixels = mainlobePaddedBins / bandWidthPerRow; + const int halfWindow = std::max( + 2, + std::min(50, static_cast(std::lround(mainlobePixels * 0.5f))) + ); + const float scaleFactor = std::max(1.0f, mainlobePixels / lineWidth); + const float effectiveSharpness = sharpness * scaleFactor; + + float localMax = peakSource[row]; + for (int offset = 1; offset <= halfWindow; offset += 1) { + if (row >= static_cast(offset)) { + localMax = std::max(localMax, peakSource[row - static_cast(offset)]); + } + if (row + static_cast(offset) < rowCount) { + localMax = std::max(localMax, peakSource[row + static_cast(offset)]); + } + } + + if (localMax > 1.0e-6f) { + const float suppression = std::pow(clamp01(sourceRaw_[row] / localMax), effectiveSharpness); + const float rawBefore = sourceRaw_[row]; + const float heatBefore = sourceHeat_[row]; + sourceRaw_[row] = std::max(rawBefore * suppression, rawBefore * detailPreserve); + sourceHeat_[row] = std::max(heatBefore * suppression, heatBefore * detailPreserve); + } + } + + const float effectiveGamma = gamma * config_.contrast; + for (size_t row = 0; row < rowCount; row += 1) { + shapedDisplay_[row] = std::pow(clamp01(sourceRaw_[row]), effectiveGamma); + shapedHeat_[row] = std::pow(clamp01(sourceHeat_[row]), SPECTROGRAM_HEAT_GAMMA); + strokedDisplay_[row] = shapedDisplay_[row]; + strokedHeat_[row] = shapedHeat_[row]; + } + + for (size_t row = 0; row < rowCount; row += 1) { + const float displayShoulder = shapedDisplay_[row] * displayShoulderWeight; + const float heatShoulder = shapedHeat_[row] * heatShoulderWeight; if (row > 0) { strokedDisplay_[row - 1] = std::max(strokedDisplay_[row - 1], displayShoulder); strokedHeat_[row - 1] = std::max(strokedHeat_[row - 1], heatShoulder); @@ -458,35 +649,54 @@ void SpectrogramAnalyzer::blendAndShapeColumn(std::vector& display, std:: void SpectrogramAnalyzer::processFrame(std::vector& display, std::vector& heat) { std::fill(windowedInput_.begin(), windowedInput_.end(), 0.0f); + std::fill(rightWindowedInput_.begin(), rightWindowedInput_.end(), 0.0f); for (size_t index = 0; index < fftSize_; index += 1) { windowedInput_[index] = frameBuffer_[index] * window_[index]; + rightWindowedInput_[index] = rightFrameBuffer_[index] * window_[index]; } fft_->forward(windowedInput_.data(), fftOutput_.data()); + fft_->forward(rightWindowedInput_.data(), rightFftOutput_.data()); const size_t numBins = paddedSize_ / 2; - const float scale = 2.0f / static_cast(fftSize_); for (size_t bin = 0; bin < numBins; bin += 1) { - const float re = fftOutput_[bin].real(); - const float im = fftOutput_[bin].imag(); - const float magnitude = std::sqrt((re * re) + (im * im)) * scale; - magnitudesLinear_[bin] = magnitude; - magnitudesDb_[bin] = 20.0f * std::log10(std::max(magnitude, 1.0e-10f)); - phases_[bin] = std::atan2(im, re); + const float leftRe = fftOutput_[bin].real(); + const float leftIm = fftOutput_[bin].imag(); + const float rightRe = rightFftOutput_[bin].real(); + const float rightIm = rightFftOutput_[bin].imag(); + const float leftMagnitude = std::sqrt((leftRe * leftRe) + (leftIm * leftIm)) * magnitudeScale_; + const float rightMagnitude = std::sqrt((rightRe * rightRe) + (rightIm * rightIm)) * magnitudeScale_; + const float stereoMagnitude = std::sqrt( + 0.5f * ((leftMagnitude * leftMagnitude) + (rightMagnitude * rightMagnitude)) + ); + magnitudesLinear_[bin] = stereoMagnitude; + magnitudesDb_[bin] = 20.0f * std::log10(std::max(stereoMagnitude, 1.0e-10f)); + phases_[bin] = std::atan2(leftIm, leftRe); + rightPhases_[bin] = std::atan2(rightIm, rightRe); + dominantRight_[bin] = rightMagnitude > leftMagnitude ? 1 : 0; } computeStandardSpectrum(); - computeReassignedSpectrum(); - blendAndShapeColumn(display, heat); + if (config_.clarityMode == "focused") { + computeFocusedSpectrum(); + } else { + computeReassignedSpectrum(); + } + shapeColumn(display, heat); lastPhases_ = phases_; + rightLastPhases_ = rightPhases_; haveLastPhase_ = true; } SpectrogramProcessResult SpectrogramAnalyzer::process(const float* samples, size_t length) { + return processStereo(samples, samples, length); +} + +SpectrogramProcessResult SpectrogramAnalyzer::processStereo(const float* left, const float* right, size_t length) { SpectrogramProcessResult result; result.rowCount = config_.rowCount; - if (!samples || length == 0 || fftSize_ == 0 || config_.rowCount == 0) { + if (!left || !right || length == 0 || fftSize_ == 0 || config_.rowCount == 0) { return result; } @@ -494,7 +704,8 @@ SpectrogramProcessResult SpectrogramAnalyzer::process(const float* samples, size const size_t overlapSamples = fftSize_ - hopSize; for (size_t index = 0; index < length; index += 1) { - frameBuffer_[frameFill_] = samples[index]; + frameBuffer_[frameFill_] = left[index]; + rightFrameBuffer_[frameFill_] = right[index]; frameFill_ += 1; if (frameFill_ >= fftSize_) { @@ -503,6 +714,7 @@ SpectrogramProcessResult SpectrogramAnalyzer::process(const float* samples, size if (overlapSamples > 0) { std::memmove(frameBuffer_.data(), frameBuffer_.data() + hopSize, overlapSamples * sizeof(float)); + std::memmove(rightFrameBuffer_.data(), rightFrameBuffer_.data() + hopSize, overlapSamples * sizeof(float)); } frameFill_ = overlapSamples; } diff --git a/native/src/spectrogram.h b/native/src/spectrogram.h index d5672df..3223b9e 100644 --- a/native/src/spectrogram.h +++ b/native/src/spectrogram.h @@ -1,6 +1,7 @@ #pragma once #include "dsp_utils.h" +#include #include #include #include @@ -37,13 +38,15 @@ public: void configure(const SpectrogramConfig& config); SpectrogramProcessResult process(const float* samples, size_t length); + SpectrogramProcessResult processStereo(const float* left, const float* right, size_t length); void reset(); private: struct ClarityProfile { float gamma; - float sharpness; - float lineWidth; + float displayShoulder; + float heatShoulder; + bool useReassignment; }; SpectrogramConfig config_; @@ -51,16 +54,23 @@ private: size_t paddedSize_; size_t frameFill_; bool haveLastPhase_; + float magnitudeScale_; std::unique_ptr fft_; std::vector frameBuffer_; + std::vector rightFrameBuffer_; std::vector window_; std::vector windowedInput_; + std::vector rightWindowedInput_; std::vector> fftOutput_; + std::vector> rightFftOutput_; std::vector magnitudesDb_; std::vector magnitudesLinear_; std::vector phases_; std::vector lastPhases_; + std::vector rightPhases_; + std::vector rightLastPhases_; + std::vector dominantRight_; std::vector rowCenterBins_; std::vector rowBandStartBins_; @@ -69,8 +79,9 @@ private: std::vector standardRaw_; std::vector standardHeat_; std::vector reassignedPower_; - std::vector blendedRaw_; - std::vector blendedHeat_; + std::vector focusedPower_; + std::vector sourceRaw_; + std::vector sourceHeat_; std::vector shapedDisplay_; std::vector shapedHeat_; std::vector strokedDisplay_; @@ -81,9 +92,12 @@ private: void processFrame(std::vector& display, std::vector& heat); void computeStandardSpectrum(); void computeReassignedSpectrum(); - void blendAndShapeColumn(std::vector& display, std::vector& heat); + void computeFocusedSpectrum(); + void shapeColumn(std::vector& display, std::vector& heat); + void shapeFocusedColumn(std::vector& display, std::vector& heat); size_t resolveHopSize() const; float sampleDbAtBin(float bin) const; + float samplePeakDbInBand(float startBin, float endBin, float& peakBin) const; float frequencyFromScale(float normalizedPosition) const; float frequencyToRow(float frequency) const; float applyDisplayTilt(float db, float frequency) const; diff --git a/package.json b/package.json index fb28733..312977c 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "test:desktop-integration": "node scripts/run-desktop-integration-tests.mjs", "test:renderer-helpers": "node scripts/run-renderer-helper-tests.mjs", "test:spectrum-native": "node --test test/spectrum-native.test.mjs", + "test:spectrogram-native": "node --test test/spectrogram-native.test.mjs", "test:tui": "node scripts/build/build-tui.cjs --test", "test:build-metadata": "node scripts/run-build-metadata-tests.mjs", "test:updates": "node scripts/run-update-tests.mjs", diff --git a/plugin/Source/SpectrogramEngine.h b/plugin/Source/SpectrogramEngine.h index 512dc8e..84d0037 100644 --- a/plugin/Source/SpectrogramEngine.h +++ b/plugin/Source/SpectrogramEngine.h @@ -10,7 +10,7 @@ * produces finished display+heat columns. Unlike the other scopes, its DSP needs * the canvas-derived rowCount (and fft/freq/db/scale/orientation), which only the * webview knows — the UI pushes the full native config via "prismSpectrogramConfig", - * routed here through configureNative(). process() mixes to mono and runs the DSP; + * routed here through configureNative(). process() preserves stereo energy in the DSP; * buildFrame() emits the columns produced since the last frame (base64) tagged with * rowCount, so the bridge can match them to the config the UI currently expects. * configure() (scope settings) is a no-op — every DSP parameter arrives in the @@ -75,12 +75,7 @@ public: { if (! hasConfig || numSamples <= 0) return; - if ((int) mono.size() < numSamples) - mono.resize((size_t) numSamples); - for (int i = 0; i < numSamples; ++i) - mono[(size_t) i] = 0.5f * (left[i] + right[i]); - - auto result = spectro.process(mono.data(), (size_t) numSamples); + auto result = spectro.processStereo(left, right, (size_t) numSamples); if (result.columnCount > 0 && result.rowCount == config.rowCount) { pendingDisplay.insert(pendingDisplay.end(), result.display.begin(), result.display.end()); @@ -116,7 +111,6 @@ private: Visualizer::SpectrogramAnalyzer spectro; Visualizer::SpectrogramConfig config; bool hasConfig = false; - std::vector mono; std::vector pendingDisplay, pendingHeat; size_t pendingColumns = 0; }; diff --git a/src/plugin-ui/spectrogramOptions.ts b/src/plugin-ui/spectrogramOptions.ts index 8ff02cd..2f19b4c 100644 --- a/src/plugin-ui/spectrogramOptions.ts +++ b/src/plugin-ui/spectrogramOptions.ts @@ -1,6 +1,7 @@ import type { ScopeSettings } from '../types/settings' import type { ResolvedSpectrogramTheme } from '../types/theme' import type { SpectrogramOptions } from '../renderer/visualizers/Spectrogram' +import { nominalFrequencyBoundsForRange } from '../types/frequencyScale' /** * Map Prism's spectrogram settings + resolved theme to Spectrogram options. @@ -10,16 +11,22 @@ export function spectrogramSettingsToOptions( settings: ScopeSettings['spectrogram'], theme: ResolvedSpectrogramTheme, ): SpectrogramOptions { + const range = nominalFrequencyBoundsForRange(settings.frequencyRangeMode) return { lineColor: theme.mono, heatColors: theme.heatColors, backgroundColor: theme.background, + gridColor: theme.guides, + labelColor: theme.labels, + minFrequency: range.minFrequency, + maxFrequency: range.maxFrequency, fftSize: settings.fftSize, tiltDbPerOctave: settings.tiltDbPerOctave, scrollSpeed: settings.scrollSpeed, contrast: settings.contrast, clarityMode: settings.clarityMode, scaleMode: settings.scaleMode, + showGrid: settings.showGrid, orientation: 'horizontal', colorScheme: settings.colorScheme, } diff --git a/src/plugin-ui/spectrumOptions.ts b/src/plugin-ui/spectrumOptions.ts index 597238a..b520092 100644 --- a/src/plugin-ui/spectrumOptions.ts +++ b/src/plugin-ui/spectrumOptions.ts @@ -1,6 +1,7 @@ import type { ScopeSettings } from '../types/settings' import type { ResolvedSpectrumTheme } from '../types/theme' import type { SpectrumAnalyzerOptions } from '../renderer/visualizers/SpectrumAnalyzer' +import { nominalFrequencyBoundsForRange } from '../types/frequencyScale' /** * Map Prism's spectrum settings + resolved theme to SpectrumAnalyzer options. @@ -11,6 +12,7 @@ export function spectrumSettingsToOptions( settings: ScopeSettings['spectrum'], theme: ResolvedSpectrumTheme, ): SpectrumAnalyzerOptions { + const range = nominalFrequencyBoundsForRange(settings.frequencyRangeMode) return { lineColor: theme.line, secondaryLineColor: theme.sideLine, @@ -19,6 +21,9 @@ export function spectrumSettingsToOptions( heatBaseColor: theme.heatBase, backgroundColor: theme.background, gridColor: theme.guides, + scaleType: settings.scaleMode, + minFrequency: range.minFrequency, + maxFrequency: range.maxFrequency, fftSize: settings.fftSize, tiltDbPerOctave: settings.tiltDbPerOctave, heatmapFill: settings.heatmap, diff --git a/src/renderer/audio/AudioRouter.ts b/src/renderer/audio/AudioRouter.ts index 6601c19..3ae0928 100644 --- a/src/renderer/audio/AudioRouter.ts +++ b/src/renderer/audio/AudioRouter.ts @@ -93,7 +93,7 @@ type ScopeRingMap = { spectrum: FixedChunkRing oscilloscope: FixedChunkRing vectorscope: FixedChunkRing - spectrogram: FixedChunkRing + spectrogram: FixedChunkRing vumeter: FixedChunkRing lufsmeter: FixedChunkRing waveform: FixedChunkRing @@ -215,7 +215,7 @@ export class AudioRouter { spectrum: new FixedChunkRing(SCOPE_RING_CAPACITY.spectrum), oscilloscope: new FixedChunkRing(SCOPE_RING_CAPACITY.oscilloscope), vectorscope: new FixedChunkRing(SCOPE_RING_CAPACITY.vectorscope), - spectrogram: new FixedChunkRing(SCOPE_RING_CAPACITY.spectrogram), + spectrogram: new FixedChunkRing(SCOPE_RING_CAPACITY.spectrogram), vumeter: new FixedChunkRing(SCOPE_RING_CAPACITY.vumeter), lufsmeter: new FixedChunkRing(SCOPE_RING_CAPACITY.lufsmeter), waveform: new FixedChunkRing(SCOPE_RING_CAPACITY.waveform), @@ -363,11 +363,10 @@ export class AudioRouter { const activeDemand = this.getActiveDemand() const needsSpectrum = Boolean(activeDemand.spectrum) - const needsMono = Boolean(activeDemand.spectrogram) - const needsStereo = Boolean(activeDemand.vectorscope || activeDemand.vumeter || activeDemand.lufsmeter || activeDemand.waveform) + const needsStereo = Boolean(activeDemand.spectrogram || activeDemand.vectorscope || activeDemand.vumeter || activeDemand.lufsmeter || activeDemand.waveform) const needsLeft = Boolean(activeDemand.oscilloscope) - if (!needsSpectrum && !needsMono && !needsStereo && !needsLeft) { + if (!needsSpectrum && !needsStereo && !needsLeft) { this.undemandedChunks += 1 return } @@ -377,14 +376,6 @@ export class AudioRouter { const leftSamples = left.length === len ? left : left.subarray(0, len) const rightSamples = resolvedRight.length === len ? resolvedRight : resolvedRight.subarray(0, len) - let mono: Float32Array | null = null - if (needsMono) { - mono = new Float32Array(len) - for (let index = 0; index < len; index += 1) { - mono[index] = (leftSamples[index] + rightSamples[index]) * 0.5 - } - } - if (activeDemand.oscilloscope) { this.rings.oscilloscope.push({ samples: leftSamples, capturedAt, sequence }) } @@ -393,8 +384,8 @@ export class AudioRouter { this.rings.spectrum.push({ left: leftSamples, right: rightSamples, capturedAt, sequence }) } - if (activeDemand.spectrogram && mono) { - this.rings.spectrogram.push({ samples: mono, capturedAt, sequence }) + if (activeDemand.spectrogram) { + this.rings.spectrogram.push({ left: leftSamples, right: rightSamples, capturedAt, sequence }) } if (activeDemand.vectorscope) { @@ -442,7 +433,20 @@ export class AudioRouter { flushPendingSpectrogramSamples(): Float32Array[] { const records = this.rings.spectrogram.drain() this.recordScopeDrain('spectrogram', records) - return records.map((record) => record.samples) + return records.map((record) => { + const length = Math.min(record.left.length, record.right.length) + const mono = new Float32Array(length) + for (let index = 0; index < length; index += 1) { + mono[index] = (record.left[index] + record.right[index]) * 0.5 + } + return mono + }) + } + + flushPendingSpectrogramStereoSamples(): { left: Float32Array; right: Float32Array }[] { + const records = this.rings.spectrogram.drain() + this.recordScopeDrain('spectrogram', records) + return records.map((record) => ({ left: record.left, right: record.right })) } flushPendingVectorscopeSamples(): { left: Float32Array; right: Float32Array }[] { diff --git a/src/renderer/audio/native/index.ts b/src/renderer/audio/native/index.ts index 6f0c909..55051ac 100644 --- a/src/renderer/audio/native/index.ts +++ b/src/renderer/audio/native/index.ts @@ -248,6 +248,7 @@ export const spectrum: SpectrumNativeAnalyzer = { export interface SpectrogramNativeAnalyzer { configure(options: SpectrogramNativeOptions): void process(audioData: Float32Array): SpectrogramNativeResult | null + processStereo?: (leftChannel: Float32Array, rightChannel: Float32Array) => SpectrogramNativeResult | null reset(): void isAvailable?: () => boolean } @@ -301,6 +302,13 @@ export const spectrogram: SpectrogramNativeAnalyzer = { return nativeModule.spectrogram.process(audioData) }, + processStereo: (leftChannel: Float32Array, rightChannel: Float32Array): SpectrogramNativeResult | null => { + if (!nativeModule?.spectrogram) return null + return typeof nativeModule.spectrogram.processStereo === 'function' + ? nativeModule.spectrogram.processStereo(leftChannel, rightChannel) + : nativeModule.spectrogram.process(leftChannel) + }, + reset: (): void => { nativeModule?.spectrogram?.reset() }, diff --git a/src/renderer/audio/native/visualizer-dsp.d.ts b/src/renderer/audio/native/visualizer-dsp.d.ts index 85c021a..34d26ae 100644 --- a/src/renderer/audio/native/visualizer-dsp.d.ts +++ b/src/renderer/audio/native/visualizer-dsp.d.ts @@ -121,6 +121,7 @@ export interface SpectrumModule { export interface SpectrogramModule { configure(options: SpectrogramNativeOptions): void; process(audioData: Float32Array): SpectrogramNativeResult; + processStereo(leftChannel: Float32Array, rightChannel: Float32Array): SpectrogramNativeResult; reset(): void; } diff --git a/src/renderer/components/ScopeModule.tsx b/src/renderer/components/ScopeModule.tsx index 9e70326..b16bacd 100644 --- a/src/renderer/components/ScopeModule.tsx +++ b/src/renderer/components/ScopeModule.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type JSX } from 'react' import { isTransformableScopeKind, type ScopeKind } from '../../types/scope' import type { ScopeSettings } from '../../types/settings' +import { nominalFrequencyBoundsForRange } from '../../types/frequencyScale' import type { ScopeDisplayRotation } from '../../types/scopeTransform' import type { PrismResolvedTheme, @@ -168,6 +169,7 @@ export function scopeSettingsToOptions( case 'spectrum': { const s = settings as ScopeSettings['spectrum'] const t = theme as ResolvedSpectrumTheme + const range = nominalFrequencyBoundsForRange(s.frequencyRangeMode) return { lineColor: t.line, secondaryLineColor: t.sideLine, @@ -177,6 +179,9 @@ export function scopeSettingsToOptions( backgroundColor: t.background, gridColor: t.guides, labelColor: t.labels, + scaleType: s.scaleMode, + minFrequency: range.minFrequency, + maxFrequency: range.maxFrequency, fftSize: s.fftSize, tiltDbPerOctave: s.tiltDbPerOctave, heatmapFill: s.heatmap, @@ -227,16 +232,22 @@ export function scopeSettingsToOptions( case 'spectrogram': { const s = settings as ScopeSettings['spectrogram'] const t = theme as ResolvedSpectrogramTheme + const range = nominalFrequencyBoundsForRange(s.frequencyRangeMode) return { lineColor: t.mono, heatColors: t.heatColors, backgroundColor: t.background, + gridColor: t.guides, + labelColor: t.labels, + minFrequency: range.minFrequency, + maxFrequency: range.maxFrequency, fftSize: s.fftSize, tiltDbPerOctave: s.tiltDbPerOctave, scrollSpeed: s.scrollSpeed, contrast: s.contrast, clarityMode: s.clarityMode, scaleMode: s.scaleMode, + showGrid: s.showGrid, orientation: 'horizontal', colorScheme: s.colorScheme, } diff --git a/src/renderer/components/ScopePopoutBridge.tsx b/src/renderer/components/ScopePopoutBridge.tsx index a1fdb8e..6385fcc 100644 --- a/src/renderer/components/ScopePopoutBridge.tsx +++ b/src/renderer/components/ScopePopoutBridge.tsx @@ -39,7 +39,7 @@ function flushScopeAudioBatch(kind: AudioScopeKind, scopeSettings: ScopeSettings case 'vectorscope': return audioRouter.flushPendingVectorscopeSamples() case 'spectrogram': - return audioRouter.flushPendingSpectrogramSamples() + return audioRouter.flushPendingSpectrogramStereoSamples() case 'vumeter': return audioRouter.flushPendingVUMeterSamples() case 'lufsmeter': diff --git a/src/renderer/components/ScopeSettingsSection.tsx b/src/renderer/components/ScopeSettingsSection.tsx index 7188ca5..9e409e6 100644 --- a/src/renderer/components/ScopeSettingsSection.tsx +++ b/src/renderer/components/ScopeSettingsSection.tsx @@ -2,9 +2,11 @@ import { useState, type CSSProperties, type JSX, type ReactNode } from 'react' import type { ScopeKind } from '../../types/scope' import { SCOPE_LABELS, isTransformableScopeKind } from '../../types/scope' import type { ScopeSettings } from '../../types/settings' +import { frequencyRangeLabel } from '../../types/frequencyScale' import { SCOPE_DISPLAY_ROTATIONS, type ScopeDisplayRotation } from '../../types/scopeTransform' import { MAX_SPECTROGRAM_CONTRAST, + MAX_SPECTROGRAM_SCROLL_SPEED, MAX_SPECTROGRAM_TILT_DB_PER_OCTAVE, MIN_SPECTROGRAM_CONTRAST, MIN_SPECTROGRAM_TILT_DB_PER_OCTAVE, @@ -76,7 +78,7 @@ export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind] switch (kind) { case 'spectrum': { const scopeSettings = settings as ScopeSettings['spectrum'] - const summary = `${scopeSettings.heatmap ? 'Heat' : 'Fill'} · FFT ${scopeSettings.fftSize}` + const summary = `${scopeSettings.scaleMode.toUpperCase()} · ${frequencyRangeLabel(scopeSettings.frequencyRangeMode)} · ${scopeSettings.heatmap ? 'Heat' : 'Fill'} · FFT ${scopeSettings.fftSize}` const parts = [summary] if (scopeSettings.showSideLine) { parts.push('Side') @@ -107,6 +109,7 @@ export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind] return [ `${scopeSettings.rotation}°`, scopeSettings.scaleMode.toUpperCase(), + frequencyRangeLabel(scopeSettings.frequencyRangeMode), scopeSettings.clarityMode, ...(scopeSettings.mirrorHorizontal ? ['Mirror'] : []), ].join(' · ') @@ -333,6 +336,29 @@ export default function ScopeSettingsSection({ ))} + onUpdate('spectrum', { + scaleMode: value as ScopeSettings['spectrum']['scaleMode'], + })} + > + + + + + + onUpdate('spectrum', { + frequencyRangeMode: value as ScopeSettings['spectrum']['frequencyRangeMode'], + })} + > + + + + onUpdate('spectrogram', { clarityMode: value as ScopeSettings['spectrogram']['clarityMode'] })} > + + onUpdate('spectrogram', { + frequencyRangeMode: value as ScopeSettings['spectrogram']['frequencyRangeMode'], + })} + > + + + + Solid + + onUpdate('spectrogram', { showGrid: !current.showGrid })} + /> + + onUpdate('spectrogram', { scrollSpeed: value })} diff --git a/src/renderer/popouts/ScopePopoutDataSource.ts b/src/renderer/popouts/ScopePopoutDataSource.ts index 97cb6f7..89a506f 100644 --- a/src/renderer/popouts/ScopePopoutDataSource.ts +++ b/src/renderer/popouts/ScopePopoutDataSource.ts @@ -35,7 +35,7 @@ function isStereoBatch(batch: ScopePopoutAudioBatch): batch is ScopePopoutStereo } function isStereoScope(kind: ScopeKind): boolean { - return kind === 'vectorscope' || kind === 'vumeter' || kind === 'lufsmeter' + return kind === 'spectrogram' || kind === 'vectorscope' || kind === 'vumeter' || kind === 'lufsmeter' } export class ScopePopoutDataSource implements AnyScopeDataSource { @@ -131,6 +131,12 @@ export class ScopePopoutDataSource implements AnyScopeDataSource { return this.scopeKind === 'spectrogram' ? batch : [] } + getPendingSpectrogramStereoSamples(): ScopePopoutStereoBatch { + const batch = this.stereoQueue + this.stereoQueue = [] + return this.scopeKind === 'spectrogram' ? batch : [] + } + getPendingWaveformSamples(): Float32Array[] { const batch = this.monoQueue this.monoQueue = [] diff --git a/src/renderer/scopeMeasurement.ts b/src/renderer/scopeMeasurement.ts index 5fd7d87..8c37de6 100644 --- a/src/renderer/scopeMeasurement.ts +++ b/src/renderer/scopeMeasurement.ts @@ -4,6 +4,11 @@ import { resolveSpectrumPitchInfo, } from '../types/spectrum' import type { SpectrogramScaleMode } from '../types/spectrogram' +import { + clampFrequencyRangeToNyquist, + frequencyAtNormalizedPosition, + type FrequencyScaleMode, +} from '../types/frequencyScale' import type { WaveformMode } from '../types/waveform' import { inverseTransformNormalizedScopePoint, @@ -36,58 +41,7 @@ function clamp01(value: number): number { return Math.max(0, Math.min(1, value)) } -function clampFrequencyRange( - sampleRate: number, - minFrequency: number, - maxFrequency: number, -): { minFrequency: number; maxFrequency: number } { - const nyquist = Math.max(1, sampleRate) / 2 - const min = Math.max(1, Math.min(minFrequency, nyquist)) - return { - minFrequency: min, - maxFrequency: Math.max(min + 1, Math.min(maxFrequency, nyquist)), - } -} - -function hzToMelSlaney(frequencyHz: number): number { - const fSp = 200 / 3 - const minLogHz = 1000 - const minLogMel = minLogHz / fSp - const logStep = Math.log(6.4) / 27 - return frequencyHz < minLogHz - ? frequencyHz / fSp - : minLogMel + (Math.log(frequencyHz / minLogHz) / logStep) -} - -function melToHzSlaney(mel: number): number { - const fSp = 200 / 3 - const minLogHz = 1000 - const minLogMel = minLogHz / fSp - const logStep = Math.log(6.4) / 27 - return mel < minLogMel - ? mel * fSp - : minLogHz * Math.exp(logStep * (mel - minLogMel)) -} - -export function frequencyAtNormalizedPosition( - position: number, - minFrequency: number, - maxFrequency: number, - scaleMode: 'linear' | 'log' | 'mel', -): number { - const t = clamp01(position) - if (scaleMode === 'linear') { - return minFrequency + t * (maxFrequency - minFrequency) - } - if (scaleMode === 'mel') { - const melMin = hzToMelSlaney(minFrequency) - const melMax = hzToMelSlaney(maxFrequency) - return melToHzSlaney(melMin + t * (melMax - melMin)) - } - const logMin = Math.log10(minFrequency) - const logMax = Math.log10(maxFrequency) - return 10 ** (logMin + t * (logMax - logMin)) -} +export { frequencyAtNormalizedPosition } from '../types/frequencyScale' export function formatMeasurementFrequency(frequencyHz: number): string { if (!Number.isFinite(frequencyHz) || frequencyHz <= 0) return '--' @@ -133,10 +87,10 @@ export function resolveSpectrumMeasurement( maxFrequency: number minDecibels: number maxDecibels: number - scaleType: 'linear' | 'log' + scaleType: FrequencyScaleMode }, ): ScopeMeasurement { - const range = clampFrequencyRange(options.sampleRate, options.minFrequency, options.maxFrequency) + const range = clampFrequencyRangeToNyquist(options.sampleRate, options.minFrequency, options.maxFrequency) const frequencyHz = frequencyAtNormalizedPosition( point.x, range.minFrequency, @@ -165,7 +119,7 @@ export function resolveSpectrogramMeasurement( canvasPixelWidth: number }, ): ScopeMeasurement { - const range = clampFrequencyRange(options.sampleRate, options.minFrequency, options.maxFrequency) + const range = clampFrequencyRangeToNyquist(options.sampleRate, options.minFrequency, options.maxFrequency) const frequencyHz = frequencyAtNormalizedPosition( 1 - point.y, range.minFrequency, diff --git a/src/renderer/visualizers/Spectrogram.ts b/src/renderer/visualizers/Spectrogram.ts index 06c87e1..9c88a57 100644 --- a/src/renderer/visualizers/Spectrogram.ts +++ b/src/renderer/visualizers/Spectrogram.ts @@ -36,9 +36,14 @@ import { type ScopeMeasurement, } from '../scopeMeasurement' import type { NormalizedScopePoint } from '../scopeCanvasTransform' +import { + buildFrequencyGuides, + clampFrequencyRangeToNyquist, +} from '../../types/frequencyScale' export interface SpectrogramDataSource extends VisualizerSessionSource { getPendingSpectrogramSamples: () => Float32Array[] + getPendingSpectrogramStereoSamples?: () => Array<{ left: Float32Array; right: Float32Array }> } export interface SpectrogramOptions { @@ -57,6 +62,9 @@ export interface SpectrogramOptions { lineColor?: string heatColors?: [string, string, string] backgroundColor?: string + showGrid?: boolean + gridColor?: string + labelColor?: string dataSource?: SpectrogramDataSource frameScheduler?: FrameScheduler nativeAnalyzer?: SpectrogramNativeAnalyzer | null @@ -67,8 +75,8 @@ type ResolvedSpectrogramOptions = Required audioRouter.flushPendingSpectrogramSamples(), + getPendingSpectrogramStereoSamples: () => audioRouter.flushPendingSpectrogramStereoSamples(), ...defaultVisualizerSessionSource, } @@ -122,6 +134,9 @@ function resolveOptions(base: ResolvedSpectrogramOptions, overrides: Partial= expectedLength && result.heat.length >= expectedLength } - private tryDrawNativeColumns(pendingSamples: Float32Array[], width: number, height: number): boolean { + private tryDrawNativeColumns( + pendingSamples: Float32Array[], + pendingStereoSamples: Array<{ left: Float32Array; right: Float32Array }>, + width: number, + height: number, + ): boolean { if (!this.isNativeAnalyzerReady()) { return false } @@ -490,6 +510,16 @@ export class Spectrogram { return false } + for (const chunk of pendingStereoSamples) { + const result = this.nativeAnalyzer?.processStereo?.(chunk.left, chunk.right) ?? null + if (!this.isValidNativeResult(result, config.rowCount)) { + return false + } + if (result.columnCount > 0) { + results.push(result) + } + } + for (const chunk of pendingSamples) { const result = this.nativeAnalyzer?.process(chunk) ?? null if (!this.isValidNativeResult(result, config.rowCount)) { @@ -548,6 +578,57 @@ export class Spectrogram { this.ctx.fillRect(0, 0, width, height) } this.ctx.drawImage(this.waterfallCanvas, 0, 0) + this.drawFrequencyGrid(width, height) + } + + private drawFrequencyGrid(width: number, height: number): void { + if (!this.options.showGrid) return + + const vertical = this.options.orientation === 'vertical' + const pixelSpan = vertical ? width : height + const range = clampFrequencyRangeToNyquist( + this.dataSource.getSampleRate(), + this.options.minFrequency, + this.options.maxFrequency, + ) + const guides = buildFrequencyGuides( + range.minFrequency, + range.maxFrequency, + this.options.scaleMode, + pixelSpan, + ) + const dpr = window.devicePixelRatio || 1 + + this.ctx.lineWidth = dpr + this.ctx.font = `${10 * dpr}px monospace` + this.ctx.textBaseline = 'bottom' + + for (const guide of guides) { + const position = guide.normalizedPosition * pixelSpan + this.ctx.beginPath() + this.ctx.strokeStyle = this.options.gridColor + this.ctx.globalAlpha = guide.kind === 'minor' ? 0.28 : 1 + if (vertical) { + this.ctx.moveTo(position, 0) + this.ctx.lineTo(position, height) + } else { + const y = height - position + this.ctx.moveTo(0, y) + this.ctx.lineTo(width, y) + } + this.ctx.stroke() + this.ctx.globalAlpha = 1 + + if (guide.label) { + this.ctx.fillStyle = this.options.labelColor + this.ctx.textAlign = vertical ? 'center' : 'left' + if (vertical) { + this.ctx.fillText(guide.label, position, height - 4 * dpr) + } else { + this.ctx.fillText(guide.label, 4 * dpr, height - position - 2 * dpr) + } + } + } } private drawFrame = (): void => { @@ -599,14 +680,21 @@ export class Spectrogram { } if (!this.dataSource.isPlaying()) { - this.dataSource.getPendingSpectrogramSamples() + if (this.dataSource.getPendingSpectrogramStereoSamples) { + this.dataSource.getPendingSpectrogramStereoSamples() + } else { + this.dataSource.getPendingSpectrogramSamples() + } // Freeze waterfall in place instead of blanking this.paintWaterfall(width, height) return } - const pendingSamples = this.dataSource.getPendingSpectrogramSamples() - this.tryDrawNativeColumns(pendingSamples, width, height) + const pendingStereoSamples = this.dataSource.getPendingSpectrogramStereoSamples?.() ?? [] + const pendingSamples = pendingStereoSamples.length > 0 + ? [] + : this.dataSource.getPendingSpectrogramSamples() + this.tryDrawNativeColumns(pendingSamples, pendingStereoSamples, width, height) this.paintWaterfall(width, height) } diff --git a/src/renderer/visualizers/SpectrumAnalyzer.ts b/src/renderer/visualizers/SpectrumAnalyzer.ts index f04459d..79627f5 100644 --- a/src/renderer/visualizers/SpectrumAnalyzer.ts +++ b/src/renderer/visualizers/SpectrumAnalyzer.ts @@ -24,6 +24,12 @@ import { type ScopeMeasurement, } from '../scopeMeasurement' import type { NormalizedScopePoint } from '../scopeCanvasTransform' +import { + buildFrequencyGuides, + clampFrequencyRangeToNyquist, + frequencyAtNormalizedPosition, + type FrequencyScaleMode, +} from '../../types/frequencyScale' type SpectrumStereoChunk = { left: Float32Array @@ -48,7 +54,7 @@ export interface SpectrumAnalyzerOptions { backgroundColor?: string showGrid?: boolean gridColor?: string - scaleType?: 'linear' | 'log' + scaleType?: FrequencyScaleMode smoothing?: number minDecibels?: number maxDecibels?: number @@ -200,8 +206,8 @@ const defaultOptions: ResolvedSpectrumAnalyzerOptions = { smoothing: 0.9, minDecibels: -90, maxDecibels: -10, - minFrequency: 20, - maxFrequency: 20000, + minFrequency: 10, + maxFrequency: 24000, tiltDbPerOctave: DEFAULT_SPECTRUM_TILT_DB_PER_OCTAVE, heatmapTiltDbPerOctave: DEFAULT_SPECTRUM_HEATMAP_TILT_DB_PER_OCTAVE, tiltReferenceHz: 1000, @@ -500,12 +506,7 @@ export class SpectrumAnalyzer { } private frequencyAtPosition(t: number, minFrequency: number, maxFrequency: number): number { - if (this.options.scaleType === 'log') { - const logMin = Math.log10(minFrequency) - const logMax = Math.log10(maxFrequency) - return Math.pow(10, logMin + t * (logMax - logMin)) - } - return minFrequency + t * (maxFrequency - minFrequency) + return frequencyAtNormalizedPosition(t, minFrequency, maxFrequency, this.options.scaleType) } private resolvePeakInRange( @@ -1032,8 +1033,8 @@ export class SpectrumAnalyzer { this.updateSampleRateIfNeeded() const nyquist = this.sampleRate / 2 - const minFrequency = Math.max(1, Math.min(options.minFrequency, nyquist)) - const maxFrequency = Math.max(minFrequency + 1, Math.min(options.maxFrequency, nyquist)) + const range = clampFrequencyRangeToNyquist(this.sampleRate, options.minFrequency, options.maxFrequency) + const { minFrequency, maxFrequency } = range if (!this.dataSource.isPlaying()) { this.clearPendingSpectrumQueues() @@ -1224,29 +1225,27 @@ export class SpectrumAnalyzer { ctx.fillStyle = options.gridColor ctx.font = `${10 * dpr}px monospace` - const freqSteps = [50, 100, 200, 500, 1000, 2000, 5000, 10000] + const guides = buildFrequencyGuides( + minFrequency, + maxFrequency, + options.scaleType, + width, + ) ctx.textAlign = 'center' - for (const freq of freqSteps) { - if (freq < minFrequency || freq > maxFrequency) continue - - let x: number - if (options.scaleType === 'log') { - const logMin = Math.log10(minFrequency) - const logMax = Math.log10(maxFrequency) - const logFreq = Math.log10(freq) - x = ((logFreq - logMin) / (logMax - logMin)) * width - } else { - x = ((freq - minFrequency) / (maxFrequency - minFrequency)) * width - } + for (const guide of guides) { + const x = guide.normalizedPosition * width ctx.beginPath() ctx.moveTo(x, 0) ctx.lineTo(x, height) + ctx.globalAlpha = guide.kind === 'minor' ? 0.38 : 1 ctx.stroke() + ctx.globalAlpha = 1 - const label = freq >= 1000 ? `${freq / 1000}k` : `${freq}` - ctx.fillText(label, x, height - 4 * dpr) + if (guide.label) { + ctx.fillText(guide.label, x, height - 4 * dpr) + } } } diff --git a/src/shared/profileState.ts b/src/shared/profileState.ts index baf8e84..c39beda 100644 --- a/src/shared/profileState.ts +++ b/src/shared/profileState.ts @@ -16,7 +16,16 @@ import { AUDIO_SCOPE_KINDS, SCOPE_KINDS, normalizeScopeKind, type ScopeKind } fr import { DEFAULT_SCOPE_SETTINGS, type ScopeSettings } from '../types/settings' import { isLUFSMeterReadout } from '../types/lufsmeter' import { normalizeSpectrumPeakInfoMode } from '../types/spectrum' -import { clampSpectrogramTiltDbPerOctave } from '../types/spectrogram' +import { + normalizeFrequencyRangeMode, + normalizeFrequencyScaleMode, +} from '../types/frequencyScale' +import { + clampSpectrogramScrollSpeed, + clampSpectrogramTiltDbPerOctave, + DEFAULT_SPECTROGRAM_CLARITY_MODE, + isSpectrogramClarityMode, +} from '../types/spectrogram' import { isVUMeterNeedleChannels, sanitizeVUReferenceDbfs } from '../types/vumeter' import { clampWaveformScrollSpeed } from '../types/waveform' import { @@ -198,6 +207,8 @@ export function mergeScopeSettings( ...DEFAULT_SCOPE_SETTINGS.spectrum, ...rawSpectrum, ...normalizeDisplayTransform(rawSpectrum), + scaleMode: normalizeFrequencyScaleMode(rawSpectrum.scaleMode), + frequencyRangeMode: normalizeFrequencyRangeMode(rawSpectrum.frequencyRangeMode), peakInfoMode: normalizeSpectrumPeakInfoMode(rawSpectrum.peakInfoMode), }, oscilloscope: { @@ -210,6 +221,15 @@ export function mergeScopeSettings( ...DEFAULT_SCOPE_SETTINGS.spectrogram, ...rawSpectrogramSettings, ...normalizeDisplayTransform(rawSpectrogram, legacySpectrogramRotation), + clarityMode: isSpectrogramClarityMode(rawSpectrogram.clarityMode) + ? rawSpectrogram.clarityMode + : DEFAULT_SPECTROGRAM_CLARITY_MODE, + scaleMode: normalizeFrequencyScaleMode(rawSpectrogram.scaleMode), + frequencyRangeMode: normalizeFrequencyRangeMode(rawSpectrogram.frequencyRangeMode), + showGrid: typeof rawSpectrogram.showGrid === 'boolean' ? rawSpectrogram.showGrid : false, + scrollSpeed: clampSpectrogramScrollSpeed( + rawSpectrogram.scrollSpeed ?? DEFAULT_SCOPE_SETTINGS.spectrogram.scrollSpeed + ), tiltDbPerOctave: clampSpectrogramTiltDbPerOctave( rawSpectrogram.tiltDbPerOctave ?? DEFAULT_SCOPE_SETTINGS.spectrogram.tiltDbPerOctave ), diff --git a/src/shared/themeState.ts b/src/shared/themeState.ts index 866a020..bb1f7ec 100644 --- a/src/shared/themeState.ts +++ b/src/shared/themeState.ts @@ -131,6 +131,8 @@ const SPECTROGRAM_SCHEMA = { heat_low: 'heatLow', heat_mid: 'heatMid', heat_high: 'heatHigh', + guides: 'guides', + labels: 'labels', } as const satisfies SectionSchema const VUMETER_SCHEMA = { @@ -1392,6 +1394,8 @@ export function createTemplateThemeFile(): string { const spectrogramSection = commentExampleTokens(serializeSection('Spectrogram', { ...base.spectrogram, background: resolved.spectrogram.background, + guides: resolved.spectrogram.guides, + labels: resolved.spectrogram.labels, }, SPECTROGRAM_SCHEMA as SectionSchema>)) const vumeterSection = commentExampleTokens(serializeSection('VUMeter', { @@ -1703,6 +1707,8 @@ function resolveSpectrogramTheme( return { mono: theme.spectrogram.mono ?? app.accent, background: theme.spectrogram.background ?? scopes.background, + guides: theme.spectrogram.guides ?? scopes.guides, + labels: theme.spectrogram.labels ?? theme.spectrogram.guides ?? scopes.guides, heatColors: [ theme.spectrogram.heatLow ?? DEFAULT_HEAT_LOW, theme.spectrogram.heatMid ?? DEFAULT_HEAT_MID, diff --git a/src/types/frequencyScale.ts b/src/types/frequencyScale.ts new file mode 100644 index 0000000..8b30cfc --- /dev/null +++ b/src/types/frequencyScale.ts @@ -0,0 +1,226 @@ +export type FrequencyScaleMode = 'log' | 'mel' | 'linear' +export type FrequencyRangeMode = 'extended' | 'audible' + +export interface FrequencyBounds { + minFrequency: number + maxFrequency: number +} + +export interface FrequencyGuide { + frequencyHz: number + normalizedPosition: number + kind: 'major' | 'minor' + label?: string +} + +export const FREQUENCY_SCALE_MODES: readonly FrequencyScaleMode[] = [ + 'log', + 'mel', + 'linear', +] + +export const DEFAULT_FREQUENCY_SCALE_MODE: FrequencyScaleMode = 'log' +export const DEFAULT_FREQUENCY_RANGE_MODE: FrequencyRangeMode = 'extended' + +export const FREQUENCY_RANGE_MODES: readonly FrequencyRangeMode[] = [ + 'extended', + 'audible', +] + +const SLANEY_F_SP = 200 / 3 +const SLANEY_MIN_LOG_HZ = 1000 +const SLANEY_MIN_LOG_MEL = SLANEY_MIN_LOG_HZ / SLANEY_F_SP +const SLANEY_LOG_STEP = Math.log(6.4) / 27 + +function clamp01(value: number): number { + return Math.max(0, Math.min(1, value)) +} + +function resolvePositiveFrequency(value: number, fallback: number): number { + return Number.isFinite(value) && value > 0 ? value : fallback +} + +function resolveFrequencyBounds( + minFrequency: number, + maxFrequency: number, +): { minFrequency: number; maxFrequency: number } { + const min = resolvePositiveFrequency(minFrequency, 1) + const max = resolvePositiveFrequency(maxFrequency, min) + return { + minFrequency: min, + maxFrequency: Math.max(min, max), + } +} + +function hzToMelSlaney(frequencyHz: number): number { + return frequencyHz < SLANEY_MIN_LOG_HZ + ? frequencyHz / SLANEY_F_SP + : SLANEY_MIN_LOG_MEL + (Math.log(frequencyHz / SLANEY_MIN_LOG_HZ) / SLANEY_LOG_STEP) +} + +function melToHzSlaney(mel: number): number { + return mel < SLANEY_MIN_LOG_MEL + ? mel * SLANEY_F_SP + : SLANEY_MIN_LOG_HZ * Math.exp(SLANEY_LOG_STEP * (mel - SLANEY_MIN_LOG_MEL)) +} + +export function isFrequencyScaleMode(value: unknown): value is FrequencyScaleMode { + return typeof value === 'string' && FREQUENCY_SCALE_MODES.includes(value as FrequencyScaleMode) +} + +export function normalizeFrequencyScaleMode(value: unknown): FrequencyScaleMode { + return isFrequencyScaleMode(value) ? value : DEFAULT_FREQUENCY_SCALE_MODE +} + +export function isFrequencyRangeMode(value: unknown): value is FrequencyRangeMode { + return typeof value === 'string' && FREQUENCY_RANGE_MODES.includes(value as FrequencyRangeMode) +} + +export function normalizeFrequencyRangeMode( + value: unknown, + fallback: FrequencyRangeMode = DEFAULT_FREQUENCY_RANGE_MODE, +): FrequencyRangeMode { + return isFrequencyRangeMode(value) ? value : fallback +} + +export function nominalFrequencyBoundsForRange(mode: FrequencyRangeMode): FrequencyBounds { + return mode === 'audible' + ? { minFrequency: 20, maxFrequency: 20000 } + : { minFrequency: 10, maxFrequency: 24000 } +} + +export function frequencyBoundsForRange( + mode: FrequencyRangeMode, + sampleRate: number, +): FrequencyBounds { + const nominal = nominalFrequencyBoundsForRange(mode) + return clampFrequencyRangeToNyquist( + sampleRate, + nominal.minFrequency, + nominal.maxFrequency, + ) +} + +export function frequencyRangeLabel(mode: FrequencyRangeMode): string { + return mode === 'audible' ? 'Audible' : 'Extended' +} + +export function clampFrequencyRangeToNyquist( + sampleRate: number, + minFrequency: number, + maxFrequency: number, +): { minFrequency: number; maxFrequency: number } { + const nyquist = Math.max(2, Number.isFinite(sampleRate) ? sampleRate : 0) / 2 + const requestedMin = resolvePositiveFrequency(minFrequency, 1) + const requestedMax = resolvePositiveFrequency(maxFrequency, nyquist) + const min = Math.min(requestedMin, nyquist) + return { + minFrequency: min, + maxFrequency: Math.max(min, Math.min(requestedMax, nyquist)), + } +} + +export function frequencyAtNormalizedPosition( + position: number, + minFrequency: number, + maxFrequency: number, + scaleMode: FrequencyScaleMode, +): number { + const t = clamp01(position) + const range = resolveFrequencyBounds(minFrequency, maxFrequency) + if (range.maxFrequency <= range.minFrequency) return range.minFrequency + + if (scaleMode === 'linear') { + return range.minFrequency + t * (range.maxFrequency - range.minFrequency) + } + if (scaleMode === 'mel') { + const melMin = hzToMelSlaney(range.minFrequency) + const melMax = hzToMelSlaney(range.maxFrequency) + return melToHzSlaney(melMin + t * (melMax - melMin)) + } + + const logMin = Math.log10(range.minFrequency) + const logMax = Math.log10(range.maxFrequency) + return 10 ** (logMin + t * (logMax - logMin)) +} + +export function normalizedPositionAtFrequency( + frequencyHz: number, + minFrequency: number, + maxFrequency: number, + scaleMode: FrequencyScaleMode, +): number { + const range = resolveFrequencyBounds(minFrequency, maxFrequency) + if (range.maxFrequency <= range.minFrequency) return 0 + const frequency = Math.max(range.minFrequency, Math.min(range.maxFrequency, frequencyHz)) + + if (scaleMode === 'linear') { + return (frequency - range.minFrequency) / (range.maxFrequency - range.minFrequency) + } + if (scaleMode === 'mel') { + const melMin = hzToMelSlaney(range.minFrequency) + const melMax = hzToMelSlaney(range.maxFrequency) + return (hzToMelSlaney(frequency) - melMin) / (melMax - melMin) + } + + const logMin = Math.log10(range.minFrequency) + const logMax = Math.log10(range.maxFrequency) + return (Math.log10(frequency) - logMin) / (logMax - logMin) +} + +export function formatFrequencyGuideLabel(frequencyHz: number): string { + if (frequencyHz >= 1000) { + const kilohertz = frequencyHz / 1000 + return `${Number.isInteger(kilohertz) ? kilohertz.toFixed(0) : kilohertz.toFixed(1)}k` + } + return `${Math.round(frequencyHz)}` +} + +export function buildFrequencyGuides( + minFrequency: number, + maxFrequency: number, + scaleMode: FrequencyScaleMode, + pixelSpan: number, +): FrequencyGuide[] { + const range = resolveFrequencyBounds(minFrequency, maxFrequency) + if (range.maxFrequency <= range.minFrequency || pixelSpan <= 0) return [] + + const candidates: FrequencyGuide[] = [] + const firstExponent = Math.floor(Math.log10(range.minFrequency)) + const lastExponent = Math.ceil(Math.log10(range.maxFrequency)) + + for (let exponent = firstExponent; exponent <= lastExponent; exponent += 1) { + const decade = 10 ** exponent + for (let multiplier = 1; multiplier <= 9; multiplier += 1) { + const frequencyHz = multiplier * decade + if (frequencyHz <= range.minFrequency || frequencyHz >= range.maxFrequency) continue + const major = multiplier === 1 || multiplier === 2 || multiplier === 5 + candidates.push({ + frequencyHz, + normalizedPosition: normalizedPositionAtFrequency( + frequencyHz, + range.minFrequency, + range.maxFrequency, + scaleMode, + ), + kind: major ? 'major' : 'minor', + ...(major ? { label: formatFrequencyGuideLabel(frequencyHz) } : {}), + }) + } + } + + candidates.sort((left, right) => left.normalizedPosition - right.normalizedPosition) + const minimumSpacing = candidates.reduce((minimum, guide, index) => { + if (index === 0) return minimum + const previous = candidates[index - 1] + return Math.min( + minimum, + (guide.normalizedPosition - previous.normalizedPosition) * pixelSpan, + ) + }, Number.POSITIVE_INFINITY) + const includeMinorGuides = minimumSpacing >= 8 + + return includeMinorGuides + ? candidates + : candidates.filter((guide) => guide.kind === 'major') +} diff --git a/src/types/settings.ts b/src/types/settings.ts index 752b8c6..32774e5 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -9,6 +9,12 @@ import { DEFAULT_VU_REFERENCE_DBFS, type VUMeterMode, type VUMeterNeedleChannels import { DEFAULT_LUFS_METER_READOUT, type LUFSMeterMode, type LUFSMeterReadout } from './lufsmeter' import { DEFAULT_WAVEFORM_MODE, DEFAULT_WAVEFORM_SCROLL_SPEED, type WaveformMode } from './waveform' import { DEFAULT_SPECTRUM_PEAK_INFO_MODE, type SpectrumPeakInfoMode } from './spectrum' +import { + DEFAULT_FREQUENCY_RANGE_MODE, + DEFAULT_FREQUENCY_SCALE_MODE, + type FrequencyRangeMode, + type FrequencyScaleMode, +} from './frequencyScale' import { DEFAULT_SCOPE_DISPLAY_ROTATION, DEFAULT_SCOPE_MIRROR_HORIZONTAL, @@ -17,6 +23,8 @@ import { export interface ScopeSettings { spectrum: ScopeDisplayTransformSettings & { + scaleMode: FrequencyScaleMode + frequencyRangeMode: FrequencyRangeMode fftSize: number tiltDbPerOctave: number heatmap: boolean @@ -48,6 +56,8 @@ export interface ScopeSettings { contrast: number clarityMode: SpectrogramClarityMode scaleMode: SpectrogramScaleMode + frequencyRangeMode: FrequencyRangeMode + showGrid: boolean colorScheme: 'heat' | 'mono' } vumeter: { @@ -76,10 +86,10 @@ export interface ScopeSettings { } export const DEFAULT_SCOPE_SETTINGS: ScopeSettings = { - spectrum: { rotation: DEFAULT_SCOPE_DISPLAY_ROTATION, mirrorHorizontal: DEFAULT_SCOPE_MIRROR_HORIZONTAL, fftSize: 2048, tiltDbPerOctave: 2.0, heatmap: false, heatmapTiltDbPerOctave: 2.0, heatmapSmoothing: 0.5, showGrid: true, smoothing: 0.9, fillGradient: true, showSideLine: false, peakInfoMode: DEFAULT_SPECTRUM_PEAK_INFO_MODE }, + spectrum: { rotation: DEFAULT_SCOPE_DISPLAY_ROTATION, mirrorHorizontal: DEFAULT_SCOPE_MIRROR_HORIZONTAL, scaleMode: DEFAULT_FREQUENCY_SCALE_MODE, frequencyRangeMode: DEFAULT_FREQUENCY_RANGE_MODE, fftSize: 2048, tiltDbPerOctave: 2.0, heatmap: false, heatmapTiltDbPerOctave: 2.0, heatmapSmoothing: 0.5, showGrid: true, smoothing: 0.9, fillGradient: true, showSideLine: false, peakInfoMode: DEFAULT_SPECTRUM_PEAK_INFO_MODE }, oscilloscope: { rotation: DEFAULT_SCOPE_DISPLAY_ROTATION, mirrorHorizontal: DEFAULT_SCOPE_MIRROR_HORIZONTAL, pitchLock: true, underfillEnabled: false, showGrid: true, lineWidth: 2 }, vectorscope: { mode: 'lissajous', multiband: false, showGrid: true, persistence: 0.10, lineWidth: 1.5 }, - spectrogram: { rotation: DEFAULT_SCOPE_DISPLAY_ROTATION, mirrorHorizontal: DEFAULT_SCOPE_MIRROR_HORIZONTAL, fftSize: 4096, tiltDbPerOctave: DEFAULT_SPECTROGRAM_TILT_DB_PER_OCTAVE, scrollSpeed: 2, contrast: DEFAULT_SPECTROGRAM_CONTRAST, clarityMode: 'sharper', scaleMode: 'log', colorScheme: 'heat' }, + spectrogram: { rotation: DEFAULT_SCOPE_DISPLAY_ROTATION, mirrorHorizontal: DEFAULT_SCOPE_MIRROR_HORIZONTAL, fftSize: 4096, tiltDbPerOctave: DEFAULT_SPECTROGRAM_TILT_DB_PER_OCTAVE, scrollSpeed: 2, contrast: DEFAULT_SPECTROGRAM_CONTRAST, clarityMode: 'sharper', scaleMode: 'log', frequencyRangeMode: DEFAULT_FREQUENCY_RANGE_MODE, showGrid: true, colorScheme: 'heat' }, vumeter: { mode: 'bar', orientation: 'horizontal', needleChannels: 'stereo', referenceDb: DEFAULT_VU_REFERENCE_DBFS }, lufsmeter: { mode: 'bar', readout: DEFAULT_LUFS_METER_READOUT }, waveform: { rotation: DEFAULT_SCOPE_DISPLAY_ROTATION, mirrorHorizontal: DEFAULT_SCOPE_MIRROR_HORIZONTAL, mode: DEFAULT_WAVEFORM_MODE, scrollSpeed: DEFAULT_WAVEFORM_SCROLL_SPEED, multiband: false }, diff --git a/src/types/spectrogram.ts b/src/types/spectrogram.ts index b76b503..deeb5e4 100644 --- a/src/types/spectrogram.ts +++ b/src/types/spectrogram.ts @@ -1,27 +1,31 @@ -export type SpectrogramClarityMode = 'classic' | 'sharp' | 'sharper' -export type SpectrogramScaleMode = 'mel' | 'log' | 'linear' +import { + DEFAULT_FREQUENCY_SCALE_MODE, + FREQUENCY_SCALE_MODES, + isFrequencyScaleMode, + type FrequencyScaleMode, +} from './frequencyScale' + +export type SpectrogramClarityMode = 'classic' | 'focused' | 'sharp' | 'sharper' +export type SpectrogramScaleMode = FrequencyScaleMode export type SpectrogramOrientation = 'horizontal' | 'vertical' export const SPECTROGRAM_CLARITY_MODES: readonly SpectrogramClarityMode[] = [ 'classic', + 'focused', 'sharp', 'sharper', ] -export const SPECTROGRAM_SCALE_MODES: readonly SpectrogramScaleMode[] = [ - 'mel', - 'log', - 'linear', -] +export const SPECTROGRAM_SCALE_MODES: readonly SpectrogramScaleMode[] = FREQUENCY_SCALE_MODES export const SPECTROGRAM_ORIENTATIONS: readonly SpectrogramOrientation[] = [ 'horizontal', 'vertical', ] export const DEFAULT_SPECTROGRAM_CLARITY_MODE: SpectrogramClarityMode = 'sharper' -export const DEFAULT_SPECTROGRAM_SCALE_MODE: SpectrogramScaleMode = 'log' +export const DEFAULT_SPECTROGRAM_SCALE_MODE: SpectrogramScaleMode = DEFAULT_FREQUENCY_SCALE_MODE export const DEFAULT_SPECTROGRAM_ORIENTATION: SpectrogramOrientation = 'horizontal' export const MIN_SPECTROGRAM_SCROLL_SPEED = 0.5 -export const MAX_SPECTROGRAM_SCROLL_SPEED = 4 +export const MAX_SPECTROGRAM_SCROLL_SPEED = 8 export const SPECTROGRAM_SCROLL_SPEED_STEP = 0.5 export const DEFAULT_SPECTROGRAM_SCROLL_SPEED = 2 @@ -40,7 +44,7 @@ export function isSpectrogramClarityMode(value: unknown): value is SpectrogramCl } export function isSpectrogramScaleMode(value: unknown): value is SpectrogramScaleMode { - return typeof value === 'string' && SPECTROGRAM_SCALE_MODES.includes(value as SpectrogramScaleMode) + return isFrequencyScaleMode(value) } export function isSpectrogramOrientation(value: unknown): value is SpectrogramOrientation { diff --git a/src/types/theme.ts b/src/types/theme.ts index b487932..60b321e 100644 --- a/src/types/theme.ts +++ b/src/types/theme.ts @@ -90,6 +90,8 @@ export interface ThemeSpectrogramTokens { heatLow?: string heatMid?: string heatHigh?: string + guides?: string + labels?: string } export interface ThemeVUMeterTokens { @@ -279,6 +281,8 @@ export interface ResolvedSpectrogramTheme { mono: string background: string heatColors: [string, string, string] + guides: string + labels: string } export interface ResolvedVUMeterTheme { diff --git a/test/audio-router.test.ts b/test/audio-router.test.ts index 4f179c4..b36a2b0 100644 --- a/test/audio-router.test.ts +++ b/test/audio-router.test.ts @@ -82,6 +82,36 @@ test('spectrum keeps stereo chunks for the side overlay path and still exposes m assert.equal(router.flushPendingSpectrumStereoSamples().length, 0) }) +test('spectrogram preserves stereo chunks so out-of-phase content cannot cancel before analysis', () => { + const router = new AudioRouter() + const sessionId = router.beginSession(48000, 2, 'native-macos') + router.setVisualizerConsumerDemand('test-consumer', { spectrogram: true }) + + router.ingestChunk(createChunk(0.5), createChunk(-0.5), { + sessionId, + channelCount: 2, + sequence: 1, + capturedAt: performance.now() - 5, + }) + + const stereoChunks = router.flushPendingSpectrogramStereoSamples() + assert.equal(stereoChunks.length, 1) + assert.deepEqual(Array.from(stereoChunks[0]?.left ?? []), [0.5, 0.5, 0.5, 0.5]) + assert.deepEqual(Array.from(stereoChunks[0]?.right ?? []), [-0.5, -0.5, -0.5, -0.5]) + assert.equal(router.flushPendingSpectrogramSamples().length, 0) + + router.ingestChunk(createChunk(2), createChunk(4), { + sessionId, + channelCount: 2, + sequence: 2, + capturedAt: performance.now() - 5, + }) + + const compatibilityMono = router.flushPendingSpectrogramSamples() + assert.deepEqual(Array.from(compatibilityMono[0] ?? []), [3, 3, 3, 3]) + assert.equal(router.flushPendingSpectrogramStereoSamples().length, 0) +}) + test('waveform keeps stereo chunks for stereo mode while mono flushes still expose the left channel', () => { const router = new AudioRouter() const sessionId = router.beginSession(48000, 2, 'native-macos') diff --git a/test/profile-library.test.ts b/test/profile-library.test.ts index 08d2ed8..46fa33f 100644 --- a/test/profile-library.test.ts +++ b/test/profile-library.test.ts @@ -50,7 +50,13 @@ function createProfile(name: string): Profile { profile.windowBounds = { x: 10, y: 20, width: 840, height: 180 } profile.scopeSettings.spectrum.showSideLine = true profile.scopeSettings.spectrum.heatmapSmoothing = 0.67 + profile.scopeSettings.spectrum.scaleMode = 'mel' + profile.scopeSettings.spectrum.frequencyRangeMode = 'audible' profile.scopeSettings.spectrogram.colorScheme = 'mono' + profile.scopeSettings.spectrogram.clarityMode = 'focused' + profile.scopeSettings.spectrogram.scaleMode = 'linear' + profile.scopeSettings.spectrogram.frequencyRangeMode = 'extended' + profile.scopeSettings.spectrogram.showGrid = true profile.scopeSettings.spectrogram.rotation = 90 profile.scopeSettings.spectrogram.mirrorHorizontal = true return profile @@ -68,6 +74,12 @@ test('profile file serialization excludes geometry and round-trips with local me assert.equal(JSON.stringify(file).includes('inputGainDb'), false) assert.deepEqual(file.scopePopouts.spectrum, { poppedOut: true }) assert.equal(file.scopeSettings.spectrum.heatmapSmoothing, 0.67) + assert.equal(file.scopeSettings.spectrum.scaleMode, 'mel') + assert.equal(file.scopeSettings.spectrum.frequencyRangeMode, 'audible') + assert.equal(file.scopeSettings.spectrogram.scaleMode, 'linear') + assert.equal(file.scopeSettings.spectrogram.clarityMode, 'focused') + assert.equal(file.scopeSettings.spectrogram.frequencyRangeMode, 'extended') + assert.equal(file.scopeSettings.spectrogram.showGrid, true) assert.equal(file.scopeSettings.spectrogram.rotation, 90) assert.equal(file.scopeSettings.spectrogram.mirrorHorizontal, true) assert.equal('orientation' in file.scopeSettings.spectrogram, false) @@ -80,7 +92,13 @@ test('profile file serialization excludes geometry and round-trips with local me assert.deepEqual(restored.scopePopouts.spectrum.windowBounds, profile.scopePopouts.spectrum.windowBounds) assert.equal(restored.scopeSettings.spectrum.showSideLine, true) assert.equal(restored.scopeSettings.spectrum.heatmapSmoothing, 0.67) + assert.equal(restored.scopeSettings.spectrum.scaleMode, 'mel') + assert.equal(restored.scopeSettings.spectrum.frequencyRangeMode, 'audible') assert.equal(restored.scopeSettings.spectrogram.colorScheme, 'mono') + assert.equal(restored.scopeSettings.spectrogram.clarityMode, 'focused') + assert.equal(restored.scopeSettings.spectrogram.scaleMode, 'linear') + assert.equal(restored.scopeSettings.spectrogram.frequencyRangeMode, 'extended') + assert.equal(restored.scopeSettings.spectrogram.showGrid, true) assert.equal(restored.scopeSettings.spectrogram.rotation, 90) assert.equal(restored.scopeSettings.spectrogram.mirrorHorizontal, true) assert.equal(restored.scopeSettings.nowPlaying.showControls, true) @@ -184,6 +202,60 @@ test('mergeScopeSettings defaults missing or invalid VU needle channel settings assert.equal(missing.vumeter.needleChannels, 'stereo') }) +test('mergeScopeSettings normalizes frequency scales, ranges, clarity, and supported spectrogram speeds', () => { + const valid = mergeScopeSettings({ + spectrum: { scaleMode: 'mel', frequencyRangeMode: 'extended' }, + spectrogram: { + scaleMode: 'linear', + frequencyRangeMode: 'audible', + clarityMode: 'focused', + showGrid: true, + scrollSpeed: 8, + }, + }) + const invalid = mergeScopeSettings({ + spectrum: { scaleMode: 'bark', frequencyRangeMode: 'full' }, + spectrogram: { + scaleMode: 42, + frequencyRangeMode: 12, + clarityMode: 'etched', + showGrid: 'yes', + scrollSpeed: 99, + }, + }) + const legacy = mergeScopeSettings({ + spectrogram: { scrollSpeed: 0.5 }, + }) + const missing = mergeScopeSettings({}) + + assert.equal(valid.spectrum.scaleMode, 'mel') + assert.equal(valid.spectrum.frequencyRangeMode, 'extended') + assert.equal(valid.spectrogram.scaleMode, 'linear') + assert.equal(valid.spectrogram.frequencyRangeMode, 'audible') + assert.equal(valid.spectrogram.clarityMode, 'focused') + assert.equal(valid.spectrogram.showGrid, true) + assert.equal(valid.spectrogram.scrollSpeed, 8) + assert.equal(invalid.spectrum.scaleMode, 'log') + assert.equal(invalid.spectrum.frequencyRangeMode, 'extended') + assert.equal(invalid.spectrogram.scaleMode, 'log') + assert.equal(invalid.spectrogram.frequencyRangeMode, 'extended') + assert.equal(invalid.spectrogram.clarityMode, 'sharper') + assert.equal(invalid.spectrogram.showGrid, false) + assert.equal(invalid.spectrogram.scrollSpeed, 8) + assert.equal(legacy.spectrogram.scrollSpeed, 0.5) + assert.equal(missing.spectrum.scaleMode, 'log') + assert.equal(missing.spectrum.frequencyRangeMode, 'extended') + assert.equal(missing.spectrogram.scaleMode, 'log') + assert.equal(missing.spectrogram.frequencyRangeMode, 'extended') + assert.equal(missing.spectrogram.clarityMode, 'sharper') + assert.equal(missing.spectrogram.showGrid, false) + + const newProfile = createDefaultProfile('New') + assert.equal(newProfile.scopeSettings.spectrum.frequencyRangeMode, 'extended') + assert.equal(newProfile.scopeSettings.spectrogram.frequencyRangeMode, 'extended') + assert.equal(newProfile.scopeSettings.spectrogram.showGrid, true) +}) + test('library saves, renames, deletes, and resolves filename collisions', async () => { const harness = await createHarness() diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index f773d25..a70e952 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -117,6 +117,8 @@ import { SpectrumAnalyzer, type SpectrumAnalyzerOptions } from '../src/renderer/ import { BridgeSpectrumAnalyzer } from '../src/plugin-ui/BridgeSpectrumAnalyzer' import { decodeSpectrumFrame } from '../src/plugin-ui/juceBridge' import { formatSpectrumPeakDbfs } from '../src/plugin-ui/peakOverlay' +import { spectrogramSettingsToOptions } from '../src/plugin-ui/spectrogramOptions' +import { spectrumSettingsToOptions } from '../src/plugin-ui/spectrumOptions' import { Spectrogram, type SpectrogramOptions } from '../src/renderer/visualizers/Spectrogram' import { Vectorscope } from '../src/renderer/visualizers/Vectorscope' import { Waveform } from '../src/renderer/visualizers/Waveform' @@ -145,6 +147,14 @@ import { type Profile, } from '../src/types/profile' import type { WindowCapabilities } from '../src/types/windowCapabilities' +import { + buildFrequencyGuides, + clampFrequencyRangeToNyquist, + frequencyBoundsForRange, + normalizeFrequencyScaleMode, + normalizedPositionAtFrequency, + type FrequencyScaleMode, +} from '../src/types/frequencyScale' type WindowWithRaf = typeof globalThis & Pick type FakeElectronAPI = { @@ -1720,6 +1730,29 @@ test('scope measurement helpers resolve MiniMeters-style cursor axis values', () assert.deepEqual(spectrum.values.slice(0, 2), ['-50.00dB', '1.00kHz']) assert.match(spectrum.values[2] ?? '', /^B5 /) + const slaneyOneKhz = 1000 / (200 / 3) + const slaneyMin = 20 / (200 / 3) + const slaneyMax = slaneyOneKhz + Math.log(20) / (Math.log(6.4) / 27) + const oneKhzPositions = new Map([ + ['log', spectrumX], + ['mel', (slaneyOneKhz - slaneyMin) / (slaneyMax - slaneyMin)], + ['linear', (1000 - 20) / (20000 - 20)], + ]) + for (const [scaleType, x] of oneKhzPositions) { + const scaleMeasurement = resolveSpectrumMeasurement( + { x, y: 0.5 }, + { + sampleRate: 48000, + minFrequency: 20, + maxFrequency: 20000, + minDecibels: -90, + maxDecibels: -10, + scaleType, + }, + ) + assert.equal(scaleMeasurement.values[1], '1.00kHz') + } + const nyquistLimited = resolveSpectrumMeasurement( { x: 1, y: 0 }, { @@ -1773,6 +1806,98 @@ test('spectrogram measurement follows scale mode and rendered history speed', () }, ) assert.deepEqual(measurement.values.slice(0, 2), ['266.67ms ago', '20.00kHz']) + + const expectedHistory = new Map([ + [1, '1.28s ago'], + [2, '640.00ms ago'], + [4, '320.00ms ago'], + [8, '160.00ms ago'], + ]) + for (const [scrollSpeed, expectedTime] of expectedHistory) { + const historyMeasurement = resolveSpectrogramMeasurement( + { x: 0, y: 1 }, + { + sampleRate: 48000, + minFrequency: 20, + maxFrequency: 20000, + scaleMode: 'linear', + fftSize: 4096, + scrollSpeed, + canvasPixelWidth: 121, + }, + ) + assert.deepEqual(historyMeasurement.values.slice(0, 2), [expectedTime, '20.00Hz']) + } +}) + +test('frequency scale transforms are monotonic, invertible, and Nyquist-safe', () => { + const minFrequency = 20 + const maxFrequency = 20000 + const positions = [0, 0.1, 0.25, 0.5, 0.75, 0.9, 1] + + for (const scaleMode of ['log', 'mel', 'linear'] as const) { + const frequencies = positions.map((position) => ( + frequencyAtNormalizedPosition(position, minFrequency, maxFrequency, scaleMode) + )) + assertAlmostEqual(frequencies[0], minFrequency, 1e-12, `${scaleMode} minimum`) + assertAlmostEqual(frequencies.at(-1) ?? 0, maxFrequency, 1e-9, `${scaleMode} maximum`) + for (let index = 1; index < frequencies.length; index += 1) { + assert.ok(frequencies[index] > frequencies[index - 1], `${scaleMode} should be monotonic`) + assertAlmostEqual( + normalizedPositionAtFrequency(frequencies[index], minFrequency, maxFrequency, scaleMode), + positions[index], + 1e-12, + `${scaleMode} inverse at ${positions[index]}`, + ) + } + } + + const slaneyMelAtOneKhz = 1000 / (200 / 3) + const slaneyMelAtTwentyKhz = 15 + Math.log(20) / (Math.log(6.4) / 27) + const expectedOneKhzPosition = ( + slaneyMelAtOneKhz - (20 / (200 / 3)) + ) / ( + slaneyMelAtTwentyKhz - (20 / (200 / 3)) + ) + assertAlmostEqual( + normalizedPositionAtFrequency(1000, 20, 20000, 'mel'), + expectedOneKhzPosition, + 1e-12, + 'Slaney mel 1kHz anchor', + ) + + assert.deepEqual(clampFrequencyRangeToNyquist(32000, 20, 20000), { + minFrequency: 20, + maxFrequency: 16000, + }) + assert.equal(normalizeFrequencyScaleMode('bark'), 'log') +}) + +test('frequency ranges and adaptive guides cover extended audio without crowding compact scopes', () => { + assert.deepEqual(frequencyBoundsForRange('extended', 44100), { + minFrequency: 10, + maxFrequency: 22050, + }) + assert.deepEqual(frequencyBoundsForRange('extended', 96000), { + minFrequency: 10, + maxFrequency: 24000, + }) + assert.deepEqual(frequencyBoundsForRange('audible', 48000), { + minFrequency: 20, + maxFrequency: 20000, + }) + + const wideGuides = buildFrequencyGuides(10, 24000, 'log', 1500) + assert.ok(wideGuides.some(({ frequencyHz, kind }) => frequencyHz === 30 && kind === 'minor')) + assert.ok(wideGuides.some(({ frequencyHz, kind, label }) => ( + frequencyHz === 1000 && kind === 'major' && label === '1k' + ))) + + const compactGuides = buildFrequencyGuides(10, 24000, 'log', 320) + assert.equal(compactGuides.some(({ kind }) => kind === 'minor'), false) + assert.ok(compactGuides.every((guide, index) => ( + index === 0 || guide.normalizedPosition > compactGuides[index - 1].normalizedPosition + ))) }) test('measurement readout flips and clamps at viewport edges', () => { @@ -1794,10 +1919,15 @@ test('measurement readout flips and clamps at viewport edges', () => { ) }) -test('scopeSettingsToOptions wires spectrum side overlay settings into analyzer options', () => { +test('scopeSettingsToOptions wires frequency ranges and overlays into desktop and plugin options', () => { const profile = createDefaultProfile('Default') profile.scopeSettings.spectrum.showSideLine = true profile.scopeSettings.spectrum.heatmapSmoothing = 0.64 + profile.scopeSettings.spectrum.scaleMode = 'mel' + profile.scopeSettings.spectrum.frequencyRangeMode = 'audible' + profile.scopeSettings.spectrogram.frequencyRangeMode = 'extended' + profile.scopeSettings.spectrogram.clarityMode = 'focused' + profile.scopeSettings.spectrogram.showGrid = true const theme = resolveTheme(createDefaultTheme()) const options = scopeSettingsToOptions('spectrum', profile.scopeSettings.spectrum, theme.spectrum) @@ -1808,10 +1938,37 @@ test('scopeSettingsToOptions wires spectrum side overlay settings into analyzer assert.equal(options.lineColor, theme.spectrum.line) assert.equal(options.backgroundColor, theme.spectrum.background) assert.equal(options.gridColor, theme.spectrum.guides) + assert.equal(options.scaleType, 'mel') + assert.equal(options.minFrequency, 20) + assert.equal(options.maxFrequency, 20000) + + const pluginOptions = spectrumSettingsToOptions(profile.scopeSettings.spectrum, theme.spectrum) + assert.equal(pluginOptions.scaleType, 'mel') + assert.equal(pluginOptions.minFrequency, 20) + assert.equal(pluginOptions.maxFrequency, 20000) + + const spectrogramOptions = scopeSettingsToOptions( + 'spectrogram', + profile.scopeSettings.spectrogram, + theme.spectrogram, + ) + assert.equal(spectrogramOptions.minFrequency, 10) + assert.equal(spectrogramOptions.maxFrequency, 24000) + assert.equal(spectrogramOptions.clarityMode, 'focused') + assert.equal(spectrogramOptions.showGrid, true) + + const pluginSpectrogramOptions = spectrogramSettingsToOptions( + profile.scopeSettings.spectrogram, + theme.spectrogram, + ) + assert.equal(pluginSpectrogramOptions.minFrequency, 10) + assert.equal(pluginSpectrogramOptions.maxFrequency, 24000) + assert.equal(pluginSpectrogramOptions.clarityMode, 'focused') + assert.equal(pluginSpectrogramOptions.showGrid, true) }) test('SpectrumAnalyzer grid only draws frequency guides when enabled', () => { - const renderGrid = (showGrid: boolean): FakeCanvasRecorder => { + const renderGrid = (showGrid: boolean, scaleType: FrequencyScaleMode = 'log'): FakeCanvasRecorder => { const recorder = createFakeCanvasRecorder() const dom = installFakeCanvasDom(() => createFakeCanvas(recorder)) const dataSource = { @@ -1823,6 +1980,7 @@ test('SpectrumAnalyzer grid only draws frequency guides when enabled', () => { } const analyzer = new SpectrumAnalyzer(createFakeCanvas(), { showGrid, + scaleType, dataSource, nativeAnalyzer: null, }) @@ -1857,6 +2015,62 @@ test('SpectrumAnalyzer grid only draws frequency guides when enabled', () => { const disabled = renderGrid(false) assert.deepEqual(disabled.fillTexts, []) assert.deepEqual(disabled.lineStrokes, []) + + for (const scaleType of ['log', 'mel', 'linear'] as const) { + const recorder = renderGrid(true, scaleType) + const oneKhz = recorder.fillTexts.find(({ text }) => text === '1k') + assert.ok(oneKhz) + assertAlmostEqual( + oneKhz.x, + normalizedPositionAtFrequency(1000, 20, 20000, scaleType) * 320, + 1e-9, + `${scaleType} 1kHz grid position`, + ) + } +}) + +test('SpectrumAnalyzer applies frequency scale changes without recreation', () => { + const recorder = createFakeCanvasRecorder() + const dom = installFakeCanvasDom(() => createFakeCanvas(recorder)) + const dataSource = { + getPendingSpectrumSamples: () => [], + getPendingSpectrumStereoSamples: () => [], + getSampleRate: () => 48000, + isPlaying: () => false, + subscribeToSessionChanges: () => () => {}, + } + const analyzer = new SpectrumAnalyzer(createFakeCanvas(), { + showGrid: true, + scaleType: 'log', + dataSource, + nativeAnalyzer: null, + }) + + try { + const state = analyzer as unknown as { + ensureStaticLayer: (minFrequency: number, maxFrequency: number) => void + } + state.ensureStaticLayer(20, 20000) + const logPosition = recorder.fillTexts.find(({ text }) => text === '1k')?.x + assert.notEqual(logPosition, undefined) + + analyzer.setOptions({ scaleType: 'linear' }) + state.ensureStaticLayer(20, 20000) + const oneKhzLabels = recorder.fillTexts.filter(({ text }) => text === '1k') + assert.equal(oneKhzLabels.length, 2) + const linearPosition = oneKhzLabels.at(-1)?.x + assert.notEqual(linearPosition, undefined) + assert.notEqual(linearPosition, logPosition) + assertAlmostEqual( + linearPosition ?? 0, + normalizedPositionAtFrequency(1000, 20, 20000, 'linear') * 320, + 1e-9, + 'live linear 1kHz grid position', + ) + } finally { + analyzer.dispose() + dom.restore() + } }) test('SpectrumAnalyzer applies and restores transient measurement smoothing without resetting', () => { @@ -2282,6 +2496,53 @@ test('Spectrogram vertical orientation shifts existing rows upward with copy com assert.equal(drawCall?.args[2], -1) }) +test('Spectrogram draws adaptive frequency guides in the selected scale', () => { + const renderGrid = (showGrid: boolean, scaleMode: FrequencyScaleMode): FakeCanvasRecorder => { + const recorder = createFakeCanvasRecorder() + const dom = installFakeCanvasDom(() => createFakeCanvas(recorder, 320, 600)) + const spectrogram = new Spectrogram(createFakeCanvas(recorder, 320, 600), { + showGrid, + scaleMode, + minFrequency: 20, + maxFrequency: 20000, + dataSource: { + getPendingSpectrogramSamples: () => [], + getPendingSpectrogramStereoSamples: () => [], + getSampleRate: () => 48000, + isPlaying: () => false, + subscribeToSessionChanges: () => () => {}, + }, + nativeAnalyzer: null, + }) + + try { + const state = spectrogram as unknown as { drawFrame: () => void } + state.drawFrame() + return recorder + } finally { + spectrogram.dispose() + dom.restore() + } + } + + const enabled = renderGrid(true, 'mel') + const oneKhz = enabled.fillTexts.find(({ text }) => text === '1k') + assert.ok(oneKhz) + assertAlmostEqual( + oneKhz.y, + 600 - (normalizedPositionAtFrequency(1000, 20, 20000, 'mel') * 600) - 2, + 1e-9, + 'Mel spectrogram 1kHz grid position', + ) + + const wideLog = renderGrid(true, 'log') + assert.ok(wideLog.lineStrokes.length > wideLog.fillTexts.length) + + const disabled = renderGrid(false, 'log') + assert.deepEqual(disabled.fillTexts, []) + assert.deepEqual(disabled.lineStrokes, []) +}) + test('Spectrogram paints multiple native analyzer columns in order', () => { const recorder = createFakeCanvasRecorder() const dom = installFakeCanvasDom(() => createFakeCanvas(recorder)) @@ -2329,6 +2590,59 @@ test('Spectrogram paints multiple native analyzer columns in order', () => { } }) +test('Spectrogram forwards stereo channels without a cancellation-prone mono downmix', () => { + const dom = installFakeCanvasDom() + const canvas = createFakeCanvas(null, 4, 2) + const left = Float32Array.from([0.5, 0, -0.5, 0]) + const right = Float32Array.from([-0.5, 0, 0.5, 0]) + let pending = [{ left, right }] + let processedLeft: Float32Array | null = null + let processedRight: Float32Array | null = null + const dataSource = { + getPendingSpectrogramSamples: (): Float32Array[] => assert.fail('stereo input must not be downmixed'), + getPendingSpectrogramStereoSamples: () => { + const chunks = pending + pending = [] + return chunks + }, + getSampleRate: () => 48000, + isPlaying: () => true, + subscribeToSessionChanges: () => () => {}, + } + const emptyResult = (): SpectrogramNativeResult => ({ + display: new Float32Array(0), + heat: new Float32Array(0), + columnCount: 0, + rowCount: 2, + }) + const nativeAnalyzer: SpectrogramNativeAnalyzer = { + isAvailable: () => true, + configure: () => {}, + process: () => assert.fail('stereo input must use processStereo'), + processStereo: (nextLeft, nextRight) => { + processedLeft = nextLeft + processedRight = nextRight + return emptyResult() + }, + reset: () => {}, + } + const spectrogram = new Spectrogram(canvas, { + dataSource, + nativeAnalyzer, + showGrid: false, + }) + + try { + const state = spectrogram as unknown as { drawFrame: () => void } + state.drawFrame() + assert.equal(processedLeft, left) + assert.equal(processedRight, right) + } finally { + spectrogram.dispose() + dom.restore() + } +}) + test('Spectrogram forwards orientation and row count to the native analyzer', () => { const dom = installFakeCanvasDom() const canvas = createFakeCanvas(null, 3, 5) @@ -2378,6 +2692,11 @@ test('Spectrogram forwards orientation and row count to the native analyzer', () assert.equal(capturedConfig?.scrollSpeed, 4) assert.equal(capturedConfig?.tiltDbPerOctave, 5.5) assert.equal(capturedConfig?.sampleRate, 44100) + + spectrogram.setOptions({ scaleMode: 'mel' }) + pending = [Float32Array.from([0, 0])] + state.drawFrame() + assert.equal(capturedConfig?.scaleMode, 'mel') } finally { spectrogram.dispose() dom.restore() @@ -2590,6 +2909,8 @@ test('SpectrumAnalyzer keeps a left-only Mid curve while reporting the left chan fillGradient: false, smoothing: 0, tiltDbPerOctave: 0, + minFrequency: 20, + maxFrequency: 20000, fftSize, dataSource: { getPendingSpectrumSamples: () => assert.fail('peak capture must preserve stereo channel data'), @@ -3333,27 +3654,35 @@ test('scopeSummary includes loudness readout source', () => { test('scopeSummary includes spectrum peak mode when enabled', () => { const profile = createDefaultProfile('Default') - assert.equal(scopeSummary('spectrum', profile.scopeSettings.spectrum), 'Fill · FFT 2048') + assert.equal(scopeSummary('spectrum', profile.scopeSettings.spectrum), 'LOG · Extended · Fill · FFT 2048') + + profile.scopeSettings.spectrum.scaleMode = 'mel' + assert.equal(scopeSummary('spectrum', profile.scopeSettings.spectrum), 'MEL · Extended · Fill · FFT 2048') + profile.scopeSettings.spectrum.scaleMode = 'log' profile.scopeSettings.spectrum.peakInfoMode = 'on' - assert.equal(scopeSummary('spectrum', profile.scopeSettings.spectrum), 'Fill · FFT 2048 · Peak') + assert.equal(scopeSummary('spectrum', profile.scopeSettings.spectrum), 'LOG · Extended · Fill · FFT 2048 · Peak') profile.scopeSettings.spectrum.peakInfoMode = 'following' - assert.equal(scopeSummary('spectrum', profile.scopeSettings.spectrum), 'Fill · FFT 2048 · Peak Follow') + assert.equal(scopeSummary('spectrum', profile.scopeSettings.spectrum), 'LOG · Extended · Fill · FFT 2048 · Peak Follow') }) test('scopeSummary includes visual-scope rotation and mirroring', () => { const profile = createDefaultProfile('Default') - assert.equal(scopeSummary('spectrogram', profile.scopeSettings.spectrogram), '0° · LOG · sharper') + assert.equal(scopeSummary('spectrogram', profile.scopeSettings.spectrogram), '0° · LOG · Extended · sharper') + + profile.scopeSettings.spectrogram.clarityMode = 'focused' + assert.equal(scopeSummary('spectrogram', profile.scopeSettings.spectrogram), '0° · LOG · Extended · focused') + profile.scopeSettings.spectrogram.clarityMode = 'sharper' profile.scopeSettings.spectrogram.rotation = 270 profile.scopeSettings.spectrogram.mirrorHorizontal = true - assert.equal(scopeSummary('spectrogram', profile.scopeSettings.spectrogram), '270° · LOG · sharper · Mirror') + assert.equal(scopeSummary('spectrogram', profile.scopeSettings.spectrogram), '270° · LOG · Extended · sharper · Mirror') profile.scopeSettings.spectrum.rotation = 90 profile.scopeSettings.spectrum.mirrorHorizontal = true - assert.equal(scopeSummary('spectrum', profile.scopeSettings.spectrum), 'Fill · FFT 2048 · R90° · Mirror') + assert.equal(scopeSummary('spectrum', profile.scopeSettings.spectrum), 'LOG · Extended · Fill · FFT 2048 · R90° · Mirror') }) test('scopeSummary summarizes now playing field visibility', () => { @@ -3387,6 +3716,20 @@ test('ScopePopoutDataSource switches waveform batches between mono and stereo qu assert.equal(dataSource.getPendingWaveformSamples()[0], nextMonoChunk) }) +test('ScopePopoutDataSource preserves spectrogram stereo batches', () => { + const dataSource = new ScopePopoutDataSource('spectrogram') + const left = new Float32Array([0.5, -0.5]) + const right = new Float32Array([-0.5, 0.5]) + + dataSource.pushAudioBatch([{ left, right }]) + + assert.equal(dataSource.getPendingSpectrogramSamples().length, 0) + const batch = dataSource.getPendingSpectrogramStereoSamples() + assert.equal(batch.length, 1) + assert.equal(batch[0]?.left, left) + assert.equal(batch[0]?.right, right) +}) + test('applying a profile snapshot does not change the machine-local frame target', () => { const previousPerformanceState = usePerformanceStore.getState() const previousSettingsState = useSettingsStore.getState() diff --git a/test/spectrogram-native.test.mjs b/test/spectrogram-native.test.mjs new file mode 100644 index 0000000..d2ff1e4 --- /dev/null +++ b/test/spectrogram-native.test.mjs @@ -0,0 +1,384 @@ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import test from 'node:test' + +const require = createRequire(import.meta.url) +const { spectrogram } = require('../native/build/Release/visualizer_dsp.node') + +const MIN_FREQUENCY = 20 +const MAX_FREQUENCY = 20000 +const CLASSIC_GAMMA = 1.4 +const SHARPER_GAMMA = 1.1 + +function createTone(frequencyHz, sampleRate, length, amplitude = 1) { + return Float32Array.from( + { length }, + (_, index) => amplitude * Math.sin((2 * Math.PI * frequencyHz * index) / sampleRate), + ) +} + +function createCompositeTone(tones, sampleRate, length) { + return Float32Array.from( + { length }, + (_, index) => tones.reduce((sample, tone) => ( + sample + tone.amplitude * Math.sin((2 * Math.PI * tone.frequencyHz * index) / sampleRate) + ), 0), + ) +} + +function configure(overrides = {}) { + spectrogram.configure({ + fftSize: 4096, + sampleRate: 48000, + rowCount: 401, + minFrequency: MIN_FREQUENCY, + maxFrequency: MAX_FREQUENCY, + minDecibels: -100, + maxDecibels: 0, + scrollSpeed: 2, + contrast: 1, + tiltDbPerOctave: 0, + clarityMode: 'classic', + scaleMode: 'log', + orientation: 'horizontal', + ...overrides, + }) + spectrogram.reset() +} + +function hzToMelSlaney(frequencyHz) { + const linearSpacing = 200 / 3 + const minimumLogMel = 1000 / linearSpacing + const logStep = Math.log(6.4) / 27 + return frequencyHz < 1000 + ? frequencyHz / linearSpacing + : minimumLogMel + Math.log(frequencyHz / 1000) / logStep +} + +function expectedNormalizedPosition(frequencyHz, scaleMode, minFrequency, maxFrequency) { + if (scaleMode === 'linear') { + return (frequencyHz - minFrequency) / (maxFrequency - minFrequency) + } + if (scaleMode === 'mel') { + const melMin = hzToMelSlaney(minFrequency) + const melMax = hzToMelSlaney(maxFrequency) + return (hzToMelSlaney(frequencyHz) - melMin) / (melMax - melMin) + } + return Math.log10(frequencyHz / minFrequency) / Math.log10(maxFrequency / minFrequency) +} + +function findPeakRow(values) { + let peakRow = 0 + for (let row = 1; row < values.length; row += 1) { + if (values[row] > values[peakRow]) peakRow = row + } + return peakRow +} + +function decodeClassicDisplayDb(value, minDecibels, maxDecibels) { + const normalized = Math.pow(value, 1 / CLASSIC_GAMMA) + return minDecibels + normalized * (maxDecibels - minDecibels) +} + +function decodeSharperDisplayDb(value, gamma, minDecibels, maxDecibels) { + const normalized = Math.pow(value, 1 / gamma) + return minDecibels + normalized * (maxDecibels - minDecibels) +} + +function finalColumn(result) { + const offset = (result.columnCount - 1) * result.rowCount + return result.display.subarray(offset, offset + result.rowCount) +} + +function localPeak(values, centerRow, radius = 2) { + let peak = 0 + for ( + let row = Math.max(0, Math.round(centerRow) - radius); + row <= Math.min(values.length - 1, Math.round(centerRow) + radius); + row += 1 + ) { + peak = Math.max(peak, values[row]) + } + return peak +} + +function assertAlmostEqual(actual, expected, tolerance, message) { + assert.ok( + Math.abs(actual - expected) <= tolerance, + `${message}: expected ${expected} +/- ${tolerance}, got ${actual}`, + ) +} + +test('spectrogram places tones accurately on log, mel, and linear axes', () => { + const rowCount = 401 + const configurations = [ + { sampleRate: 44100, fftSize: 2048 }, + { sampleRate: 48000, fftSize: 4096 }, + { sampleRate: 96000, fftSize: 8192 }, + ] + const frequencies = [55, 440, 1000, 5234.5, 15000, 19777] + const amplitudes = [0.001, 0.1] + + for (const { sampleRate, fftSize } of configurations) { + const maximum = Math.min(MAX_FREQUENCY, sampleRate / 2) + for (const scaleMode of ['log', 'mel', 'linear']) { + for (const frequencyHz of frequencies) { + if (frequencyHz > maximum) continue + for (const amplitude of amplitudes) { + configure({ sampleRate, fftSize, rowCount, scaleMode }) + const result = spectrogram.process(createTone(frequencyHz, sampleRate, fftSize, amplitude)) + const peakRow = findPeakRow(result.display) + const expectedRow = ( + 1 - expectedNormalizedPosition(frequencyHz, scaleMode, MIN_FREQUENCY, maximum) + ) * (rowCount - 1) + + assert.equal(result.columnCount, 1) + assert.ok(result.display[peakRow] > 0.05, `${scaleMode} ${frequencyHz}Hz should remain visible`) + assertAlmostEqual( + peakRow, + expectedRow, + 1, + `${scaleMode} ${frequencyHz}Hz at ${sampleRate}Hz / FFT ${fftSize}, amplitude ${amplitude}`, + ) + } + } + } + } +}) + +test('spectrogram log rows retain narrow high-frequency tones between row centers', () => { + configure({ scaleMode: 'log', rowCount: 401 }) + const result = spectrogram.process(createTone(15000, 48000, 4096, 0.01)) + const peakRow = findPeakRow(result.display) + const expectedRow = ( + 1 - expectedNormalizedPosition(15000, 'log', MIN_FREQUENCY, MAX_FREQUENCY) + ) * 400 + + assert.ok(result.display[peakRow] > 0.3) + assertAlmostEqual(peakRow, expectedRow, 1, '15kHz log-axis regression') +}) + +test('spectrogram Hann normalization reports calibrated tone levels', () => { + const minDecibels = -120 + const maxDecibels = 12 + + for (const fftSize of [1024, 4096, 8192]) { + for (const binPosition of [37, 37.37]) { + const frequencyHz = binPosition * 48000 / fftSize + for (const amplitude of [1, 0.5, 0.1]) { + configure({ fftSize, rowCount: 1, minDecibels, maxDecibels }) + const result = spectrogram.process(createTone(frequencyHz, 48000, fftSize, amplitude)) + const measuredDbfs = decodeClassicDisplayDb(result.display[0], minDecibels, maxDecibels) + assertAlmostEqual( + measuredDbfs, + 20 * Math.log10(amplitude), + 0.3, + `amplitude ${amplitude} at FFT ${fftSize}, bin ${binPosition}`, + ) + } + } + } + + configure({ fftSize: 4096, rowCount: 1, minDecibels, maxDecibels }) + const silence = spectrogram.process(new Float32Array(4096)) + assert.equal(silence.display[0], 0) + assert.ok(silence.display.every(Number.isFinite)) + assert.ok(silence.heat.every(Number.isFinite)) +}) + +test('spectrogram Sharper conserves calibrated power while frequency-reassigning every visible bin', () => { + const sampleRate = 48000 + const minDecibels = -120 + const maxDecibels = 12 + + for (const fftSize of [1024, 4096, 8192]) { + const hopSize = fftSize / 16 + for (const binPosition of [37, 37.37]) { + const frequencyHz = binPosition * sampleRate / fftSize + for (const amplitude of [0.5, 0.1]) { + configure({ + fftSize, + sampleRate, + rowCount: 1, + minDecibels, + maxDecibels, + clarityMode: 'sharper', + }) + const result = spectrogram.process(createTone( + frequencyHz, + sampleRate, + fftSize + hopSize, + amplitude, + )) + const measuredDbfs = decodeSharperDisplayDb( + finalColumn(result)[0], + SHARPER_GAMMA, + minDecibels, + maxDecibels, + ) + assertAlmostEqual( + measuredDbfs, + 20 * Math.log10(amplitude), + 0.3, + `Sharper amplitude ${amplitude} at FFT ${fftSize}, bin ${binPosition}`, + ) + } + } + } +}) + +test('spectrogram Sharper keeps reassigned tones on the correct Log, Mel, and Linear rows', () => { + const rowCount = 601 + for (const { sampleRate, fftSize } of [ + { sampleRate: 44100, fftSize: 2048 }, + { sampleRate: 48000, fftSize: 4096 }, + { sampleRate: 96000, fftSize: 8192 }, + ]) { + const hopSize = fftSize / 16 + const maximum = Math.min(MAX_FREQUENCY, sampleRate / 2) + for (const scaleMode of ['log', 'mel', 'linear']) { + for (const frequencyHz of [440, 5234.5, 15000]) { + configure({ sampleRate, fftSize, rowCount, scaleMode, clarityMode: 'sharper' }) + const result = spectrogram.process(createTone( + frequencyHz, + sampleRate, + fftSize + hopSize, + 0.01, + )) + const peakRow = findPeakRow(finalColumn(result)) + const expectedRow = ( + 1 - expectedNormalizedPosition(frequencyHz, scaleMode, MIN_FREQUENCY, maximum) + ) * (rowCount - 1) + assertAlmostEqual( + peakRow, + expectedRow, + 1, + `Sharper ${scaleMode} ${frequencyHz}Hz at ${sampleRate}Hz / FFT ${fftSize}`, + ) + } + } + } +}) + +test('spectrogram Sharper resolves quiet nearby detail without retaining the Classic blob', () => { + const sampleRate = 48000 + const fftSize = 4096 + const rowCount = 801 + const hopSize = fftSize / 16 + const strongFrequencyHz = 1000 + const quietFrequencyHz = 1120 + const strongOnly = [{ frequencyHz: strongFrequencyHz, amplitude: 1 }] + const withQuietDetail = [...strongOnly, { frequencyHz: quietFrequencyHz, amplitude: 0.01 }] + + const renderFinalColumn = (clarityMode, tones) => { + configure({ fftSize, sampleRate, rowCount, clarityMode }) + return finalColumn(spectrogram.process(createCompositeTone( + tones, + sampleRate, + fftSize + hopSize, + ))) + } + + const classicTone = renderFinalColumn('classic', strongOnly) + const sharperTone = renderFinalColumn('sharper', strongOnly) + const classicHalfHeightRows = classicTone.filter((value) => value >= Math.max(...classicTone) * 0.5).length + const sharperHalfHeightRows = sharperTone.filter((value) => value >= Math.max(...sharperTone) * 0.5).length + assert.ok(sharperHalfHeightRows <= 3, `expected a narrow Sharper line, got ${sharperHalfHeightRows} rows`) + assert.ok(sharperHalfHeightRows < classicHalfHeightRows) + + const detailed = renderFinalColumn('sharper', withQuietDetail) + const quietRow = ( + 1 - expectedNormalizedPosition(quietFrequencyHz, 'log', MIN_FREQUENCY, MAX_FREQUENCY) + ) * (rowCount - 1) + const quietDisplay = localPeak(detailed, quietRow) + const quietDbfs = decodeSharperDisplayDb(quietDisplay, SHARPER_GAMMA, -100, 0) + assertAlmostEqual(quietDbfs, -40, 2, 'quiet detail beside a full-scale tone') +}) + +test('spectrogram Focused restores the former sparse peak-isolation profile', () => { + const sampleRate = 48000 + const fftSize = 4096 + const rowCount = 801 + const hopSize = fftSize / 16 + let noiseState = 7 + const signal = Float32Array.from({ length: fftSize + hopSize }, (_, index) => { + noiseState = ((noiseState * 1664525) + 1013904223) >>> 0 + const noise = (((noiseState / 0x100000000) * 2) - 1) * 0.03 + return ( + (0.55 * Math.sin((2 * Math.PI * 220 * index) / sampleRate)) + + (0.3 * Math.sin((2 * Math.PI * 440.3 * index) / sampleRate)) + + (0.15 * Math.sin((2 * Math.PI * 997 * index) / sampleRate)) + + noise + ) + }) + + const render = (clarityMode) => { + configure({ fftSize, sampleRate, rowCount, clarityMode }) + return finalColumn(spectrogram.process(signal)) + } + const focused = render('focused') + const sharper = render('sharper') + const focusedActiveRows = Array.from(focused).filter((value) => value > 0.1).length + const sharperActiveRows = Array.from(sharper).filter((value) => value > 0.1).length + + assert.ok(Math.max(...focused) > 0.5, 'Focused should retain strong spectral peaks') + assert.ok( + focusedActiveRows < sharperActiveRows * 0.6, + `Focused should isolate peaks (${focusedActiveRows} active rows vs ${sharperActiveRows} in Sharper)`, + ) +}) + +test('spectrogram combines stereo energy without dropping anti-phase content', () => { + assert.equal(typeof spectrogram.processStereo, 'function') + const fftSize = 4096 + const minDecibels = -120 + const maxDecibels = 12 + const amplitude = 0.5 + const frequencyHz = 83 * 48000 / fftSize + const tone = createTone(frequencyHz, 48000, fftSize, amplitude) + const invertedTone = Float32Array.from(tone, (sample) => -sample) + const silence = new Float32Array(fftSize) + const expectedStereoDbfs = 20 * Math.log10(amplitude) + + for (const [label, left, right, expectedDbfs] of [ + ['centered', tone, tone, expectedStereoDbfs], + ['anti-phase', tone, invertedTone, expectedStereoDbfs], + ['left-only', tone, silence, expectedStereoDbfs - (20 * Math.log10(Math.sqrt(2)))], + ]) { + configure({ fftSize, rowCount: 1, minDecibels, maxDecibels }) + const result = spectrogram.processStereo(left, right) + const measuredDbfs = decodeClassicDisplayDb(result.display[0], minDecibels, maxDecibels) + assertAlmostEqual(measuredDbfs, expectedDbfs, 0.3, `${label} stereo level`) + } +}) + +test('spectrogram clamps its frequency mapping to Nyquist', () => { + const sampleRate = 32000 + const fftSize = 4096 + const rowCount = 401 + const frequencyHz = 15000 + const nyquist = sampleRate / 2 + configure({ sampleRate, fftSize, rowCount, maxFrequency: MAX_FREQUENCY, scaleMode: 'log' }) + + const result = spectrogram.process(createTone(frequencyHz, sampleRate, fftSize, 0.01)) + const peakRow = findPeakRow(result.display) + const expectedRow = ( + 1 - expectedNormalizedPosition(frequencyHz, 'log', MIN_FREQUENCY, nyquist) + ) * (rowCount - 1) + + assertAlmostEqual(peakRow, expectedRow, 1, 'Nyquist-clamped row') +}) + +test('spectrogram scroll speeds produce the expected analysis hop counts through x8', () => { + const fftSize = 1024 + const extraSamples = 512 + for (const scrollSpeed of [1, 2, 4, 8]) { + configure({ fftSize, rowCount: 8, scrollSpeed }) + const result = spectrogram.process(new Float32Array(fftSize + extraSamples)) + const hopSize = fftSize / Math.round(8 * scrollSpeed) + const expectedColumns = 1 + Math.floor(extraSamples / hopSize) + assert.equal(result.columnCount, expectedColumns, `x${scrollSpeed} column count`) + assert.equal(result.display.length, expectedColumns * 8) + assert.equal(result.heat.length, expectedColumns * 8) + } +})