BS.1770 true-peak

This commit is contained in:
Boof2015
2026-08-18 02:11:46 -04:00
parent 741de975bb
commit eda0fbef51
16 changed files with 694 additions and 71 deletions
+3
View File
@@ -193,6 +193,9 @@ jobs:
- name: Test native vectorscope calibration - name: Test native vectorscope calibration
run: npm run test:vectorscope-native run: npm run test:vectorscope-native
- name: Test native BS.1770 true-peak calibration
run: npm run test:lufsmeter-native
- name: Build and test Prism TUI - name: Build and test Prism TUI
run: npm run test:tui run: npm run test:tui
+3
View File
@@ -127,6 +127,9 @@ jobs:
- name: Test native vectorscope calibration - name: Test native vectorscope calibration
run: npm run test:vectorscope-native run: npm run test:vectorscope-native
- name: Test native BS.1770 true-peak calibration
run: npm run test:lufsmeter-native
- name: Build and test Prism TUI - name: Build and test Prism TUI
run: npm run test:tui run: npm run test:tui
+10 -2
View File
@@ -27,7 +27,7 @@ Prism includes seven real-time scopes and meters:
* **Vectorscope** — Full-band stereo phase analysis with XY, Polar, and M/S Linear views, calibrated references, adjustable zoom, and optional multiband RGB * **Vectorscope** — Full-band stereo phase analysis with XY, Polar, and M/S Linear views, calibrated references, adjustable zoom, and optional multiband RGB
* **Spectrogram** — Scrolling frequency-over-time display with Log/Mel/Linear scales, stereo-energy analysis, and frequency reassignment in Sharp and Sharper modes * **Spectrogram** — Scrolling frequency-over-time display with Log/Mel/Linear scales, stereo-energy analysis, and frequency reassignment in Sharp and Sharper modes
* **VU Meter** — 300 ms metering with adjustable 0 VU reference, stereo correlation, and needle or bar displays * **VU Meter** — 300 ms metering with adjustable 0 VU reference, stereo correlation, and needle or bar displays
* **Loudness Meter** — ITU-R BS.1770 momentary, short-term, and integrated LUFS metering with stereo peak activity * **Loudness Meter** — ITU-R BS.1770 momentary, short-term, and integrated LUFS metering with BS.1770 true-peak activity
* **Waveform** — Scrolling mono or stereo waveform with transient-preserving min/max sampling and optional multiband energy visualization * **Waveform** — Scrolling mono or stereo waveform with transient-preserving min/max sampling and optional multiband energy visualization
Every scope can be configured independently. Resize and rearrange them into a rack, rotate supported scopes, pop them into separate windows, or pin them on top of other applications. Every scope can be configured independently. Resize and rearrange them into a rack, rotate supported scopes, pop them into separate windows, or pin them on top of other applications.
@@ -98,7 +98,8 @@ Most of Prism's analysis runs in native C++, with the same DSP implementations r
* −70 LUFS absolute gate * −70 LUFS absolute gate
* Relative gate at −10 LU below ungated programme loudness * Relative gate at −10 LU below ungated programme loudness
* Momentary, short-term, and integrated LUFS are calculated independently * Momentary, short-term, and integrated LUFS are calculated independently
* Peak readouts are sample peak, not true peak * Stereo true-peak reconstruction uses a rate-aware 48-tap-per-phase FIR at 2× to 8× oversampling for common 44.1–192 kHz host rates
* Held L/R true-peak markers and a reset-scoped combined maximum are reported in dBTP
### VU and correlation ### VU and correlation
@@ -277,12 +278,19 @@ npm run build # Build application assets
npm run configure:tui # Configure the standalone CMake project npm run configure:tui # Configure the standalone CMake project
npm run build:tui # Build prism-tui npm run build:tui # Build prism-tui
npm run test:tui # Build and run native TUI tests npm run test:tui # Build and run native TUI tests
npm run test:lufsmeter-native # Run generated BS.1770/EBU true-peak vectors
npm run dist # Package for current platform npm run dist # Package for current platform
npm run dist:mac # macOS npm run dist:mac # macOS
npm run dist:win # Windows npm run dist:win # Windows
npm run dist:linux # Linux npm run dist:linux # Linux
``` ```
If you have downloaded and extracted the official [EBU Loudness Test Set](https://tech.ebu.ch/publications/ebu_loudness_test_set) for internal testing, run cases 15–23 without copying the WAVs into the repository. The files are not downloaded or redistributed by Prism, in accordance with the [EBU test-sequence usage terms](https://tech.ebu.ch/files/live/sites/tech/files/shared/testmaterial/use%20of%20EBU%20AUDIO%20test%20sequences.pdf).
```bash
npm run test:lufsmeter-ebu -- /path/to/extracted-ebu-loudness-test-set
```
The TUI build downloads the pinned FTXUI source through CMake. Linux also requires the PulseAudio development package. The TUI build downloads the pinned FTXUI source through CMake. Linux also requires the PulseAudio development package.
The DAW plugins build with CMake from the [`plugin/`](plugin/) directory. See [`plugin/README.md`](plugin/README.md) for per-platform build and installation details. The DAW plugins build with CMake from the [`plugin/`](plugin/) directory. See [`plugin/README.md`](plugin/README.md) for per-platform build and installation details.
+142 -28
View File
@@ -31,10 +31,37 @@ constexpr double RLB_FILTER_F0_HZ = 38.13547087613982;
constexpr double RLB_FILTER_Q = 0.5003270373223665; constexpr double RLB_FILTER_Q = 0.5003270373223665;
constexpr double VU_INTEGRATION_WINDOW_MS = 300.0; constexpr double VU_INTEGRATION_WINDOW_MS = 300.0;
constexpr double VU_PEAK_HOLD_MS = 750.0; constexpr double TRUE_PEAK_HOLD_MS = 750.0;
constexpr double VU_PEAK_DECAY_DB_PER_SECOND = 18.0; constexpr double TRUE_PEAK_DECAY_DB_PER_SECOND = 18.0;
constexpr double BAR_ATTACK_MS = 5.0; constexpr double BAR_ATTACK_MS = 5.0;
constexpr double BAR_RELEASE_MS = 180.0; constexpr double BAR_RELEASE_MS = 180.0;
constexpr double TRUE_PEAK_MIN_OUTPUT_RATE_HZ = 320000.0;
constexpr size_t TRUE_PEAK_TAPS_PER_PHASE = 48;
constexpr size_t TRUE_PEAK_MAX_OVERSAMPLE_FACTOR = 64;
constexpr double TRUE_PEAK_KAISER_BETA = 8.6;
double besselI0(double value) {
const double absoluteValue = std::abs(value);
if (absoluteValue < 3.75) {
const double ratio = value / 3.75;
const double squared = ratio * ratio;
return 1.0 + squared * (3.5156229 + squared * (3.0899424 + squared * (1.2067492
+ squared * (0.2659732 + squared * (0.0360768 + squared * 0.0045813)))));
}
const double ratio = 3.75 / absoluteValue;
return (std::exp(absoluteValue) / std::sqrt(absoluteValue))
* (0.39894228 + ratio * (0.01328592 + ratio * (0.00225319 + ratio * (-0.00157565
+ ratio * (0.00916281 + ratio * (-0.02057706 + ratio * (0.02635537
+ ratio * (-0.01647633 + ratio * 0.00392377))))))));
}
double normalizedSinc(double value) {
if (std::abs(value) < 1e-12) {
return 1.0;
}
return std::sin(PI * value) / (PI * value);
}
LUFSMeterSnapshot makeInitialSnapshot() { LUFSMeterSnapshot makeInitialSnapshot() {
return { return {
@@ -47,6 +74,7 @@ LUFSMeterSnapshot makeInitialSnapshot() {
static_cast<float>(VU_METER_MIN_DB), static_cast<float>(VU_METER_MIN_DB),
static_cast<float>(VU_METER_MIN_DB), static_cast<float>(VU_METER_MIN_DB),
static_cast<float>(VU_METER_MIN_DB), static_cast<float>(VU_METER_MIN_DB),
static_cast<float>(VU_METER_MIN_DB),
0.0f, 0.0f,
}; };
} }
@@ -82,6 +110,7 @@ void LUFSMeterAnalyzer::configureForSampleRate(float sampleRate) {
integratedHistogramPowerSums_.assign(INTEGRATED_HISTOGRAM_BIN_COUNT, 0.0); integratedHistogramPowerSums_.assign(INTEGRATED_HISTOGRAM_BIN_COUNT, 0.0);
configureFastMeter(); configureFastMeter();
configureTruePeakDetector();
reset(); reset();
} }
@@ -102,6 +131,44 @@ void LUFSMeterAnalyzer::configureFastMeter() {
barReleaseCoeff_ = std::exp(-1.0 / (static_cast<double>(sampleRate_) * (BAR_RELEASE_MS / 1000.0))); barReleaseCoeff_ = std::exp(-1.0 / (static_cast<double>(sampleRate_) * (BAR_RELEASE_MS / 1000.0)));
} }
void LUFSMeterAnalyzer::configureTruePeakDetector() {
truePeakOversampleFactor_ = 1;
while (static_cast<double>(sampleRate_) * truePeakOversampleFactor_ < TRUE_PEAK_MIN_OUTPUT_RATE_HZ
&& truePeakOversampleFactor_ < TRUE_PEAK_MAX_OVERSAMPLE_FACTOR) {
truePeakOversampleFactor_ *= 2;
}
truePeakCoefficients_.assign(truePeakOversampleFactor_ * TRUE_PEAK_TAPS_PER_PHASE, 0.0);
truePeakHistoryL_.assign(TRUE_PEAK_TAPS_PER_PHASE * 2, 0.0);
truePeakHistoryR_.assign(TRUE_PEAK_TAPS_PER_PHASE * 2, 0.0);
const double windowRadius = static_cast<double>(TRUE_PEAK_TAPS_PER_PHASE) / 2.0;
const double inverseI0Beta = 1.0 / besselI0(TRUE_PEAK_KAISER_BETA);
// Each phase is a causal fractional-delay reconstruction. The 24-sample
// delay keeps the 48 source taps centered without requiring future input.
for (size_t phase = 0; phase < truePeakOversampleFactor_; phase += 1) {
const double fraction = static_cast<double>(phase) / truePeakOversampleFactor_;
double coefficientSum = 0.0;
for (size_t tap = 0; tap < TRUE_PEAK_TAPS_PER_PHASE; tap += 1) {
const double distance = static_cast<double>(tap) - windowRadius + fraction;
const double normalizedDistance = distance / windowRadius;
const double windowArgument = std::sqrt(std::max(0.0, 1.0 - normalizedDistance * normalizedDistance));
const double window = std::abs(normalizedDistance) <= 1.0
? besselI0(TRUE_PEAK_KAISER_BETA * windowArgument) * inverseI0Beta
: 0.0;
const double coefficient = normalizedSinc(distance) * window;
truePeakCoefficients_[phase * TRUE_PEAK_TAPS_PER_PHASE + tap] = coefficient;
coefficientSum += coefficient;
}
if (std::abs(coefficientSum) > 1e-12) {
for (size_t tap = 0; tap < TRUE_PEAK_TAPS_PER_PHASE; tap += 1) {
truePeakCoefficients_[phase * TRUE_PEAK_TAPS_PER_PHASE + tap] /= coefficientSum;
}
}
}
}
void LUFSMeterAnalyzer::reset() { void LUFSMeterAnalyzer::reset() {
std::fill(ringBufferL_.begin(), ringBufferL_.end(), 0.0); std::fill(ringBufferL_.begin(), ringBufferL_.end(), 0.0);
std::fill(ringBufferR_.begin(), ringBufferR_.end(), 0.0); std::fill(ringBufferR_.begin(), ringBufferR_.end(), 0.0);
@@ -131,6 +198,10 @@ void LUFSMeterAnalyzer::reset() {
lastPeakUpdateMs_ = 0.0; lastPeakUpdateMs_ = 0.0;
hasLastPeakUpdate_ = false; hasLastPeakUpdate_ = false;
std::fill(truePeakHistoryL_.begin(), truePeakHistoryL_.end(), 0.0);
std::fill(truePeakHistoryR_.begin(), truePeakHistoryR_.end(), 0.0);
truePeakHistoryWriteIndex_ = 0;
snapshot_ = makeInitialSnapshot(); snapshot_ = makeInitialSnapshot();
} }
@@ -140,27 +211,35 @@ void LUFSMeterAnalyzer::pushSamples(const float* leftChannel, const float* right
} }
const double nowMs = currentTimeMs(); const double nowMs = currentTimeMs();
advancePeaks(nowMs); advanceTruePeaks(nowMs);
double maxPeakL = 0.0; double maxTruePeakL = 0.0;
double maxPeakR = 0.0; double maxTruePeakR = 0.0;
for (size_t index = 0; index < length; index += 1) { for (size_t index = 0; index < length; index += 1) {
const float left = leftChannel[index]; const float left = leftChannel[index];
const float right = rightChannel[index]; const float right = rightChannel[index];
processLoudnessSample(left, right); processLoudnessSample(left, right);
processFastMeterSample(left, right, maxPeakL, maxPeakR); processFastMeterSample(left, right);
processTruePeakSample(left, right, maxTruePeakL, maxTruePeakR);
} }
maybeUpdatePeak(amplitudeToDb(maxPeakL), nowMs, true); const double truePeakLDb = truePeakAmplitudeToDb(maxTruePeakL);
maybeUpdatePeak(amplitudeToDb(maxPeakR), nowMs, false); const double truePeakRDb = truePeakAmplitudeToDb(maxTruePeakR);
maybeUpdateTruePeak(truePeakLDb, nowMs, true);
maybeUpdateTruePeak(truePeakRDb, nowMs, false);
snapshot_.maxTruePeakDb = static_cast<float>(std::max({
static_cast<double>(snapshot_.maxTruePeakDb),
truePeakLDb,
truePeakRDb,
}));
recomputeFastSnapshot(); recomputeFastSnapshot();
updateMomentaryShortTermLoudness(); updateMomentaryShortTermLoudness();
snapshot_.integratedLUFS = static_cast<float>(computeGatedIntegratedLoudness()); snapshot_.integratedLUFS = static_cast<float>(computeGatedIntegratedLoudness());
} }
LUFSMeterSnapshot LUFSMeterAnalyzer::getSnapshot() { LUFSMeterSnapshot LUFSMeterAnalyzer::getSnapshot() {
advancePeaks(currentTimeMs()); advanceTruePeaks(currentTimeMs());
recomputeFastSnapshot(); recomputeFastSnapshot();
return snapshot_; return snapshot_;
} }
@@ -303,7 +382,7 @@ double LUFSMeterAnalyzer::applyBiquad(const BiquadCoeffs& coeffs, BiquadState& s
return output; return output;
} }
void LUFSMeterAnalyzer::processFastMeterSample(float left, float right, double& maxPeakL, double& maxPeakR) { void LUFSMeterAnalyzer::processFastMeterSample(float left, float right) {
if (fastSqL_.empty()) { if (fastSqL_.empty()) {
return; return;
} }
@@ -334,16 +413,44 @@ void LUFSMeterAnalyzer::processFastMeterSample(float left, float right, double&
const double coeffR = absR > barEnvelopeR_ ? barAttackCoeff_ : barReleaseCoeff_; const double coeffR = absR > barEnvelopeR_ ? barAttackCoeff_ : barReleaseCoeff_;
barEnvelopeL_ = coeffL * barEnvelopeL_ + (1.0 - coeffL) * absL; barEnvelopeL_ = coeffL * barEnvelopeL_ + (1.0 - coeffL) * absL;
barEnvelopeR_ = coeffR * barEnvelopeR_ + (1.0 - coeffR) * absR; barEnvelopeR_ = coeffR * barEnvelopeR_ + (1.0 - coeffR) * absR;
if (absL > maxPeakL) {
maxPeakL = absL;
}
if (absR > maxPeakR) {
maxPeakR = absR;
}
} }
void LUFSMeterAnalyzer::advancePeaks(double nowMs) { void LUFSMeterAnalyzer::processTruePeakSample(
float left,
float right,
double& maxTruePeakL,
double& maxTruePeakR
) {
if (truePeakHistoryL_.empty() || truePeakCoefficients_.empty()) {
return;
}
const double leftSample = static_cast<double>(left);
const double rightSample = static_cast<double>(right);
truePeakHistoryL_[truePeakHistoryWriteIndex_] = leftSample;
truePeakHistoryL_[truePeakHistoryWriteIndex_ + TRUE_PEAK_TAPS_PER_PHASE] = leftSample;
truePeakHistoryR_[truePeakHistoryWriteIndex_] = rightSample;
truePeakHistoryR_[truePeakHistoryWriteIndex_ + TRUE_PEAK_TAPS_PER_PHASE] = rightSample;
for (size_t phase = 0; phase < truePeakOversampleFactor_; phase += 1) {
const size_t coefficientOffset = phase * TRUE_PEAK_TAPS_PER_PHASE;
double reconstructedL = 0.0;
double reconstructedR = 0.0;
const size_t newestHistoryIndex = truePeakHistoryWriteIndex_ + TRUE_PEAK_TAPS_PER_PHASE;
for (size_t tap = 0; tap < TRUE_PEAK_TAPS_PER_PHASE; tap += 1) {
const size_t historyIndex = newestHistoryIndex - tap;
const double coefficient = truePeakCoefficients_[coefficientOffset + tap];
reconstructedL += coefficient * truePeakHistoryL_[historyIndex];
reconstructedR += coefficient * truePeakHistoryR_[historyIndex];
}
maxTruePeakL = std::max(maxTruePeakL, std::abs(reconstructedL));
maxTruePeakR = std::max(maxTruePeakR, std::abs(reconstructedR));
}
truePeakHistoryWriteIndex_ = (truePeakHistoryWriteIndex_ + 1) % TRUE_PEAK_TAPS_PER_PHASE;
}
void LUFSMeterAnalyzer::advanceTruePeaks(double nowMs) {
if (!std::isfinite(nowMs)) { if (!std::isfinite(nowMs)) {
return; return;
} }
@@ -358,23 +465,23 @@ void LUFSMeterAnalyzer::advancePeaks(double nowMs) {
return; return;
} }
snapshot_.peakLDb = static_cast<float>(applyPeakDecay(snapshot_.peakLDb, peakHoldUntilL_, nowMs)); snapshot_.truePeakLDb = static_cast<float>(applyPeakDecay(snapshot_.truePeakLDb, peakHoldUntilL_, nowMs));
snapshot_.peakRDb = static_cast<float>(applyPeakDecay(snapshot_.peakRDb, peakHoldUntilR_, nowMs)); snapshot_.truePeakRDb = static_cast<float>(applyPeakDecay(snapshot_.truePeakRDb, peakHoldUntilR_, nowMs));
lastPeakUpdateMs_ = nowMs; lastPeakUpdateMs_ = nowMs;
} }
void LUFSMeterAnalyzer::maybeUpdatePeak(double peakDb, double nowMs, bool leftChannel) { void LUFSMeterAnalyzer::maybeUpdateTruePeak(double peakDb, double nowMs, bool leftChannel) {
if (leftChannel) { if (leftChannel) {
if (peakDb > snapshot_.peakLDb) { if (peakDb > snapshot_.truePeakLDb) {
snapshot_.peakLDb = static_cast<float>(peakDb); snapshot_.truePeakLDb = static_cast<float>(peakDb);
peakHoldUntilL_ = nowMs + VU_PEAK_HOLD_MS; peakHoldUntilL_ = nowMs + TRUE_PEAK_HOLD_MS;
} }
return; return;
} }
if (peakDb > snapshot_.peakRDb) { if (peakDb > snapshot_.truePeakRDb) {
snapshot_.peakRDb = static_cast<float>(peakDb); snapshot_.truePeakRDb = static_cast<float>(peakDb);
peakHoldUntilR_ = nowMs + VU_PEAK_HOLD_MS; peakHoldUntilR_ = nowMs + TRUE_PEAK_HOLD_MS;
} }
} }
@@ -384,7 +491,7 @@ double LUFSMeterAnalyzer::applyPeakDecay(double currentDb, double holdUntilMs, d
return currentDb; return currentDb;
} }
const double decayAmount = ((nowMs - decayStartMs) / 1000.0) * VU_PEAK_DECAY_DB_PER_SECOND; const double decayAmount = ((nowMs - decayStartMs) / 1000.0) * TRUE_PEAK_DECAY_DB_PER_SECOND;
return std::max(VU_METER_MIN_DB, currentDb - decayAmount); return std::max(VU_METER_MIN_DB, currentDb - decayAmount);
} }
@@ -424,6 +531,13 @@ double LUFSMeterAnalyzer::amplitudeToDb(double amplitude) {
return clampDb(20.0 * std::log10(std::max(amplitude, 1e-10)), VU_METER_MIN_DB, VU_METER_MAX_DB); return clampDb(20.0 * std::log10(std::max(amplitude, 1e-10)), VU_METER_MIN_DB, VU_METER_MAX_DB);
} }
double LUFSMeterAnalyzer::truePeakAmplitudeToDb(double amplitude) {
if (!std::isfinite(amplitude) || amplitude <= 0.0) {
return VU_METER_MIN_DB;
}
return std::max(VU_METER_MIN_DB, 20.0 * std::log10(std::max(amplitude, 1e-10)));
}
double LUFSMeterAnalyzer::clampDb(double db, double minDb, double maxDb) { double LUFSMeterAnalyzer::clampDb(double db, double minDb, double maxDb) {
return std::max(minDb, std::min(maxDb, db)); return std::max(minDb, std::min(maxDb, db));
} }
+15 -5
View File
@@ -14,8 +14,9 @@ struct LUFSMeterSnapshot {
float vuRDb; float vuRDb;
float barLDb; float barLDb;
float barRDb; float barRDb;
float peakLDb; float truePeakLDb;
float peakRDb; float truePeakRDb;
float maxTruePeakDb;
float correlation; float correlation;
}; };
@@ -47,6 +48,7 @@ private:
void configureForSampleRate(float sampleRate); void configureForSampleRate(float sampleRate);
void configureKWeighting(); void configureKWeighting();
void configureFastMeter(); void configureFastMeter();
void configureTruePeakDetector();
void processLoudnessSample(float left, float right); void processLoudnessSample(float left, float right);
void updateMomentaryShortTermLoudness(); void updateMomentaryShortTermLoudness();
double computeGatedIntegratedLoudness() const; double computeGatedIntegratedLoudness() const;
@@ -54,13 +56,15 @@ private:
double histogramLufsAtIndex(size_t index) const; double histogramLufsAtIndex(size_t index) const;
double applyBiquad(const BiquadCoeffs& coeffs, BiquadState& state, double input); double applyBiquad(const BiquadCoeffs& coeffs, BiquadState& state, double input);
void processFastMeterSample(float left, float right, double& maxPeakL, double& maxPeakR); void processFastMeterSample(float left, float right);
void advancePeaks(double nowMs); void processTruePeakSample(float left, float right, double& maxTruePeakL, double& maxTruePeakR);
void maybeUpdatePeak(double peakDb, double nowMs, bool leftChannel); void advanceTruePeaks(double nowMs);
void maybeUpdateTruePeak(double peakDb, double nowMs, bool leftChannel);
double applyPeakDecay(double currentDb, double holdUntilMs, double nowMs) const; double applyPeakDecay(double currentDb, double holdUntilMs, double nowMs) const;
void recomputeFastSnapshot(); void recomputeFastSnapshot();
static double currentTimeMs(); static double currentTimeMs();
static double amplitudeToDb(double amplitude); static double amplitudeToDb(double amplitude);
static double truePeakAmplitudeToDb(double amplitude);
static double clampDb(double db, double minDb, double maxDb); static double clampDb(double db, double minDb, double maxDb);
static BiquadCoeffs preFilterCoeffs(double sampleRate); static BiquadCoeffs preFilterCoeffs(double sampleRate);
static BiquadCoeffs rlbFilterCoeffs(double sampleRate); static BiquadCoeffs rlbFilterCoeffs(double sampleRate);
@@ -99,6 +103,12 @@ private:
double lastPeakUpdateMs_ = 0.0; double lastPeakUpdateMs_ = 0.0;
bool hasLastPeakUpdate_ = false; bool hasLastPeakUpdate_ = false;
size_t truePeakOversampleFactor_ = 1;
std::vector<double> truePeakCoefficients_;
std::vector<double> truePeakHistoryL_;
std::vector<double> truePeakHistoryR_;
size_t truePeakHistoryWriteIndex_ = 0;
LUFSMeterSnapshot snapshot_{}; LUFSMeterSnapshot snapshot_{};
}; };
+3 -2
View File
@@ -690,8 +690,9 @@ Napi::Value LUFSMeterGetSnapshot(const Napi::CallbackInfo& info) {
obj.Set("vuRDb", Napi::Number::New(env, snapshot.vuRDb)); obj.Set("vuRDb", Napi::Number::New(env, snapshot.vuRDb));
obj.Set("barLDb", Napi::Number::New(env, snapshot.barLDb)); obj.Set("barLDb", Napi::Number::New(env, snapshot.barLDb));
obj.Set("barRDb", Napi::Number::New(env, snapshot.barRDb)); obj.Set("barRDb", Napi::Number::New(env, snapshot.barRDb));
obj.Set("peakLDb", Napi::Number::New(env, snapshot.peakLDb)); obj.Set("truePeakLDb", Napi::Number::New(env, snapshot.truePeakLDb));
obj.Set("peakRDb", Napi::Number::New(env, snapshot.peakRDb)); obj.Set("truePeakRDb", Napi::Number::New(env, snapshot.truePeakRDb));
obj.Set("maxTruePeakDb", Napi::Number::New(env, snapshot.maxTruePeakDb));
obj.Set("correlation", Napi::Number::New(env, snapshot.correlation)); obj.Set("correlation", Napi::Number::New(env, snapshot.correlation));
return obj; return obj;
} }
+2
View File
@@ -27,6 +27,8 @@
"test:spectrum-native": "node --test test/spectrum-native.test.mjs", "test:spectrum-native": "node --test test/spectrum-native.test.mjs",
"test:spectrogram-native": "node --test test/spectrogram-native.test.mjs", "test:spectrogram-native": "node --test test/spectrogram-native.test.mjs",
"test:vectorscope-native": "node --test test/vectorscope-native.test.mjs", "test:vectorscope-native": "node --test test/vectorscope-native.test.mjs",
"test:lufsmeter-native": "node --test test/lufsmeter-native.test.mjs",
"test:lufsmeter-ebu": "node scripts/run-ebu-true-peak-tests.mjs",
"test:tui": "node scripts/build/build-tui.cjs --test", "test:tui": "node scripts/build/build-tui.cjs --test",
"test:build-metadata": "node scripts/run-build-metadata-tests.mjs", "test:build-metadata": "node scripts/run-build-metadata-tests.mjs",
"test:updates": "node scripts/run-update-tests.mjs", "test:updates": "node scripts/run-update-tests.mjs",
+6 -5
View File
@@ -5,9 +5,9 @@
/** /**
* Loudness (LUFS) meter engine. Pushes stereo audio into the reused * Loudness (LUFS) meter engine. Pushes stereo audio into the reused
* `Visualizer::LUFSMeterAnalyzer` (K-weighting + gated integration + the same fast * `Visualizer::LUFSMeterAnalyzer` (K-weighting, gated integration, true peak, and
* VU/peak/correlation block) and emits its scalar snapshot each frame. Like the VU * the same fast VU/correlation block) and emits its scalar snapshot each frame.
* engine the frame is plain numbers (no base64); getSnapshot() advances peak decay * The frame is plain numbers (no base64); getSnapshot() advances true-peak decay
* on the steady clock, so it's safe to call every frame. configure() is a no-op — * on the steady clock, so it's safe to call every frame. configure() is a no-op —
* LUFS settings (mode/readout) are render-side. * LUFS settings (mode/readout) are render-side.
*/ */
@@ -44,8 +44,9 @@ public:
obj->setProperty("vuRDb", snap.vuRDb); obj->setProperty("vuRDb", snap.vuRDb);
obj->setProperty("barLDb", snap.barLDb); obj->setProperty("barLDb", snap.barLDb);
obj->setProperty("barRDb", snap.barRDb); obj->setProperty("barRDb", snap.barRDb);
obj->setProperty("peakLDb", snap.peakLDb); obj->setProperty("truePeakLDb", snap.truePeakLDb);
obj->setProperty("peakRDb", snap.peakRDb); obj->setProperty("truePeakRDb", snap.truePeakRDb);
obj->setProperty("maxTruePeakDb", snap.maxTruePeakDb);
obj->setProperty("correlation", snap.correlation); obj->setProperty("correlation", snap.correlation);
return juce::var(obj); return juce::var(obj);
} }
+126
View File
@@ -0,0 +1,126 @@
import { readFile, readdir, stat } from 'node:fs/promises'
import { basename, join, resolve } from 'node:path'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const { lufsmeter } = require('../native/build/Release/visualizer_dsp.node')
const sourcePath = process.argv[2]
if (!sourcePath) {
console.error('Usage: npm run test:lufsmeter-ebu -- /path/to/extracted-ebu-loudness-test-set')
process.exit(2)
}
async function collectFiles(path) {
const details = await stat(path)
if (details.isFile()) return [path]
if (!details.isDirectory()) return []
const files = []
for (const entry of await readdir(path, { withFileTypes: true })) {
files.push(...await collectFiles(join(path, entry.name)))
}
return files
}
function decodeWave(buffer) {
if (buffer.length < 12 || buffer.toString('ascii', 0, 4) !== 'RIFF'
|| buffer.toString('ascii', 8, 12) !== 'WAVE') {
throw new Error('not a RIFF/WAVE file')
}
let format = null
let data = null
for (let offset = 12; offset + 8 <= buffer.length;) {
const chunkId = buffer.toString('ascii', offset, offset + 4)
const chunkSize = buffer.readUInt32LE(offset + 4)
const chunkOffset = offset + 8
if (chunkOffset + chunkSize > buffer.length) throw new Error(`truncated ${chunkId} chunk`)
if (chunkId === 'fmt ') {
if (chunkSize < 16) throw new Error('invalid fmt chunk')
let formatTag = buffer.readUInt16LE(chunkOffset)
if (formatTag === 0xfffe && chunkSize >= 40) {
formatTag = buffer.readUInt16LE(chunkOffset + 24)
}
format = {
formatTag,
channels: buffer.readUInt16LE(chunkOffset + 2),
sampleRate: buffer.readUInt32LE(chunkOffset + 4),
blockAlign: buffer.readUInt16LE(chunkOffset + 12),
bitsPerSample: buffer.readUInt16LE(chunkOffset + 14),
}
} else if (chunkId === 'data') {
data = buffer.subarray(chunkOffset, chunkOffset + chunkSize)
}
offset = chunkOffset + chunkSize + (chunkSize % 2)
}
if (!format || !data) throw new Error('missing fmt or data chunk')
if (format.channels < 1 || format.blockAlign < 1 || format.sampleRate < 1) {
throw new Error('invalid WAVE format')
}
const bytesPerSample = Math.ceil(format.bitsPerSample / 8)
const frameCount = Math.floor(data.length / format.blockAlign)
const left = new Float32Array(frameCount)
const right = new Float32Array(frameCount)
const readSample = (offset) => {
if (format.formatTag === 3 && format.bitsPerSample === 32) return data.readFloatLE(offset)
if (format.formatTag !== 1) throw new Error(`unsupported WAVE format tag ${format.formatTag}`)
if (format.bitsPerSample === 16) return data.readInt16LE(offset) / 0x8000
if (format.bitsPerSample === 24) return data.readIntLE(offset, 3) / 0x800000
if (format.bitsPerSample === 32) return data.readInt32LE(offset) / 0x80000000
throw new Error(`unsupported PCM depth ${format.bitsPerSample}`)
}
for (let frame = 0; frame < frameCount; frame += 1) {
const frameOffset = frame * format.blockAlign
left[frame] = readSample(frameOffset)
right[frame] = format.channels > 1 ? readSample(frameOffset + bytesPerSample) : left[frame]
}
return { sampleRate: format.sampleRate, left, right }
}
function expectedDbtp(caseNumber) {
if (caseNumber >= 15 && caseNumber <= 18) return -6
if (caseNumber === 19) return 3
return 0
}
const files = await collectFiles(resolve(sourcePath))
const cases = new Map()
for (const file of files) {
const match = basename(file).match(/3341[-_ ](1[5-9]|2[0-3])(?:\D|$)/i)
if (match && /\.wav(?:\.wav)?$/i.test(file)) cases.set(Number(match[1]), file)
}
const missing = []
for (let caseNumber = 15; caseNumber <= 23; caseNumber += 1) {
if (!cases.has(caseNumber)) missing.push(caseNumber)
}
if (missing.length > 0) {
console.error(`Could not find EBU Tech 3341 WAV cases: ${missing.join(', ')}`)
process.exit(2)
}
let failed = false
for (let caseNumber = 15; caseNumber <= 23; caseNumber += 1) {
const file = cases.get(caseNumber)
try {
const wave = decodeWave(await readFile(file))
lufsmeter.setSampleRate(wave.sampleRate)
lufsmeter.reset()
lufsmeter.pushSamples(wave.left, wave.right)
const actual = lufsmeter.getSnapshot().maxTruePeakDb
const expected = expectedDbtp(caseNumber)
const passed = actual >= expected - 0.4 && actual <= expected + 0.2
console.log(`${passed ? 'PASS' : 'FAIL'} EBU 3341-${caseNumber}: ${actual.toFixed(3)} dBTP (expected ${expected}, +0.2/-0.4)`)
failed ||= !passed
} catch (error) {
failed = true
console.error(`FAIL EBU 3341-${caseNumber}: ${error instanceof Error ? error.message : String(error)}`)
}
}
process.exit(failed ? 1 : 0)
+1
View File
@@ -19,6 +19,7 @@ const testScripts = [
'test:spectrum-native', 'test:spectrum-native',
'test:spectrogram-native', 'test:spectrogram-native',
'test:vectorscope-native', 'test:vectorscope-native',
'test:lufsmeter-native',
] ]
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm' const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'
+1 -1
View File
@@ -3,7 +3,7 @@ import type { LUFSMeterNativeAnalyzer, LUFSMeterNativeSnapshot } from '../render
/** /**
* Drop-in `LUFSMeterNativeAnalyzer` for the plugin webview. * Drop-in `LUFSMeterNativeAnalyzer` for the plugin webview.
* *
* The loudness DSP (K-weighting, gated integration, fast VU/peak/correlation) runs * The loudness DSP (K-weighting, gated integration, true peak, fast VU/correlation) runs
* in the C++ plugin, which pushes a finished scalar snapshot each frame. This shim * in the C++ plugin, which pushes a finished scalar snapshot each frame. This shim
* caches that snapshot and serves it through the interface `LUFSMeter` consumes. * caches that snapshot and serves it through the interface `LUFSMeter` consumes.
* `pushSamples` is a no-op (audio never flows through the webview); `reset` clears * `pushSamples` is a no-op (audio never flows through the webview); `reset` clears
+36 -9
View File
@@ -275,16 +275,25 @@ export interface LUFSMeterFrame {
vuRDb: number vuRDb: number
barLDb: number barLDb: number
barRDb: number barRDb: number
peakLDb: number truePeakLDb: number
peakRDb: number truePeakRDb: number
maxTruePeakDb: number
correlation: number correlation: number
} }
function decodeLUFSMeterFrame(payload: unknown): LUFSMeterFrame | null { export function decodeLUFSMeterFrame(
payload: unknown,
previousMaxTruePeakDb = -60,
): LUFSMeterFrame | null {
if (typeof payload !== 'object' || payload === null) return null if (typeof payload !== 'object' || payload === null) return null
const p = payload as Record<string, unknown> const p = payload as Record<string, unknown>
const num = (key: string, fallback: number): number => const num = (key: string, fallback: number): number =>
typeof p[key] === 'number' && Number.isFinite(p[key]) ? (p[key] as number) : fallback typeof p[key] === 'number' && Number.isFinite(p[key]) ? (p[key] as number) : fallback
const truePeakLDb = num('truePeakLDb', num('peakLDb', -60))
const truePeakRDb = num('truePeakRDb', num('peakRDb', -60))
const explicitMaximum = typeof p.maxTruePeakDb === 'number' && Number.isFinite(p.maxTruePeakDb)
? p.maxTruePeakDb
: null
return { return {
sampleRate: num('sampleRate', 48000) > 0 ? num('sampleRate', 48000) : 48000, sampleRate: num('sampleRate', 48000) > 0 ? num('sampleRate', 48000) : 48000,
momentaryLUFS: num('momentaryLUFS', -70), momentaryLUFS: num('momentaryLUFS', -70),
@@ -294,8 +303,13 @@ function decodeLUFSMeterFrame(payload: unknown): LUFSMeterFrame | null {
vuRDb: num('vuRDb', -60), vuRDb: num('vuRDb', -60),
barLDb: num('barLDb', -60), barLDb: num('barLDb', -60),
barRDb: num('barRDb', -60), barRDb: num('barRDb', -60),
peakLDb: num('peakLDb', -60), truePeakLDb,
peakRDb: num('peakRDb', -60), truePeakRDb,
maxTruePeakDb: explicitMaximum ?? Math.max(
Number.isFinite(previousMaxTruePeakDb) ? previousMaxTruePeakDb : -60,
truePeakLDb,
truePeakRDb,
),
correlation: num('correlation', 0), correlation: num('correlation', 0),
} }
} }
@@ -309,6 +323,8 @@ export function connectLUFSMeterBridge(handlers: LUFSMeterBridgeHandlers): () =>
let disposed = false let disposed = false
let listenerId: number | null = null let listenerId: number | null = null
let mockRaf: number | null = null let mockRaf: number | null = null
let maxTruePeakDb = -60
let previousSampleRate = 0
const startMock = (): void => { const startMock = (): void => {
handlers.onConnected?.(true) handlers.onConnected?.(true)
@@ -320,6 +336,9 @@ export function connectLUFSMeterBridge(handlers: LUFSMeterBridgeHandlers): () =>
const lufs = (offset: number): number => -24 + Math.sin(phase + offset) * 6 const lufs = (offset: number): number => -24 + Math.sin(phase + offset) * 6
const vuL = -36 + (Math.sin(phase) * 0.5 + 0.5) * 30 const vuL = -36 + (Math.sin(phase) * 0.5 + 0.5) * 30
const vuR = -36 + (Math.sin(phase + 0.7) * 0.5 + 0.5) * 30 const vuR = -36 + (Math.sin(phase + 0.7) * 0.5 + 0.5) * 30
const truePeakLDb = vuL + 3
const truePeakRDb = vuR + 3
maxTruePeakDb = Math.max(maxTruePeakDb, truePeakLDb, truePeakRDb)
handlers.onFrame({ handlers.onFrame({
sampleRate: 48000, sampleRate: 48000,
momentaryLUFS: lufs(0), momentaryLUFS: lufs(0),
@@ -329,8 +348,9 @@ export function connectLUFSMeterBridge(handlers: LUFSMeterBridgeHandlers): () =>
vuRDb: vuR, vuRDb: vuR,
barLDb: vuL, barLDb: vuL,
barRDb: vuR, barRDb: vuR,
peakLDb: vuL + 3, truePeakLDb,
peakRDb: vuR + 3, truePeakRDb,
maxTruePeakDb,
correlation: Math.sin(phase * 0.3), correlation: Math.sin(phase * 0.3),
}) })
mockRaf = requestAnimationFrame(tick) mockRaf = requestAnimationFrame(tick)
@@ -342,8 +362,15 @@ export function connectLUFSMeterBridge(handlers: LUFSMeterBridgeHandlers): () =>
if (disposed) return if (disposed) return
if (backend) { if (backend) {
listenerId = backend.addEventListener('lufsmeterFrame', (payload) => { listenerId = backend.addEventListener('lufsmeterFrame', (payload) => {
const frame = decodeLUFSMeterFrame(payload) let frame = decodeLUFSMeterFrame(payload, maxTruePeakDb)
if (frame) handlers.onFrame(frame) if (frame && previousSampleRate > 0 && Math.abs(frame.sampleRate - previousSampleRate) > 1) {
frame = decodeLUFSMeterFrame(payload, -60)
}
if (frame) {
previousSampleRate = frame.sampleRate
maxTruePeakDb = frame.maxTruePeakDb
handlers.onFrame(frame)
}
}) })
handlers.onConnected?.(false) handlers.onConnected?.(false)
console.log('[prism-plugin] connected to JUCE host (lufsmeter)') console.log('[prism-plugin] connected to JUCE host (lufsmeter)')
+3 -2
View File
@@ -54,8 +54,9 @@ export interface LUFSMeterNativeSnapshot {
vuRDb: number; vuRDb: number;
barLDb: number; barLDb: number;
barRDb: number; barRDb: number;
peakLDb: number; truePeakLDb: number;
peakRDb: number; truePeakRDb: number;
maxTruePeakDb: number;
correlation: number; correlation: number;
} }
+44 -7
View File
@@ -73,6 +73,7 @@ const TARGET_LUFS = -14
const METER_MIN_DB = -60 const METER_MIN_DB = -60
const MAX_METER_VIEWPORT_CSS_WIDTH = 240 const MAX_METER_VIEWPORT_CSS_WIDTH = 240
const FULL_READOUT_WIDTH_SAMPLE = '-00.0LUFS' const FULL_READOUT_WIDTH_SAMPLE = '-00.0LUFS'
const TRUE_PEAK_EMPTY_DB = METER_MIN_DB
const INITIAL_NATIVE_SNAPSHOT: LUFSMeterNativeSnapshot = { const INITIAL_NATIVE_SNAPSHOT: LUFSMeterNativeSnapshot = {
momentaryLUFS: METER_MIN_LUFS, momentaryLUFS: METER_MIN_LUFS,
@@ -82,8 +83,9 @@ const INITIAL_NATIVE_SNAPSHOT: LUFSMeterNativeSnapshot = {
vuRDb: METER_MIN_DB, vuRDb: METER_MIN_DB,
barLDb: METER_MIN_DB, barLDb: METER_MIN_DB,
barRDb: METER_MIN_DB, barRDb: METER_MIN_DB,
peakLDb: METER_MIN_DB, truePeakLDb: METER_MIN_DB,
peakRDb: METER_MIN_DB, truePeakRDb: METER_MIN_DB,
maxTruePeakDb: TRUE_PEAK_EMPTY_DB,
correlation: 0, correlation: 0,
} }
@@ -104,12 +106,20 @@ function normalizeNativeSnapshot(snapshot: LUFSMeterNativeSnapshot | null): LUFS
vuRDb: finiteNumber(snapshot.vuRDb, METER_MIN_DB), vuRDb: finiteNumber(snapshot.vuRDb, METER_MIN_DB),
barLDb: finiteNumber(snapshot.barLDb, METER_MIN_DB), barLDb: finiteNumber(snapshot.barLDb, METER_MIN_DB),
barRDb: finiteNumber(snapshot.barRDb, METER_MIN_DB), barRDb: finiteNumber(snapshot.barRDb, METER_MIN_DB),
peakLDb: finiteNumber(snapshot.peakLDb, METER_MIN_DB), truePeakLDb: finiteNumber(snapshot.truePeakLDb, METER_MIN_DB),
peakRDb: finiteNumber(snapshot.peakRDb, METER_MIN_DB), truePeakRDb: finiteNumber(snapshot.truePeakRDb, METER_MIN_DB),
maxTruePeakDb: finiteNumber(snapshot.maxTruePeakDb, TRUE_PEAK_EMPTY_DB),
correlation: finiteNumber(snapshot.correlation, 0), correlation: finiteNumber(snapshot.correlation, 0),
} }
} }
export function formatMaxTruePeakDb(maxTruePeakDb: number, compact = false): string {
const value = Number.isFinite(maxTruePeakDb) && maxTruePeakDb > TRUE_PEAK_EMPTY_DB
? `${maxTruePeakDb >= 0 ? '+' : '−'}${Math.abs(maxTruePeakDb).toFixed(1)}`
: '−∞'
return compact ? `${value}dBTP` : `MAX TP ${value} dBTP`
}
// ---- Loudness meter class ---- // ---- Loudness meter class ----
export class LUFSMeter { export class LUFSMeter {
@@ -383,8 +393,12 @@ export class LUFSMeter {
const paddingX = Math.round(Math.max(4, Math.floor(cssWidth * 0.012)) * dpr) const paddingX = Math.round(Math.max(4, Math.floor(cssWidth * 0.012)) * dpr)
const paddingY = Math.round(Math.max(4, Math.floor(cssHeight * 0.025)) * dpr) const paddingY = Math.round(Math.max(4, Math.floor(cssHeight * 0.025)) * dpr)
const footerFontSizeCss = Math.max(7, Math.min(11, Math.floor(cssHeight * 0.052)))
const footerFontSize = Math.max(1, Math.round(footerFontSizeCss * dpr))
const footerHeight = Math.max(1, Math.round((footerFontSizeCss + 4) * dpr))
const footerGap = Math.max(1, Math.round(4 * dpr))
const meterTop = paddingY const meterTop = paddingY
const meterBottom = height - paddingY const meterBottom = Math.max(meterTop + 1, height - paddingY - footerHeight - footerGap)
const meterHeight = Math.max(1, meterBottom - meterTop) const meterHeight = Math.max(1, meterBottom - meterTop)
const availableViewportWidth = Math.max(1, width - paddingX * 2) const availableViewportWidth = Math.max(1, width - paddingX * 2)
const viewportWidth = Math.max(1, Math.min( const viewportWidth = Math.max(1, Math.min(
@@ -448,7 +462,7 @@ export class LUFSMeter {
barWidth, barWidth,
meterHeight, meterHeight,
this.snapshot.barLDb, this.snapshot.barLDb,
this.snapshot.peakLDb, this.snapshot.truePeakLDb,
tint, tint,
dpr, dpr,
) )
@@ -458,7 +472,7 @@ export class LUFSMeter {
barWidth, barWidth,
meterHeight, meterHeight,
this.snapshot.barRDb, this.snapshot.barRDb,
this.snapshot.peakRDb, this.snapshot.truePeakRDb,
tint, tint,
dpr, dpr,
) )
@@ -509,6 +523,29 @@ export class LUFSMeter {
ctx.textBaseline = 'middle' ctx.textBaseline = 'middle'
ctx.fillStyle = this.contrastForLevelColor() ctx.fillStyle = this.contrastForLevelColor()
ctx.fillText(readoutLayout.text, tagX + tagPadding, tagY + tagHeight / 2) ctx.fillText(readoutLayout.text, tagX + tagPadding, tagY + tagHeight / 2)
const fullTruePeakText = formatMaxTruePeakDb(this.snapshot.maxTruePeakDb)
const compactTruePeakText = formatMaxTruePeakDb(this.snapshot.maxTruePeakDb, true)
ctx.font = `700 ${footerFontSize}px "JetBrains Mono", "SF Mono", monospace`
const truePeakText = viewportCssWidth >= 160
&& ctx.measureText(fullTruePeakText).width <= viewportWidth
? fullTruePeakText
: compactTruePeakText
const truePeakLayout = this.resolveReadoutTextLayout(
[truePeakText],
viewportWidth,
footerFontSize,
Math.max(1, Math.round(7 * dpr)),
)
ctx.font = `700 ${truePeakLayout.fontSize}px "JetBrains Mono", "SF Mono", monospace`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillStyle = this.options.labelColor
ctx.fillText(
truePeakLayout.text,
Math.round(width / 2),
Math.round(meterBottom + footerGap + footerHeight / 2),
)
} }
dispose(): void { dispose(): void {
+234
View File
@@ -0,0 +1,234 @@
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import test from 'node:test'
const require = createRequire(import.meta.url)
const { lufsmeter } = require('../native/build/Release/visualizer_dsp.node')
const EBU_SAMPLE_RATE = 48000
const EBU_TOLERANCE_BELOW_DB = 0.4
const EBU_TOLERANCE_ABOVE_DB = 0.2
const SILENCE_DB = -60
function assertEbuTolerance(actual, expected, label) {
assert.ok(
actual >= expected - EBU_TOLERANCE_BELOW_DB
&& actual <= expected + EBU_TOLERANCE_ABOVE_DB,
`${label}: expected ${expected} dBTP (+${EBU_TOLERANCE_ABOVE_DB}/-${EBU_TOLERANCE_BELOW_DB}), got ${actual}`,
)
}
function createPhaseTone(
sampleRate,
frequency,
amplitude,
phaseDegrees,
durationSeconds = 0.1,
) {
const length = Math.max(1, Math.round(sampleRate * durationSeconds))
const taperSamples = Math.max(1, Math.round(sampleRate * 0.01))
const phaseRadians = phaseDegrees * Math.PI / 180
const samples = new Float32Array(length)
for (let index = 0; index < length; index += 1) {
const fadeIn = Math.min(1, index / taperSamples)
const fadeOut = Math.min(1, (length - 1 - index) / taperSamples)
const taper = Math.max(0, Math.min(fadeIn, fadeOut))
samples[index] = amplitude
* Math.sin((2 * Math.PI * frequency * index) / sampleRate + phaseRadians)
* taper
}
return samples
}
function pushStereo(left, right = left, chunkSizes = null) {
if (!chunkSizes) {
lufsmeter.pushSamples(left, right)
return
}
let offset = 0
let chunkIndex = 0
while (offset < Math.min(left.length, right.length)) {
const requestedSize = chunkSizes[chunkIndex % chunkSizes.length]
const length = Math.min(requestedSize, left.length - offset, right.length - offset)
lufsmeter.pushSamples(
left.subarray(offset, offset + length),
right.subarray(offset, offset + length),
)
offset += length
chunkIndex += 1
}
}
function measure(sampleRate, left, right = left, chunkSizes = null) {
lufsmeter.setSampleRate(sampleRate)
lufsmeter.reset()
pushStereo(left, right, chunkSizes)
return lufsmeter.getSnapshot()
}
function besselI0(value) {
let sum = 1
let term = 1
const squaredQuarter = value * value / 4
for (let index = 1; index < 40; index += 1) {
term *= squaredQuarter / (index * index)
sum += term
if (term < sum * 1e-15) break
}
return sum
}
function createTransientReferenceAt4x(sampleRate = EBU_SAMPLE_RATE) {
const oversample = 4
const highRate = sampleRate * oversample
const durationSeconds = 0.12
const length = Math.round(highRate * durationSeconds)
const source = new Float64Array(length)
const taperSamples = Math.round(highRate * 0.01)
for (let index = 0; index < length; index += 1) {
const taper = Math.max(0, Math.min(
1,
index / taperSamples,
(length - 1 - index) / taperSamples,
))
source[index] = 0.5 * Math.sin((2 * Math.PI * (sampleRate / 6) * index) / highRate) * taper
}
const transientStart = Math.floor(length / 2) - 8
for (let index = 0; index < 16; index += 1) {
source[transientStart + index] += Math.sin(Math.PI * index / 8)
}
const tapCount = 129
const half = (tapCount - 1) / 2
const cutoff = 0.5 / oversample
const beta = 8.6
const inverseI0Beta = 1 / besselI0(beta)
const coefficients = new Float64Array(tapCount)
let coefficientSum = 0
for (let tap = 0; tap < tapCount; tap += 1) {
const distance = tap - half
const sincArgument = 2 * cutoff * distance
const sinc = Math.abs(sincArgument) < 1e-12
? 1
: Math.sin(Math.PI * sincArgument) / (Math.PI * sincArgument)
const ratio = distance / half
const window = besselI0(beta * Math.sqrt(Math.max(0, 1 - ratio * ratio))) * inverseI0Beta
coefficients[tap] = 2 * cutoff * sinc * window
coefficientSum += coefficients[tap]
}
for (let tap = 0; tap < tapCount; tap += 1) coefficients[tap] /= coefficientSum
const filtered = new Float64Array(length)
for (let index = half; index < length - half; index += 1) {
let value = 0
for (let tap = 0; tap < tapCount; tap += 1) {
value += coefficients[tap] * source[index + tap - half]
}
filtered[index] = value
}
let maximum = 0
for (const value of filtered) maximum = Math.max(maximum, Math.abs(value))
for (let index = 0; index < filtered.length; index += 1) filtered[index] /= maximum
return filtered
}
function downsample4x(reference, offset) {
const length = Math.floor((reference.length - offset) / 4)
const samples = new Float32Array(length)
for (let index = 0; index < length; index += 1) {
samples[index] = reference[index * 4 + offset]
}
return samples
}
test('BS.1770 true peak passes EBU Tech 3341 phase-tone cases 15-19', () => {
const cases = [
{ number: 15, divisor: 4, amplitude: 0.5, phase: 0, expected: -6 },
{ number: 16, divisor: 4, amplitude: 0.5, phase: 45, expected: -6 },
{ number: 17, divisor: 6, amplitude: 0.5, phase: 60, expected: -6 },
{ number: 18, divisor: 8, amplitude: 0.5, phase: 67.5, expected: -6 },
{ number: 19, divisor: 4, amplitude: 1.41, phase: 45, expected: 3 },
]
for (const vector of cases) {
const tone = createPhaseTone(
EBU_SAMPLE_RATE,
EBU_SAMPLE_RATE / vector.divisor,
vector.amplitude,
vector.phase,
)
const snapshot = measure(EBU_SAMPLE_RATE, tone)
assertEbuTolerance(snapshot.maxTruePeakDb, vector.expected, `EBU case ${vector.number}`)
assertEbuTolerance(snapshot.truePeakLDb, vector.expected, `EBU case ${vector.number} left marker`)
assertEbuTolerance(snapshot.truePeakRDb, vector.expected, `EBU case ${vector.number} right marker`)
}
})
test('BS.1770 true peak passes four phase offsets of a filtered transient (EBU cases 20-23)', () => {
const reference = createTransientReferenceAt4x()
for (let offset = 0; offset < 4; offset += 1) {
const samples = downsample4x(reference, offset)
const snapshot = measure(EBU_SAMPLE_RATE, samples)
assertEbuTolerance(snapshot.maxTruePeakDb, 0, `EBU transient case ${20 + offset}`)
}
})
test('true-peak state is independent of arbitrary audio chunk boundaries', () => {
const tone = createPhaseTone(EBU_SAMPLE_RATE, EBU_SAMPLE_RATE / 4, 1.41, 45)
const contiguous = measure(EBU_SAMPLE_RATE, tone).maxTruePeakDb
const irregular = measure(EBU_SAMPLE_RATE, tone, tone, [1, 7, 113, 2, 509, 31]).maxTruePeakDb
assert.ok(Math.abs(contiguous - irregular) < 1e-6, `${contiguous} vs ${irregular}`)
})
test('true peak supports common host rates with rate-aware oversampling', () => {
for (const sampleRate of [44100, 48000, 96000, 192000]) {
const tone = createPhaseTone(sampleRate, sampleRate / 4, 1.41, 45)
const snapshot = measure(sampleRate, tone)
assertEbuTolerance(snapshot.maxTruePeakDb, 3, `${sampleRate} Hz`)
}
})
test('true-peak channels preserve polarity and remain independent', () => {
const positive = createPhaseTone(EBU_SAMPLE_RATE, EBU_SAMPLE_RATE / 4, 1.41, 45)
const negative = Float32Array.from(positive, (sample) => -sample)
const silence = new Float32Array(positive.length)
const positiveSnapshot = measure(EBU_SAMPLE_RATE, positive, silence)
assertEbuTolerance(positiveSnapshot.truePeakLDb, 3, 'left channel')
assert.equal(positiveSnapshot.truePeakRDb, SILENCE_DB)
const negativeSnapshot = measure(EBU_SAMPLE_RATE, negative, silence)
assert.ok(Math.abs(negativeSnapshot.truePeakLDb - positiveSnapshot.truePeakLDb) < 1e-6)
assert.equal(negativeSnapshot.truePeakRDb, SILENCE_DB)
})
test('maximum true peak persists across quieter input and clears on reset', () => {
const loud = createPhaseTone(EBU_SAMPLE_RATE, EBU_SAMPLE_RATE / 4, 1.41, 45)
const quiet = createPhaseTone(EBU_SAMPLE_RATE, EBU_SAMPLE_RATE / 4, 0.1, 45)
const silence = new Float32Array(1024)
lufsmeter.setSampleRate(EBU_SAMPLE_RATE)
lufsmeter.reset()
pushStereo(loud)
const loudSnapshot = lufsmeter.getSnapshot()
assert.ok(loudSnapshot.maxTruePeakDb > 0)
pushStereo(quiet)
assert.equal(lufsmeter.getSnapshot().maxTruePeakDb, loudSnapshot.maxTruePeakDb)
lufsmeter.reset()
assert.deepEqual(
{
truePeakLDb: lufsmeter.getSnapshot().truePeakLDb,
truePeakRDb: lufsmeter.getSnapshot().truePeakRDb,
maxTruePeakDb: lufsmeter.getSnapshot().maxTruePeakDb,
},
{ truePeakLDb: SILENCE_DB, truePeakRDb: SILENCE_DB, maxTruePeakDb: SILENCE_DB },
)
pushStereo(silence)
assert.equal(lufsmeter.getSnapshot().maxTruePeakDb, SILENCE_DB)
})
+65 -10
View File
@@ -115,11 +115,11 @@ import {
HEAT_MIN_DB, HEAT_MIN_DB,
normalizeHeatDb, normalizeHeatDb,
} from '../src/renderer/visualizers/heatScale' } from '../src/renderer/visualizers/heatScale'
import { LUFSMeter } from '../src/renderer/visualizers/LUFSMeter' import { LUFSMeter, formatMaxTruePeakDb } from '../src/renderer/visualizers/LUFSMeter'
import { Oscilloscope } from '../src/renderer/visualizers/Oscilloscope' import { Oscilloscope } from '../src/renderer/visualizers/Oscilloscope'
import { SpectrumAnalyzer, type SpectrumAnalyzerOptions } from '../src/renderer/visualizers/SpectrumAnalyzer' import { SpectrumAnalyzer, type SpectrumAnalyzerOptions } from '../src/renderer/visualizers/SpectrumAnalyzer'
import { BridgeSpectrumAnalyzer } from '../src/plugin-ui/BridgeSpectrumAnalyzer' import { BridgeSpectrumAnalyzer } from '../src/plugin-ui/BridgeSpectrumAnalyzer'
import { decodeSpectrumFrame } from '../src/plugin-ui/juceBridge' import { decodeLUFSMeterFrame, decodeSpectrumFrame } from '../src/plugin-ui/juceBridge'
import { formatSpectrumPeakDbfs } from '../src/plugin-ui/peakOverlay' import { formatSpectrumPeakDbfs } from '../src/plugin-ui/peakOverlay'
import { spectrogramSettingsToOptions } from '../src/plugin-ui/spectrogramOptions' import { spectrogramSettingsToOptions } from '../src/plugin-ui/spectrogramOptions'
import { spectrumSettingsToOptions } from '../src/plugin-ui/spectrumOptions' import { spectrumSettingsToOptions } from '../src/plugin-ui/spectrumOptions'
@@ -6296,8 +6296,9 @@ function createFakeLUFSMeterNativeAnalyzer(
vuRDb: -13, vuRDb: -13,
barLDb: -10, barLDb: -10,
barRDb: -12, barRDb: -12,
peakLDb: -4, truePeakLDb: -4,
peakRDb: -5, truePeakRDb: -5,
maxTruePeakDb: -4,
correlation: 0.5, correlation: 0.5,
...snapshotOverrides, ...snapshotOverrides,
} satisfies LUFSMeterNativeSnapshot, } satisfies LUFSMeterNativeSnapshot,
@@ -6319,7 +6320,45 @@ function createFakeLUFSMeterNativeAnalyzer(
return analyzer return analyzer
} }
test('LUFSMeter draws compact fast bars, a thicker LUFS bar, scale labels, and attached readout', () => { test('LUFSMeter formats positive, negative, compact, and reset true-peak maxima', () => {
assert.equal(formatMaxTruePeakDb(3), 'MAX TP +3.0 dBTP')
assert.equal(formatMaxTruePeakDb(-4.25), 'MAX TP −4.3 dBTP')
assert.equal(formatMaxTruePeakDb(3, true), '+3.0dBTP')
assert.equal(formatMaxTruePeakDb(-60), 'MAX TP −∞ dBTP')
assert.equal(formatMaxTruePeakDb(Number.NaN, true), '−∞dBTP')
})
test('loudness bridge decodes true-peak fields and accumulates legacy peak maxima', () => {
const current = decodeLUFSMeterFrame({
sampleRate: 96000,
truePeakLDb: 1.25,
truePeakRDb: -2,
maxTruePeakDb: 2.5,
})
assert.ok(current)
assert.equal(current.sampleRate, 96000)
assert.equal(current.truePeakLDb, 1.25)
assert.equal(current.truePeakRDb, -2)
assert.equal(current.maxTruePeakDb, 2.5)
const firstLegacy = decodeLUFSMeterFrame({ peakLDb: -3, peakRDb: -5 }, -60)
assert.ok(firstLegacy)
assert.equal(firstLegacy.truePeakLDb, -3)
assert.equal(firstLegacy.truePeakRDb, -5)
assert.equal(firstLegacy.maxTruePeakDb, -3)
const quieterLegacy = decodeLUFSMeterFrame({ peakLDb: -9, peakRDb: -7 }, firstLegacy.maxTruePeakDb)
assert.equal(quieterLegacy?.maxTruePeakDb, -3)
const explicitReset = decodeLUFSMeterFrame({
truePeakLDb: -60,
truePeakRDb: -60,
maxTruePeakDb: -60,
}, 2.5)
assert.equal(explicitReset?.maxTruePeakDb, -60)
})
test('LUFSMeter draws fast bars, true-peak markers, LUFS readout, and maximum dBTP footer', () => {
const dom = installFakeCanvasDom() const dom = installFakeCanvasDom()
const recorder = createFakeCanvasRecorder() const recorder = createFakeCanvasRecorder()
const leftChunk = new Float32Array(4800) const leftChunk = new Float32Array(4800)
@@ -6343,7 +6382,12 @@ test('LUFSMeter draws compact fast bars, a thicker LUFS bar, scale labels, and a
isPlaying: () => true, isPlaying: () => true,
subscribeToSessionChanges: () => () => {}, subscribeToSessionChanges: () => () => {},
} }
const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer({ momentaryLUFS: -18.4 }) const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer({
momentaryLUFS: -18.4,
truePeakLDb: 3,
truePeakRDb: -5,
maxTruePeakDb: 3,
})
const meter = new LUFSMeter(createFakeCanvas(recorder), { const meter = new LUFSMeter(createFakeCanvas(recorder), {
dataSource, dataSource,
nativeAnalyzer, nativeAnalyzer,
@@ -6392,6 +6436,10 @@ test('LUFSMeter draws compact fast bars, a thicker LUFS bar, scale labels, and a
recorder.fillTexts.some((text) => text.text.endsWith('LUFS') && text.fillStyle === 'rgba(0, 0, 0, 0.9)'), recorder.fillTexts.some((text) => text.text.endsWith('LUFS') && text.fillStyle === 'rgba(0, 0, 0, 0.9)'),
true, true,
) )
assert.equal(
recorder.fillTexts.some((text) => text.text === 'MAX TP +3.0 dBTP' && text.fillStyle === 'rgb(7, 8, 9)'),
true,
)
} finally { } finally {
meter.dispose() meter.dispose()
dom.restore() dom.restore()
@@ -6419,7 +6467,7 @@ test('LUFSMeter fits readout text inside narrow tags', () => {
isPlaying: () => true, isPlaying: () => true,
subscribeToSessionChanges: () => () => {}, subscribeToSessionChanges: () => () => {},
} }
const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer({ momentaryLUFS: -8.1 }) const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer({ momentaryLUFS: -8.1, maxTruePeakDb: 3 })
const meter = new LUFSMeter(createFakeCanvas(recorder, 180, 360), { const meter = new LUFSMeter(createFakeCanvas(recorder, 180, 360), {
dataSource, dataSource,
nativeAnalyzer, nativeAnalyzer,
@@ -6444,6 +6492,7 @@ test('LUFSMeter fits readout text inside narrow tags', () => {
const fontSize = Number(readout.font.match(/(\d+(?:\.\d+)?)px/)?.[1] ?? 0) const fontSize = Number(readout.font.match(/(\d+(?:\.\d+)?)px/)?.[1] ?? 0)
const estimatedTextWidth = readout.text.length * fontSize * 0.62 const estimatedTextWidth = readout.text.length * fontSize * 0.62
assert.equal(estimatedTextWidth <= tagRect.width - 8, true) assert.equal(estimatedTextWidth <= tagRect.width - 8, true)
assert.equal(recorder.fillTexts.some((text) => text.text === 'MAX TP +3.0 dBTP'), true)
} finally { } finally {
meter.dispose() meter.dispose()
dom.restore() dom.restore()
@@ -6475,7 +6524,7 @@ test('LUFSMeter centers wide layouts in a bounded viewport', () => {
const tracks = recorder.fillRects.filter((rect) => rect.fillStyle === '#111111') const tracks = recorder.fillRects.filter((rect) => rect.fillStyle === '#111111')
const target = recorder.fillRects.find((rect) => rect.fillStyle === '#222222') const target = recorder.fillRects.find((rect) => rect.fillStyle === '#222222')
const tag = recorder.fillRects.find((rect) => rect.fillStyle === '#123456' && rect.height <= 22) const tag = recorder.fillRects.find((rect) => rect.fillStyle === '#123456' && rect.height <= 22)
const readout = recorder.fillTexts[recorder.fillTexts.length - 1] const readout = recorder.fillTexts.find((text) => text.text.endsWith('LUFS'))
const viewportLeft = (canvas.width - 240) / 2 const viewportLeft = (canvas.width - 240) / 2
const viewportRight = viewportLeft + 240 const viewportRight = viewportLeft + 240
@@ -6503,7 +6552,7 @@ test('LUFSMeter keeps minimum-width layout and compact readout inside canvas bou
const dom = installFakeCanvasDom() const dom = installFakeCanvasDom()
const recorder = createFakeCanvasRecorder() const recorder = createFakeCanvasRecorder()
const canvas = createFakeCssSizedCanvas(recorder, 112, 196) const canvas = createFakeCssSizedCanvas(recorder, 112, 196)
const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer({ shortTermLUFS: -8.7 }) const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer({ shortTermLUFS: -8.7, maxTruePeakDb: 3 })
const meter = new LUFSMeter(canvas, { const meter = new LUFSMeter(canvas, {
dataSource: { dataSource: {
getPendingLUFSMeterSamples: () => [], getPendingLUFSMeterSamples: () => [],
@@ -6527,11 +6576,17 @@ test('LUFSMeter keeps minimum-width layout and compact readout inside canvas bou
&& rect.height > 2 && rect.height > 2
&& rect.height <= 22 && rect.height <= 22
)) ))
const readout = recorder.fillTexts[recorder.fillTexts.length - 1] const readout = recorder.fillTexts.find((text) => text.text === '-8.7')
assert.deepEqual(tracks.map((rect) => rect.width), [6, 6, 12]) assert.deepEqual(tracks.map((rect) => rect.width), [6, 6, 12])
assert.ok(tag) assert.ok(tag)
assert.equal(readout?.text, '-8.7') assert.equal(readout?.text, '-8.7')
const truePeakReadout = recorder.fillTexts.find((text) => text.text.endsWith('dBTP'))
assert.equal(truePeakReadout?.text, '+3.0dBTP')
const truePeakFontSize = Number(truePeakReadout?.font.match(/(\d+(?:\.\d+)?)px/)?.[1] ?? 0)
const truePeakWidth = (truePeakReadout?.text.length ?? 0) * truePeakFontSize * 0.62
assert.ok((truePeakReadout?.x ?? 0) - truePeakWidth / 2 >= 0)
assert.ok((truePeakReadout?.x ?? 0) + truePeakWidth / 2 <= canvas.width)
for (const rect of recorder.fillRects) { for (const rect of recorder.fillRects) {
assert.ok(rect.x >= 0, `rectangle starts before canvas: ${JSON.stringify(rect)}`) assert.ok(rect.x >= 0, `rectangle starts before canvas: ${JSON.stringify(rect)}`)