rewrite VU to native

This commit is contained in:
Boof2015
2026-05-19 18:29:31 -04:00
parent bc1d796786
commit 3c9413acd4
10 changed files with 718 additions and 13 deletions
+1
View File
@@ -11,6 +11,7 @@
"src/spectrum.cpp",
"src/spectrogram.cpp",
"src/vectorscope.cpp",
"src/vumeter.cpp",
"src/lufsmeter.cpp",
"src/dsp_utils.cpp"
],
+57
View File
@@ -1,4 +1,5 @@
#include <napi.h>
#include <algorithm>
#include <cstring>
#include <string>
#include "linux_capture.h"
@@ -8,6 +9,7 @@
#include "spectrum.h"
#include "spectrogram.h"
#include "vectorscope.h"
#include "vumeter.h"
#include "lufsmeter.h"
// Global instances
@@ -15,6 +17,7 @@ static Visualizer::Oscilloscope oscilloscope;
static Visualizer::Spectrum spectrum(2048);
static Visualizer::SpectrogramAnalyzer spectrogramAnalyzer;
static Visualizer::Vectorscope vectorscope;
static Visualizer::VUMeterAnalyzer vuMeter;
static Visualizer::LUFSMeterAnalyzer lufsMeter;
// ============== Oscilloscope ==============
@@ -428,6 +431,52 @@ Napi::Value VectorscopeReset(const Napi::CallbackInfo& info) {
return info.Env().Undefined();
}
// ============== VU Meter ==============
Napi::Value VUMeterSetSampleRate(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();
}
vuMeter.setSampleRate(info[0].As<Napi::Number>().FloatValue());
return env.Undefined();
}
Napi::Value VUMeterPushSamples(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());
vuMeter.pushSamples(leftData.Data(), rightData.Data(), length);
return env.Undefined();
}
Napi::Value VUMeterGetSnapshot(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
const auto snapshot = vuMeter.getSnapshot();
Napi::Object obj = Napi::Object::New(env);
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 VUMeterReset(const Napi::CallbackInfo& info) {
vuMeter.reset();
return info.Env().Undefined();
}
// ============== LUFS Meter ==============
Napi::Value LUFSMeterSetSampleRate(const Napi::CallbackInfo& info) {
@@ -529,6 +578,14 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
vecExports.Set("reset", Napi::Function::New(env, VectorscopeReset));
exports.Set("vectorscope", vecExports);
// VU Meter
Napi::Object vuExports = Napi::Object::New(env);
vuExports.Set("setSampleRate", Napi::Function::New(env, VUMeterSetSampleRate));
vuExports.Set("pushSamples", Napi::Function::New(env, VUMeterPushSamples));
vuExports.Set("getSnapshot", Napi::Function::New(env, VUMeterGetSnapshot));
vuExports.Set("reset", Napi::Function::New(env, VUMeterReset));
exports.Set("vumeter", vuExports);
// LUFS Meter
Napi::Object lufsExports = Napi::Object::New(env);
lufsExports.Set("setSampleRate", Napi::Function::New(env, LUFSMeterSetSampleRate));
+228
View File
@@ -0,0 +1,228 @@
#include "vumeter.h"
#include <algorithm>
#include <chrono>
#include <cmath>
namespace Visualizer {
namespace {
constexpr double VU_METER_MIN_DB = -60.0;
constexpr double VU_METER_MAX_DB = 0.0;
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;
VUMeterSnapshot makeInitialSnapshot() {
return {
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
VUMeterAnalyzer::VUMeterAnalyzer() {
configureForSampleRate(sampleRate_);
}
void VUMeterAnalyzer::setSampleRate(float sampleRate) {
configureForSampleRate(sampleRate);
}
void VUMeterAnalyzer::configureForSampleRate(float sampleRate) {
sampleRate_ = sanitizeSampleRate(sampleRate);
integrationWindowSamples_ = std::max<size_t>(
1,
static_cast<size_t>(std::round((static_cast<double>(sampleRate_) * VU_INTEGRATION_WINDOW_MS) / 1000.0))
);
sqL_.assign(integrationWindowSamples_, 0.0);
sqR_.assign(integrationWindowSamples_, 0.0);
cross_.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)));
reset();
}
void VUMeterAnalyzer::reset() {
std::fill(sqL_.begin(), sqL_.end(), 0.0);
std::fill(sqR_.begin(), sqR_.end(), 0.0);
std::fill(cross_.begin(), cross_.end(), 0.0);
writeIndex_ = 0;
sampleCount_ = 0;
sumSqL_ = 0.0;
sumSqR_ = 0.0;
sumCross_ = 0.0;
barEnvelopeL_ = 0.0;
barEnvelopeR_ = 0.0;
peakHoldUntilL_ = 0.0;
peakHoldUntilR_ = 0.0;
lastPeakUpdateMs_ = 0.0;
hasLastPeakUpdate_ = false;
snapshot_ = makeInitialSnapshot();
}
void VUMeterAnalyzer::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) {
processSample(leftChannel[index], rightChannel[index], maxPeakL, maxPeakR);
}
maybeUpdatePeak(amplitudeToDb(maxPeakL), nowMs, true);
maybeUpdatePeak(amplitudeToDb(maxPeakR), nowMs, false);
recomputeSnapshot();
}
VUMeterSnapshot VUMeterAnalyzer::getSnapshot() {
advancePeaks(currentTimeMs());
recomputeSnapshot();
return snapshot_;
}
void VUMeterAnalyzer::processSample(float left, float right, double& maxPeakL, double& maxPeakR) {
if (sqL_.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 (sampleCount_ == integrationWindowSamples_) {
sumSqL_ = std::max(0.0, sumSqL_ - sqL_[writeIndex_]);
sumSqR_ = std::max(0.0, sumSqR_ - sqR_[writeIndex_]);
sumCross_ -= cross_[writeIndex_];
} else {
sampleCount_ += 1;
}
sqL_[writeIndex_] = sqL;
sqR_[writeIndex_] = sqR;
cross_[writeIndex_] = cross;
sumSqL_ += sqL;
sumSqR_ += sqR;
sumCross_ += cross;
writeIndex_ = (writeIndex_ + 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 VUMeterAnalyzer::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 VUMeterAnalyzer::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 VUMeterAnalyzer::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 VUMeterAnalyzer::recomputeSnapshot() {
if (sampleCount_ == 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, sumSqL_) / sampleCount_;
const double meanSqR = std::max(0.0, sumSqR_) / sampleCount_;
const double denominator = std::sqrt(std::max(0.0, sumSqL_) * std::max(0.0, sumSqR_));
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(sumCross_ / denominator, -1.0, 1.0))
: 0.0f;
}
double VUMeterAnalyzer::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 VUMeterAnalyzer::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 VUMeterAnalyzer::clampDb(double db, double minDb, double maxDb) {
return std::max(minDb, std::min(maxDb, db));
}
} // namespace Visualizer
+60
View File
@@ -0,0 +1,60 @@
#pragma once
#include <cstddef>
#include <vector>
namespace Visualizer {
struct VUMeterSnapshot {
float vuLDb;
float vuRDb;
float barLDb;
float barRDb;
float peakLDb;
float peakRDb;
float correlation;
};
class VUMeterAnalyzer {
public:
VUMeterAnalyzer();
void setSampleRate(float sampleRate);
void pushSamples(const float* leftChannel, const float* rightChannel, size_t length);
VUMeterSnapshot getSnapshot();
void reset();
private:
void configureForSampleRate(float sampleRate);
void processSample(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 recomputeSnapshot();
static double currentTimeMs();
static double amplitudeToDb(double amplitude);
static double clampDb(double db, double minDb, double maxDb);
float sampleRate_ = 48000.0f;
size_t integrationWindowSamples_ = 1;
std::vector<double> sqL_;
std::vector<double> sqR_;
std::vector<double> cross_;
size_t writeIndex_ = 0;
size_t sampleCount_ = 0;
double sumSqL_ = 0.0;
double sumSqR_ = 0.0;
double sumCross_ = 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;
VUMeterSnapshot snapshot_{};
};
} // namespace Visualizer
+1
View File
@@ -247,6 +247,7 @@ const visualizerAPI = nativeAddonModule
spectrum: nativeAddonModule.spectrum,
spectrogram: nativeAddonModule.spectrogram,
vectorscope: nativeAddonModule.vectorscope,
vumeter: nativeAddonModule.vumeter,
lufsmeter: nativeAddonModule.lufsmeter,
}
: null
+33
View File
@@ -9,6 +9,7 @@ import type {
SpectrogramNativeResult,
VectorscopeResult,
VectorscopePointsResult,
VUMeterNativeSnapshot,
} from './visualizer-dsp'
let nativeModule: VisualizerDSP | null = null
@@ -176,6 +177,14 @@ export interface LUFSMeterNativeAnalyzer {
isAvailable?: () => boolean
}
export interface VUMeterNativeAnalyzer {
setSampleRate(sampleRate: number): void
pushSamples(leftChannel: Float32Array, rightChannel: Float32Array): void
getSnapshot(): VUMeterNativeSnapshot | null
reset(): void
isAvailable?: () => boolean
}
export const spectrogram: SpectrogramNativeAnalyzer = {
isAvailable: (): boolean => {
return Boolean(nativeModule?.spectrogram)
@@ -238,6 +247,29 @@ export const vectorscope = {
}
}
export const vumeter: VUMeterNativeAnalyzer = {
isAvailable: (): boolean => {
return Boolean(nativeModule?.vumeter)
},
setSampleRate: (sampleRate: number): void => {
nativeModule?.vumeter?.setSampleRate(sampleRate)
},
pushSamples: (leftChannel: Float32Array, rightChannel: Float32Array): void => {
nativeModule?.vumeter?.pushSamples(leftChannel, rightChannel)
},
getSnapshot: (): VUMeterNativeSnapshot | null => {
if (!nativeModule?.vumeter) return null
return nativeModule.vumeter.getSnapshot()
},
reset: (): void => {
nativeModule?.vumeter?.reset()
},
}
export const lufsmeter: LUFSMeterNativeAnalyzer = {
isAvailable: (): boolean => {
return Boolean(nativeModule?.lufsmeter)
@@ -268,4 +300,5 @@ export type {
SpectrogramNativeResult,
VectorscopeResult,
VectorscopePointsResult,
VUMeterNativeSnapshot,
}
+18
View File
@@ -54,6 +54,16 @@ export interface LUFSMeterNativeSnapshot {
correlation: number;
}
export interface VUMeterNativeSnapshot {
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;
@@ -122,11 +132,19 @@ export interface LUFSMeterModule {
reset(): void;
}
export interface VUMeterModule {
setSampleRate(sampleRate: number): void;
pushSamples(leftChannel: Float32Array, rightChannel: Float32Array): void;
getSnapshot(): VUMeterNativeSnapshot;
reset(): void;
}
export interface VisualizerDSP {
oscilloscope: OscilloscopeModule;
spectrum: SpectrumModule;
spectrogram: SpectrogramModule;
vectorscope: VectorscopeModule;
vumeter: VUMeterModule;
lufsmeter: LUFSMeterModule;
}
+135 -13
View File
@@ -1,4 +1,9 @@
import { audioRouter } from '../audio/AudioRouter'
import {
vumeter as nativeVUMeter,
type VUMeterNativeAnalyzer,
type VUMeterNativeSnapshot,
} from '../audio/native'
import { resolveColorToRgb } from '../utils/color'
import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource'
import { FrameScheduler } from './frameScheduler'
@@ -39,9 +44,10 @@ export interface VUMeterOptions {
referenceDb?: number
dataSource?: VUMeterDataSource
frameScheduler?: FrameScheduler
nativeAnalyzer?: VUMeterNativeAnalyzer | null
}
type ResolvedVUMeterOptions = Required<Omit<VUMeterOptions, 'dataSource' | 'frameScheduler'>>
type ResolvedVUMeterOptions = Required<Omit<VUMeterOptions, 'dataSource' | 'frameScheduler' | 'nativeAnalyzer'>>
const defaultOptions: ResolvedVUMeterOptions = {
mode: 'bar',
@@ -65,6 +71,16 @@ const defaultVUMeterDataSource: VUMeterDataSource = {
...defaultVisualizerSessionSource,
}
const INITIAL_NATIVE_SNAPSHOT: VUMeterNativeSnapshot = {
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,
correlation: 0,
}
const NEEDLE_VISUAL_SMOOTHING_SECONDS = 0.065
const NEEDLE_PEAK_DECAY_DB_PER_SECOND = 12
const NEEDLE_PIVOT_FRACTION = 0.91
@@ -93,6 +109,26 @@ function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value))
}
function finiteNumber(value: number, fallback: number): number {
return Number.isFinite(value) ? value : fallback
}
function normalizeNativeSnapshot(snapshot: VUMeterNativeSnapshot | null): VUMeterNativeSnapshot {
if (!snapshot) {
return { ...INITIAL_NATIVE_SNAPSHOT }
}
return {
vuLDb: finiteNumber(snapshot.vuLDb, VU_METER_MIN_DB),
vuRDb: finiteNumber(snapshot.vuRDb, VU_METER_MIN_DB),
barLDb: finiteNumber(snapshot.barLDb, VU_METER_MIN_DB),
barRDb: finiteNumber(snapshot.barRDb, VU_METER_MIN_DB),
peakLDb: finiteNumber(snapshot.peakLDb, VU_METER_MIN_DB),
peakRDb: finiteNumber(snapshot.peakRDb, VU_METER_MIN_DB),
correlation: finiteNumber(snapshot.correlation, 0),
}
}
function normalizeDevicePixelRatio(devicePixelRatio: number): number {
return Number.isFinite(devicePixelRatio) && devicePixelRatio > 0
? devicePixelRatio
@@ -255,8 +291,12 @@ export class VUMeter {
private ctx: CanvasRenderingContext2D
private options: ResolvedVUMeterOptions
private dataSource: VUMeterDataSource
private nativeAnalyzer: VUMeterNativeAnalyzer | null
private frameLoop: VisualizerFrameLoop
private meterBallistics: VUMeterBallistics
private currentSampleRate = 0
private pushScratchL = new Float32Array(0)
private pushScratchR = new Float32Array(0)
private unsubscribeSessionChange: (() => void) | null = null
// Meter state
@@ -277,15 +317,17 @@ export class VUMeter {
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 ?? defaultVUMeterDataSource
this.nativeAnalyzer = nativeAnalyzer === undefined ? nativeVUMeter : nativeAnalyzer
this.meterBallistics = new VUMeterBallistics(this.dataSource.getSampleRate())
this.frameLoop = new VisualizerFrameLoop({
frameScheduler,
shouldRun: () => this.dataSource.isPlaying(),
onFrame: this.drawFrame,
})
this.resetMeters()
this.subscribeToSessionChanges()
}
@@ -299,21 +341,35 @@ export class VUMeter {
}
private resetMeters(): void {
this.meterBallistics.reinitialize(this.dataSource.getSampleRate())
this.currentSampleRate = Math.max(1, this.dataSource.getSampleRate())
this.meterBallistics.reinitialize(this.currentSampleRate)
if (this.isNativeAnalyzerReady()) {
this.nativeAnalyzer?.setSampleRate(this.currentSampleRate)
this.nativeAnalyzer?.reset()
}
this.applySnapshot(this.meterBallistics.getSnapshot())
this.resetNeedleVisuals()
this.invalidate()
}
setOptions(options: Partial<VUMeterOptions>): 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.resetMeters()
didReset = true
}
if (!didReset) {
this.invalidate()
}
this.invalidate()
}
start(): void {
@@ -389,20 +445,83 @@ export class VUMeter {
}
private processAudio(nowMs = performance.now()): void {
const sampleRate = this.dataSource.getSampleRate()
if (Math.abs(sampleRate - this.meterBallistics.getSampleRate()) > 100) {
this.meterBallistics.reinitialize(sampleRate)
}
if (!this.dataSource.isPlaying()) {
this.applySnapshot(this.meterBallistics.getSnapshot())
return
const sampleRate = Math.max(1, this.dataSource.getSampleRate())
if (
Math.abs(sampleRate - this.currentSampleRate) > 100
|| Math.abs(sampleRate - this.meterBallistics.getSampleRate()) > 100
) {
this.resetMeters()
}
const chunks = this.dataSource.getPendingVUMeterSamples()
if (!this.dataSource.isPlaying()) {
const snapshot = this.isNativeAnalyzerReady()
? normalizeNativeSnapshot(this.nativeAnalyzer?.getSnapshot() ?? null)
: this.meterBallistics.getSnapshot()
this.applySnapshot(snapshot)
return
}
if (this.isNativeAnalyzerReady()) {
if (chunks.length > 0) {
const batch = this.concatStereoChunks(chunks)
if (batch.left.length > 0 && batch.right.length > 0) {
this.nativeAnalyzer?.pushSamples(batch.left, batch.right)
}
}
this.applySnapshot(normalizeNativeSnapshot(this.nativeAnalyzer?.getSnapshot() ?? null))
return
}
this.applySnapshot(this.meterBallistics.process(chunks, nowMs))
}
private isNativeAnalyzerReady(): boolean {
if (!this.nativeAnalyzer) {
return false
}
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),
}
}
let totalLength = 0
for (const chunk of chunks) {
totalLength += Math.min(chunk.left.length, chunk.right.length)
}
if (totalLength === 0) {
return { left: new Float32Array(0), right: new Float32Array(0) }
}
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 dbToNormalized(db: number): number {
// Map dBFS to bar position via the VU calibration so bars and the needle
// share the same scale (0 VU sits at the hot threshold, +3 VU at full scale).
@@ -1137,5 +1256,8 @@ export class VUMeter {
this.unsubscribeSessionChange()
this.unsubscribeSessionChange = null
}
if (this.isNativeAnalyzerReady()) {
this.nativeAnalyzer?.reset()
}
}
}
+1
View File
@@ -63,4 +63,5 @@ test('preload no longer exposes desktop source capture APIs', async () => {
assert.doesNotMatch(preloadSource, /getDesktopSources/)
assert.doesNotMatch(preloadSource, /capture:get-backend-support/)
assert.doesNotMatch(preloadSource, /audio:get-desktop-sources/)
assert.match(preloadSource, /vumeter: nativeAddonModule\.vumeter/)
})
+184
View File
@@ -81,6 +81,8 @@ import type {
SpectrogramNativeAnalyzer,
SpectrogramNativeOptions,
SpectrogramNativeResult,
VUMeterNativeAnalyzer,
VUMeterNativeSnapshot,
} from '../src/renderer/audio/native'
import {
HEAT_LOW_DB,
@@ -95,6 +97,7 @@ import { SpectrumAnalyzer, type SpectrumAnalyzerOptions } from '../src/renderer/
import { Spectrogram, type SpectrogramOptions } from '../src/renderer/visualizers/Spectrogram'
import { Vectorscope } from '../src/renderer/visualizers/Vectorscope'
import {
VUMeter,
VU_NEEDLE_FACE_HEIGHT_CSS_PX,
VU_NEEDLE_FACE_WIDTH_CSS_PX,
classicVuToNormalized,
@@ -4026,6 +4029,187 @@ test('MultibandSplitter and MultibandBuffer reuse caller-owned buffers', () => {
assert.notEqual(pointTarget.low.left[0], 0)
})
function createFakeVUMeterNativeAnalyzer(
snapshotOverrides: Partial<VUMeterNativeSnapshot> = {},
available = true,
): VUMeterNativeAnalyzer & {
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: {
vuLDb: -12,
vuRDb: -13,
barLDb: -10,
barRDb: -12,
peakLDb: -4,
peakRDb: -5,
correlation: 0.5,
...snapshotOverrides,
} satisfies VUMeterNativeSnapshot,
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(): VUMeterNativeSnapshot {
return this.snapshot
},
reset(): void {
this.resetCount += 1
},
}
return analyzer
}
test('VUMeter draws from native DSP snapshots when available', () => {
const recorder = createFakeCanvasRecorder()
const dataSource = {
getPendingVUMeterSamples: () => [],
getSampleRate: () => 48000,
isPlaying: () => true,
subscribeToSessionChanges: () => () => {},
}
const nativeAnalyzer = createFakeVUMeterNativeAnalyzer({
vuLDb: -12.4,
vuRDb: -16.2,
peakLDb: -3,
peakRDb: -5,
correlation: 0.25,
})
const meter = new VUMeter(createFakeCanvas(recorder), {
dataSource,
nativeAnalyzer,
lineColor: 'rgb(255, 0, 96)',
})
try {
;(meter as unknown as { drawFrame: () => void }).drawFrame()
assert.equal(nativeAnalyzer.pushed.length, 0)
assert.equal(recorder.fillTexts.some((text) => text.text === '-12.4'), true)
assert.equal(recorder.fillTexts.some((text) => text.text === '-16.2'), true)
assert.equal(
recorder.fillRects.some((rect) => rect.fillStyle === 'rgba(255, 0, 96, 0.82)'),
true,
)
} finally {
meter.dispose()
}
})
test('VUMeter 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 = {
getPendingVUMeterSamples: () => {
const drained = chunkQueue.slice()
chunkQueue.length = 0
return drained
},
getSampleRate: () => 48000,
isPlaying: () => true,
subscribeToSessionChanges: () => () => {},
}
const nativeAnalyzer = createFakeVUMeterNativeAnalyzer()
const meter = new VUMeter(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('VUMeter 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 = {
getPendingVUMeterSamples: () => {
const drained = pendingChunks
pendingChunks = []
return drained
},
getSampleRate: () => sampleRate,
isPlaying: () => true,
subscribeToSessionChanges: (listener: () => void) => {
sessionListener = listener
return () => {}
},
}
const nativeAnalyzer = createFakeVUMeterNativeAnalyzer()
const meter = new VUMeter(createFakeCanvas(), { dataSource, nativeAnalyzer })
const processAudio = (meter as unknown as { processAudio: () => void }).processAudio.bind(meter)
try {
processAudio()
sampleRate = 96000
pendingChunks = [{ left: new Float32Array([0.3]), right: new Float32Array([0.4]) }]
processAudio()
sessionListener?.()
assert.deepEqual(nativeAnalyzer.sampleRates, [48000, 96000, 96000])
assert.equal(nativeAnalyzer.resetCount, 3)
} finally {
meter.dispose()
}
})
test('VUMeter drains audio and renders fallback state when native DSP is unavailable', () => {
const recorder = createFakeCanvasRecorder()
let pendingChunks: Array<{ left: Float32Array; right: Float32Array }> = [
{ left: new Float32Array([0.5, 0.5, 0.5, 0.5]), right: new Float32Array([0.5, 0.5, 0.5, 0.5]) },
]
const dataSource = {
getPendingVUMeterSamples: () => {
const drained = pendingChunks
pendingChunks = []
return drained
},
getSampleRate: () => 48000,
isPlaying: () => true,
subscribeToSessionChanges: () => () => {},
}
const nativeAnalyzer = createFakeVUMeterNativeAnalyzer({ vuLDb: -1, vuRDb: -1 }, false)
const meter = new VUMeter(createFakeCanvas(recorder), {
dataSource,
nativeAnalyzer,
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 === '-6.0'), true)
} finally {
meter.dispose()
}
})
function createFakeLUFSMeterNativeAnalyzer(
snapshotOverrides: Partial<LUFSMeterNativeSnapshot> = {},
available = true,