mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-18 19:44:16 +02:00
make Spectrum and Spectrogram more frequency accurate and detail preserving
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -18,10 +18,10 @@ Prism taps into your system audio and runs it through a rack of real-time scopes
|
||||
|
||||
Seven visualizers driven by a native C++ analysis engine:
|
||||
|
||||
- **Spectrum Analyzer** — FFT frequency display with heatmap and fill modes, configurable FFT size, spectral tilt, and log or linear scaling
|
||||
- **Spectrum Analyzer** — FFT frequency display with heatmap and fill modes, configurable FFT size, spectral tilt, Log/Mel/Linear scaling, and Extended (default, 10 Hz–up to 24 kHz) or Audible (20 Hz–20 kHz) ranges
|
||||
- **Oscilloscope** — Time-domain waveform with a pitch-lock mode that syncs the display to the fundamental frequency
|
||||
- **Vectorscope** — Stereo phase visualization in five display modes (Lissajous, polar, linear) with optional multiband RGB split
|
||||
- **Spectrogram** — Scrolling frequency-over-time display with mel, log, and linear scale modes
|
||||
- **Spectrogram** — Scrolling frequency-over-time display with Log/Mel/Linear scales, Extended (default) or Audible ranges, optional adaptive frequency guides, stereo-energy analysis, a legacy-style Focused mode, and detail-preserving frequency reassignment in Sharp/Sharper modes
|
||||
- **VU Meter** — Classic loudness metering in needle or bar style, horizontal or vertical
|
||||
- **Loudness Meter** — Compact LUFS metering following ITU-R BS.1770 with fast stereo peak activity
|
||||
- **Waveform** — Scrolling time-domain view with mono, stereo, and multiband modes
|
||||
|
||||
+34
-13
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }[] {
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 Hz–up to 24 kHz)</option>
|
||||
<option value="audible">Audible (20 Hz–20 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 Hz–up to 24 kHz)</option>
|
||||
<option value="audible">Audible (20 Hz–20 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 = []
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user