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
|
- 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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
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)}`)
|
||||||
|
|||||||
Reference in New Issue
Block a user