mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
rewrite VU to native
This commit is contained in:
@@ -247,6 +247,7 @@ const visualizerAPI = nativeAddonModule
|
||||
spectrum: nativeAddonModule.spectrum,
|
||||
spectrogram: nativeAddonModule.spectrogram,
|
||||
vectorscope: nativeAddonModule.vectorscope,
|
||||
vumeter: nativeAddonModule.vumeter,
|
||||
lufsmeter: nativeAddonModule.lufsmeter,
|
||||
}
|
||||
: null
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user