make Spectrum and Spectrogram more frequency accurate and detail preserving

This commit is contained in:
Boof2015
2026-08-16 16:43:31 -04:00
parent f6d9719c43
commit 3b302d5526
29 changed files with 1715 additions and 242 deletions
+3
View File
@@ -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
+2 -2
View File
@@ -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 Hzup to 24 kHz) or Audible (20 Hz20 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
+34 -13
View File
@@ -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<Napi::String>().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<double>(result.columnCount)));
obj.Set("rowCount", Napi::Number::New(env, static_cast<double>(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<Napi::Float32Array>();
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<double>(result.columnCount)));
obj.Set("rowCount", Napi::Number::New(env, static_cast<double>(result.rowCount)));
return obj;
Napi::Float32Array left = info[0].As<Napi::Float32Array>();
Napi::Float32Array right = info[1].As<Napi::Float32Array>();
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);
+298 -86
View File
@@ -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<float>(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<DSP::FFT>(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<float>(0.0f, 0.0f));
rightFftOutput_.assign(paddedSize_, std::complex<float>(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<float>(M_PI) * index) / (fftSize_ - 1)));
}
float windowSum = 0.0f;
for (const float coefficient : window_) {
windowSum += coefficient;
}
magnitudeScale_ = windowSum > std::numeric_limits<float>::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<float>(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<float>(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<size_t>(std::ceil(lo));
const size_t lastWholeBin = static_cast<size_t>(std::floor(hi));
for (size_t bin = firstWholeBin; bin <= lastWholeBin && bin < magnitudesDb_.size(); bin += 1) {
consider(static_cast<float>(bin));
}
return peakDb;
}
void SpectrogramAnalyzer::computeStandardSpectrum() {
const size_t rowCount = config_.rowCount;
const float binWidth = std::max(1.0f, config_.sampleRate) / static_cast<float>(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<float>(paddedSize_);
const float hopDt = static_cast<float>(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<float>(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<float>(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<size_t>(std::floor(std::clamp(rowF, 0.0f, static_cast<float>(config_.rowCount - 1))));
const float frac = rowF - static_cast<float>(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<float>(paddedSize_);
const float hopDt = static_cast<float>(resolveHopSize()) / sampleRate;
const float ampThreshold = std::pow(10.0f, config_.minDecibels / 20.0f);
const float twoPi = static_cast<float>(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<float>(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<float>(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<size_t>(std::floor(std::clamp(rowF, 0.0f, static_cast<float>(config_.rowCount - 1))));
if (focusedFrequency < config_.minFrequency || focusedFrequency > config_.maxFrequency) {
continue;
}
const float rowF = frequencyToRow(focusedFrequency);
const size_t row0 = static_cast<size_t>(std::floor(
std::clamp(rowF, 0.0f, static_cast<float>(config_.rowCount - 1))
));
const float frac = rowF - static_cast<float>(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<float>& display, std::vector<float>& heat) {
void SpectrogramAnalyzer::shapeColumn(std::vector<float>& display, std::vector<float>& 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<float> peakSource = blendedRaw_;
const float mainlobePaddedBins = 4.0f * static_cast<float>(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<int>(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<size_t>(offset)) {
localMax = std::max(localMax, peakSource[row - static_cast<size_t>(offset)]);
}
if (row + static_cast<size_t>(offset) < rowCount) {
localMax = std::max(localMax, peakSource[row + static_cast<size_t>(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<float>& display, std::vector<float>& 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<float> peakSource = sourceRaw_;
const float mainlobePaddedBins = 4.0f * static_cast<float>(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<int>(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<size_t>(offset)) {
localMax = std::max(localMax, peakSource[row - static_cast<size_t>(offset)]);
}
if (row + static_cast<size_t>(offset) < rowCount) {
localMax = std::max(localMax, peakSource[row + static_cast<size_t>(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<float>& display, std::
void SpectrogramAnalyzer::processFrame(std::vector<float>& display, std::vector<float>& 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<float>(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;
}
+19 -5
View File
@@ -1,6 +1,7 @@
#pragma once
#include "dsp_utils.h"
#include <cstdint>
#include <complex>
#include <memory>
#include <string>
@@ -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<DSP::FFT> fft_;
std::vector<float> frameBuffer_;
std::vector<float> rightFrameBuffer_;
std::vector<float> window_;
std::vector<float> windowedInput_;
std::vector<float> rightWindowedInput_;
std::vector<std::complex<float>> fftOutput_;
std::vector<std::complex<float>> rightFftOutput_;
std::vector<float> magnitudesDb_;
std::vector<float> magnitudesLinear_;
std::vector<float> phases_;
std::vector<float> lastPhases_;
std::vector<float> rightPhases_;
std::vector<float> rightLastPhases_;
std::vector<uint8_t> dominantRight_;
std::vector<float> rowCenterBins_;
std::vector<float> rowBandStartBins_;
@@ -69,8 +79,9 @@ private:
std::vector<float> standardRaw_;
std::vector<float> standardHeat_;
std::vector<float> reassignedPower_;
std::vector<float> blendedRaw_;
std::vector<float> blendedHeat_;
std::vector<float> focusedPower_;
std::vector<float> sourceRaw_;
std::vector<float> sourceHeat_;
std::vector<float> shapedDisplay_;
std::vector<float> shapedHeat_;
std::vector<float> strokedDisplay_;
@@ -81,9 +92,12 @@ private:
void processFrame(std::vector<float>& display, std::vector<float>& heat);
void computeStandardSpectrum();
void computeReassignedSpectrum();
void blendAndShapeColumn(std::vector<float>& display, std::vector<float>& heat);
void computeFocusedSpectrum();
void shapeColumn(std::vector<float>& display, std::vector<float>& heat);
void shapeFocusedColumn(std::vector<float>& display, std::vector<float>& 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;
+1
View File
@@ -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",
+2 -8
View File
@@ -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<float> mono;
std::vector<float> pendingDisplay, pendingHeat;
size_t pendingColumns = 0;
};
+7
View File
@@ -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,
}
+5
View File
@@ -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,
+20 -16
View File
@@ -93,7 +93,7 @@ type ScopeRingMap = {
spectrum: FixedChunkRing<StereoChunkRecord>
oscilloscope: FixedChunkRing<MonoChunkRecord>
vectorscope: FixedChunkRing<StereoChunkRecord>
spectrogram: FixedChunkRing<MonoChunkRecord>
spectrogram: FixedChunkRing<StereoChunkRecord>
vumeter: FixedChunkRing<StereoChunkRecord>
lufsmeter: FixedChunkRing<StereoChunkRecord>
waveform: FixedChunkRing<StereoChunkRecord>
@@ -215,7 +215,7 @@ export class AudioRouter {
spectrum: new FixedChunkRing<StereoChunkRecord>(SCOPE_RING_CAPACITY.spectrum),
oscilloscope: new FixedChunkRing<MonoChunkRecord>(SCOPE_RING_CAPACITY.oscilloscope),
vectorscope: new FixedChunkRing<StereoChunkRecord>(SCOPE_RING_CAPACITY.vectorscope),
spectrogram: new FixedChunkRing<MonoChunkRecord>(SCOPE_RING_CAPACITY.spectrogram),
spectrogram: new FixedChunkRing<StereoChunkRecord>(SCOPE_RING_CAPACITY.spectrogram),
vumeter: new FixedChunkRing<StereoChunkRecord>(SCOPE_RING_CAPACITY.vumeter),
lufsmeter: new FixedChunkRing<StereoChunkRecord>(SCOPE_RING_CAPACITY.lufsmeter),
waveform: new FixedChunkRing<StereoChunkRecord>(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 }[] {
+8
View File
@@ -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()
},
+1
View File
@@ -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;
}
+11
View File
@@ -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,
}
@@ -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':
@@ -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({
))}
</SelectControl>
<SelectControl
label="Scale"
value={current.scaleMode}
onChange={(value) => onUpdate('spectrum', {
scaleMode: value as ScopeSettings['spectrum']['scaleMode'],
})}
>
<option value="log">Log</option>
<option value="mel">Mel</option>
<option value="linear">Linear</option>
</SelectControl>
<SelectControl
label="Range"
value={current.frequencyRangeMode}
onChange={(value) => onUpdate('spectrum', {
frequencyRangeMode: value as ScopeSettings['spectrum']['frequencyRangeMode'],
})}
>
<option value="extended">Extended (10 Hzup to 24 kHz)</option>
<option value="audible">Audible (20 Hz20 kHz)</option>
</SelectControl>
<SelectControl
label="Peak"
value={current.peakInfoMode}
@@ -539,10 +565,22 @@ export default function ScopeSettingsSection({
onChange={(value) => onUpdate('spectrogram', { clarityMode: value as ScopeSettings['spectrogram']['clarityMode'] })}
>
<option value="classic">Classic</option>
<option value="focused">Focused</option>
<option value="sharp">Sharp</option>
<option value="sharper">Sharper</option>
</SelectControl>
<SelectControl
label="Range"
value={current.frequencyRangeMode}
onChange={(value) => onUpdate('spectrogram', {
frequencyRangeMode: value as ScopeSettings['spectrogram']['frequencyRangeMode'],
})}
>
<option value="extended">Extended (10 Hzup to 24 kHz)</option>
<option value="audible">Audible (20 Hz20 kHz)</option>
</SelectControl>
<SelectControl
label="Color"
value={current.colorScheme}
@@ -552,12 +590,20 @@ export default function ScopeSettingsSection({
<option value="mono">Solid</option>
</SelectControl>
<ToggleGroup label="Overlay">
<ToggleChip
label="Frequency Grid"
active={current.showGrid}
onClick={() => onUpdate('spectrogram', { showGrid: !current.showGrid })}
/>
</ToggleGroup>
<RangeControl
label="Speed"
value={current.scrollSpeed}
valueLabel={`x${current.scrollSpeed.toFixed(0)}`}
min={1}
max={8}
max={MAX_SPECTROGRAM_SCROLL_SPEED}
step={1}
fullWidth={false}
onChange={(value) => onUpdate('spectrogram', { scrollSpeed: value })}
@@ -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 = []
+9 -55
View File
@@ -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,
+94 -6
View File
@@ -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<Omit<SpectrogramOptions, 'dataSource'
const defaultOptions: ResolvedSpectrogramOptions = {
fftSize: 4096,
tiltDbPerOctave: DEFAULT_SPECTROGRAM_TILT_DB_PER_OCTAVE,
minFrequency: 20,
maxFrequency: 20000,
minFrequency: 10,
maxFrequency: 24000,
minDecibels: -90,
maxDecibels: -12,
scrollSpeed: DEFAULT_SPECTROGRAM_SCROLL_SPEED,
@@ -80,10 +88,14 @@ const defaultOptions: ResolvedSpectrogramOptions = {
lineColor: '#38bdf8',
heatColors: ['rgb(15, 7, 33)', 'rgb(163, 26, 121)', 'rgb(255, 241, 209)'],
backgroundColor: 'transparent',
showGrid: true,
gridColor: 'rgba(255, 255, 255, 0.12)',
labelColor: 'rgba(255, 255, 255, 0.4)',
}
const defaultSpectrogramDataSource: SpectrogramDataSource = {
getPendingSpectrogramSamples: () => audioRouter.flushPendingSpectrogramSamples(),
getPendingSpectrogramStereoSamples: () => audioRouter.flushPendingSpectrogramStereoSamples(),
...defaultVisualizerSessionSource,
}
@@ -122,6 +134,9 @@ function resolveOptions(base: ResolvedSpectrogramOptions, overrides: Partial<Spe
lineColor: overrides.lineColor ?? base.lineColor,
heatColors: overrides.heatColors ?? base.heatColors,
backgroundColor: overrides.backgroundColor ?? base.backgroundColor,
showGrid: overrides.showGrid ?? base.showGrid,
gridColor: overrides.gridColor ?? base.gridColor,
labelColor: overrides.labelColor ?? base.labelColor,
}
}
@@ -472,7 +487,12 @@ export class Spectrogram {
return result.display.length >= 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)
}
+25 -26
View File
@@ -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)
}
}
}
+21 -1
View File
@@ -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
),
+6
View File
@@ -131,6 +131,8 @@ const SPECTROGRAM_SCHEMA = {
heat_low: 'heatLow',
heat_mid: 'heatMid',
heat_high: 'heatHigh',
guides: 'guides',
labels: 'labels',
} as const satisfies SectionSchema<ThemeSpectrogramTokens>
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<Record<string, string | undefined>>))
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,
+226
View File
@@ -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')
}
+12 -2
View File
@@ -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 },
+14 -10
View File
@@ -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 {
+4
View File
@@ -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 {
+30
View File
@@ -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')
+72
View File
@@ -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()
+351 -8
View File
@@ -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<Window, 'requestAnimationFrame' | 'cancelAnimationFrame'>
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<FrequencyScaleMode, number>([
['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()
+384
View File
@@ -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)
}
})