BS.1770 true-peak

This commit is contained in:
Boof2015
2026-08-18 02:11:46 -04:00
parent 741de975bb
commit eda0fbef51
16 changed files with 694 additions and 71 deletions
+3
View File
@@ -193,6 +193,9 @@ jobs:
- name: Test native vectorscope calibration
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
+3
View File
@@ -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
+10 -2
View File
@@ -27,7 +27,7 @@ Prism includes seven real-time scopes and meters:
* **Vectorscope** — Full-band stereo phase analysis with XY, Polar, and M/S Linear views, calibrated references, adjustable zoom, and optional multiband RGB
* **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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+2
View File
@@ -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",
+6 -5
View File
@@ -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);
}
+126
View File
@@ -0,0 +1,126 @@
import { readFile, readdir, stat } from 'node:fs/promises'
import { basename, join, resolve } from 'node:path'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const { lufsmeter } = require('../native/build/Release/visualizer_dsp.node')
const sourcePath = process.argv[2]
if (!sourcePath) {
console.error('Usage: npm run test:lufsmeter-ebu -- /path/to/extracted-ebu-loudness-test-set')
process.exit(2)
}
async function collectFiles(path) {
const details = await stat(path)
if (details.isFile()) return [path]
if (!details.isDirectory()) return []
const files = []
for (const entry of await readdir(path, { withFileTypes: true })) {
files.push(...await collectFiles(join(path, entry.name)))
}
return files
}
function decodeWave(buffer) {
if (buffer.length < 12 || buffer.toString('ascii', 0, 4) !== 'RIFF'
|| buffer.toString('ascii', 8, 12) !== 'WAVE') {
throw new Error('not a RIFF/WAVE file')
}
let format = null
let data = null
for (let offset = 12; offset + 8 <= buffer.length;) {
const chunkId = buffer.toString('ascii', offset, offset + 4)
const chunkSize = buffer.readUInt32LE(offset + 4)
const chunkOffset = offset + 8
if (chunkOffset + chunkSize > buffer.length) throw new Error(`truncated ${chunkId} chunk`)
if (chunkId === 'fmt ') {
if (chunkSize < 16) throw new Error('invalid fmt chunk')
let formatTag = buffer.readUInt16LE(chunkOffset)
if (formatTag === 0xfffe && chunkSize >= 40) {
formatTag = buffer.readUInt16LE(chunkOffset + 24)
}
format = {
formatTag,
channels: buffer.readUInt16LE(chunkOffset + 2),
sampleRate: buffer.readUInt32LE(chunkOffset + 4),
blockAlign: buffer.readUInt16LE(chunkOffset + 12),
bitsPerSample: buffer.readUInt16LE(chunkOffset + 14),
}
} else if (chunkId === 'data') {
data = buffer.subarray(chunkOffset, chunkOffset + chunkSize)
}
offset = chunkOffset + chunkSize + (chunkSize % 2)
}
if (!format || !data) throw new Error('missing fmt or data chunk')
if (format.channels < 1 || format.blockAlign < 1 || format.sampleRate < 1) {
throw new Error('invalid WAVE format')
}
const bytesPerSample = Math.ceil(format.bitsPerSample / 8)
const frameCount = Math.floor(data.length / format.blockAlign)
const left = new Float32Array(frameCount)
const right = new Float32Array(frameCount)
const readSample = (offset) => {
if (format.formatTag === 3 && format.bitsPerSample === 32) return data.readFloatLE(offset)
if (format.formatTag !== 1) throw new Error(`unsupported WAVE format tag ${format.formatTag}`)
if (format.bitsPerSample === 16) return data.readInt16LE(offset) / 0x8000
if (format.bitsPerSample === 24) return data.readIntLE(offset, 3) / 0x800000
if (format.bitsPerSample === 32) return data.readInt32LE(offset) / 0x80000000
throw new Error(`unsupported PCM depth ${format.bitsPerSample}`)
}
for (let frame = 0; frame < frameCount; frame += 1) {
const frameOffset = frame * format.blockAlign
left[frame] = readSample(frameOffset)
right[frame] = format.channels > 1 ? readSample(frameOffset + bytesPerSample) : left[frame]
}
return { sampleRate: format.sampleRate, left, right }
}
function expectedDbtp(caseNumber) {
if (caseNumber >= 15 && caseNumber <= 18) return -6
if (caseNumber === 19) return 3
return 0
}
const files = await collectFiles(resolve(sourcePath))
const cases = new Map()
for (const file of files) {
const match = basename(file).match(/3341[-_ ](1[5-9]|2[0-3])(?:\D|$)/i)
if (match && /\.wav(?:\.wav)?$/i.test(file)) cases.set(Number(match[1]), file)
}
const missing = []
for (let caseNumber = 15; caseNumber <= 23; caseNumber += 1) {
if (!cases.has(caseNumber)) missing.push(caseNumber)
}
if (missing.length > 0) {
console.error(`Could not find EBU Tech 3341 WAV cases: ${missing.join(', ')}`)
process.exit(2)
}
let failed = false
for (let caseNumber = 15; caseNumber <= 23; caseNumber += 1) {
const file = cases.get(caseNumber)
try {
const wave = decodeWave(await readFile(file))
lufsmeter.setSampleRate(wave.sampleRate)
lufsmeter.reset()
lufsmeter.pushSamples(wave.left, wave.right)
const actual = lufsmeter.getSnapshot().maxTruePeakDb
const expected = expectedDbtp(caseNumber)
const passed = actual >= expected - 0.4 && actual <= expected + 0.2
console.log(`${passed ? 'PASS' : 'FAIL'} EBU 3341-${caseNumber}: ${actual.toFixed(3)} dBTP (expected ${expected}, +0.2/-0.4)`)
failed ||= !passed
} catch (error) {
failed = true
console.error(`FAIL EBU 3341-${caseNumber}: ${error instanceof Error ? error.message : String(error)}`)
}
}
process.exit(failed ? 1 : 0)
+1
View File
@@ -19,6 +19,7 @@ const testScripts = [
'test:spectrum-native',
'test:spectrogram-native',
'test:vectorscope-native',
'test:lufsmeter-native',
]
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'
+1 -1
View File
@@ -3,7 +3,7 @@ import type { LUFSMeterNativeAnalyzer, LUFSMeterNativeSnapshot } from '../render
/**
* Drop-in `LUFSMeterNativeAnalyzer` for the plugin webview.
*
* 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
+36 -9
View File
@@ -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
View File
@@ -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;
}
+44 -7
View File
@@ -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 {
+234
View File
@@ -0,0 +1,234 @@
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import test from 'node:test'
const require = createRequire(import.meta.url)
const { lufsmeter } = require('../native/build/Release/visualizer_dsp.node')
const EBU_SAMPLE_RATE = 48000
const EBU_TOLERANCE_BELOW_DB = 0.4
const EBU_TOLERANCE_ABOVE_DB = 0.2
const SILENCE_DB = -60
function assertEbuTolerance(actual, expected, label) {
assert.ok(
actual >= expected - EBU_TOLERANCE_BELOW_DB
&& actual <= expected + EBU_TOLERANCE_ABOVE_DB,
`${label}: expected ${expected} dBTP (+${EBU_TOLERANCE_ABOVE_DB}/-${EBU_TOLERANCE_BELOW_DB}), got ${actual}`,
)
}
function createPhaseTone(
sampleRate,
frequency,
amplitude,
phaseDegrees,
durationSeconds = 0.1,
) {
const length = Math.max(1, Math.round(sampleRate * durationSeconds))
const taperSamples = Math.max(1, Math.round(sampleRate * 0.01))
const phaseRadians = phaseDegrees * Math.PI / 180
const samples = new Float32Array(length)
for (let index = 0; index < length; index += 1) {
const fadeIn = Math.min(1, index / taperSamples)
const fadeOut = Math.min(1, (length - 1 - index) / taperSamples)
const taper = Math.max(0, Math.min(fadeIn, fadeOut))
samples[index] = amplitude
* Math.sin((2 * Math.PI * frequency * index) / sampleRate + phaseRadians)
* taper
}
return samples
}
function pushStereo(left, right = left, chunkSizes = null) {
if (!chunkSizes) {
lufsmeter.pushSamples(left, right)
return
}
let offset = 0
let chunkIndex = 0
while (offset < Math.min(left.length, right.length)) {
const requestedSize = chunkSizes[chunkIndex % chunkSizes.length]
const length = Math.min(requestedSize, left.length - offset, right.length - offset)
lufsmeter.pushSamples(
left.subarray(offset, offset + length),
right.subarray(offset, offset + length),
)
offset += length
chunkIndex += 1
}
}
function measure(sampleRate, left, right = left, chunkSizes = null) {
lufsmeter.setSampleRate(sampleRate)
lufsmeter.reset()
pushStereo(left, right, chunkSizes)
return lufsmeter.getSnapshot()
}
function besselI0(value) {
let sum = 1
let term = 1
const squaredQuarter = value * value / 4
for (let index = 1; index < 40; index += 1) {
term *= squaredQuarter / (index * index)
sum += term
if (term < sum * 1e-15) break
}
return sum
}
function createTransientReferenceAt4x(sampleRate = EBU_SAMPLE_RATE) {
const oversample = 4
const highRate = sampleRate * oversample
const durationSeconds = 0.12
const length = Math.round(highRate * durationSeconds)
const source = new Float64Array(length)
const taperSamples = Math.round(highRate * 0.01)
for (let index = 0; index < length; index += 1) {
const taper = Math.max(0, Math.min(
1,
index / taperSamples,
(length - 1 - index) / taperSamples,
))
source[index] = 0.5 * Math.sin((2 * Math.PI * (sampleRate / 6) * index) / highRate) * taper
}
const transientStart = Math.floor(length / 2) - 8
for (let index = 0; index < 16; index += 1) {
source[transientStart + index] += Math.sin(Math.PI * index / 8)
}
const tapCount = 129
const half = (tapCount - 1) / 2
const cutoff = 0.5 / oversample
const beta = 8.6
const inverseI0Beta = 1 / besselI0(beta)
const coefficients = new Float64Array(tapCount)
let coefficientSum = 0
for (let tap = 0; tap < tapCount; tap += 1) {
const distance = tap - half
const sincArgument = 2 * cutoff * distance
const sinc = Math.abs(sincArgument) < 1e-12
? 1
: Math.sin(Math.PI * sincArgument) / (Math.PI * sincArgument)
const ratio = distance / half
const window = besselI0(beta * Math.sqrt(Math.max(0, 1 - ratio * ratio))) * inverseI0Beta
coefficients[tap] = 2 * cutoff * sinc * window
coefficientSum += coefficients[tap]
}
for (let tap = 0; tap < tapCount; tap += 1) coefficients[tap] /= coefficientSum
const filtered = new Float64Array(length)
for (let index = half; index < length - half; index += 1) {
let value = 0
for (let tap = 0; tap < tapCount; tap += 1) {
value += coefficients[tap] * source[index + tap - half]
}
filtered[index] = value
}
let maximum = 0
for (const value of filtered) maximum = Math.max(maximum, Math.abs(value))
for (let index = 0; index < filtered.length; index += 1) filtered[index] /= maximum
return filtered
}
function downsample4x(reference, offset) {
const length = Math.floor((reference.length - offset) / 4)
const samples = new Float32Array(length)
for (let index = 0; index < length; index += 1) {
samples[index] = reference[index * 4 + offset]
}
return samples
}
test('BS.1770 true peak passes EBU Tech 3341 phase-tone cases 15-19', () => {
const cases = [
{ number: 15, divisor: 4, amplitude: 0.5, phase: 0, expected: -6 },
{ number: 16, divisor: 4, amplitude: 0.5, phase: 45, expected: -6 },
{ number: 17, divisor: 6, amplitude: 0.5, phase: 60, expected: -6 },
{ number: 18, divisor: 8, amplitude: 0.5, phase: 67.5, expected: -6 },
{ number: 19, divisor: 4, amplitude: 1.41, phase: 45, expected: 3 },
]
for (const vector of cases) {
const tone = createPhaseTone(
EBU_SAMPLE_RATE,
EBU_SAMPLE_RATE / vector.divisor,
vector.amplitude,
vector.phase,
)
const snapshot = measure(EBU_SAMPLE_RATE, tone)
assertEbuTolerance(snapshot.maxTruePeakDb, vector.expected, `EBU case ${vector.number}`)
assertEbuTolerance(snapshot.truePeakLDb, vector.expected, `EBU case ${vector.number} left marker`)
assertEbuTolerance(snapshot.truePeakRDb, vector.expected, `EBU case ${vector.number} right marker`)
}
})
test('BS.1770 true peak passes four phase offsets of a filtered transient (EBU cases 20-23)', () => {
const reference = createTransientReferenceAt4x()
for (let offset = 0; offset < 4; offset += 1) {
const samples = downsample4x(reference, offset)
const snapshot = measure(EBU_SAMPLE_RATE, samples)
assertEbuTolerance(snapshot.maxTruePeakDb, 0, `EBU transient case ${20 + offset}`)
}
})
test('true-peak state is independent of arbitrary audio chunk boundaries', () => {
const tone = createPhaseTone(EBU_SAMPLE_RATE, EBU_SAMPLE_RATE / 4, 1.41, 45)
const contiguous = measure(EBU_SAMPLE_RATE, tone).maxTruePeakDb
const irregular = measure(EBU_SAMPLE_RATE, tone, tone, [1, 7, 113, 2, 509, 31]).maxTruePeakDb
assert.ok(Math.abs(contiguous - irregular) < 1e-6, `${contiguous} vs ${irregular}`)
})
test('true peak supports common host rates with rate-aware oversampling', () => {
for (const sampleRate of [44100, 48000, 96000, 192000]) {
const tone = createPhaseTone(sampleRate, sampleRate / 4, 1.41, 45)
const snapshot = measure(sampleRate, tone)
assertEbuTolerance(snapshot.maxTruePeakDb, 3, `${sampleRate} Hz`)
}
})
test('true-peak channels preserve polarity and remain independent', () => {
const positive = createPhaseTone(EBU_SAMPLE_RATE, EBU_SAMPLE_RATE / 4, 1.41, 45)
const negative = Float32Array.from(positive, (sample) => -sample)
const silence = new Float32Array(positive.length)
const positiveSnapshot = measure(EBU_SAMPLE_RATE, positive, silence)
assertEbuTolerance(positiveSnapshot.truePeakLDb, 3, 'left channel')
assert.equal(positiveSnapshot.truePeakRDb, SILENCE_DB)
const negativeSnapshot = measure(EBU_SAMPLE_RATE, negative, silence)
assert.ok(Math.abs(negativeSnapshot.truePeakLDb - positiveSnapshot.truePeakLDb) < 1e-6)
assert.equal(negativeSnapshot.truePeakRDb, SILENCE_DB)
})
test('maximum true peak persists across quieter input and clears on reset', () => {
const loud = createPhaseTone(EBU_SAMPLE_RATE, EBU_SAMPLE_RATE / 4, 1.41, 45)
const quiet = createPhaseTone(EBU_SAMPLE_RATE, EBU_SAMPLE_RATE / 4, 0.1, 45)
const silence = new Float32Array(1024)
lufsmeter.setSampleRate(EBU_SAMPLE_RATE)
lufsmeter.reset()
pushStereo(loud)
const loudSnapshot = lufsmeter.getSnapshot()
assert.ok(loudSnapshot.maxTruePeakDb > 0)
pushStereo(quiet)
assert.equal(lufsmeter.getSnapshot().maxTruePeakDb, loudSnapshot.maxTruePeakDb)
lufsmeter.reset()
assert.deepEqual(
{
truePeakLDb: lufsmeter.getSnapshot().truePeakLDb,
truePeakRDb: lufsmeter.getSnapshot().truePeakRDb,
maxTruePeakDb: lufsmeter.getSnapshot().maxTruePeakDb,
},
{ truePeakLDb: SILENCE_DB, truePeakRDb: SILENCE_DB, maxTruePeakDb: SILENCE_DB },
)
pushStereo(silence)
assert.equal(lufsmeter.getSnapshot().maxTruePeakDb, SILENCE_DB)
})
+65 -10
View File
@@ -115,11 +115,11 @@ import {
HEAT_MIN_DB,
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)}`)