mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
@@ -98,6 +98,19 @@ Napi::Value OscilloscopeGetSamples(const Napi::CallbackInfo& info) {
|
||||
return output;
|
||||
}
|
||||
|
||||
Napi::Value OscilloscopeFillSamples(const Napi::CallbackInfo& info) {
|
||||
Napi::Env env = info.Env();
|
||||
if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsTypedArray()) {
|
||||
Napi::TypeError::New(env, "Expected startPos (float) and output Float32Array").ThrowAsJavaScriptException();
|
||||
return env.Null();
|
||||
}
|
||||
float startPos = info[0].As<Napi::Number>().FloatValue();
|
||||
Napi::Float32Array output = info[1].As<Napi::Float32Array>();
|
||||
const size_t count = output.ElementLength();
|
||||
oscilloscope.getSamplesInterpolated(output.Data(), startPos, count);
|
||||
return Napi::Number::New(env, static_cast<double>(count));
|
||||
}
|
||||
|
||||
Napi::Value OscilloscopeReset(const Napi::CallbackInfo& info) {
|
||||
oscilloscope.reset();
|
||||
return info.Env().Undefined();
|
||||
@@ -158,6 +171,22 @@ Napi::Value SpectrumGetMagnitudes(const Napi::CallbackInfo& info) {
|
||||
return result;
|
||||
}
|
||||
|
||||
Napi::Value SpectrumFillMagnitudes(const Napi::CallbackInfo& info) {
|
||||
Napi::Env env = info.Env();
|
||||
if (info.Length() < 1 || !info[0].IsTypedArray()) {
|
||||
Napi::TypeError::New(env, "Expected output Float32Array").ThrowAsJavaScriptException();
|
||||
return env.Null();
|
||||
}
|
||||
|
||||
Napi::Float32Array output = info[0].As<Napi::Float32Array>();
|
||||
const auto& magnitudes = spectrum.getMagnitudes();
|
||||
const size_t count = std::min(output.ElementLength(), magnitudes.size());
|
||||
if (count > 0) {
|
||||
memcpy(output.Data(), magnitudes.data(), count * sizeof(float));
|
||||
}
|
||||
return Napi::Number::New(env, static_cast<double>(count));
|
||||
}
|
||||
|
||||
Napi::Value SpectrumProcess(const Napi::CallbackInfo& info) {
|
||||
Napi::Env env = info.Env();
|
||||
if (info.Length() < 1 || !info[0].IsTypedArray()) {
|
||||
@@ -236,6 +265,20 @@ Napi::Value VectorscopeGetPoints(const Napi::CallbackInfo& info) {
|
||||
return result;
|
||||
}
|
||||
|
||||
Napi::Value VectorscopeFillPoints(const Napi::CallbackInfo& info) {
|
||||
Napi::Env env = info.Env();
|
||||
if (info.Length() < 2 || !info[0].IsTypedArray() || !info[1].IsTypedArray()) {
|
||||
Napi::TypeError::New(env, "Expected output x/y Float32Arrays").ThrowAsJavaScriptException();
|
||||
return env.Null();
|
||||
}
|
||||
|
||||
Napi::Float32Array xArray = info[0].As<Napi::Float32Array>();
|
||||
Napi::Float32Array yArray = info[1].As<Napi::Float32Array>();
|
||||
const size_t maxPoints = std::min(xArray.ElementLength(), yArray.ElementLength());
|
||||
const size_t actual = vectorscope.getPoints(xArray.Data(), yArray.Data(), maxPoints);
|
||||
return Napi::Number::New(env, static_cast<double>(actual));
|
||||
}
|
||||
|
||||
Napi::Value VectorscopeSetBufferSize(const Napi::CallbackInfo& info) {
|
||||
Napi::Env env = info.Env();
|
||||
if (info.Length() < 1 || !info[0].IsNumber()) {
|
||||
@@ -289,6 +332,7 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
|
||||
oscExports.Set("pushSamples", Napi::Function::New(env, OscilloscopePushSamples));
|
||||
oscExports.Set("processContinuous", Napi::Function::New(env, OscilloscopeProcessContinuous));
|
||||
oscExports.Set("getWritePos", Napi::Function::New(env, OscilloscopeGetWritePos));
|
||||
oscExports.Set("fillSamples", Napi::Function::New(env, OscilloscopeFillSamples));
|
||||
oscExports.Set("getSamples", Napi::Function::New(env, OscilloscopeGetSamples));
|
||||
oscExports.Set("reset", Napi::Function::New(env, OscilloscopeReset));
|
||||
exports.Set("oscilloscope", oscExports);
|
||||
@@ -300,6 +344,7 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
|
||||
specExports.Set("setSampleRate", Napi::Function::New(env, SpectrumSetSampleRate));
|
||||
specExports.Set("setSmoothing", Napi::Function::New(env, SpectrumSetSmoothing));
|
||||
specExports.Set("pushSamples", Napi::Function::New(env, SpectrumPushSamples));
|
||||
specExports.Set("fillMagnitudes", Napi::Function::New(env, SpectrumFillMagnitudes));
|
||||
specExports.Set("getMagnitudes", Napi::Function::New(env, SpectrumGetMagnitudes));
|
||||
specExports.Set("process", Napi::Function::New(env, SpectrumProcess));
|
||||
specExports.Set("binToFrequency", Napi::Function::New(env, SpectrumBinToFrequency));
|
||||
@@ -310,6 +355,7 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
|
||||
Napi::Object vecExports = Napi::Object::New(env);
|
||||
vecExports.Set("setSampleRate", Napi::Function::New(env, VectorscopeSetSampleRate));
|
||||
vecExports.Set("pushSamples", Napi::Function::New(env, VectorscopePushSamples));
|
||||
vecExports.Set("fillPoints", Napi::Function::New(env, VectorscopeFillPoints));
|
||||
vecExports.Set("getPoints", Napi::Function::New(env, VectorscopeGetPoints));
|
||||
vecExports.Set("setBufferSize", Napi::Function::New(env, VectorscopeSetBufferSize));
|
||||
vecExports.Set("getBufferSize", Napi::Function::New(env, VectorscopeGetBufferSize));
|
||||
|
||||
+36
-23
@@ -1219,35 +1219,48 @@ function setupIPC(): void {
|
||||
function setupShortcuts(): void {
|
||||
if (!mainWindow) return
|
||||
|
||||
const scopeKeys = ['1', '2', '3', '4', '5', '6', '7', '8']
|
||||
scopeKeys.forEach((key) => {
|
||||
mainWindow!.webContents.on('before-input-event', (_event, input) => {
|
||||
if (input.type === 'keyDown' && input.key === key && !input.alt && !input.control && !input.meta && !input.shift) {
|
||||
mainWindow?.webContents.send('shortcut:toggle-scope', parseInt(key) - 1)
|
||||
const shortcutWindow = mainWindow
|
||||
const scopeKeys = new Map([
|
||||
['1', 0],
|
||||
['2', 1],
|
||||
['3', 2],
|
||||
['4', 3],
|
||||
['5', 4],
|
||||
['6', 5],
|
||||
['7', 6],
|
||||
['8', 7],
|
||||
])
|
||||
const beforeInputHandler = (_event: Electron.Event, input: Electron.Input) => {
|
||||
if (input.type !== 'keyDown') {
|
||||
return
|
||||
}
|
||||
|
||||
if (!input.alt && !input.control && !input.meta && !input.shift) {
|
||||
const scopeIndex = scopeKeys.get(input.key)
|
||||
if (scopeIndex !== undefined) {
|
||||
shortcutWindow.webContents.send('shortcut:toggle-scope', scopeIndex)
|
||||
return
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
mainWindow.webContents.on('before-input-event', (_event, input) => {
|
||||
if (input.type === 'keyDown' && input.key === 't' && !input.alt && !input.control && !input.meta && !input.shift) {
|
||||
const current = mainWindow!.isAlwaysOnTop()
|
||||
const next = !current
|
||||
setAllWindowsAlwaysOnTop(next)
|
||||
mainWindow!.webContents.send('window:always-on-top-changed', next)
|
||||
}
|
||||
})
|
||||
if (input.key === 't') {
|
||||
const next = !shortcutWindow.isAlwaysOnTop()
|
||||
setAllWindowsAlwaysOnTop(next)
|
||||
shortcutWindow.webContents.send('window:always-on-top-changed', next)
|
||||
return
|
||||
}
|
||||
|
||||
mainWindow.webContents.on('before-input-event', (_event, input) => {
|
||||
if (input.type === 'keyDown' && input.key === ' ' && !input.alt && !input.control && !input.meta && !input.shift) {
|
||||
mainWindow?.webContents.send('shortcut:toggle-capture')
|
||||
if (input.key === ' ') {
|
||||
shortcutWindow.webContents.send('shortcut:toggle-capture')
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.webContents.on('before-input-event', (_event, input) => {
|
||||
if (input.type === 'keyDown' && input.key === ',' && input.meta && !input.alt && !input.control && !input.shift) {
|
||||
mainWindow?.webContents.send('shortcut:toggle-settings')
|
||||
if (input.key === ',' && input.meta && !input.alt && !input.control && !input.shift) {
|
||||
shortcutWindow.webContents.send('shortcut:toggle-settings')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
shortcutWindow.webContents.on('before-input-event', beforeInputHandler)
|
||||
}
|
||||
|
||||
const hasSingleInstanceLock = app.requestSingleInstanceLock()
|
||||
|
||||
@@ -23,7 +23,7 @@ export interface NativeVisualizerTransportBridge {
|
||||
spectrum: {
|
||||
setSampleRate: (sampleRate: number) => void
|
||||
pushSamples: (samples: Float32Array) => void
|
||||
getMagnitudes: () => Float32Array | null
|
||||
fillMagnitudes: (output: Float32Array) => number
|
||||
reset: () => void
|
||||
}
|
||||
vectorscope: {
|
||||
@@ -53,7 +53,7 @@ const defaultBridge: NativeVisualizerTransportBridge = {
|
||||
spectrum: {
|
||||
setSampleRate: (sampleRate) => spectrum.setSampleRate(sampleRate),
|
||||
pushSamples: (samples) => spectrum.pushSamples(samples),
|
||||
getMagnitudes: () => spectrum.getMagnitudes(),
|
||||
fillMagnitudes: (output) => spectrum.fillMagnitudes(output),
|
||||
reset: () => spectrum.reset(),
|
||||
},
|
||||
vectorscope: {
|
||||
@@ -82,11 +82,19 @@ export class NativeVisualizerTransport {
|
||||
private sampleRate = 48000
|
||||
private capturing = false
|
||||
private hasSpectrumData = false
|
||||
private spectrumMonoScratch = new Float32Array(0)
|
||||
|
||||
constructor(bridge: NativeVisualizerTransportBridge = defaultBridge) {
|
||||
this.bridge = bridge
|
||||
}
|
||||
|
||||
private ensureSpectrumMonoScratch(length: number): Float32Array {
|
||||
if (this.spectrumMonoScratch.length !== length) {
|
||||
this.spectrumMonoScratch = new Float32Array(length)
|
||||
}
|
||||
return this.spectrumMonoScratch
|
||||
}
|
||||
|
||||
setDemand(demand: VisualizerConsumerDemand): void {
|
||||
const nextDemand = normalizeDemand(demand)
|
||||
if (
|
||||
@@ -159,7 +167,7 @@ export class NativeVisualizerTransport {
|
||||
}
|
||||
|
||||
if (this.demand.spectrum) {
|
||||
const mono = new Float32Array(length)
|
||||
const mono = this.ensureSpectrumMonoScratch(length)
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
mono[index] = (leftSamples[index] + rightSamples[index]) * 0.5
|
||||
}
|
||||
@@ -197,12 +205,12 @@ export class NativeVisualizerTransport {
|
||||
}
|
||||
}
|
||||
|
||||
getLatestSpectrumMagnitudes(): Float32Array | null {
|
||||
fillLatestSpectrumMagnitudes(output: Float32Array): number {
|
||||
if (!this.bridge.isAvailable() || !this.demand.spectrum || !this.hasSpectrumData) {
|
||||
return null
|
||||
return 0
|
||||
}
|
||||
|
||||
return this.bridge.spectrum.getMagnitudes()
|
||||
return this.bridge.spectrum.fillMagnitudes(output)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,18 @@ export const oscilloscope = {
|
||||
return nativeModule?.oscilloscope.getWritePos() ?? 0
|
||||
},
|
||||
|
||||
fillSamples: (startPos: number, output: Float32Array): number => {
|
||||
if (!nativeModule) return 0
|
||||
// `visualizerAPI` crosses Electron's context bridge, so mutating a renderer-owned
|
||||
// typed array in preload/native does not write back into the caller's buffer.
|
||||
const samples = nativeModule.oscilloscope.getSamples(startPos, output.length)
|
||||
const count = Math.min(output.length, samples.length)
|
||||
if (count > 0) {
|
||||
output.set(samples.subarray(0, count), 0)
|
||||
}
|
||||
return count
|
||||
},
|
||||
|
||||
// Get samples from circular buffer for rendering
|
||||
getSamples: (startPos: number, count: number): Float32Array | null => {
|
||||
if (!nativeModule) return null
|
||||
@@ -97,6 +109,16 @@ export const spectrum = {
|
||||
nativeModule?.spectrum.pushSamples(audioData)
|
||||
},
|
||||
|
||||
fillMagnitudes: (output: Float32Array): number => {
|
||||
if (!nativeModule) return 0
|
||||
const magnitudes = nativeModule.spectrum.getMagnitudes()
|
||||
const count = Math.min(output.length, magnitudes.length)
|
||||
if (count > 0) {
|
||||
output.set(magnitudes.subarray(0, count), 0)
|
||||
}
|
||||
return count
|
||||
},
|
||||
|
||||
getMagnitudes: (): Float32Array | null => {
|
||||
if (!nativeModule) return null
|
||||
return nativeModule.spectrum.getMagnitudes()
|
||||
@@ -125,6 +147,17 @@ export const vectorscope = {
|
||||
nativeModule?.vectorscope.pushSamples(leftChannel, rightChannel)
|
||||
},
|
||||
|
||||
fillPoints: (xOut: Float32Array, yOut: Float32Array): number => {
|
||||
if (!nativeModule) return 0
|
||||
const result = nativeModule.vectorscope.getPoints(Math.min(xOut.length, yOut.length))
|
||||
const count = Math.min(xOut.length, yOut.length, result.count, result.x.length, result.y.length)
|
||||
if (count > 0) {
|
||||
xOut.set(result.x.subarray(0, count), 0)
|
||||
yOut.set(result.y.subarray(0, count), 0)
|
||||
}
|
||||
return count
|
||||
},
|
||||
|
||||
getPoints: (maxPoints: number): VectorscopePointsResult | null => {
|
||||
if (!nativeModule) return null
|
||||
return nativeModule.vectorscope.getPoints(maxPoints)
|
||||
|
||||
@@ -38,6 +38,9 @@ export interface OscilloscopeModule {
|
||||
// Get current write position in circular buffer
|
||||
getWritePos(): number;
|
||||
|
||||
// Fill a caller-owned buffer with rendered samples, returning the number written.
|
||||
fillSamples(startPos: number, output: Float32Array): number;
|
||||
|
||||
// Get samples from circular buffer for rendering
|
||||
getSamples(startPos: number, count: number): Float32Array;
|
||||
|
||||
@@ -50,6 +53,7 @@ export interface SpectrumModule {
|
||||
setSampleRate(sampleRate: number): void;
|
||||
setSmoothing(smoothing: number): void;
|
||||
pushSamples(audioData: Float32Array): void;
|
||||
fillMagnitudes(output: Float32Array): number;
|
||||
getMagnitudes(): Float32Array;
|
||||
process(audioData: Float32Array): Float32Array;
|
||||
binToFrequency(bin: number): number;
|
||||
@@ -59,6 +63,7 @@ export interface SpectrumModule {
|
||||
export interface VectorscopeModule {
|
||||
setSampleRate(sampleRate: number): void;
|
||||
pushSamples(leftChannel: Float32Array, rightChannel: Float32Array): void;
|
||||
fillPoints(xOut: Float32Array, yOut: Float32Array): number;
|
||||
getPoints(maxPoints: number): VectorscopePointsResult;
|
||||
setBufferSize(size: number): void;
|
||||
getBufferSize(): number;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { create } from 'zustand'
|
||||
import { isVisualizerFrameTarget, type VisualizerFrameTarget } from '../../types/performance'
|
||||
import {
|
||||
isVisualizerFrameTarget,
|
||||
type VisualizerFrameTarget,
|
||||
} from '../../types/performance'
|
||||
|
||||
const STORAGE_KEY = 'prism:performance'
|
||||
const SYNC_CHANNEL_NAME = 'prism:performance'
|
||||
|
||||
@@ -1414,6 +1414,12 @@ select {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bottom-bar__inline--memory {
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.bottom-bar__divider {
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
|
||||
@@ -46,6 +46,12 @@ 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
|
||||
|
||||
// ---- K-weighting filter coefficients (ITU-R BS.1770) ----
|
||||
|
||||
@@ -107,6 +113,15 @@ function applyBiquad(coeffs: BiquadCoeffs, state: BiquadState, input: number): n
|
||||
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)
|
||||
}
|
||||
|
||||
// ---- LUFS Meter class ----
|
||||
|
||||
export class LUFSMeter {
|
||||
@@ -135,7 +150,8 @@ export class LUFSMeter {
|
||||
private integratedBlockSumR = 0
|
||||
private integratedBlockSamples = 0
|
||||
private integratedHopCounter = 0
|
||||
private integratedBlockLoudness: number[] = [] // LUFS per block
|
||||
private integratedHistogramCounts = new Uint32Array(INTEGRATED_HISTOGRAM_BIN_COUNT)
|
||||
private integratedHistogramPowerSums = new Float64Array(INTEGRATED_HISTOGRAM_BIN_COUNT)
|
||||
|
||||
// Smoothed display values
|
||||
private momentaryLUFS = METER_MIN_LUFS
|
||||
@@ -193,7 +209,8 @@ export class LUFSMeter {
|
||||
this.integratedBlockSumR = 0
|
||||
this.integratedBlockSamples = 0
|
||||
this.integratedHopCounter = 0
|
||||
this.integratedBlockLoudness = []
|
||||
this.integratedHistogramCounts.fill(0)
|
||||
this.integratedHistogramPowerSums.fill(0)
|
||||
this.preFilterL = createBiquadState()
|
||||
this.preFilterR = createBiquadState()
|
||||
this.rlbFilterL = createBiquadState()
|
||||
@@ -281,8 +298,13 @@ export class LUFSMeter {
|
||||
if (this.integratedHopCounter >= hopSamples && this.integratedBlockSamples >= blockSamples) {
|
||||
const meanSqL = this.integratedBlockSumL / this.integratedBlockSamples
|
||||
const meanSqR = this.integratedBlockSumR / this.integratedBlockSamples
|
||||
const blockLUFS = -0.691 + 10 * Math.log10(Math.max(meanSqL + meanSqR, 1e-10))
|
||||
this.integratedBlockLoudness.push(blockLUFS)
|
||||
const blockPower = Math.max(meanSqL + meanSqR, 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
|
||||
}
|
||||
|
||||
// Slide the block window: remove oldest hop worth of samples
|
||||
// Approximate by keeping a running sum and subtracting the hop fraction
|
||||
@@ -336,27 +358,41 @@ export class LUFSMeter {
|
||||
}
|
||||
|
||||
private computeGatedIntegratedLoudness(): number {
|
||||
const blocks = this.integratedBlockLoudness
|
||||
if (blocks.length === 0) return METER_MIN_LUFS
|
||||
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]
|
||||
}
|
||||
if (absoluteCount === 0 || absolutePowerSum <= 0) {
|
||||
return METER_MIN_LUFS
|
||||
}
|
||||
|
||||
// Absolute gate: remove blocks below -70 LUFS
|
||||
const afterAbsolute = blocks.filter(l => l > ABSOLUTE_GATE_LUFS)
|
||||
if (afterAbsolute.length === 0) return METER_MIN_LUFS
|
||||
|
||||
// Compute mean of blocks passing absolute gate
|
||||
let sum = 0
|
||||
for (const l of afterAbsolute) sum += Math.pow(10, l / 10)
|
||||
const ungatedMean = 10 * Math.log10(sum / afterAbsolute.length)
|
||||
|
||||
// Relative gate: remove blocks below (ungatedMean - 10) LUFS
|
||||
const ungatedMean = -0.691 + 10 * Math.log10(absolutePowerSum / absoluteCount)
|
||||
const relativeThreshold = ungatedMean + RELATIVE_GATE_OFFSET
|
||||
const afterRelative = afterAbsolute.filter(l => l > relativeThreshold)
|
||||
if (afterRelative.length === 0) return METER_MIN_LUFS
|
||||
|
||||
// Final integrated loudness
|
||||
let finalSum = 0
|
||||
for (const l of afterRelative) finalSum += Math.pow(10, l / 10)
|
||||
return Math.max(METER_MIN_LUFS, 10 * Math.log10(finalSum / afterRelative.length))
|
||||
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]
|
||||
}
|
||||
if (relativeCount === 0 || relativePowerSum <= 0) {
|
||||
return METER_MIN_LUFS
|
||||
}
|
||||
|
||||
return Math.max(METER_MIN_LUFS, -0.691 + 10 * Math.log10(relativePowerSum / relativeCount))
|
||||
}
|
||||
|
||||
private drawFrame = (): void => {
|
||||
|
||||
@@ -83,6 +83,7 @@ export class Oscilloscope {
|
||||
private staticLayerCanvas: HTMLCanvasElement
|
||||
private staticLayerCtx: CanvasRenderingContext2D
|
||||
private staticLayerKey = ''
|
||||
private renderBuffer = new Float32Array(0)
|
||||
private static readonly WARMUP_SAMPLES = 4096
|
||||
|
||||
constructor(canvas: HTMLCanvasElement, options: OscilloscopeOptions = {}) {
|
||||
@@ -176,6 +177,13 @@ export class Oscilloscope {
|
||||
this.invalidate()
|
||||
}
|
||||
|
||||
private ensureRenderBuffer(size: number): Float32Array {
|
||||
if (this.renderBuffer.length !== size) {
|
||||
this.renderBuffer = new Float32Array(size)
|
||||
}
|
||||
return this.renderBuffer
|
||||
}
|
||||
|
||||
private drawFrame = (): void => {
|
||||
const { canvas, ctx, options } = this
|
||||
const width = canvas.width
|
||||
@@ -224,34 +232,25 @@ export class Oscilloscope {
|
||||
while (triggerIndex < 0) triggerIndex += OSCILLOSCOPE_BUFFER_SIZE
|
||||
}
|
||||
|
||||
const renderData = nativeOscilloscope.getSamples(Math.floor(triggerIndex), samplesToShow)
|
||||
if (!renderData || renderData.length === 0) {
|
||||
const renderData = this.ensureRenderBuffer(samplesToShow)
|
||||
const sampleCount = nativeOscilloscope.fillSamples(triggerIndex, renderData)
|
||||
if (sampleCount < 2) {
|
||||
return
|
||||
}
|
||||
|
||||
const sliceWidth = width / samplesToShow
|
||||
const sliceWidth = width / sampleCount
|
||||
const centerY = height / 2
|
||||
const visualGain = 1.8
|
||||
const points: Array<{ x: number; y: number }> = []
|
||||
|
||||
for (let i = 0; i < samplesToShow && i < renderData.length; i++) {
|
||||
const sample = renderData[i]
|
||||
const y = ((1 - sample * visualGain) / 2) * height
|
||||
const x = i * sliceWidth
|
||||
points.push({ x, y })
|
||||
}
|
||||
|
||||
if (points.length < 2) {
|
||||
return
|
||||
}
|
||||
|
||||
if (options.underfillEnabled) {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(points[0].x, centerY)
|
||||
for (const point of points) {
|
||||
ctx.lineTo(point.x, point.y)
|
||||
ctx.moveTo(0, centerY)
|
||||
for (let i = 0; i < sampleCount; i += 1) {
|
||||
const x = i * sliceWidth
|
||||
const y = ((1 - renderData[i] * visualGain) / 2) * height
|
||||
ctx.lineTo(x, y)
|
||||
}
|
||||
ctx.lineTo(points[points.length - 1].x, centerY)
|
||||
ctx.lineTo((sampleCount - 1) * sliceWidth, centerY)
|
||||
ctx.closePath()
|
||||
const peakAlpha = 0.28
|
||||
const shoulderAlpha = peakAlpha * 0.74
|
||||
@@ -273,9 +272,11 @@ export class Oscilloscope {
|
||||
ctx.lineCap = 'round'
|
||||
ctx.lineJoin = 'round'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(points[0].x, points[0].y)
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
ctx.lineTo(points[i].x, points[i].y)
|
||||
ctx.moveTo(0, ((1 - renderData[0] * visualGain) / 2) * height)
|
||||
for (let i = 1; i < sampleCount; i += 1) {
|
||||
const x = i * sliceWidth
|
||||
const y = ((1 - renderData[i] * visualGain) / 2) * height
|
||||
ctx.lineTo(x, y)
|
||||
}
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
@@ -299,6 +299,7 @@ export class Spectrogram {
|
||||
|
||||
private fftRe: Float32Array
|
||||
private fftIm: Float32Array
|
||||
private fftMagnitudes: Float32Array
|
||||
private sampleBuffer: Float32Array
|
||||
private sampleBufferPos = 0
|
||||
|
||||
@@ -342,6 +343,7 @@ export class Spectrogram {
|
||||
const paddedSize = windowSize * FFT_PAD_FACTOR
|
||||
this.fftRe = new Float32Array(paddedSize)
|
||||
this.fftIm = new Float32Array(paddedSize)
|
||||
this.fftMagnitudes = new Float32Array(paddedSize / 2)
|
||||
this.sampleBuffer = new Float32Array(windowSize)
|
||||
|
||||
this.waterfallCanvas = document.createElement('canvas')
|
||||
@@ -389,6 +391,7 @@ export class Spectrogram {
|
||||
const paddedSize = windowSize * FFT_PAD_FACTOR
|
||||
this.fftRe = new Float32Array(paddedSize)
|
||||
this.fftIm = new Float32Array(paddedSize)
|
||||
this.fftMagnitudes = new Float32Array(paddedSize / 2)
|
||||
this.sampleBuffer = new Float32Array(windowSize)
|
||||
this.sampleBufferPos = 0
|
||||
this.lastFftSize = 0
|
||||
@@ -533,7 +536,7 @@ export class Spectrogram {
|
||||
fft(this.fftRe, this.fftIm)
|
||||
|
||||
const numBins = paddedSize / 2
|
||||
const magnitudes = new Float32Array(numBins)
|
||||
const magnitudes = this.fftMagnitudes
|
||||
const scale = 2 / windowSize // normalize by window size, not padded size
|
||||
|
||||
for (let index = 0; index < numBins; index += 1) {
|
||||
|
||||
@@ -16,12 +16,6 @@ type SpectrumStereoChunk = {
|
||||
right: Float32Array
|
||||
}
|
||||
|
||||
type SpectrumPoint = {
|
||||
x: number
|
||||
y: number
|
||||
heatmapIntensity: number
|
||||
}
|
||||
|
||||
export interface SpectrumAnalyzerDataSource extends VisualizerSessionSource {
|
||||
getPendingSpectrumSamples: () => Float32Array[]
|
||||
getPendingSpectrumStereoSamples: () => SpectrumStereoChunk[]
|
||||
@@ -249,6 +243,12 @@ export class SpectrumAnalyzer {
|
||||
private jsFftIm = new Float32Array(defaultOptions.fftSize)
|
||||
private jsBufferedSamples = 0
|
||||
private jsHasSpectrumData = false
|
||||
private nativeMagnitudeBuffer = new Float32Array(0)
|
||||
private primaryPointX = new Float32Array(0)
|
||||
private primaryPointY = new Float32Array(0)
|
||||
private primaryPointHeatmap = new Float32Array(0)
|
||||
private secondaryPointX = new Float32Array(0)
|
||||
private secondaryPointY = new Float32Array(0)
|
||||
|
||||
constructor(canvas: HTMLCanvasElement, options: SpectrumAnalyzerOptions = {}) {
|
||||
this.canvas = canvas
|
||||
@@ -476,23 +476,30 @@ export class SpectrumAnalyzer {
|
||||
return db + tiltDbPerOctave * octaves
|
||||
}
|
||||
|
||||
private mergePendingSpectrumChunks(pendingSpectrum: Float32Array[]): Float32Array | null {
|
||||
if (pendingSpectrum.length === 0) return null
|
||||
if (pendingSpectrum.length === 1) return pendingSpectrum[0]
|
||||
|
||||
let totalLength = 0
|
||||
for (const chunk of pendingSpectrum) {
|
||||
totalLength += chunk.length
|
||||
private ensureNativeMagnitudeBuffer(): Float32Array {
|
||||
const length = Math.max(1, Math.floor(this.options.fftSize / 2))
|
||||
if (this.nativeMagnitudeBuffer.length !== length) {
|
||||
this.nativeMagnitudeBuffer = new Float32Array(length)
|
||||
}
|
||||
return this.nativeMagnitudeBuffer
|
||||
}
|
||||
|
||||
const monoData = new Float32Array(totalLength)
|
||||
let offset = 0
|
||||
for (const chunk of pendingSpectrum) {
|
||||
monoData.set(chunk, offset)
|
||||
offset += chunk.length
|
||||
private ensurePointBuffers(pointCount: number): void {
|
||||
if (this.primaryPointX.length !== pointCount) {
|
||||
this.primaryPointX = new Float32Array(pointCount)
|
||||
this.primaryPointY = new Float32Array(pointCount)
|
||||
this.primaryPointHeatmap = new Float32Array(pointCount)
|
||||
this.secondaryPointX = new Float32Array(pointCount)
|
||||
this.secondaryPointY = new Float32Array(pointCount)
|
||||
}
|
||||
}
|
||||
|
||||
return monoData
|
||||
private pushPendingSpectrumChunks(pendingSpectrum: Float32Array[]): void {
|
||||
for (const chunk of pendingSpectrum) {
|
||||
if (chunk.length > 0) {
|
||||
nativeSpectrum.pushSamples(chunk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private clearPendingSpectrumQueues(): void {
|
||||
@@ -578,17 +585,23 @@ export class SpectrumAnalyzer {
|
||||
this.jsHasSpectrumData = true
|
||||
}
|
||||
|
||||
private buildSpectrumPoints(
|
||||
private fillSpectrumPoints(
|
||||
frequencyData: Float32Array,
|
||||
dataLength: number,
|
||||
width: number,
|
||||
height: number,
|
||||
minFrequency: number,
|
||||
maxFrequency: number,
|
||||
nyquist: number,
|
||||
): SpectrumPoint[] {
|
||||
const bufferLength = frequencyData.length
|
||||
xOut: Float32Array,
|
||||
yOut: Float32Array,
|
||||
heatmapIntensityOut: Float32Array | null,
|
||||
): number {
|
||||
const bufferLength = Math.min(dataLength, frequencyData.length)
|
||||
if (bufferLength <= 0) {
|
||||
return 0
|
||||
}
|
||||
const binWidth = nyquist / bufferLength
|
||||
const points: SpectrumPoint[] = []
|
||||
const numPoints = Math.max(2, Math.floor(width))
|
||||
|
||||
for (let index = 0; index < numPoints; index += 1) {
|
||||
@@ -612,28 +625,28 @@ export class SpectrumAnalyzer {
|
||||
const normalized = (db - this.options.minDecibels) / (this.options.maxDecibels - this.options.minDecibels)
|
||||
const heatmapNormalized = (heatmapDb - this.options.minDecibels) / (this.options.maxDecibels - this.options.minDecibels)
|
||||
|
||||
points.push({
|
||||
x,
|
||||
y: height - Math.max(0, Math.min(1, normalized)) * height,
|
||||
heatmapIntensity: Math.pow(Math.max(0, Math.min(1, heatmapNormalized)), HEATMAP_GAMMA),
|
||||
})
|
||||
xOut[index] = x
|
||||
yOut[index] = height - Math.max(0, Math.min(1, normalized)) * height
|
||||
if (heatmapIntensityOut) {
|
||||
heatmapIntensityOut[index] = Math.pow(Math.max(0, Math.min(1, heatmapNormalized)), HEATMAP_GAMMA)
|
||||
}
|
||||
}
|
||||
|
||||
return points
|
||||
return numPoints
|
||||
}
|
||||
|
||||
private renderHeatmap(points: SpectrumPoint[], width: number, height: number): void {
|
||||
for (let index = 0; index < points.length; index += 1) {
|
||||
const x = Math.floor(points[index].x)
|
||||
const y = points[index].y
|
||||
const nextX = index < points.length - 1 ? Math.floor(points[index + 1].x) : width
|
||||
private renderHeatmap(xPoints: Float32Array, yPoints: Float32Array, heatmapIntensity: Float32Array, pointCount: number, width: number, height: number): void {
|
||||
for (let index = 0; index < pointCount; index += 1) {
|
||||
const x = Math.floor(xPoints[index])
|
||||
const y = yPoints[index]
|
||||
const nextX = index < pointCount - 1 ? Math.floor(xPoints[index + 1]) : width
|
||||
const columnWidth = Math.max(1, nextX - x)
|
||||
const fillHeight = height - y
|
||||
if (fillHeight <= 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const lutIndex = Math.round(points[index].heatmapIntensity * 255)
|
||||
const lutIndex = Math.round(heatmapIntensity[index] * 255)
|
||||
const r = this.heatLut[lutIndex * 3]
|
||||
const g = this.heatLut[lutIndex * 3 + 1]
|
||||
const b = this.heatLut[lutIndex * 3 + 2]
|
||||
@@ -643,12 +656,12 @@ export class SpectrumAnalyzer {
|
||||
}
|
||||
}
|
||||
|
||||
private renderGradientFill(points: SpectrumPoint[], width: number, height: number): void {
|
||||
private renderGradientFill(xPoints: Float32Array, yPoints: Float32Array, pointCount: number, width: number, height: number): void {
|
||||
this.ctx.beginPath()
|
||||
this.ctx.moveTo(points[0].x, points[0].y)
|
||||
this.ctx.moveTo(xPoints[0], yPoints[0])
|
||||
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
this.ctx.lineTo(points[index].x, points[index].y)
|
||||
for (let index = 1; index < pointCount; index += 1) {
|
||||
this.ctx.lineTo(xPoints[index], yPoints[index])
|
||||
}
|
||||
|
||||
this.ctx.lineTo(width, height)
|
||||
@@ -665,15 +678,15 @@ export class SpectrumAnalyzer {
|
||||
this.ctx.fill()
|
||||
}
|
||||
|
||||
private renderStroke(points: SpectrumPoint[], color: string, lineWidth: number): void {
|
||||
if (points.length === 0) {
|
||||
private renderStroke(xPoints: Float32Array, yPoints: Float32Array, pointCount: number, color: string, lineWidth: number): void {
|
||||
if (pointCount === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this.ctx.beginPath()
|
||||
this.ctx.moveTo(points[0].x, points[0].y)
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
this.ctx.lineTo(points[index].x, points[index].y)
|
||||
this.ctx.moveTo(xPoints[0], yPoints[0])
|
||||
for (let index = 1; index < pointCount; index += 1) {
|
||||
this.ctx.lineTo(xPoints[index], yPoints[index])
|
||||
}
|
||||
|
||||
this.ctx.lineWidth = lineWidth
|
||||
@@ -710,11 +723,15 @@ export class SpectrumAnalyzer {
|
||||
|
||||
let primaryData: Float32Array | null = null
|
||||
let secondaryData: Float32Array | null = null
|
||||
let primaryDataLength = 0
|
||||
let secondaryDataLength = 0
|
||||
|
||||
if (options.showSideLine) {
|
||||
this.processJsSpectrumChunks(this.dataSource.getPendingSpectrumStereoSamples())
|
||||
primaryData = this.jsHasSpectrumData ? this.jsMidMagnitudes : null
|
||||
secondaryData = this.jsHasSpectrumData ? this.jsSideMagnitudes : null
|
||||
primaryDataLength = primaryData?.length ?? 0
|
||||
secondaryDataLength = secondaryData?.length ?? 0
|
||||
} else {
|
||||
if (!isNativeAvailable()) {
|
||||
console.error('SpectrumAnalyzer: Native DSP required')
|
||||
@@ -725,39 +742,62 @@ export class SpectrumAnalyzer {
|
||||
const nativeTransport = this.dataSource.getNativeVisualizerTransport?.() ?? null
|
||||
const pendingSpectrum = this.dataSource.getPendingSpectrumSamples()
|
||||
if (!nativeTransport) {
|
||||
const monoData = this.mergePendingSpectrumChunks(pendingSpectrum)
|
||||
if (monoData) {
|
||||
nativeSpectrum.pushSamples(monoData)
|
||||
}
|
||||
this.pushPendingSpectrumChunks(pendingSpectrum)
|
||||
}
|
||||
|
||||
primaryData = nativeTransport
|
||||
? nativeTransport.getLatestSpectrumMagnitudes()
|
||||
: nativeSpectrum.getMagnitudes()
|
||||
const nativeMagnitudes = this.ensureNativeMagnitudeBuffer()
|
||||
primaryData = nativeMagnitudes
|
||||
primaryDataLength = nativeTransport
|
||||
? nativeTransport.fillLatestSpectrumMagnitudes(nativeMagnitudes)
|
||||
: nativeSpectrum.fillMagnitudes(nativeMagnitudes)
|
||||
}
|
||||
|
||||
if (!primaryData || primaryData.length === 0) {
|
||||
if (!primaryData || primaryDataLength === 0) {
|
||||
this.renderStaticLayer(minFrequency, maxFrequency)
|
||||
return
|
||||
}
|
||||
|
||||
const primaryPoints = this.buildSpectrumPoints(primaryData, width, height, minFrequency, maxFrequency, nyquist)
|
||||
const secondaryPoints = secondaryData && secondaryData.length > 0
|
||||
? this.buildSpectrumPoints(secondaryData, width, height, minFrequency, maxFrequency, nyquist)
|
||||
: null
|
||||
const pointCount = Math.max(2, Math.floor(width))
|
||||
this.ensurePointBuffers(pointCount)
|
||||
const primaryPointCount = this.fillSpectrumPoints(
|
||||
primaryData,
|
||||
primaryDataLength,
|
||||
width,
|
||||
height,
|
||||
minFrequency,
|
||||
maxFrequency,
|
||||
nyquist,
|
||||
this.primaryPointX,
|
||||
this.primaryPointY,
|
||||
this.primaryPointHeatmap,
|
||||
)
|
||||
const secondaryPointCount = secondaryData && secondaryDataLength > 0
|
||||
? this.fillSpectrumPoints(
|
||||
secondaryData,
|
||||
secondaryDataLength,
|
||||
width,
|
||||
height,
|
||||
minFrequency,
|
||||
maxFrequency,
|
||||
nyquist,
|
||||
this.secondaryPointX,
|
||||
this.secondaryPointY,
|
||||
null,
|
||||
)
|
||||
: 0
|
||||
|
||||
this.renderStaticLayer(minFrequency, maxFrequency)
|
||||
|
||||
if (options.heatmapFill && primaryPoints.length > 0) {
|
||||
this.renderHeatmap(primaryPoints, width, height)
|
||||
} else if (options.fillGradient && primaryPoints.length > 0) {
|
||||
this.renderGradientFill(primaryPoints, width, height)
|
||||
if (options.heatmapFill && primaryPointCount > 0) {
|
||||
this.renderHeatmap(this.primaryPointX, this.primaryPointY, this.primaryPointHeatmap, primaryPointCount, width, height)
|
||||
} else if (options.fillGradient && primaryPointCount > 0) {
|
||||
this.renderGradientFill(this.primaryPointX, this.primaryPointY, primaryPointCount, width, height)
|
||||
}
|
||||
|
||||
this.renderStroke(primaryPoints, options.lineColor, options.lineWidth * dpr)
|
||||
if (secondaryPoints && secondaryPoints.length > 0) {
|
||||
this.renderStroke(this.primaryPointX, this.primaryPointY, primaryPointCount, options.lineColor, options.lineWidth * dpr)
|
||||
if (secondaryPointCount > 0) {
|
||||
const secondaryLineWidth = Math.max(dpr, options.lineWidth * SIDE_LINE_WIDTH_RATIO * dpr)
|
||||
this.renderStroke(secondaryPoints, options.secondaryLineColor, secondaryLineWidth)
|
||||
this.renderStroke(this.secondaryPointX, this.secondaryPointY, secondaryPointCount, options.secondaryLineColor, secondaryLineWidth)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { audioRouter } from '../audio/AudioRouter'
|
||||
import { vectorscope as nativeVectorscope, isNativeAvailable } from '../audio/native'
|
||||
import { transformPoint, drawVectorscopeGridForMode, getVectorscopeLayout } from './vectorscopeGrids'
|
||||
import { MultibandSplitter, MultibandBuffer } from './multibandSplitter'
|
||||
import { drawVectorscopeGridForMode, getVectorscopeLayout } from './vectorscopeGrids'
|
||||
import { MultibandSplitter, MultibandBuffer, createMultibandChunk, type MultibandChunk } from './multibandSplitter'
|
||||
import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource'
|
||||
import { FrameScheduler } from './frameScheduler'
|
||||
import { VisualizerFrameLoop } from './visualizerFrameLoop'
|
||||
@@ -56,6 +56,7 @@ const defaultVectorscopeDataSource: VectorscopeDataSource = {
|
||||
}
|
||||
|
||||
const BAND_ORDER = ['low', 'mid', 'high'] as const
|
||||
const INV_SQRT2 = 1 / Math.sqrt(2)
|
||||
|
||||
export class Vectorscope {
|
||||
private canvas: HTMLCanvasElement
|
||||
@@ -72,6 +73,10 @@ export class Vectorscope {
|
||||
private unsubscribeSessionChange: (() => void) | null = null
|
||||
private splitter: MultibandSplitter = new MultibandSplitter()
|
||||
private multibandBuffer: MultibandBuffer = new MultibandBuffer()
|
||||
private multibandScratch: MultibandChunk = createMultibandChunk(0)
|
||||
private multibandPointScratch: MultibandChunk = createMultibandChunk(0)
|
||||
private nativePointX = new Float32Array(0)
|
||||
private nativePointY = new Float32Array(0)
|
||||
private staticLayerKey = ''
|
||||
|
||||
constructor(canvas: HTMLCanvasElement, options: VectorscopeOptions = {}) {
|
||||
@@ -217,9 +222,9 @@ export class Vectorscope {
|
||||
}
|
||||
}
|
||||
|
||||
const pointsResult = nativeVectorscope.getPoints(options.displayPoints)
|
||||
if (pointsResult && pointsResult.count > 0) {
|
||||
this.drawPoints(offscreenCtx, pointsResult.x, pointsResult.y, pointsResult.count, centerX, centerY, scale)
|
||||
const count = this.fillNativePoints(options.displayPoints)
|
||||
if (count > 0) {
|
||||
this.drawPoints(offscreenCtx, this.nativePointX, this.nativePointY, count, centerX, centerY, scale)
|
||||
}
|
||||
} else {
|
||||
this.drawFallbackPoints(offscreenCtx, pendingSamples, centerX, centerY, scale)
|
||||
@@ -295,12 +300,7 @@ export class Vectorscope {
|
||||
ctx.globalAlpha = alpha
|
||||
|
||||
for (let i = startIdx; i < endIdx; i++) {
|
||||
const point = transformPoint(y[i], x[i], mode)
|
||||
if (!point) continue
|
||||
|
||||
const px = centerX + point.dx * scale
|
||||
const py = centerY - point.dy * scale
|
||||
ctx.fillRect(px - dotSize / 2, py - dotSize / 2, dotSize, dotSize)
|
||||
this.drawProjectedDot(ctx, y[i], x[i], mode, centerX, centerY, scale, dotSize)
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1.0
|
||||
@@ -325,12 +325,7 @@ export class Vectorscope {
|
||||
|
||||
for (const chunk of pendingSamples) {
|
||||
for (let i = 0; i < chunk.left.length; i++) {
|
||||
const point = transformPoint(chunk.left[i], chunk.right[i], mode)
|
||||
if (!point) continue
|
||||
|
||||
const px = centerX + point.dx * scale
|
||||
const py = centerY - point.dy * scale
|
||||
ctx.fillRect(px - dotSize / 2, py - dotSize / 2, dotSize, dotSize)
|
||||
this.drawProjectedDot(ctx, chunk.left[i], chunk.right[i], mode, centerX, centerY, scale, dotSize)
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1.0
|
||||
@@ -353,48 +348,114 @@ export class Vectorscope {
|
||||
this.splitter.configure(sampleRate)
|
||||
}
|
||||
|
||||
if (isNativeAvailable()) {
|
||||
for (const chunk of pendingSamples) {
|
||||
nativeVectorscope.pushSamples(chunk.left, chunk.right)
|
||||
}
|
||||
}
|
||||
|
||||
for (const chunk of pendingSamples) {
|
||||
const bands = this.splitter.split(chunk.left, chunk.right)
|
||||
this.multibandBuffer.push(bands)
|
||||
const bands = this.ensureMultibandScratch(chunk.left.length, chunk.right.length)
|
||||
const count = this.splitter.splitInto(chunk.left, chunk.right, bands)
|
||||
this.multibandBuffer.push(bands, count)
|
||||
}
|
||||
|
||||
const result = this.multibandBuffer.getPoints(options.displayPoints)
|
||||
if (result.count === 0) return
|
||||
const result = this.ensureMultibandPointScratch(options.displayPoints)
|
||||
const count = this.multibandBuffer.fillPointsInto(result, options.displayPoints)
|
||||
if (count === 0) return
|
||||
|
||||
const segments = 8
|
||||
const pointsPerSegment = Math.ceil(result.count / segments)
|
||||
const pointsPerSegment = Math.ceil(count / segments)
|
||||
|
||||
for (let seg = 0; seg < segments; seg++) {
|
||||
const startIdx = seg * pointsPerSegment
|
||||
const endIdx = Math.min((seg + 1) * pointsPerSegment, result.count)
|
||||
if (startIdx >= result.count) break
|
||||
const endIdx = Math.min((seg + 1) * pointsPerSegment, count)
|
||||
if (startIdx >= count) break
|
||||
|
||||
const alpha = 0.15 + 0.85 * (seg / Math.max(segments - 1, 1))
|
||||
ctx.globalAlpha = alpha
|
||||
|
||||
for (const band of BAND_ORDER) {
|
||||
const bandData = result.bands[band]
|
||||
const bandData = result[band]
|
||||
ctx.fillStyle = options.bandColors[band]
|
||||
|
||||
for (let i = startIdx; i < endIdx; i++) {
|
||||
const point = transformPoint(bandData.left[i], bandData.right[i], mode)
|
||||
if (!point) continue
|
||||
|
||||
const px = centerX + point.dx * scale
|
||||
const py = centerY - point.dy * scale
|
||||
ctx.fillRect(px - dotSize / 2, py - dotSize / 2, dotSize, dotSize)
|
||||
this.drawProjectedDot(ctx, bandData.left[i], bandData.right[i], mode, centerX, centerY, scale, dotSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1.0
|
||||
}
|
||||
|
||||
private ensureNativePointBuffers(displayPoints: number): void {
|
||||
if (this.nativePointX.length !== displayPoints) {
|
||||
this.nativePointX = new Float32Array(displayPoints)
|
||||
this.nativePointY = new Float32Array(displayPoints)
|
||||
}
|
||||
}
|
||||
|
||||
private fillNativePoints(displayPoints: number): number {
|
||||
this.ensureNativePointBuffers(displayPoints)
|
||||
return nativeVectorscope.fillPoints(this.nativePointX, this.nativePointY)
|
||||
}
|
||||
|
||||
private ensureMultibandScratch(leftLength: number, rightLength: number): MultibandChunk {
|
||||
const length = Math.min(leftLength, rightLength)
|
||||
if (this.multibandScratch.low.left.length < length) {
|
||||
this.multibandScratch = createMultibandChunk(length)
|
||||
}
|
||||
return this.multibandScratch
|
||||
}
|
||||
|
||||
private ensureMultibandPointScratch(displayPoints: number): MultibandChunk {
|
||||
if (this.multibandPointScratch.low.left.length !== displayPoints) {
|
||||
this.multibandPointScratch = createMultibandChunk(displayPoints)
|
||||
}
|
||||
return this.multibandPointScratch
|
||||
}
|
||||
|
||||
private drawProjectedDot(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
left: number,
|
||||
right: number,
|
||||
mode: VectorscopeMode,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
scale: number,
|
||||
dotSize: number,
|
||||
): void {
|
||||
let dx: number
|
||||
let dy: number
|
||||
|
||||
if (mode === 'lissajous') {
|
||||
dx = right
|
||||
dy = left
|
||||
} else {
|
||||
const mid = (left + right) * INV_SQRT2
|
||||
const side = (right - left) * INV_SQRT2
|
||||
const isUnipolar = mode === 'polar-unipolar' || mode === 'linear-unipolar'
|
||||
if (isUnipolar && mid < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const isPolar = mode === 'polar-unipolar' || mode === 'polar-bipolar'
|
||||
if (isPolar) {
|
||||
const amplitudeSquared = (mid * mid) + (side * side)
|
||||
if (amplitudeSquared < 1e-12) {
|
||||
dx = 0
|
||||
dy = 0
|
||||
} else {
|
||||
const amplitude = Math.sqrt(amplitudeSquared)
|
||||
const scaledAmplitude = Math.pow(amplitude, 0.35)
|
||||
const factor = scaledAmplitude / amplitude
|
||||
dx = side * factor
|
||||
dy = mid * factor
|
||||
}
|
||||
} else {
|
||||
dx = side
|
||||
dy = mid
|
||||
}
|
||||
}
|
||||
|
||||
const px = centerX + dx * scale
|
||||
const py = centerY - dy * scale
|
||||
ctx.fillRect(px - dotSize / 2, py - dotSize / 2, dotSize, dotSize)
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.stop()
|
||||
this.frameLoop.dispose()
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
clampWaveformScrollSpeed,
|
||||
type WaveformMode,
|
||||
} from '../../types/waveform'
|
||||
import { MultibandSplitter } from './multibandSplitter'
|
||||
import { MultibandSplitter, createMultibandChunk, type MultibandChunk } from './multibandSplitter'
|
||||
|
||||
export interface WaveformStereoChunk {
|
||||
left: Float32Array
|
||||
@@ -97,6 +97,7 @@ export class Waveform {
|
||||
private rightBandLowAcc: Float32Array = new Float32Array(0)
|
||||
private rightBandMidAcc: Float32Array = new Float32Array(0)
|
||||
private rightBandHighAcc: Float32Array = new Float32Array(0)
|
||||
private multibandScratch: MultibandChunk = createMultibandChunk(0)
|
||||
private unsubscribeSessionChange: (() => void) | null = null
|
||||
|
||||
constructor(canvas: HTMLCanvasElement, options: WaveformOptions = {}) {
|
||||
@@ -458,7 +459,8 @@ export class Waveform {
|
||||
let midBand: Float32Array | null = null
|
||||
let highBand: Float32Array | null = null
|
||||
if (this.options.multiband) {
|
||||
const bands = this.splitter.split(chunk, chunk)
|
||||
const bands = this.ensureMultibandScratch(chunk.length)
|
||||
this.splitter.splitInto(chunk, chunk, bands)
|
||||
lowBand = bands.low.left
|
||||
midBand = bands.mid.left
|
||||
highBand = bands.high.left
|
||||
@@ -499,7 +501,8 @@ export class Waveform {
|
||||
let midRight: Float32Array | null = null
|
||||
let highRight: Float32Array | null = null
|
||||
if (this.options.multiband) {
|
||||
const bands = this.splitter.split(leftSamples, rightSamples)
|
||||
const bands = this.ensureMultibandScratch(length)
|
||||
this.splitter.splitInto(leftSamples, rightSamples, bands)
|
||||
lowLeft = bands.low.left
|
||||
midLeft = bands.mid.left
|
||||
highLeft = bands.high.left
|
||||
@@ -535,6 +538,13 @@ export class Waveform {
|
||||
}
|
||||
}
|
||||
|
||||
private ensureMultibandScratch(length: number): MultibandChunk {
|
||||
if (this.multibandScratch.low.left.length < length) {
|
||||
this.multibandScratch = createMultibandChunk(length)
|
||||
}
|
||||
return this.multibandScratch
|
||||
}
|
||||
|
||||
private drawFrame = (): void => {
|
||||
const width = this.canvas.width
|
||||
const height = this.canvas.height
|
||||
|
||||
@@ -27,6 +27,14 @@ export interface MultibandChunk {
|
||||
high: { left: Float32Array; right: Float32Array }
|
||||
}
|
||||
|
||||
export function createMultibandChunk(length: number): MultibandChunk {
|
||||
return {
|
||||
low: { left: new Float32Array(length), right: new Float32Array(length) },
|
||||
mid: { left: new Float32Array(length), right: new Float32Array(length) },
|
||||
high: { left: new Float32Array(length), right: new Float32Array(length) },
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Biquad filter ----------
|
||||
|
||||
class BiquadFilter {
|
||||
@@ -114,6 +122,15 @@ export class MultibandSplitter {
|
||||
private highHpR = new BiquadFilter()
|
||||
|
||||
private configuredSampleRate = 0
|
||||
private midTmpL = new Float32Array(0)
|
||||
private midTmpR = new Float32Array(0)
|
||||
|
||||
private ensureScratch(size: number): void {
|
||||
if (this.midTmpL.length !== size) {
|
||||
this.midTmpL = new Float32Array(size)
|
||||
this.midTmpR = new Float32Array(size)
|
||||
}
|
||||
}
|
||||
|
||||
configure(sampleRate: number): void {
|
||||
if (sampleRate === this.configuredSampleRate) return
|
||||
@@ -134,38 +151,42 @@ export class MultibandSplitter {
|
||||
}
|
||||
|
||||
split(left: Float32Array, right: Float32Array): MultibandChunk {
|
||||
const n = left.length
|
||||
const target = createMultibandChunk(Math.min(left.length, right.length))
|
||||
this.splitInto(left, right, target)
|
||||
return target
|
||||
}
|
||||
|
||||
const lowL = new Float32Array(n)
|
||||
const lowR = new Float32Array(n)
|
||||
const midL = new Float32Array(n)
|
||||
const midR = new Float32Array(n)
|
||||
const highL = new Float32Array(n)
|
||||
const highR = new Float32Array(n)
|
||||
|
||||
// Temp buffers for mid band (highpass then lowpass)
|
||||
const midTmpL = new Float32Array(n)
|
||||
const midTmpR = new Float32Array(n)
|
||||
|
||||
// Low band
|
||||
this.lowLpL.process(left, lowL)
|
||||
this.lowLpR.process(right, lowR)
|
||||
|
||||
// Mid band (highpass → lowpass)
|
||||
this.midHpL.process(left, midTmpL)
|
||||
this.midHpR.process(right, midTmpR)
|
||||
this.midLpL.process(midTmpL, midL)
|
||||
this.midLpR.process(midTmpR, midR)
|
||||
|
||||
// High band
|
||||
this.highHpL.process(left, highL)
|
||||
this.highHpR.process(right, highR)
|
||||
|
||||
return {
|
||||
low: { left: lowL, right: lowR },
|
||||
mid: { left: midL, right: midR },
|
||||
high: { left: highL, right: highR },
|
||||
splitInto(left: Float32Array, right: Float32Array, target: MultibandChunk): number {
|
||||
const n = Math.min(
|
||||
left.length,
|
||||
right.length,
|
||||
target.low.left.length,
|
||||
target.low.right.length,
|
||||
target.mid.left.length,
|
||||
target.mid.right.length,
|
||||
target.high.left.length,
|
||||
target.high.right.length,
|
||||
)
|
||||
if (n <= 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const leftInput = left.length === n ? left : left.subarray(0, n)
|
||||
const rightInput = right.length === n ? right : right.subarray(0, n)
|
||||
this.ensureScratch(n)
|
||||
|
||||
this.lowLpL.process(leftInput, target.low.left)
|
||||
this.lowLpR.process(rightInput, target.low.right)
|
||||
|
||||
this.midHpL.process(leftInput, this.midTmpL)
|
||||
this.midHpR.process(rightInput, this.midTmpR)
|
||||
this.midLpL.process(this.midTmpL, target.mid.left)
|
||||
this.midLpR.process(this.midTmpR, target.mid.right)
|
||||
|
||||
this.highHpL.process(leftInput, target.high.left)
|
||||
this.highHpR.process(rightInput, target.high.right)
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
@@ -211,8 +232,16 @@ export class MultibandBuffer {
|
||||
}
|
||||
}
|
||||
|
||||
push(bands: MultibandChunk): void {
|
||||
const n = bands.low.left.length
|
||||
push(bands: MultibandChunk, count = bands.low.left.length): void {
|
||||
const n = Math.min(
|
||||
count,
|
||||
bands.low.left.length,
|
||||
bands.low.right.length,
|
||||
bands.mid.left.length,
|
||||
bands.mid.right.length,
|
||||
bands.high.left.length,
|
||||
bands.high.right.length,
|
||||
)
|
||||
for (let i = 0; i < n; i++) {
|
||||
const pos = this.writePos
|
||||
this.buffers.low.left[pos] = bands.low.left[i]
|
||||
@@ -233,6 +262,34 @@ export class MultibandBuffer {
|
||||
* Returns the most recent `maxPoints` samples for each band,
|
||||
* ordered oldest-first (matching the native getPoints() convention).
|
||||
*/
|
||||
fillPointsInto(target: MultibandChunk, maxPoints: number): number {
|
||||
const count = Math.min(
|
||||
maxPoints,
|
||||
this.validSamples,
|
||||
target.low.left.length,
|
||||
target.low.right.length,
|
||||
target.mid.left.length,
|
||||
target.mid.right.length,
|
||||
target.high.left.length,
|
||||
target.high.right.length,
|
||||
)
|
||||
if (count === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const idx = (this.writePos + this.capacity - count + i) % this.capacity
|
||||
target.low.left[i] = this.buffers.low.left[idx]
|
||||
target.low.right[i] = this.buffers.low.right[idx]
|
||||
target.mid.left[i] = this.buffers.mid.left[idx]
|
||||
target.mid.right[i] = this.buffers.mid.right[idx]
|
||||
target.high.left[i] = this.buffers.high.left[idx]
|
||||
target.high.right[i] = this.buffers.high.right[idx]
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
getPoints(maxPoints: number): {
|
||||
bands: Record<'low' | 'mid' | 'high', { left: Float32Array; right: Float32Array }>
|
||||
count: number
|
||||
@@ -249,21 +306,8 @@ export class MultibandBuffer {
|
||||
}
|
||||
}
|
||||
|
||||
const out = {
|
||||
low: { left: new Float32Array(count), right: new Float32Array(count) },
|
||||
mid: { left: new Float32Array(count), right: new Float32Array(count) },
|
||||
high: { left: new Float32Array(count), right: new Float32Array(count) },
|
||||
}
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const idx = (this.writePos + this.capacity - count + i) % this.capacity
|
||||
out.low.left[i] = this.buffers.low.left[idx]
|
||||
out.low.right[i] = this.buffers.low.right[idx]
|
||||
out.mid.left[i] = this.buffers.mid.left[idx]
|
||||
out.mid.right[i] = this.buffers.mid.right[idx]
|
||||
out.high.left[i] = this.buffers.high.left[idx]
|
||||
out.high.right[i] = this.buffers.high.right[idx]
|
||||
}
|
||||
const out = createMultibandChunk(count)
|
||||
this.fillPointsInto(out, count)
|
||||
|
||||
return { bands: out, count }
|
||||
}
|
||||
|
||||
@@ -47,6 +47,12 @@ import {
|
||||
NativeVisualizerTransport,
|
||||
type NativeVisualizerTransportBridge,
|
||||
} from '../src/renderer/audio/NativeVisualizerTransport'
|
||||
import { LUFSMeter } from '../src/renderer/visualizers/LUFSMeter'
|
||||
import {
|
||||
MultibandBuffer,
|
||||
MultibandSplitter,
|
||||
createMultibandChunk,
|
||||
} from '../src/renderer/visualizers/multibandSplitter'
|
||||
import {
|
||||
DEFAULT_PROFILE_ID,
|
||||
DEFAULT_PROFILE_NAME,
|
||||
@@ -373,7 +379,16 @@ function createFakeTransportBridge(): {
|
||||
calls.spectrumPushes.push(samples)
|
||||
latestSpectrumMagnitudes = new Float32Array([samples[0] ?? 0, samples[samples.length - 1] ?? 0])
|
||||
},
|
||||
getMagnitudes: () => latestSpectrumMagnitudes,
|
||||
fillMagnitudes: (output) => {
|
||||
if (!latestSpectrumMagnitudes) {
|
||||
return 0
|
||||
}
|
||||
const count = Math.min(output.length, latestSpectrumMagnitudes.length)
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
output[index] = latestSpectrumMagnitudes[index]
|
||||
}
|
||||
return count
|
||||
},
|
||||
reset: () => {
|
||||
calls.spectrumResets += 1
|
||||
latestSpectrumMagnitudes = null
|
||||
@@ -395,6 +410,51 @@ function createFakeTransportBridge(): {
|
||||
}
|
||||
}
|
||||
|
||||
function readSpectrumMagnitudes(transport: NativeVisualizerTransport, size = 8): number[] {
|
||||
const output = new Float32Array(size)
|
||||
const count = transport.fillLatestSpectrumMagnitudes(output)
|
||||
return Array.from(output.subarray(0, count))
|
||||
}
|
||||
|
||||
function createFakeCanvasContext(): CanvasRenderingContext2D {
|
||||
return {
|
||||
clearRect() {},
|
||||
fillRect() {},
|
||||
fillText() {},
|
||||
beginPath() {},
|
||||
moveTo() {},
|
||||
lineTo() {},
|
||||
stroke() {},
|
||||
save() {},
|
||||
restore() {},
|
||||
translate() {},
|
||||
rotate() {},
|
||||
createLinearGradient() {
|
||||
return {
|
||||
addColorStop() {},
|
||||
} as CanvasGradient
|
||||
},
|
||||
measureText() {
|
||||
return { width: 0 } as TextMetrics
|
||||
},
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 1,
|
||||
font: '',
|
||||
textAlign: 'left',
|
||||
textBaseline: 'top',
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
}
|
||||
|
||||
function createFakeCanvas(): HTMLCanvasElement {
|
||||
const context = createFakeCanvasContext()
|
||||
return {
|
||||
width: 320,
|
||||
height: 180,
|
||||
getContext: (kind: string) => kind === '2d' ? context : null,
|
||||
} as unknown as HTMLCanvasElement
|
||||
}
|
||||
|
||||
test('parseColorToRgb handles hex, rgb, rgba, and percentage formats', () => {
|
||||
assert.deepEqual(parseColorToRgb('#38bdf8'), { r: 56, g: 189, b: 248 })
|
||||
assert.deepEqual(parseColorToRgb('#3bf'), { r: 51, g: 187, b: 255 })
|
||||
@@ -1406,7 +1466,7 @@ test('NativeVisualizerTransport feeds native scope state from chunk arrival with
|
||||
assert.equal(calls.vectorscopePushes[0]?.left, left)
|
||||
assert.equal(calls.vectorscopePushes[0]?.right, right)
|
||||
assert.deepEqual(Array.from(calls.spectrumPushes[0]), [0.5, 0.5, 0.5])
|
||||
assert.deepEqual(Array.from(transport.getLatestSpectrumMagnitudes() ?? []), [0.5, 0.5])
|
||||
assert.deepEqual(readSpectrumMagnitudes(transport), [0.5, 0.5])
|
||||
})
|
||||
|
||||
test('NativeVisualizerTransport resets cached state on session changes and sample-rate updates', () => {
|
||||
@@ -1424,7 +1484,7 @@ test('NativeVisualizerTransport resets cached state on session changes and sampl
|
||||
sessionId: 7,
|
||||
channelCount: 2,
|
||||
})
|
||||
assert.deepEqual(Array.from(transport.getLatestSpectrumMagnitudes() ?? []), [1, 1])
|
||||
assert.deepEqual(readSpectrumMagnitudes(transport), [1, 1])
|
||||
|
||||
transport.reset({
|
||||
sessionId: 8,
|
||||
@@ -1439,7 +1499,7 @@ test('NativeVisualizerTransport resets cached state on session changes and sampl
|
||||
assert.equal(calls.oscilloscopeResets >= 2, true)
|
||||
assert.equal(calls.spectrumResets >= 2, true)
|
||||
assert.equal(calls.vectorscopeResets >= 2, true)
|
||||
assert.equal(transport.getLatestSpectrumMagnitudes(), null)
|
||||
assert.equal(transport.fillLatestSpectrumMagnitudes(new Float32Array(4)), 0)
|
||||
})
|
||||
|
||||
test('NativeVisualizerTransport stops feeding scopes when demand is removed', () => {
|
||||
@@ -1467,7 +1527,71 @@ test('NativeVisualizerTransport stops feeding scopes when demand is removed', ()
|
||||
|
||||
assert.equal(calls.spectrumPushes.length, 1)
|
||||
assert.equal(calls.spectrumResets >= 1, true)
|
||||
assert.equal(transport.getLatestSpectrumMagnitudes(), null)
|
||||
assert.equal(transport.fillLatestSpectrumMagnitudes(new Float32Array(4)), 0)
|
||||
})
|
||||
|
||||
test('MultibandSplitter and MultibandBuffer reuse caller-owned buffers', () => {
|
||||
const splitter = new MultibandSplitter()
|
||||
splitter.configure(48000)
|
||||
|
||||
const splitTarget = createMultibandChunk(8)
|
||||
const leftRef = splitTarget.low.left
|
||||
const rightRef = splitTarget.high.right
|
||||
const left = new Float32Array([1, 0.75, 0.25, -0.25, -0.75, -1, -0.5, 0.5])
|
||||
const right = new Float32Array([0.5, 0.25, -0.5, -1, -0.5, 0.25, 0.75, 1])
|
||||
|
||||
const splitCount = splitter.splitInto(left, right, splitTarget)
|
||||
assert.equal(splitCount, 8)
|
||||
assert.equal(splitTarget.low.left, leftRef)
|
||||
assert.equal(splitTarget.high.right, rightRef)
|
||||
|
||||
const buffer = new MultibandBuffer(16)
|
||||
buffer.push(splitTarget, splitCount)
|
||||
|
||||
const pointTarget = createMultibandChunk(8)
|
||||
const pointRef = pointTarget.mid.left
|
||||
const pointCount = buffer.fillPointsInto(pointTarget, 8)
|
||||
assert.equal(pointCount, 8)
|
||||
assert.equal(pointTarget.mid.left, pointRef)
|
||||
assert.notEqual(pointTarget.low.left[0], 0)
|
||||
})
|
||||
|
||||
test('LUFSMeter keeps integrated history bounded over long runs', () => {
|
||||
const chunkQueue: Array<{ left: Float32Array; right: Float32Array }> = []
|
||||
const dataSource = {
|
||||
getPendingLUFSMeterSamples: () => {
|
||||
const drained = chunkQueue.slice()
|
||||
chunkQueue.length = 0
|
||||
return drained
|
||||
},
|
||||
getSampleRate: () => 48000,
|
||||
isPlaying: () => true,
|
||||
subscribeToSessionChanges: () => () => {},
|
||||
}
|
||||
const meter = new LUFSMeter(createFakeCanvas(), { dataSource })
|
||||
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,
|
||||
})
|
||||
processAudio()
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
test('NativePolledCaptureBackend schedules immediate, backoff, and hidden-document polls and cancels on stop', async () => {
|
||||
|
||||
Reference in New Issue
Block a user