rewrite LUFS meter to native

This commit is contained in:
Boof2015
2026-05-19 15:17:32 -04:00
parent eccd7227d9
commit bc1d796786
9 changed files with 940 additions and 314 deletions
+1
View File
@@ -11,6 +11,7 @@
"src/spectrum.cpp",
"src/spectrogram.cpp",
"src/vectorscope.cpp",
"src/lufsmeter.cpp",
"src/dsp_utils.cpp"
],
"include_dirs": [
+459
View File
@@ -0,0 +1,459 @@
#include "lufsmeter.h"
#include <algorithm>
#include <chrono>
#include <cmath>
namespace Visualizer {
namespace {
constexpr double PI = 3.14159265358979323846;
constexpr double METER_MIN_LUFS = -60.0;
constexpr double VU_METER_MIN_DB = -60.0;
constexpr double VU_METER_MAX_DB = 0.0;
constexpr double MOMENTARY_WINDOW_S = 0.4;
constexpr double SHORT_TERM_WINDOW_S = 3.0;
constexpr double INTEGRATED_BLOCK_S = 0.4;
constexpr double INTEGRATED_HOP_S = 0.1;
constexpr double ABSOLUTE_GATE_LUFS = -70.0;
constexpr double RELATIVE_GATE_OFFSET = -10.0;
constexpr double INTEGRATED_HISTOGRAM_MIN_LUFS = ABSOLUTE_GATE_LUFS;
constexpr double INTEGRATED_HISTOGRAM_MAX_LUFS = 10.0;
constexpr double INTEGRATED_HISTOGRAM_BIN_WIDTH = 0.1;
constexpr size_t INTEGRATED_HISTOGRAM_BIN_COUNT =
static_cast<size_t>((INTEGRATED_HISTOGRAM_MAX_LUFS - INTEGRATED_HISTOGRAM_MIN_LUFS)
/ INTEGRATED_HISTOGRAM_BIN_WIDTH + 0.5) + 1;
constexpr double PRE_FILTER_F0_HZ = 1681.9744509555319;
constexpr double PRE_FILTER_GAIN_DB = 3.999843853973347;
constexpr double PRE_FILTER_Q = 0.7071752369554193;
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 BAR_ATTACK_MS = 5.0;
constexpr double BAR_RELEASE_MS = 180.0;
LUFSMeterSnapshot makeInitialSnapshot() {
return {
static_cast<float>(METER_MIN_LUFS),
static_cast<float>(METER_MIN_LUFS),
static_cast<float>(METER_MIN_LUFS),
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,
};
}
float sanitizeSampleRate(float sampleRate) {
if (!std::isfinite(sampleRate) || sampleRate <= 0.0f) {
return 1.0f;
}
return std::max(1.0f, std::floor(sampleRate));
}
} // namespace
LUFSMeterAnalyzer::LUFSMeterAnalyzer() {
configureForSampleRate(sampleRate_);
}
void LUFSMeterAnalyzer::setSampleRate(float sampleRate) {
configureForSampleRate(sampleRate);
}
void LUFSMeterAnalyzer::configureForSampleRate(float sampleRate) {
sampleRate_ = sanitizeSampleRate(sampleRate);
configureKWeighting();
const size_t ringSize = std::max<size_t>(
1,
static_cast<size_t>(std::ceil(static_cast<double>(sampleRate_) * SHORT_TERM_WINDOW_S))
);
ringBufferL_.assign(ringSize, 0.0);
ringBufferR_.assign(ringSize, 0.0);
integratedHistogramCounts_.assign(INTEGRATED_HISTOGRAM_BIN_COUNT, 0);
integratedHistogramPowerSums_.assign(INTEGRATED_HISTOGRAM_BIN_COUNT, 0.0);
configureFastMeter();
reset();
}
void LUFSMeterAnalyzer::configureKWeighting() {
preCoeffs_ = preFilterCoeffs(sampleRate_);
rlbCoeffs_ = rlbFilterCoeffs(sampleRate_);
}
void LUFSMeterAnalyzer::configureFastMeter() {
integrationWindowSamples_ = std::max<size_t>(
1,
static_cast<size_t>(std::round((static_cast<double>(sampleRate_) * VU_INTEGRATION_WINDOW_MS) / 1000.0))
);
fastSqL_.assign(integrationWindowSamples_, 0.0);
fastSqR_.assign(integrationWindowSamples_, 0.0);
fastCross_.assign(integrationWindowSamples_, 0.0);
barAttackCoeff_ = std::exp(-1.0 / (static_cast<double>(sampleRate_) * (BAR_ATTACK_MS / 1000.0)));
barReleaseCoeff_ = std::exp(-1.0 / (static_cast<double>(sampleRate_) * (BAR_RELEASE_MS / 1000.0)));
}
void LUFSMeterAnalyzer::reset() {
std::fill(ringBufferL_.begin(), ringBufferL_.end(), 0.0);
std::fill(ringBufferR_.begin(), ringBufferR_.end(), 0.0);
ringBufferPos_ = 0;
ringBufferFilled_ = 0;
integratedHopCounter_ = 0;
std::fill(integratedHistogramCounts_.begin(), integratedHistogramCounts_.end(), 0);
std::fill(integratedHistogramPowerSums_.begin(), integratedHistogramPowerSums_.end(), 0.0);
preFilterL_ = {};
preFilterR_ = {};
rlbFilterL_ = {};
rlbFilterR_ = {};
std::fill(fastSqL_.begin(), fastSqL_.end(), 0.0);
std::fill(fastSqR_.begin(), fastSqR_.end(), 0.0);
std::fill(fastCross_.begin(), fastCross_.end(), 0.0);
fastWriteIndex_ = 0;
fastSampleCount_ = 0;
fastSumSqL_ = 0.0;
fastSumSqR_ = 0.0;
fastSumCross_ = 0.0;
barEnvelopeL_ = 0.0;
barEnvelopeR_ = 0.0;
peakHoldUntilL_ = 0.0;
peakHoldUntilR_ = 0.0;
lastPeakUpdateMs_ = 0.0;
hasLastPeakUpdate_ = false;
snapshot_ = makeInitialSnapshot();
}
void LUFSMeterAnalyzer::pushSamples(const float* leftChannel, const float* rightChannel, size_t length) {
if (!leftChannel || !rightChannel || length == 0) {
return;
}
const double nowMs = currentTimeMs();
advancePeaks(nowMs);
double maxPeakL = 0.0;
double maxPeakR = 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);
}
maybeUpdatePeak(amplitudeToDb(maxPeakL), nowMs, true);
maybeUpdatePeak(amplitudeToDb(maxPeakR), nowMs, false);
recomputeFastSnapshot();
updateMomentaryShortTermLoudness();
snapshot_.integratedLUFS = static_cast<float>(computeGatedIntegratedLoudness());
}
LUFSMeterSnapshot LUFSMeterAnalyzer::getSnapshot() {
advancePeaks(currentTimeMs());
recomputeFastSnapshot();
return snapshot_;
}
void LUFSMeterAnalyzer::processLoudnessSample(float left, float right) {
if (ringBufferL_.empty()) {
return;
}
const double kwL = applyBiquad(rlbCoeffs_, rlbFilterL_, applyBiquad(preCoeffs_, preFilterL_, left));
const double kwR = applyBiquad(rlbCoeffs_, rlbFilterR_, applyBiquad(preCoeffs_, preFilterR_, right));
ringBufferL_[ringBufferPos_] = kwL * kwL;
ringBufferR_[ringBufferPos_] = kwR * kwR;
ringBufferPos_ = (ringBufferPos_ + 1) % ringBufferL_.size();
if (ringBufferFilled_ < ringBufferL_.size()) {
ringBufferFilled_ += 1;
}
integratedHopCounter_ += 1;
const size_t hopSamples = std::max<size_t>(
1,
static_cast<size_t>(std::round(static_cast<double>(sampleRate_) * INTEGRATED_HOP_S))
);
const size_t blockSamples = std::max<size_t>(
1,
static_cast<size_t>(std::round(static_cast<double>(sampleRate_) * INTEGRATED_BLOCK_S))
);
if (integratedHopCounter_ < hopSamples || ringBufferFilled_ < blockSamples) {
return;
}
double sumL = 0.0;
double sumR = 0.0;
const size_t bufferLength = ringBufferL_.size();
for (size_t index = 0; index < blockSamples; index += 1) {
const size_t bufferIndex = (ringBufferPos_ + bufferLength - 1 - index) % bufferLength;
sumL += ringBufferL_[bufferIndex];
sumR += ringBufferR_[bufferIndex];
}
const double blockPower = std::max((sumL / blockSamples) + (sumR / blockSamples), 1e-10);
const double blockLUFS = -0.691 + 10.0 * std::log10(blockPower);
if (blockLUFS > ABSOLUTE_GATE_LUFS) {
const size_t histogramIndex = histogramIndexFromLufs(blockLUFS);
integratedHistogramCounts_[histogramIndex] += 1;
integratedHistogramPowerSums_[histogramIndex] += blockPower;
}
integratedHopCounter_ = 0;
}
void LUFSMeterAnalyzer::updateMomentaryShortTermLoudness() {
if (ringBufferL_.empty() || ringBufferFilled_ == 0) {
snapshot_.momentaryLUFS = static_cast<float>(METER_MIN_LUFS);
snapshot_.shortTermLUFS = static_cast<float>(METER_MIN_LUFS);
return;
}
const size_t bufferLength = ringBufferL_.size();
const auto computeWindow = [&](double seconds) -> double {
const size_t samples = std::min(
static_cast<size_t>(std::round(static_cast<double>(sampleRate_) * seconds)),
ringBufferFilled_
);
if (samples == 0) {
return METER_MIN_LUFS;
}
double sumL = 0.0;
double sumR = 0.0;
for (size_t index = 0; index < samples; index += 1) {
const size_t bufferIndex = (ringBufferPos_ + bufferLength - 1 - index) % bufferLength;
sumL += ringBufferL_[bufferIndex];
sumR += ringBufferR_[bufferIndex];
}
const double power = std::max((sumL / samples) + (sumR / samples), 1e-10);
return std::max(METER_MIN_LUFS, -0.691 + 10.0 * std::log10(power));
};
snapshot_.momentaryLUFS = static_cast<float>(computeWindow(MOMENTARY_WINDOW_S));
snapshot_.shortTermLUFS = static_cast<float>(computeWindow(SHORT_TERM_WINDOW_S));
}
double LUFSMeterAnalyzer::computeGatedIntegratedLoudness() const {
uint64_t absoluteCount = 0;
double absolutePowerSum = 0.0;
for (size_t index = 0; index < integratedHistogramCounts_.size(); index += 1) {
const uint32_t count = integratedHistogramCounts_[index];
if (count == 0) {
continue;
}
absoluteCount += count;
absolutePowerSum += integratedHistogramPowerSums_[index];
}
if (absoluteCount == 0 || absolutePowerSum <= 0.0) {
return METER_MIN_LUFS;
}
const double ungatedMean = -0.691 + 10.0 * std::log10(absolutePowerSum / absoluteCount);
const double relativeThreshold = ungatedMean + RELATIVE_GATE_OFFSET;
uint64_t relativeCount = 0;
double relativePowerSum = 0.0;
for (size_t index = 0; index < integratedHistogramCounts_.size(); index += 1) {
const uint32_t count = integratedHistogramCounts_[index];
if (count == 0 || histogramLufsAtIndex(index) <= relativeThreshold) {
continue;
}
relativeCount += count;
relativePowerSum += integratedHistogramPowerSums_[index];
}
if (relativeCount == 0 || relativePowerSum <= 0.0) {
return METER_MIN_LUFS;
}
return std::max(METER_MIN_LUFS, -0.691 + 10.0 * std::log10(relativePowerSum / relativeCount));
}
size_t LUFSMeterAnalyzer::histogramIndexFromLufs(double lufs) const {
const double normalized = (lufs - INTEGRATED_HISTOGRAM_MIN_LUFS) / INTEGRATED_HISTOGRAM_BIN_WIDTH;
const long rounded = static_cast<long>(std::llround(normalized));
return static_cast<size_t>(std::clamp<long>(
rounded,
0,
static_cast<long>(integratedHistogramCounts_.size() - 1)
));
}
double LUFSMeterAnalyzer::histogramLufsAtIndex(size_t index) const {
return INTEGRATED_HISTOGRAM_MIN_LUFS + (static_cast<double>(index) * INTEGRATED_HISTOGRAM_BIN_WIDTH);
}
double LUFSMeterAnalyzer::applyBiquad(const BiquadCoeffs& coeffs, BiquadState& state, double input) {
const double output = coeffs.b0 * input + coeffs.b1 * state.x1 + coeffs.b2 * state.x2
- coeffs.a1 * state.y1 - coeffs.a2 * state.y2;
state.x2 = state.x1;
state.x1 = input;
state.y2 = state.y1;
state.y1 = output;
return output;
}
void LUFSMeterAnalyzer::processFastMeterSample(float left, float right, double& maxPeakL, double& maxPeakR) {
if (fastSqL_.empty()) {
return;
}
const double sqL = static_cast<double>(left) * left;
const double sqR = static_cast<double>(right) * right;
const double cross = static_cast<double>(left) * right;
if (fastSampleCount_ == integrationWindowSamples_) {
fastSumSqL_ = std::max(0.0, fastSumSqL_ - fastSqL_[fastWriteIndex_]);
fastSumSqR_ = std::max(0.0, fastSumSqR_ - fastSqR_[fastWriteIndex_]);
fastSumCross_ -= fastCross_[fastWriteIndex_];
} else {
fastSampleCount_ += 1;
}
fastSqL_[fastWriteIndex_] = sqL;
fastSqR_[fastWriteIndex_] = sqR;
fastCross_[fastWriteIndex_] = cross;
fastSumSqL_ += sqL;
fastSumSqR_ += sqR;
fastSumCross_ += cross;
fastWriteIndex_ = (fastWriteIndex_ + 1) % integrationWindowSamples_;
const double absL = std::abs(static_cast<double>(left));
const double absR = std::abs(static_cast<double>(right));
const double coeffL = absL > barEnvelopeL_ ? barAttackCoeff_ : barReleaseCoeff_;
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) {
if (!std::isfinite(nowMs)) {
return;
}
if (!hasLastPeakUpdate_) {
lastPeakUpdateMs_ = nowMs;
hasLastPeakUpdate_ = true;
return;
}
if (nowMs <= lastPeakUpdateMs_) {
return;
}
snapshot_.peakLDb = static_cast<float>(applyPeakDecay(snapshot_.peakLDb, peakHoldUntilL_, nowMs));
snapshot_.peakRDb = static_cast<float>(applyPeakDecay(snapshot_.peakRDb, peakHoldUntilR_, nowMs));
lastPeakUpdateMs_ = nowMs;
}
void LUFSMeterAnalyzer::maybeUpdatePeak(double peakDb, double nowMs, bool leftChannel) {
if (leftChannel) {
if (peakDb > snapshot_.peakLDb) {
snapshot_.peakLDb = static_cast<float>(peakDb);
peakHoldUntilL_ = nowMs + VU_PEAK_HOLD_MS;
}
return;
}
if (peakDb > snapshot_.peakRDb) {
snapshot_.peakRDb = static_cast<float>(peakDb);
peakHoldUntilR_ = nowMs + VU_PEAK_HOLD_MS;
}
}
double LUFSMeterAnalyzer::applyPeakDecay(double currentDb, double holdUntilMs, double nowMs) const {
const double decayStartMs = std::max(lastPeakUpdateMs_, holdUntilMs);
if (nowMs <= decayStartMs) {
return currentDb;
}
const double decayAmount = ((nowMs - decayStartMs) / 1000.0) * VU_PEAK_DECAY_DB_PER_SECOND;
return std::max(VU_METER_MIN_DB, currentDb - decayAmount);
}
void LUFSMeterAnalyzer::recomputeFastSnapshot() {
if (fastSampleCount_ == 0) {
snapshot_.vuLDb = static_cast<float>(VU_METER_MIN_DB);
snapshot_.vuRDb = static_cast<float>(VU_METER_MIN_DB);
snapshot_.barLDb = static_cast<float>(amplitudeToDb(barEnvelopeL_));
snapshot_.barRDb = static_cast<float>(amplitudeToDb(barEnvelopeR_));
snapshot_.correlation = 0.0f;
return;
}
const double meanSqL = std::max(0.0, fastSumSqL_) / fastSampleCount_;
const double meanSqR = std::max(0.0, fastSumSqR_) / fastSampleCount_;
const double denominator = std::sqrt(std::max(0.0, fastSumSqL_) * std::max(0.0, fastSumSqR_));
snapshot_.vuLDb = static_cast<float>(amplitudeToDb(std::sqrt(meanSqL)));
snapshot_.vuRDb = static_cast<float>(amplitudeToDb(std::sqrt(meanSqR)));
snapshot_.barLDb = static_cast<float>(amplitudeToDb(barEnvelopeL_));
snapshot_.barRDb = static_cast<float>(amplitudeToDb(barEnvelopeR_));
snapshot_.correlation = denominator > 1e-10
? static_cast<float>(std::clamp(fastSumCross_ / denominator, -1.0, 1.0))
: 0.0f;
}
double LUFSMeterAnalyzer::currentTimeMs() {
using Clock = std::chrono::steady_clock;
const auto now = Clock::now().time_since_epoch();
return std::chrono::duration<double, std::milli>(now).count();
}
double LUFSMeterAnalyzer::amplitudeToDb(double amplitude) {
if (!std::isfinite(amplitude) || amplitude <= 0.0) {
return VU_METER_MIN_DB;
}
return clampDb(20.0 * std::log10(std::max(amplitude, 1e-10)), VU_METER_MIN_DB, VU_METER_MAX_DB);
}
double LUFSMeterAnalyzer::clampDb(double db, double minDb, double maxDb) {
return std::max(minDb, std::min(maxDb, db));
}
LUFSMeterAnalyzer::BiquadCoeffs LUFSMeterAnalyzer::preFilterCoeffs(double sampleRate) {
const double K = std::tan(PI * PRE_FILTER_F0_HZ / sampleRate);
const double Vh = std::pow(10.0, PRE_FILTER_GAIN_DB / 20.0);
const double Vb = std::pow(Vh, 0.499666774155997);
const double KK = K * K;
const double a0 = 1.0 + K / PRE_FILTER_Q + KK;
return {
(Vh + (Vb * K) / PRE_FILTER_Q + KK) / a0,
(2.0 * (KK - Vh)) / a0,
(Vh - (Vb * K) / PRE_FILTER_Q + KK) / a0,
(2.0 * (KK - 1.0)) / a0,
(1.0 - K / PRE_FILTER_Q + KK) / a0,
};
}
LUFSMeterAnalyzer::BiquadCoeffs LUFSMeterAnalyzer::rlbFilterCoeffs(double sampleRate) {
const double K = std::tan(PI * RLB_FILTER_F0_HZ / sampleRate);
const double KK = K * K;
const double a0 = 1.0 + K / RLB_FILTER_Q + KK;
return {
1.0,
-2.0,
1.0,
(2.0 * (KK - 1.0)) / a0,
(1.0 - K / RLB_FILTER_Q + KK) / a0,
};
}
} // namespace Visualizer
+105
View File
@@ -0,0 +1,105 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <vector>
namespace Visualizer {
struct LUFSMeterSnapshot {
float momentaryLUFS;
float shortTermLUFS;
float integratedLUFS;
float vuLDb;
float vuRDb;
float barLDb;
float barRDb;
float peakLDb;
float peakRDb;
float correlation;
};
class LUFSMeterAnalyzer {
public:
LUFSMeterAnalyzer();
void setSampleRate(float sampleRate);
void pushSamples(const float* leftChannel, const float* rightChannel, size_t length);
LUFSMeterSnapshot getSnapshot();
void reset();
private:
struct BiquadCoeffs {
double b0;
double b1;
double b2;
double a1;
double a2;
};
struct BiquadState {
double x1 = 0.0;
double x2 = 0.0;
double y1 = 0.0;
double y2 = 0.0;
};
void configureForSampleRate(float sampleRate);
void configureKWeighting();
void configureFastMeter();
void processLoudnessSample(float left, float right);
void updateMomentaryShortTermLoudness();
double computeGatedIntegratedLoudness() const;
size_t histogramIndexFromLufs(double lufs) const;
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);
double applyPeakDecay(double currentDb, double holdUntilMs, double nowMs) const;
void recomputeFastSnapshot();
static double currentTimeMs();
static double amplitudeToDb(double amplitude);
static double clampDb(double db, double minDb, double maxDb);
static BiquadCoeffs preFilterCoeffs(double sampleRate);
static BiquadCoeffs rlbFilterCoeffs(double sampleRate);
float sampleRate_ = 48000.0f;
BiquadCoeffs preCoeffs_{};
BiquadCoeffs rlbCoeffs_{};
BiquadState preFilterL_;
BiquadState preFilterR_;
BiquadState rlbFilterL_;
BiquadState rlbFilterR_;
std::vector<double> ringBufferL_;
std::vector<double> ringBufferR_;
size_t ringBufferPos_ = 0;
size_t ringBufferFilled_ = 0;
size_t integratedHopCounter_ = 0;
std::vector<uint32_t> integratedHistogramCounts_;
std::vector<double> integratedHistogramPowerSums_;
size_t integrationWindowSamples_ = 1;
std::vector<double> fastSqL_;
std::vector<double> fastSqR_;
std::vector<double> fastCross_;
size_t fastWriteIndex_ = 0;
size_t fastSampleCount_ = 0;
double fastSumSqL_ = 0.0;
double fastSumSqR_ = 0.0;
double fastSumCross_ = 0.0;
double barEnvelopeL_ = 0.0;
double barEnvelopeR_ = 0.0;
double barAttackCoeff_ = 0.0;
double barReleaseCoeff_ = 0.0;
double peakHoldUntilL_ = 0.0;
double peakHoldUntilR_ = 0.0;
double lastPeakUpdateMs_ = 0.0;
bool hasLastPeakUpdate_ = false;
LUFSMeterSnapshot snapshot_{};
};
} // namespace Visualizer
+59
View File
@@ -8,12 +8,14 @@
#include "spectrum.h"
#include "spectrogram.h"
#include "vectorscope.h"
#include "lufsmeter.h"
// Global instances
static Visualizer::Oscilloscope oscilloscope;
static Visualizer::Spectrum spectrum(2048);
static Visualizer::SpectrogramAnalyzer spectrogramAnalyzer;
static Visualizer::Vectorscope vectorscope;
static Visualizer::LUFSMeterAnalyzer lufsMeter;
// ============== Oscilloscope ==============
@@ -426,6 +428,55 @@ Napi::Value VectorscopeReset(const Napi::CallbackInfo& info) {
return info.Env().Undefined();
}
// ============== LUFS Meter ==============
Napi::Value LUFSMeterSetSampleRate(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
if (info.Length() < 1 || !info[0].IsNumber()) {
Napi::TypeError::New(env, "Expected sample rate").ThrowAsJavaScriptException();
return env.Null();
}
lufsMeter.setSampleRate(info[0].As<Napi::Number>().FloatValue());
return env.Undefined();
}
Napi::Value LUFSMeterPushSamples(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
if (info.Length() < 2 || !info[0].IsTypedArray() || !info[1].IsTypedArray()) {
Napi::TypeError::New(env, "Expected two Float32Arrays (left, right)").ThrowAsJavaScriptException();
return env.Null();
}
Napi::Float32Array leftData = info[0].As<Napi::Float32Array>();
Napi::Float32Array rightData = info[1].As<Napi::Float32Array>();
const size_t length = std::min(leftData.ElementLength(), rightData.ElementLength());
lufsMeter.pushSamples(leftData.Data(), rightData.Data(), length);
return env.Undefined();
}
Napi::Value LUFSMeterGetSnapshot(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
const auto snapshot = lufsMeter.getSnapshot();
Napi::Object obj = Napi::Object::New(env);
obj.Set("momentaryLUFS", Napi::Number::New(env, snapshot.momentaryLUFS));
obj.Set("shortTermLUFS", Napi::Number::New(env, snapshot.shortTermLUFS));
obj.Set("integratedLUFS", Napi::Number::New(env, snapshot.integratedLUFS));
obj.Set("vuLDb", Napi::Number::New(env, snapshot.vuLDb));
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("correlation", Napi::Number::New(env, snapshot.correlation));
return obj;
}
Napi::Value LUFSMeterReset(const Napi::CallbackInfo& info) {
lufsMeter.reset();
return info.Env().Undefined();
}
// ============== Module Init ==============
Napi::Object Init(Napi::Env env, Napi::Object exports) {
@@ -478,6 +529,14 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
vecExports.Set("reset", Napi::Function::New(env, VectorscopeReset));
exports.Set("vectorscope", vecExports);
// LUFS Meter
Napi::Object lufsExports = Napi::Object::New(env);
lufsExports.Set("setSampleRate", Napi::Function::New(env, LUFSMeterSetSampleRate));
lufsExports.Set("pushSamples", Napi::Function::New(env, LUFSMeterPushSamples));
lufsExports.Set("getSnapshot", Napi::Function::New(env, LUFSMeterGetSnapshot));
lufsExports.Set("reset", Napi::Function::New(env, LUFSMeterReset));
exports.Set("lufsmeter", lufsExports);
RegisterMacOSCapture(env, exports);
RegisterWindowsCapture(env, exports);
RegisterLinuxCapture(env, exports);
+1
View File
@@ -247,6 +247,7 @@ const visualizerAPI = nativeAddonModule
spectrum: nativeAddonModule.spectrum,
spectrogram: nativeAddonModule.spectrogram,
vectorscope: nativeAddonModule.vectorscope,
lufsmeter: nativeAddonModule.lufsmeter,
}
: null
+33
View File
@@ -4,6 +4,7 @@
import type {
VisualizerDSP,
OscilloscopeResult,
LUFSMeterNativeSnapshot,
SpectrogramNativeOptions,
SpectrogramNativeResult,
VectorscopeResult,
@@ -167,6 +168,14 @@ export interface SpectrogramNativeAnalyzer {
isAvailable?: () => boolean
}
export interface LUFSMeterNativeAnalyzer {
setSampleRate(sampleRate: number): void
pushSamples(leftChannel: Float32Array, rightChannel: Float32Array): void
getSnapshot(): LUFSMeterNativeSnapshot | null
reset(): void
isAvailable?: () => boolean
}
export const spectrogram: SpectrogramNativeAnalyzer = {
isAvailable: (): boolean => {
return Boolean(nativeModule?.spectrogram)
@@ -229,7 +238,31 @@ export const vectorscope = {
}
}
export const lufsmeter: LUFSMeterNativeAnalyzer = {
isAvailable: (): boolean => {
return Boolean(nativeModule?.lufsmeter)
},
setSampleRate: (sampleRate: number): void => {
nativeModule?.lufsmeter?.setSampleRate(sampleRate)
},
pushSamples: (leftChannel: Float32Array, rightChannel: Float32Array): void => {
nativeModule?.lufsmeter?.pushSamples(leftChannel, rightChannel)
},
getSnapshot: (): LUFSMeterNativeSnapshot | null => {
if (!nativeModule?.lufsmeter) return null
return nativeModule.lufsmeter.getSnapshot()
},
reset: (): void => {
nativeModule?.lufsmeter?.reset()
},
}
export type {
LUFSMeterNativeSnapshot,
OscilloscopeResult,
SpectrogramNativeOptions,
SpectrogramNativeResult,
+21
View File
@@ -41,6 +41,19 @@ export interface SpectrogramNativeResult {
rowCount: number;
}
export interface LUFSMeterNativeSnapshot {
momentaryLUFS: number;
shortTermLUFS: number;
integratedLUFS: number;
vuLDb: number;
vuRDb: number;
barLDb: number;
barRDb: number;
peakLDb: number;
peakRDb: number;
correlation: number;
}
// Circular buffer size (must match native code)
export const OSCILLOSCOPE_BUFFER_SIZE = 32768;
@@ -102,11 +115,19 @@ export interface VectorscopeModule {
reset(): void;
}
export interface LUFSMeterModule {
setSampleRate(sampleRate: number): void;
pushSamples(leftChannel: Float32Array, rightChannel: Float32Array): void;
getSnapshot(): LUFSMeterNativeSnapshot;
reset(): void;
}
export interface VisualizerDSP {
oscilloscope: OscilloscopeModule;
spectrum: SpectrumModule;
spectrogram: SpectrogramModule;
vectorscope: VectorscopeModule;
lufsmeter: LUFSMeterModule;
}
declare const visualizerDSP: VisualizerDSP;
+115 -292
View File
@@ -1,14 +1,14 @@
import { audioRouter } from '../audio/AudioRouter'
import {
lufsmeter as nativeLUFSMeter,
type LUFSMeterNativeAnalyzer,
type LUFSMeterNativeSnapshot,
} from '../audio/native'
import type { LUFSMeterMode, LUFSMeterReadout } from '../../types/lufsmeter'
import { resolveColorToRgb } from '../utils/color'
import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource'
import { FrameScheduler } from './frameScheduler'
import { VisualizerFrameLoop } from './visualizerFrameLoop'
import {
VUMeterBallistics,
VU_METER_MIN_DB,
type VUMeterSnapshot,
} from './vuMeterBallistics'
export interface LUFSMeterDataSource extends VisualizerSessionSource {
getPendingLUFSMeterSamples: () => Array<{ left: Float32Array; right: Float32Array }>
@@ -25,9 +25,10 @@ export interface LUFSMeterOptions {
labelColor?: string
dataSource?: LUFSMeterDataSource
frameScheduler?: FrameScheduler
nativeAnalyzer?: LUFSMeterNativeAnalyzer | null
}
type ResolvedLUFSMeterOptions = Required<Omit<LUFSMeterOptions, 'dataSource' | 'frameScheduler'>>
type ResolvedLUFSMeterOptions = Required<Omit<LUFSMeterOptions, 'dataSource' | 'frameScheduler' | 'nativeAnalyzer'>>
const defaultOptions: ResolvedLUFSMeterOptions = {
mode: 'bar',
@@ -67,114 +68,45 @@ function contrastRatio(luminanceA: number, luminanceB: number): number {
const METER_MIN_LUFS = -60
const COMPACT_METER_MIN_DB = -50
const COMPACT_METER_MAX_DB = 0
const MOMENTARY_WINDOW_S = 0.4
const SHORT_TERM_WINDOW_S = 3.0
const INTEGRATED_BLOCK_S = 0.4
const INTEGRATED_HOP_S = 0.1
const ABSOLUTE_GATE_LUFS = -70
const RELATIVE_GATE_OFFSET = -10
const TARGET_LUFS = -14
const SMOOTHING = 0.7
const INTEGRATED_HISTOGRAM_MIN_LUFS = ABSOLUTE_GATE_LUFS
const INTEGRATED_HISTOGRAM_MAX_LUFS = 10
const INTEGRATED_HISTOGRAM_BIN_WIDTH = 0.1
const INTEGRATED_HISTOGRAM_BIN_COUNT = Math.round(
(INTEGRATED_HISTOGRAM_MAX_LUFS - INTEGRATED_HISTOGRAM_MIN_LUFS) / INTEGRATED_HISTOGRAM_BIN_WIDTH
) + 1
const METER_MIN_DB = -60
const INITIAL_VU_SNAPSHOT: VUMeterSnapshot = {
vuLDb: VU_METER_MIN_DB,
vuRDb: VU_METER_MIN_DB,
barLDb: VU_METER_MIN_DB,
barRDb: VU_METER_MIN_DB,
peakLDb: VU_METER_MIN_DB,
peakRDb: VU_METER_MIN_DB,
const INITIAL_NATIVE_SNAPSHOT: LUFSMeterNativeSnapshot = {
momentaryLUFS: METER_MIN_LUFS,
shortTermLUFS: METER_MIN_LUFS,
integratedLUFS: METER_MIN_LUFS,
vuLDb: METER_MIN_DB,
vuRDb: METER_MIN_DB,
barLDb: METER_MIN_DB,
barRDb: METER_MIN_DB,
peakLDb: METER_MIN_DB,
peakRDb: METER_MIN_DB,
correlation: 0,
}
// ---- K-weighting filter coefficients (ITU-R BS.1770-4) ----
interface BiquadCoeffs {
b0: number; b1: number; b2: number
a1: number; a2: number
function finiteNumber(value: number, fallback: number): number {
return Number.isFinite(value) ? value : fallback
}
// BS.1770-4 reference design parameters. The values below reproduce the
// standard's reference coefficients at 48 kHz to within 1e-5 and remain
// accurate at any sample rate (44.1k, 48k, 88.2k, 96k, 192k) via the
// bilinear transform with frequency pre-warping. Derivation follows the
// canonical analog prototype used by the ITU reference and pyloudnorm.
const PRE_FILTER_F0_HZ = 1681.9744509555319
const PRE_FILTER_GAIN_DB = 3.999843853973347
const PRE_FILTER_Q = 0.7071752369554193
const RLB_FILTER_F0_HZ = 38.13547087613982
const RLB_FILTER_Q = 0.5003270373223665
function preFilterCoeffs(sampleRate: number): BiquadCoeffs {
const K = Math.tan(Math.PI * PRE_FILTER_F0_HZ / sampleRate)
const Vh = Math.pow(10, PRE_FILTER_GAIN_DB / 20)
const Vb = Math.pow(Vh, 0.499666774155997)
const KK = K * K
const a0 = 1 + K / PRE_FILTER_Q + KK
return {
b0: (Vh + (Vb * K) / PRE_FILTER_Q + KK) / a0,
b1: (2 * (KK - Vh)) / a0,
b2: (Vh - (Vb * K) / PRE_FILTER_Q + KK) / a0,
a1: (2 * (KK - 1)) / a0,
a2: (1 - K / PRE_FILTER_Q + KK) / a0,
function normalizeNativeSnapshot(snapshot: LUFSMeterNativeSnapshot | null): LUFSMeterNativeSnapshot {
if (!snapshot) {
return { ...INITIAL_NATIVE_SNAPSHOT }
}
}
function rlbFilterCoeffs(sampleRate: number): BiquadCoeffs {
const K = Math.tan(Math.PI * RLB_FILTER_F0_HZ / sampleRate)
const KK = K * K
const a0 = 1 + K / RLB_FILTER_Q + KK
return {
b0: 1,
b1: -2,
b2: 1,
a1: (2 * (KK - 1)) / a0,
a2: (1 - K / RLB_FILTER_Q + KK) / a0,
momentaryLUFS: finiteNumber(snapshot.momentaryLUFS, METER_MIN_LUFS),
shortTermLUFS: finiteNumber(snapshot.shortTermLUFS, METER_MIN_LUFS),
integratedLUFS: finiteNumber(snapshot.integratedLUFS, METER_MIN_LUFS),
vuLDb: finiteNumber(snapshot.vuLDb, METER_MIN_DB),
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),
correlation: finiteNumber(snapshot.correlation, 0),
}
}
function getKWeightingCoeffs(sampleRate: number): { pre: BiquadCoeffs; rlb: BiquadCoeffs } {
return {
pre: preFilterCoeffs(sampleRate),
rlb: rlbFilterCoeffs(sampleRate),
}
}
// ---- Biquad filter state ----
interface BiquadState {
x1: number; x2: number
y1: number; y2: number
}
function createBiquadState(): BiquadState {
return { x1: 0, x2: 0, y1: 0, y2: 0 }
}
function applyBiquad(coeffs: BiquadCoeffs, state: BiquadState, input: number): number {
const output = coeffs.b0 * input + coeffs.b1 * state.x1 + coeffs.b2 * state.x2
- coeffs.a1 * state.y1 - coeffs.a2 * state.y2
state.x2 = state.x1
state.x1 = input
state.y2 = state.y1
state.y1 = output
return output
}
function histogramIndexFromLufs(lufs: number): number {
const normalized = (lufs - INTEGRATED_HISTOGRAM_MIN_LUFS) / INTEGRATED_HISTOGRAM_BIN_WIDTH
return Math.max(0, Math.min(INTEGRATED_HISTOGRAM_BIN_COUNT - 1, Math.round(normalized)))
}
function histogramLufsAtIndex(index: number): number {
return INTEGRATED_HISTOGRAM_MIN_LUFS + (index * INTEGRATED_HISTOGRAM_BIN_WIDTH)
}
// ---- Loudness meter class ----
export class LUFSMeter {
@@ -182,34 +114,12 @@ export class LUFSMeter {
private ctx: CanvasRenderingContext2D
private options: ResolvedLUFSMeterOptions
private dataSource: LUFSMeterDataSource
private nativeAnalyzer: LUFSMeterNativeAnalyzer | null
private frameLoop: VisualizerFrameLoop
private meterBallistics: VUMeterBallistics
// K-weighting filter state (per channel, two stages)
private preFilterL = createBiquadState()
private preFilterR = createBiquadState()
private rlbFilterL = createBiquadState()
private rlbFilterR = createBiquadState()
private currentSampleRate = 48000
private kWeightingCoeffs = getKWeightingCoeffs(48000)
// Ring buffer for K-weighted squared samples (sized for SHORT_TERM_WINDOW_S)
private ringBufferL = new Float32Array(0)
private ringBufferR = new Float32Array(0)
private ringBufferPos = 0
private ringBufferFilled = 0 // how many samples have been written total (capped at buffer size)
// Integrated loudness: emit one 400ms block per 100ms hop, summed directly
// from the K-weighted ring buffer (BS.1770-4 overlapping-block method).
private integratedHopCounter = 0
private integratedHistogramCounts = new Uint32Array(INTEGRATED_HISTOGRAM_BIN_COUNT)
private integratedHistogramPowerSums = new Float64Array(INTEGRATED_HISTOGRAM_BIN_COUNT)
// Smoothed display values
private momentaryLUFS = METER_MIN_LUFS
private shortTermLUFS = METER_MIN_LUFS
private integratedLUFS = METER_MIN_LUFS
private fastSnapshot: VUMeterSnapshot = { ...INITIAL_VU_SNAPSHOT }
private currentSampleRate = 0
private snapshot: LUFSMeterNativeSnapshot = { ...INITIAL_NATIVE_SNAPSHOT }
private pushScratchL = new Float32Array(0)
private pushScratchR = new Float32Array(0)
private unsubscribeSessionChange: (() => void) | null = null
constructor(canvas: HTMLCanvasElement, options: LUFSMeterOptions = {}) {
@@ -218,17 +128,17 @@ export class LUFSMeter {
if (!ctx) throw new Error('Could not get 2D context')
this.ctx = ctx
const { dataSource, frameScheduler, ...optionOverrides } = options
const { dataSource, frameScheduler, nativeAnalyzer, ...optionOverrides } = options
this.options = { ...defaultOptions, ...optionOverrides }
this.dataSource = dataSource ?? defaultLUFSMeterDataSource
this.meterBallistics = new VUMeterBallistics(this.dataSource.getSampleRate())
this.nativeAnalyzer = nativeAnalyzer === undefined ? nativeLUFSMeter : nativeAnalyzer
this.frameLoop = new VisualizerFrameLoop({
frameScheduler,
shouldRun: () => this.dataSource.isPlaying(),
onFrame: this.drawFrame,
})
this.initRingBuffer(this.dataSource.getSampleRate())
this.resetMeters()
this.subscribeToSessionChanges()
}
@@ -241,47 +151,34 @@ export class LUFSMeter {
})
}
private initRingBuffer(sampleRate: number): void {
this.currentSampleRate = Math.max(1, sampleRate)
this.kWeightingCoeffs = getKWeightingCoeffs(this.currentSampleRate)
this.meterBallistics.reinitialize(this.currentSampleRate)
const bufferSize = Math.ceil(this.currentSampleRate * SHORT_TERM_WINDOW_S)
this.ringBufferL = new Float32Array(bufferSize)
this.ringBufferR = new Float32Array(bufferSize)
this.ringBufferPos = 0
this.ringBufferFilled = 0
}
private resetMeters(): void {
this.momentaryLUFS = METER_MIN_LUFS
this.shortTermLUFS = METER_MIN_LUFS
this.integratedLUFS = METER_MIN_LUFS
this.meterBallistics.reset()
this.fastSnapshot = this.meterBallistics.getSnapshot()
this.ringBufferL.fill(0)
this.ringBufferR.fill(0)
this.ringBufferPos = 0
this.ringBufferFilled = 0
this.integratedHopCounter = 0
this.integratedHistogramCounts.fill(0)
this.integratedHistogramPowerSums.fill(0)
this.preFilterL = createBiquadState()
this.preFilterR = createBiquadState()
this.rlbFilterL = createBiquadState()
this.rlbFilterR = createBiquadState()
this.currentSampleRate = Math.max(1, this.dataSource.getSampleRate())
this.snapshot = { ...INITIAL_NATIVE_SNAPSHOT }
if (this.isNativeAnalyzerReady()) {
this.nativeAnalyzer?.setSampleRate(this.currentSampleRate)
this.nativeAnalyzer?.reset()
}
this.invalidate()
}
setOptions(options: Partial<LUFSMeterOptions>): void {
const { dataSource, frameScheduler: _frameScheduler, ...optionUpdates } = options
const { dataSource, frameScheduler: _frameScheduler, nativeAnalyzer, ...optionUpdates } = options
this.options = { ...this.options, ...optionUpdates }
let didReset = false
if (nativeAnalyzer !== undefined && nativeAnalyzer !== this.nativeAnalyzer) {
this.nativeAnalyzer = nativeAnalyzer
this.resetMeters()
didReset = true
}
if (dataSource && dataSource !== this.dataSource) {
this.dataSource = dataSource
this.subscribeToSessionChanges()
this.initRingBuffer(this.dataSource.getSampleRate())
this.resetMeters()
didReset = true
}
if (!didReset) {
this.invalidate()
}
this.invalidate()
}
start(): void {
@@ -303,149 +200,72 @@ export class LUFSMeter {
private processAudio(): void {
const chunks = this.dataSource.getPendingLUFSMeterSamples()
const sampleRate = Math.max(1, this.dataSource.getSampleRate())
// Check if sample rate changed
const sr = this.dataSource.getSampleRate()
if (Math.abs(sr - this.currentSampleRate) > 100) {
this.initRingBuffer(sr)
if (Math.abs(sampleRate - this.currentSampleRate) > 100) {
this.resetMeters()
}
const playing = this.dataSource.isPlaying()
if (!playing && chunks.length === 0) {
this.meterBallistics.reset()
this.fastSnapshot = this.meterBallistics.getSnapshot()
// Decay toward silence only when truly stopped
this.momentaryLUFS = this.momentaryLUFS * SMOOTHING + METER_MIN_LUFS * (1 - SMOOTHING)
this.shortTermLUFS = this.shortTermLUFS * SMOOTHING + METER_MIN_LUFS * (1 - SMOOTHING)
if (!this.isNativeAnalyzerReady() || !this.dataSource.isPlaying()) {
this.nativeAnalyzer?.reset()
this.snapshot = { ...INITIAL_NATIVE_SNAPSHOT }
return
}
this.fastSnapshot = this.meterBallistics.process(chunks, performance.now())
// Process any new audio chunks into the ring buffer
if (chunks.length > 0) {
const { pre, rlb } = this.kWeightingCoeffs
const bufLen = this.ringBufferL.length
const hopSamples = Math.round(this.currentSampleRate * INTEGRATED_HOP_S)
const blockSamples = Math.round(this.currentSampleRate * INTEGRATED_BLOCK_S)
for (const chunk of chunks) {
const len = Math.min(chunk.left.length, chunk.right.length)
for (let i = 0; i < len; i++) {
// Apply K-weighting: pre-filter then RLB, per channel
const kwL = applyBiquad(rlb, this.rlbFilterL, applyBiquad(pre, this.preFilterL, chunk.left[i]))
const kwR = applyBiquad(rlb, this.rlbFilterR, applyBiquad(pre, this.preFilterR, chunk.right[i]))
// Store squared K-weighted samples in ring buffer
const sqL = kwL * kwL
const sqR = kwR * kwR
this.ringBufferL[this.ringBufferPos] = sqL
this.ringBufferR[this.ringBufferPos] = sqR
this.ringBufferPos = (this.ringBufferPos + 1) % bufLen
if (this.ringBufferFilled < bufLen) this.ringBufferFilled++
this.integratedHopCounter++
// Every hop interval (100ms), emit one 400ms block computed from
// the ring buffer per BS.1770-4 overlapping-block method.
if (this.integratedHopCounter >= hopSamples && this.ringBufferFilled >= blockSamples) {
let sumL = 0
let sumR = 0
for (let j = 0; j < blockSamples; j++) {
const idx = (this.ringBufferPos - 1 - j + bufLen) % bufLen
sumL += this.ringBufferL[idx]
sumR += this.ringBufferR[idx]
}
const blockPower = Math.max(sumL / blockSamples + sumR / blockSamples, 1e-10)
const blockLUFS = -0.691 + 10 * Math.log10(blockPower)
if (blockLUFS > ABSOLUTE_GATE_LUFS) {
const histogramIndex = histogramIndexFromLufs(blockLUFS)
this.integratedHistogramCounts[histogramIndex] += 1
this.integratedHistogramPowerSums[histogramIndex] += blockPower
}
this.integratedHopCounter = 0
}
}
const batch = this.concatStereoChunks(chunks)
if (batch.left.length > 0 && batch.right.length > 0) {
this.nativeAnalyzer?.pushSamples(batch.left, batch.right)
}
}
// Always compute M/S from the ring buffer (it persists across frames)
const bufLen = this.ringBufferL.length
// Compute momentary loudness (last 400ms)
const momentarySamples = Math.min(
Math.round(this.currentSampleRate * MOMENTARY_WINDOW_S),
this.ringBufferFilled
)
if (momentarySamples > 0) {
let sumL = 0, sumR = 0
for (let i = 0; i < momentarySamples; i++) {
const idx = (this.ringBufferPos - 1 - i + bufLen) % bufLen
sumL += this.ringBufferL[idx]
sumR += this.ringBufferR[idx]
}
const rawM = -0.691 + 10 * Math.log10(Math.max(sumL / momentarySamples + sumR / momentarySamples, 1e-10))
this.momentaryLUFS = Math.max(METER_MIN_LUFS, rawM)
}
// Compute short-term loudness (last 3s)
const shortTermSamples = Math.min(
Math.round(this.currentSampleRate * SHORT_TERM_WINDOW_S),
this.ringBufferFilled
)
if (shortTermSamples > 0) {
let sumL = 0, sumR = 0
for (let i = 0; i < shortTermSamples; i++) {
const idx = (this.ringBufferPos - 1 - i + bufLen) % bufLen
sumL += this.ringBufferL[idx]
sumR += this.ringBufferR[idx]
}
const rawS = -0.691 + 10 * Math.log10(Math.max(sumL / shortTermSamples + sumR / shortTermSamples, 1e-10))
this.shortTermLUFS = Math.max(METER_MIN_LUFS, rawS)
}
// Compute integrated loudness with gating
this.integratedLUFS = this.computeGatedIntegratedLoudness()
this.snapshot = normalizeNativeSnapshot(this.nativeAnalyzer?.getSnapshot() ?? null)
}
private computeGatedIntegratedLoudness(): number {
let absoluteCount = 0
let absolutePowerSum = 0
for (let index = 0; index < this.integratedHistogramCounts.length; index += 1) {
const count = this.integratedHistogramCounts[index]
if (count === 0) {
continue
}
absoluteCount += count
absolutePowerSum += this.integratedHistogramPowerSums[index]
private isNativeAnalyzerReady(): boolean {
if (!this.nativeAnalyzer) {
return false
}
if (absoluteCount === 0 || absolutePowerSum <= 0) {
return METER_MIN_LUFS
return this.nativeAnalyzer.isAvailable?.() ?? true
}
private concatStereoChunks(chunks: Array<{ left: Float32Array; right: Float32Array }>): { left: Float32Array; right: Float32Array } {
if (chunks.length === 1) {
const chunk = chunks[0]
const length = Math.min(chunk.left.length, chunk.right.length)
return {
left: chunk.left.length === length ? chunk.left : chunk.left.subarray(0, length),
right: chunk.right.length === length ? chunk.right : chunk.right.subarray(0, length),
}
}
const ungatedMean = -0.691 + 10 * Math.log10(absolutePowerSum / absoluteCount)
const relativeThreshold = ungatedMean + RELATIVE_GATE_OFFSET
let relativeCount = 0
let relativePowerSum = 0
for (let index = 0; index < this.integratedHistogramCounts.length; index += 1) {
const count = this.integratedHistogramCounts[index]
if (count === 0) {
continue
}
if (histogramLufsAtIndex(index) <= relativeThreshold) {
continue
}
relativeCount += count
relativePowerSum += this.integratedHistogramPowerSums[index]
let totalLength = 0
for (const chunk of chunks) {
totalLength += Math.min(chunk.left.length, chunk.right.length)
}
if (relativeCount === 0 || relativePowerSum <= 0) {
return METER_MIN_LUFS
if (totalLength === 0) {
return { left: new Float32Array(0), right: new Float32Array(0) }
}
return Math.max(METER_MIN_LUFS, -0.691 + 10 * Math.log10(relativePowerSum / relativeCount))
if (this.pushScratchL.length < totalLength) {
this.pushScratchL = new Float32Array(totalLength)
this.pushScratchR = new Float32Array(totalLength)
}
const left = this.pushScratchL.subarray(0, totalLength)
const right = this.pushScratchR.subarray(0, totalLength)
let offset = 0
for (const chunk of chunks) {
const length = Math.min(chunk.left.length, chunk.right.length)
if (length <= 0) {
continue
}
left.set(chunk.left.subarray(0, length), offset)
right.set(chunk.right.subarray(0, length), offset)
offset += length
}
return { left, right }
}
private drawFrame = (): void => {
@@ -471,12 +291,12 @@ export class LUFSMeter {
private selectedLufs(): number {
switch (this.options.readout) {
case 'momentary':
return this.momentaryLUFS
return this.snapshot.momentaryLUFS
case 'shortTerm':
return this.shortTermLUFS
return this.snapshot.shortTermLUFS
case 'integrated':
default:
return this.integratedLUFS
return this.snapshot.integratedLUFS
}
}
@@ -578,8 +398,8 @@ export class LUFSMeter {
meterTop,
barWidth,
meterHeight,
this.fastSnapshot.barLDb,
this.fastSnapshot.peakLDb,
this.snapshot.barLDb,
this.snapshot.peakLDb,
tint,
dpr,
)
@@ -588,8 +408,8 @@ export class LUFSMeter {
meterTop,
barWidth,
meterHeight,
this.fastSnapshot.barRDb,
this.fastSnapshot.peakRDb,
this.snapshot.barRDb,
this.snapshot.peakRDb,
tint,
dpr,
)
@@ -671,5 +491,8 @@ export class LUFSMeter {
this.unsubscribeSessionChange()
this.unsubscribeSessionChange = null
}
if (this.isNativeAnalyzerReady()) {
this.nativeAnalyzer?.reset()
}
}
}
+146 -22
View File
@@ -76,6 +76,8 @@ import {
type NativeVisualizerTransportBridge,
} from '../src/renderer/audio/NativeVisualizerTransport'
import type {
LUFSMeterNativeAnalyzer,
LUFSMeterNativeSnapshot,
SpectrogramNativeAnalyzer,
SpectrogramNativeOptions,
SpectrogramNativeResult,
@@ -4024,6 +4026,49 @@ test('MultibandSplitter and MultibandBuffer reuse caller-owned buffers', () => {
assert.notEqual(pointTarget.low.left[0], 0)
})
function createFakeLUFSMeterNativeAnalyzer(
snapshotOverrides: Partial<LUFSMeterNativeSnapshot> = {},
available = true,
): LUFSMeterNativeAnalyzer & {
pushed: Array<{ left: Float32Array; right: Float32Array }>
resetCount: number
sampleRates: number[]
} {
const analyzer = {
pushed: [] as Array<{ left: Float32Array; right: Float32Array }>,
resetCount: 0,
sampleRates: [] as number[],
snapshot: {
momentaryLUFS: -18.4,
shortTermLUFS: -19.1,
integratedLUFS: -20.2,
vuLDb: -12,
vuRDb: -13,
barLDb: -10,
barRDb: -12,
peakLDb: -4,
peakRDb: -5,
correlation: 0.5,
...snapshotOverrides,
} satisfies LUFSMeterNativeSnapshot,
isAvailable: () => available,
setSampleRate(sampleRate: number): void {
this.sampleRates.push(sampleRate)
},
pushSamples(left: Float32Array, right: Float32Array): void {
this.pushed.push({ left: new Float32Array(left), right: new Float32Array(right) })
},
getSnapshot(): LUFSMeterNativeSnapshot {
return this.snapshot
},
reset(): void {
this.resetCount += 1
},
}
return analyzer
}
test('LUFSMeter draws compact fast bars, a thicker LUFS bar, scale labels, and attached readout', () => {
const dom = installFakeCanvasDom()
const recorder = createFakeCanvasRecorder()
@@ -4048,8 +4093,10 @@ test('LUFSMeter draws compact fast bars, a thicker LUFS bar, scale labels, and a
isPlaying: () => true,
subscribeToSessionChanges: () => () => {},
}
const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer({ momentaryLUFS: -18.4 })
const meter = new LUFSMeter(createFakeCanvas(recorder), {
dataSource,
nativeAnalyzer,
readout: 'momentary',
lineColor: 'rgb(255, 0, 96)',
trackColor: 'rgba(255, 0, 96, 0.08)',
@@ -4061,6 +4108,8 @@ test('LUFSMeter draws compact fast bars, a thicker LUFS bar, scale labels, and a
try {
;(meter as unknown as { drawFrame: () => void }).drawFrame()
assert.equal(nativeAnalyzer.pushed.length, 1)
assert.equal(nativeAnalyzer.pushed[0]?.left.length, leftChunk.length)
const trackRects = recorder.fillRects.filter((rect) => rect.fillStyle === 'rgba(255, 0, 96, 0.08)')
assert.equal(trackRects.length >= 3, true)
assert.equal(trackRects.some((rect) => rect.width > 12), true)
@@ -4120,8 +4169,10 @@ test('LUFSMeter fits readout text inside narrow tags', () => {
isPlaying: () => true,
subscribeToSessionChanges: () => () => {},
}
const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer({ momentaryLUFS: -8.1 })
const meter = new LUFSMeter(createFakeCanvas(recorder, 180, 360), {
dataSource,
nativeAnalyzer,
readout: 'momentary',
lineColor: 'rgb(255, 0, 96)',
trackColor: 'rgba(255, 0, 96, 0.08)',
@@ -4149,8 +4200,17 @@ test('LUFSMeter fits readout text inside narrow tags', () => {
}
})
test('LUFSMeter keeps integrated history bounded over long runs', () => {
const chunkQueue: Array<{ left: Float32Array; right: Float32Array }> = []
test('LUFSMeter concatenates queued chunks before pushing them to native DSP', () => {
const chunkQueue: Array<{ left: Float32Array; right: Float32Array }> = [
{
left: new Float32Array([1, 2, 3]),
right: new Float32Array([4, 5, 6]),
},
{
left: new Float32Array([7, 8]),
right: new Float32Array([9, 10]),
},
]
const dataSource = {
getPendingLUFSMeterSamples: () => {
const drained = chunkQueue.slice()
@@ -4161,30 +4221,94 @@ test('LUFSMeter keeps integrated history bounded over long runs', () => {
isPlaying: () => true,
subscribeToSessionChanges: () => () => {},
}
const meter = new LUFSMeter(createFakeCanvas(), { dataSource })
const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer()
const meter = new LUFSMeter(createFakeCanvas(), { dataSource, nativeAnalyzer })
try {
;(meter as unknown as { processAudio: () => void }).processAudio()
assert.equal(nativeAnalyzer.pushed.length, 1)
assert.deepEqual(Array.from(nativeAnalyzer.pushed[0]?.left ?? []), [1, 2, 3, 7, 8])
assert.deepEqual(Array.from(nativeAnalyzer.pushed[0]?.right ?? []), [4, 5, 6, 9, 10])
} finally {
meter.dispose()
}
})
test('LUFSMeter resets native DSP when the sample rate or session changes', () => {
let sampleRate = 48000
let pendingChunks: Array<{ left: Float32Array; right: Float32Array }> = [
{ left: new Float32Array([0.1]), right: new Float32Array([0.2]) },
]
let sessionListener: (() => void) | null = null
const dataSource = {
getPendingLUFSMeterSamples: () => {
const drained = pendingChunks
pendingChunks = []
return drained
},
getSampleRate: () => sampleRate,
isPlaying: () => true,
subscribeToSessionChanges: (listener: () => void) => {
sessionListener = listener
return () => {}
},
}
const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer()
const meter = new LUFSMeter(createFakeCanvas(), { dataSource, nativeAnalyzer })
const processAudio = (meter as unknown as { processAudio: () => void }).processAudio.bind(meter)
const leftChunk = new Float32Array(4800)
const rightChunk = new Float32Array(4800)
for (let index = 0; index < leftChunk.length; index += 1) {
const sample = index % 2 === 0 ? 0.35 : -0.35
leftChunk[index] = sample
rightChunk[index] = sample
}
for (let iteration = 0; iteration < 400; iteration += 1) {
chunkQueue.push({
left: leftChunk,
right: rightChunk,
})
try {
processAudio()
}
sampleRate = 96000
pendingChunks = [{ left: new Float32Array([0.3]), right: new Float32Array([0.4]) }]
processAudio()
sessionListener?.()
const histogramCounts = (meter as unknown as { integratedHistogramCounts: Uint32Array }).integratedHistogramCounts
const storedBlocks = histogramCounts.reduce((total, count) => total + count, 0)
assert.equal(Object.prototype.hasOwnProperty.call(meter, 'integratedBlockLoudness'), false)
assert.equal(histogramCounts.length > 0, true)
assert.equal(storedBlocks > 100, true)
assert.equal(Number.isFinite((meter as unknown as { integratedLUFS: number }).integratedLUFS), true)
assert.deepEqual(nativeAnalyzer.sampleRates, [48000, 96000, 96000])
assert.equal(nativeAnalyzer.resetCount, 3)
} finally {
meter.dispose()
}
})
test('LUFSMeter drains audio and renders silence when native DSP is unavailable', () => {
const dom = installFakeCanvasDom()
const recorder = createFakeCanvasRecorder()
let pendingChunks: Array<{ left: Float32Array; right: Float32Array }> = [
{ left: new Float32Array([0.9, 0.9]), right: new Float32Array([0.9, 0.9]) },
]
const dataSource = {
getPendingLUFSMeterSamples: () => {
const drained = pendingChunks
pendingChunks = []
return drained
},
getSampleRate: () => 48000,
isPlaying: () => true,
subscribeToSessionChanges: () => () => {},
}
const nativeAnalyzer = createFakeLUFSMeterNativeAnalyzer({ momentaryLUFS: -5 }, false)
const meter = new LUFSMeter(createFakeCanvas(recorder), {
dataSource,
nativeAnalyzer,
readout: 'momentary',
lineColor: 'rgb(255, 0, 96)',
})
try {
;(meter as unknown as { drawFrame: () => void }).drawFrame()
assert.equal(nativeAnalyzer.pushed.length, 0)
assert.equal(pendingChunks.length, 0)
assert.equal(
recorder.fillTexts.some((text) => text.text === '-∞LUFS'),
true,
)
} finally {
meter.dispose()
dom.restore()
}
})
test('NativePolledCaptureBackend forwards all drained chunks, respects hidden-document backoff, and cancels on stop', async () => {