mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-28 16:50:57 +02:00
BS.1770 true-peak
This commit is contained in:
@@ -193,6 +193,9 @@ jobs:
|
||||
- name: Test native vectorscope calibration
|
||||
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
|
||||
run: npm run test:tui
|
||||
|
||||
|
||||
@@ -127,6 +127,9 @@ jobs:
|
||||
- name: Test native vectorscope calibration
|
||||
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
|
||||
run: npm run test:tui
|
||||
|
||||
|
||||
@@ -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
|
||||
* **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
|
||||
* **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
|
||||
|
||||
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
|
||||
* Relative gate at −10 LU below ungated programme loudness
|
||||
* 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
|
||||
|
||||
@@ -277,12 +278,19 @@ npm run build # Build application assets
|
||||
npm run configure:tui # Configure the standalone CMake project
|
||||
npm run build:tui # Build prism-tui
|
||||
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:mac # macOS
|
||||
npm run dist:win # Windows
|
||||
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 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
@@ -31,10 +31,37 @@ constexpr double RLB_FILTER_F0_HZ = 38.13547087613982;
|
||||
constexpr double RLB_FILTER_Q = 0.5003270373223665;
|
||||
|
||||
constexpr double VU_INTEGRATION_WINDOW_MS = 300.0;
|
||||
constexpr double VU_PEAK_HOLD_MS = 750.0;
|
||||
constexpr double VU_PEAK_DECAY_DB_PER_SECOND = 18.0;
|
||||
constexpr double TRUE_PEAK_HOLD_MS = 750.0;
|
||||
constexpr double TRUE_PEAK_DECAY_DB_PER_SECOND = 18.0;
|
||||
constexpr double BAR_ATTACK_MS = 5.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() {
|
||||
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),
|
||||
0.0f,
|
||||
};
|
||||
}
|
||||
@@ -82,6 +110,7 @@ void LUFSMeterAnalyzer::configureForSampleRate(float sampleRate) {
|
||||
integratedHistogramPowerSums_.assign(INTEGRATED_HISTOGRAM_BIN_COUNT, 0.0);
|
||||
|
||||
configureFastMeter();
|
||||
configureTruePeakDetector();
|
||||
reset();
|
||||
}
|
||||
|
||||
@@ -102,6 +131,44 @@ void LUFSMeterAnalyzer::configureFastMeter() {
|
||||
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() {
|
||||
std::fill(ringBufferL_.begin(), ringBufferL_.end(), 0.0);
|
||||
std::fill(ringBufferR_.begin(), ringBufferR_.end(), 0.0);
|
||||
@@ -131,6 +198,10 @@ void LUFSMeterAnalyzer::reset() {
|
||||
lastPeakUpdateMs_ = 0.0;
|
||||
hasLastPeakUpdate_ = false;
|
||||
|
||||
std::fill(truePeakHistoryL_.begin(), truePeakHistoryL_.end(), 0.0);
|
||||
std::fill(truePeakHistoryR_.begin(), truePeakHistoryR_.end(), 0.0);
|
||||
truePeakHistoryWriteIndex_ = 0;
|
||||
|
||||
snapshot_ = makeInitialSnapshot();
|
||||
}
|
||||
|
||||
@@ -140,27 +211,35 @@ void LUFSMeterAnalyzer::pushSamples(const float* leftChannel, const float* right
|
||||
}
|
||||
|
||||
const double nowMs = currentTimeMs();
|
||||
advancePeaks(nowMs);
|
||||
advanceTruePeaks(nowMs);
|
||||
|
||||
double maxPeakL = 0.0;
|
||||
double maxPeakR = 0.0;
|
||||
double maxTruePeakL = 0.0;
|
||||
double maxTruePeakR = 0.0;
|
||||
|
||||
for (size_t index = 0; index < length; index += 1) {
|
||||
const float left = leftChannel[index];
|
||||
const float right = rightChannel[index];
|
||||
processLoudnessSample(left, right);
|
||||
processFastMeterSample(left, right, maxPeakL, maxPeakR);
|
||||
processFastMeterSample(left, right);
|
||||
processTruePeakSample(left, right, maxTruePeakL, maxTruePeakR);
|
||||
}
|
||||
|
||||
maybeUpdatePeak(amplitudeToDb(maxPeakL), nowMs, true);
|
||||
maybeUpdatePeak(amplitudeToDb(maxPeakR), nowMs, false);
|
||||
const double truePeakLDb = truePeakAmplitudeToDb(maxTruePeakL);
|
||||
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();
|
||||
updateMomentaryShortTermLoudness();
|
||||
snapshot_.integratedLUFS = static_cast<float>(computeGatedIntegratedLoudness());
|
||||
}
|
||||
|
||||
LUFSMeterSnapshot LUFSMeterAnalyzer::getSnapshot() {
|
||||
advancePeaks(currentTimeMs());
|
||||
advanceTruePeaks(currentTimeMs());
|
||||
recomputeFastSnapshot();
|
||||
return snapshot_;
|
||||
}
|
||||
@@ -303,7 +382,7 @@ double LUFSMeterAnalyzer::applyBiquad(const BiquadCoeffs& coeffs, BiquadState& s
|
||||
return output;
|
||||
}
|
||||
|
||||
void LUFSMeterAnalyzer::processFastMeterSample(float left, float right, double& maxPeakL, double& maxPeakR) {
|
||||
void LUFSMeterAnalyzer::processFastMeterSample(float left, float right) {
|
||||
if (fastSqL_.empty()) {
|
||||
return;
|
||||
}
|
||||
@@ -334,16 +413,44 @@ void LUFSMeterAnalyzer::processFastMeterSample(float left, float right, double&
|
||||
const double coeffR = absR > barEnvelopeR_ ? barAttackCoeff_ : barReleaseCoeff_;
|
||||
barEnvelopeL_ = coeffL * barEnvelopeL_ + (1.0 - coeffL) * absL;
|
||||
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)) {
|
||||
return;
|
||||
}
|
||||
@@ -358,23 +465,23 @@ void LUFSMeterAnalyzer::advancePeaks(double nowMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
snapshot_.peakLDb = static_cast<float>(applyPeakDecay(snapshot_.peakLDb, peakHoldUntilL_, nowMs));
|
||||
snapshot_.peakRDb = static_cast<float>(applyPeakDecay(snapshot_.peakRDb, peakHoldUntilR_, nowMs));
|
||||
snapshot_.truePeakLDb = static_cast<float>(applyPeakDecay(snapshot_.truePeakLDb, peakHoldUntilL_, nowMs));
|
||||
snapshot_.truePeakRDb = static_cast<float>(applyPeakDecay(snapshot_.truePeakRDb, peakHoldUntilR_, nowMs));
|
||||
lastPeakUpdateMs_ = nowMs;
|
||||
}
|
||||
|
||||
void LUFSMeterAnalyzer::maybeUpdatePeak(double peakDb, double nowMs, bool leftChannel) {
|
||||
void LUFSMeterAnalyzer::maybeUpdateTruePeak(double peakDb, double nowMs, bool leftChannel) {
|
||||
if (leftChannel) {
|
||||
if (peakDb > snapshot_.peakLDb) {
|
||||
snapshot_.peakLDb = static_cast<float>(peakDb);
|
||||
peakHoldUntilL_ = nowMs + VU_PEAK_HOLD_MS;
|
||||
if (peakDb > snapshot_.truePeakLDb) {
|
||||
snapshot_.truePeakLDb = static_cast<float>(peakDb);
|
||||
peakHoldUntilL_ = nowMs + TRUE_PEAK_HOLD_MS;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (peakDb > snapshot_.peakRDb) {
|
||||
snapshot_.peakRDb = static_cast<float>(peakDb);
|
||||
peakHoldUntilR_ = nowMs + VU_PEAK_HOLD_MS;
|
||||
if (peakDb > snapshot_.truePeakRDb) {
|
||||
snapshot_.truePeakRDb = static_cast<float>(peakDb);
|
||||
peakHoldUntilR_ = nowMs + TRUE_PEAK_HOLD_MS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,7 +491,7 @@ double LUFSMeterAnalyzer::applyPeakDecay(double currentDb, double holdUntilMs, d
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
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) {
|
||||
return std::max(minDb, std::min(maxDb, db));
|
||||
}
|
||||
|
||||
+15
-5
@@ -14,8 +14,9 @@ struct LUFSMeterSnapshot {
|
||||
float vuRDb;
|
||||
float barLDb;
|
||||
float barRDb;
|
||||
float peakLDb;
|
||||
float peakRDb;
|
||||
float truePeakLDb;
|
||||
float truePeakRDb;
|
||||
float maxTruePeakDb;
|
||||
float correlation;
|
||||
};
|
||||
|
||||
@@ -47,6 +48,7 @@ private:
|
||||
void configureForSampleRate(float sampleRate);
|
||||
void configureKWeighting();
|
||||
void configureFastMeter();
|
||||
void configureTruePeakDetector();
|
||||
void processLoudnessSample(float left, float right);
|
||||
void updateMomentaryShortTermLoudness();
|
||||
double computeGatedIntegratedLoudness() const;
|
||||
@@ -54,13 +56,15 @@ private:
|
||||
double histogramLufsAtIndex(size_t index) const;
|
||||
double applyBiquad(const BiquadCoeffs& coeffs, BiquadState& state, double input);
|
||||
|
||||
void processFastMeterSample(float left, float right, double& maxPeakL, double& maxPeakR);
|
||||
void advancePeaks(double nowMs);
|
||||
void maybeUpdatePeak(double peakDb, double nowMs, bool leftChannel);
|
||||
void processFastMeterSample(float left, float right);
|
||||
void processTruePeakSample(float left, float right, double& maxTruePeakL, double& maxTruePeakR);
|
||||
void advanceTruePeaks(double nowMs);
|
||||
void maybeUpdateTruePeak(double peakDb, double nowMs, bool leftChannel);
|
||||
double applyPeakDecay(double currentDb, double holdUntilMs, double nowMs) const;
|
||||
void recomputeFastSnapshot();
|
||||
static double currentTimeMs();
|
||||
static double amplitudeToDb(double amplitude);
|
||||
static double truePeakAmplitudeToDb(double amplitude);
|
||||
static double clampDb(double db, double minDb, double maxDb);
|
||||
static BiquadCoeffs preFilterCoeffs(double sampleRate);
|
||||
static BiquadCoeffs rlbFilterCoeffs(double sampleRate);
|
||||
@@ -99,6 +103,12 @@ private:
|
||||
double lastPeakUpdateMs_ = 0.0;
|
||||
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_{};
|
||||
};
|
||||
|
||||
|
||||
+3
-2
@@ -690,8 +690,9 @@ Napi::Value LUFSMeterGetSnapshot(const Napi::CallbackInfo& info) {
|
||||
obj.Set("vuRDb", Napi::Number::New(env, snapshot.vuRDb));
|
||||
obj.Set("barLDb", Napi::Number::New(env, snapshot.barLDb));
|
||||
obj.Set("barRDb", Napi::Number::New(env, snapshot.barRDb));
|
||||
obj.Set("peakLDb", Napi::Number::New(env, snapshot.peakLDb));
|
||||
obj.Set("peakRDb", Napi::Number::New(env, snapshot.peakRDb));
|
||||
obj.Set("truePeakLDb", Napi::Number::New(env, snapshot.truePeakLDb));
|
||||
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));
|
||||
return obj;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
"test:spectrum-native": "node --test test/spectrum-native.test.mjs",
|
||||
"test:spectrogram-native": "node --test test/spectrogram-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:build-metadata": "node scripts/run-build-metadata-tests.mjs",
|
||||
"test:updates": "node scripts/run-update-tests.mjs",
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
|
||||
/**
|
||||
* Loudness (LUFS) meter engine. Pushes stereo audio into the reused
|
||||
* `Visualizer::LUFSMeterAnalyzer` (K-weighting + gated integration + the same fast
|
||||
* VU/peak/correlation block) and emits its scalar snapshot each frame. Like the VU
|
||||
* engine the frame is plain numbers (no base64); getSnapshot() advances peak decay
|
||||
* `Visualizer::LUFSMeterAnalyzer` (K-weighting, gated integration, true peak, and
|
||||
* the same fast VU/correlation block) and emits its scalar snapshot each frame.
|
||||
* 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 —
|
||||
* LUFS settings (mode/readout) are render-side.
|
||||
*/
|
||||
@@ -44,8 +44,9 @@ public:
|
||||
obj->setProperty("vuRDb", snap.vuRDb);
|
||||
obj->setProperty("barLDb", snap.barLDb);
|
||||
obj->setProperty("barRDb", snap.barRDb);
|
||||
obj->setProperty("peakLDb", snap.peakLDb);
|
||||
obj->setProperty("peakRDb", snap.peakRDb);
|
||||
obj->setProperty("truePeakLDb", snap.truePeakLDb);
|
||||
obj->setProperty("truePeakRDb", snap.truePeakRDb);
|
||||
obj->setProperty("maxTruePeakDb", snap.maxTruePeakDb);
|
||||
obj->setProperty("correlation", snap.correlation);
|
||||
return juce::var(obj);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -19,6 +19,7 @@ const testScripts = [
|
||||
'test:spectrum-native',
|
||||
'test:spectrogram-native',
|
||||
'test:vectorscope-native',
|
||||
'test:lufsmeter-native',
|
||||
]
|
||||
|
||||
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { LUFSMeterNativeAnalyzer, LUFSMeterNativeSnapshot } from '../render
|
||||
/**
|
||||
* 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
|
||||
* caches that snapshot and serves it through the interface `LUFSMeter` consumes.
|
||||
* `pushSamples` is a no-op (audio never flows through the webview); `reset` clears
|
||||
|
||||
@@ -275,16 +275,25 @@ export interface LUFSMeterFrame {
|
||||
vuRDb: number
|
||||
barLDb: number
|
||||
barRDb: number
|
||||
peakLDb: number
|
||||
peakRDb: number
|
||||
truePeakLDb: number
|
||||
truePeakRDb: number
|
||||
maxTruePeakDb: 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
|
||||
const p = payload as Record<string, unknown>
|
||||
const num = (key: string, fallback: number): number =>
|
||||
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 {
|
||||
sampleRate: num('sampleRate', 48000) > 0 ? num('sampleRate', 48000) : 48000,
|
||||
momentaryLUFS: num('momentaryLUFS', -70),
|
||||
@@ -294,8 +303,13 @@ function decodeLUFSMeterFrame(payload: unknown): LUFSMeterFrame | null {
|
||||
vuRDb: num('vuRDb', -60),
|
||||
barLDb: num('barLDb', -60),
|
||||
barRDb: num('barRDb', -60),
|
||||
peakLDb: num('peakLDb', -60),
|
||||
peakRDb: num('peakRDb', -60),
|
||||
truePeakLDb,
|
||||
truePeakRDb,
|
||||
maxTruePeakDb: explicitMaximum ?? Math.max(
|
||||
Number.isFinite(previousMaxTruePeakDb) ? previousMaxTruePeakDb : -60,
|
||||
truePeakLDb,
|
||||
truePeakRDb,
|
||||
),
|
||||
correlation: num('correlation', 0),
|
||||
}
|
||||
}
|
||||
@@ -309,6 +323,8 @@ export function connectLUFSMeterBridge(handlers: LUFSMeterBridgeHandlers): () =>
|
||||
let disposed = false
|
||||
let listenerId: number | null = null
|
||||
let mockRaf: number | null = null
|
||||
let maxTruePeakDb = -60
|
||||
let previousSampleRate = 0
|
||||
|
||||
const startMock = (): void => {
|
||||
handlers.onConnected?.(true)
|
||||
@@ -320,6 +336,9 @@ export function connectLUFSMeterBridge(handlers: LUFSMeterBridgeHandlers): () =>
|
||||
const lufs = (offset: number): number => -24 + Math.sin(phase + offset) * 6
|
||||
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 truePeakLDb = vuL + 3
|
||||
const truePeakRDb = vuR + 3
|
||||
maxTruePeakDb = Math.max(maxTruePeakDb, truePeakLDb, truePeakRDb)
|
||||
handlers.onFrame({
|
||||
sampleRate: 48000,
|
||||
momentaryLUFS: lufs(0),
|
||||
@@ -329,8 +348,9 @@ export function connectLUFSMeterBridge(handlers: LUFSMeterBridgeHandlers): () =>
|
||||
vuRDb: vuR,
|
||||
barLDb: vuL,
|
||||
barRDb: vuR,
|
||||
peakLDb: vuL + 3,
|
||||
peakRDb: vuR + 3,
|
||||
truePeakLDb,
|
||||
truePeakRDb,
|
||||
maxTruePeakDb,
|
||||
correlation: Math.sin(phase * 0.3),
|
||||
})
|
||||
mockRaf = requestAnimationFrame(tick)
|
||||
@@ -342,8 +362,15 @@ export function connectLUFSMeterBridge(handlers: LUFSMeterBridgeHandlers): () =>
|
||||
if (disposed) return
|
||||
if (backend) {
|
||||
listenerId = backend.addEventListener('lufsmeterFrame', (payload) => {
|
||||
const frame = decodeLUFSMeterFrame(payload)
|
||||
if (frame) handlers.onFrame(frame)
|
||||
let frame = decodeLUFSMeterFrame(payload, maxTruePeakDb)
|
||||
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)
|
||||
console.log('[prism-plugin] connected to JUCE host (lufsmeter)')
|
||||
|
||||
+3
-2
@@ -54,8 +54,9 @@ export interface LUFSMeterNativeSnapshot {
|
||||
vuRDb: number;
|
||||
barLDb: number;
|
||||
barRDb: number;
|
||||
peakLDb: number;
|
||||
peakRDb: number;
|
||||
truePeakLDb: number;
|
||||
truePeakRDb: number;
|
||||
maxTruePeakDb: number;
|
||||
correlation: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ const TARGET_LUFS = -14
|
||||
const METER_MIN_DB = -60
|
||||
const MAX_METER_VIEWPORT_CSS_WIDTH = 240
|
||||
const FULL_READOUT_WIDTH_SAMPLE = '-00.0LUFS'
|
||||
const TRUE_PEAK_EMPTY_DB = METER_MIN_DB
|
||||
|
||||
const INITIAL_NATIVE_SNAPSHOT: LUFSMeterNativeSnapshot = {
|
||||
momentaryLUFS: METER_MIN_LUFS,
|
||||
@@ -82,8 +83,9 @@ const INITIAL_NATIVE_SNAPSHOT: LUFSMeterNativeSnapshot = {
|
||||
vuRDb: METER_MIN_DB,
|
||||
barLDb: METER_MIN_DB,
|
||||
barRDb: METER_MIN_DB,
|
||||
peakLDb: METER_MIN_DB,
|
||||
peakRDb: METER_MIN_DB,
|
||||
truePeakLDb: METER_MIN_DB,
|
||||
truePeakRDb: METER_MIN_DB,
|
||||
maxTruePeakDb: TRUE_PEAK_EMPTY_DB,
|
||||
correlation: 0,
|
||||
}
|
||||
|
||||
@@ -104,12 +106,20 @@ function normalizeNativeSnapshot(snapshot: LUFSMeterNativeSnapshot | null): LUFS
|
||||
vuRDb: finiteNumber(snapshot.vuRDb, METER_MIN_DB),
|
||||
barLDb: finiteNumber(snapshot.barLDb, METER_MIN_DB),
|
||||
barRDb: finiteNumber(snapshot.barRDb, METER_MIN_DB),
|
||||
peakLDb: finiteNumber(snapshot.peakLDb, METER_MIN_DB),
|
||||
peakRDb: finiteNumber(snapshot.peakRDb, METER_MIN_DB),
|
||||
truePeakLDb: finiteNumber(snapshot.truePeakLDb, METER_MIN_DB),
|
||||
truePeakRDb: finiteNumber(snapshot.truePeakRDb, METER_MIN_DB),
|
||||
maxTruePeakDb: finiteNumber(snapshot.maxTruePeakDb, TRUE_PEAK_EMPTY_DB),
|
||||
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 ----
|
||||
|
||||
export class LUFSMeter {
|
||||
@@ -383,8 +393,12 @@ export class LUFSMeter {
|
||||
|
||||
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 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 meterBottom = height - paddingY
|
||||
const meterBottom = Math.max(meterTop + 1, height - paddingY - footerHeight - footerGap)
|
||||
const meterHeight = Math.max(1, meterBottom - meterTop)
|
||||
const availableViewportWidth = Math.max(1, width - paddingX * 2)
|
||||
const viewportWidth = Math.max(1, Math.min(
|
||||
@@ -448,7 +462,7 @@ export class LUFSMeter {
|
||||
barWidth,
|
||||
meterHeight,
|
||||
this.snapshot.barLDb,
|
||||
this.snapshot.peakLDb,
|
||||
this.snapshot.truePeakLDb,
|
||||
tint,
|
||||
dpr,
|
||||
)
|
||||
@@ -458,7 +472,7 @@ export class LUFSMeter {
|
||||
barWidth,
|
||||
meterHeight,
|
||||
this.snapshot.barRDb,
|
||||
this.snapshot.peakRDb,
|
||||
this.snapshot.truePeakRDb,
|
||||
tint,
|
||||
dpr,
|
||||
)
|
||||
@@ -509,6 +523,29 @@ export class LUFSMeter {
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillStyle = this.contrastForLevelColor()
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -115,11 +115,11 @@ import {
|
||||
HEAT_MIN_DB,
|
||||
normalizeHeatDb,
|
||||
} 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 { SpectrumAnalyzer, type SpectrumAnalyzerOptions } from '../src/renderer/visualizers/SpectrumAnalyzer'
|
||||
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 { spectrogramSettingsToOptions } from '../src/plugin-ui/spectrogramOptions'
|
||||
import { spectrumSettingsToOptions } from '../src/plugin-ui/spectrumOptions'
|
||||
@@ -6296,8 +6296,9 @@ function createFakeLUFSMeterNativeAnalyzer(
|
||||
vuRDb: -13,
|
||||
barLDb: -10,
|
||||
barRDb: -12,
|
||||
peakLDb: -4,
|
||||
peakRDb: -5,
|
||||
truePeakLDb: -4,
|
||||
truePeakRDb: -5,
|
||||
maxTruePeakDb: -4,
|
||||
correlation: 0.5,
|
||||
...snapshotOverrides,
|
||||
} satisfies LUFSMeterNativeSnapshot,
|
||||
@@ -6319,7 +6320,45 @@ function createFakeLUFSMeterNativeAnalyzer(
|
||||
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 recorder = createFakeCanvasRecorder()
|
||||
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,
|
||||
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), {
|
||||
dataSource,
|
||||
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)'),
|
||||
true,
|
||||
)
|
||||
assert.equal(
|
||||
recorder.fillTexts.some((text) => text.text === 'MAX TP +3.0 dBTP' && text.fillStyle === 'rgb(7, 8, 9)'),
|
||||
true,
|
||||
)
|
||||
} finally {
|
||||
meter.dispose()
|
||||
dom.restore()
|
||||
@@ -6419,7 +6467,7 @@ test('LUFSMeter fits readout text inside narrow tags', () => {
|
||||
isPlaying: () => true,
|
||||
subscribeToSessionChanges: () => () => {},
|
||||
}
|
||||
const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer({ momentaryLUFS: -8.1 })
|
||||
const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer({ momentaryLUFS: -8.1, maxTruePeakDb: 3 })
|
||||
const meter = new LUFSMeter(createFakeCanvas(recorder, 180, 360), {
|
||||
dataSource,
|
||||
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 estimatedTextWidth = readout.text.length * fontSize * 0.62
|
||||
assert.equal(estimatedTextWidth <= tagRect.width - 8, true)
|
||||
assert.equal(recorder.fillTexts.some((text) => text.text === 'MAX TP +3.0 dBTP'), true)
|
||||
} finally {
|
||||
meter.dispose()
|
||||
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 target = recorder.fillRects.find((rect) => rect.fillStyle === '#222222')
|
||||
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 viewportRight = viewportLeft + 240
|
||||
|
||||
@@ -6503,7 +6552,7 @@ test('LUFSMeter keeps minimum-width layout and compact readout inside canvas bou
|
||||
const dom = installFakeCanvasDom()
|
||||
const recorder = createFakeCanvasRecorder()
|
||||
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, {
|
||||
dataSource: {
|
||||
getPendingLUFSMeterSamples: () => [],
|
||||
@@ -6527,11 +6576,17 @@ test('LUFSMeter keeps minimum-width layout and compact readout inside canvas bou
|
||||
&& rect.height > 2
|
||||
&& 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.ok(tag)
|
||||
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) {
|
||||
assert.ok(rect.x >= 0, `rectangle starts before canvas: ${JSON.stringify(rect)}`)
|
||||
|
||||
Reference in New Issue
Block a user