diff --git a/native/binding.gyp b/native/binding.gyp index bc586f0..a6a3ce1 100644 --- a/native/binding.gyp +++ b/native/binding.gyp @@ -11,6 +11,7 @@ "src/spectrum.cpp", "src/spectrogram.cpp", "src/vectorscope.cpp", + "src/vumeter.cpp", "src/lufsmeter.cpp", "src/dsp_utils.cpp" ], diff --git a/native/src/main.cpp b/native/src/main.cpp index 16b34af..0be8c10 100644 --- a/native/src/main.cpp +++ b/native/src/main.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #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().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 rightData = info[1].As(); + 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)); diff --git a/native/src/vumeter.cpp b/native/src/vumeter.cpp new file mode 100644 index 0000000..bd39470 --- /dev/null +++ b/native/src/vumeter.cpp @@ -0,0 +1,228 @@ +#include "vumeter.h" + +#include +#include +#include + +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(VU_METER_MIN_DB), + static_cast(VU_METER_MIN_DB), + static_cast(VU_METER_MIN_DB), + static_cast(VU_METER_MIN_DB), + static_cast(VU_METER_MIN_DB), + static_cast(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( + 1, + static_cast(std::round((static_cast(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(sampleRate_) * (BAR_ATTACK_MS / 1000.0))); + barReleaseCoeff_ = std::exp(-1.0 / (static_cast(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(left) * left; + const double sqR = static_cast(right) * right; + const double cross = static_cast(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(left)); + const double absR = std::abs(static_cast(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(applyPeakDecay(snapshot_.peakLDb, peakHoldUntilL_, nowMs)); + snapshot_.peakRDb = static_cast(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(peakDb); + peakHoldUntilL_ = nowMs + VU_PEAK_HOLD_MS; + } + return; + } + + if (peakDb > snapshot_.peakRDb) { + snapshot_.peakRDb = static_cast(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(VU_METER_MIN_DB); + snapshot_.vuRDb = static_cast(VU_METER_MIN_DB); + snapshot_.barLDb = static_cast(amplitudeToDb(barEnvelopeL_)); + snapshot_.barRDb = static_cast(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(amplitudeToDb(std::sqrt(meanSqL))); + snapshot_.vuRDb = static_cast(amplitudeToDb(std::sqrt(meanSqR))); + snapshot_.barLDb = static_cast(amplitudeToDb(barEnvelopeL_)); + snapshot_.barRDb = static_cast(amplitudeToDb(barEnvelopeR_)); + snapshot_.correlation = denominator > 1e-10 + ? static_cast(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(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 diff --git a/native/src/vumeter.h b/native/src/vumeter.h new file mode 100644 index 0000000..c298f53 --- /dev/null +++ b/native/src/vumeter.h @@ -0,0 +1,60 @@ +#pragma once + +#include +#include + +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 sqL_; + std::vector sqR_; + std::vector 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 diff --git a/src/preload/index.ts b/src/preload/index.ts index 95038ee..031acf1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -247,6 +247,7 @@ const visualizerAPI = nativeAddonModule spectrum: nativeAddonModule.spectrum, spectrogram: nativeAddonModule.spectrogram, vectorscope: nativeAddonModule.vectorscope, + vumeter: nativeAddonModule.vumeter, lufsmeter: nativeAddonModule.lufsmeter, } : null diff --git a/src/renderer/audio/native/index.ts b/src/renderer/audio/native/index.ts index 9f2969e..074d053 100644 --- a/src/renderer/audio/native/index.ts +++ b/src/renderer/audio/native/index.ts @@ -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, } diff --git a/src/renderer/audio/native/visualizer-dsp.d.ts b/src/renderer/audio/native/visualizer-dsp.d.ts index 149a982..b86f74f 100644 --- a/src/renderer/audio/native/visualizer-dsp.d.ts +++ b/src/renderer/audio/native/visualizer-dsp.d.ts @@ -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; } diff --git a/src/renderer/visualizers/VUMeter.ts b/src/renderer/visualizers/VUMeter.ts index ae43912..693bfbd 100644 --- a/src/renderer/visualizers/VUMeter.ts +++ b/src/renderer/visualizers/VUMeter.ts @@ -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> +type ResolvedVUMeterOptions = Required> 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): 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() + } } } diff --git a/test/capture-support.test.ts b/test/capture-support.test.ts index a693034..5fecf54 100644 --- a/test/capture-support.test.ts +++ b/test/capture-support.test.ts @@ -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/) }) diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index 12f6730..d4522d9 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -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 = {}, + 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 = {}, available = true,