add stereo to waveform scope, add side metering to spectrum

This commit is contained in:
Boof2015
2026-03-28 21:27:45 -04:00
parent ee234cad7c
commit 604a867708
13 changed files with 917 additions and 244 deletions
+33 -13
View File
@@ -90,13 +90,13 @@ interface ScopeLatencyTracker {
}
type ScopeRingMap = {
spectrum: FixedChunkRing<MonoChunkRecord>
spectrum: FixedChunkRing<StereoChunkRecord>
oscilloscope: FixedChunkRing<MonoChunkRecord>
vectorscope: FixedChunkRing<StereoChunkRecord>
spectrogram: FixedChunkRing<MonoChunkRecord>
vumeter: FixedChunkRing<StereoChunkRecord>
lufsmeter: FixedChunkRing<StereoChunkRecord>
waveform: FixedChunkRing<MonoChunkRecord>
waveform: FixedChunkRing<StereoChunkRecord>
}
class FixedChunkRing<T> {
@@ -212,13 +212,13 @@ function createScopeLatencyTracker(): ScopeLatencyTracker {
export class AudioRouter {
private readonly rings: ScopeRingMap = {
spectrum: new FixedChunkRing<MonoChunkRecord>(SCOPE_RING_CAPACITY.spectrum),
spectrum: new FixedChunkRing<StereoChunkRecord>(SCOPE_RING_CAPACITY.spectrum),
oscilloscope: new FixedChunkRing<MonoChunkRecord>(SCOPE_RING_CAPACITY.oscilloscope),
vectorscope: new FixedChunkRing<StereoChunkRecord>(SCOPE_RING_CAPACITY.vectorscope),
spectrogram: new FixedChunkRing<MonoChunkRecord>(SCOPE_RING_CAPACITY.spectrogram),
vumeter: new FixedChunkRing<StereoChunkRecord>(SCOPE_RING_CAPACITY.vumeter),
lufsmeter: new FixedChunkRing<StereoChunkRecord>(SCOPE_RING_CAPACITY.lufsmeter),
waveform: new FixedChunkRing<MonoChunkRecord>(SCOPE_RING_CAPACITY.waveform),
waveform: new FixedChunkRing<StereoChunkRecord>(SCOPE_RING_CAPACITY.waveform),
}
private readonly scopeLatency: Record<ScopeKind, ScopeLatencyTracker> = {
@@ -362,11 +362,12 @@ export class AudioRouter {
if (len === 0) return
const activeDemand = this.getActiveDemand()
const needsMono = Boolean(activeDemand.spectrum || activeDemand.spectrogram)
const needsStereo = Boolean(activeDemand.vectorscope || activeDemand.vumeter || activeDemand.lufsmeter)
const needsLeft = Boolean(activeDemand.oscilloscope || activeDemand.waveform)
const needsSpectrum = Boolean(activeDemand.spectrum)
const needsMono = Boolean(activeDemand.spectrogram)
const needsStereo = Boolean(activeDemand.vectorscope || activeDemand.vumeter || activeDemand.lufsmeter || activeDemand.waveform)
const needsLeft = Boolean(activeDemand.oscilloscope)
if (!needsMono && !needsStereo && !needsLeft) {
if (!needsSpectrum && !needsMono && !needsStereo && !needsLeft) {
this.undemandedChunks += 1
return
}
@@ -388,8 +389,8 @@ export class AudioRouter {
this.rings.oscilloscope.push({ samples: leftSamples, capturedAt, sequence })
}
if (activeDemand.spectrum && mono) {
this.rings.spectrum.push({ samples: mono, capturedAt, sequence })
if (activeDemand.spectrum) {
this.rings.spectrum.push({ left: leftSamples, right: rightSamples, capturedAt, sequence })
}
if (activeDemand.spectrogram && mono) {
@@ -409,7 +410,7 @@ export class AudioRouter {
}
if (activeDemand.waveform) {
this.rings.waveform.push({ samples: leftSamples, capturedAt, sequence })
this.rings.waveform.push({ left: leftSamples, right: rightSamples, capturedAt, sequence })
}
}
@@ -422,7 +423,20 @@ export class AudioRouter {
flushPendingSpectrumSamples(): Float32Array[] {
const records = this.rings.spectrum.drain()
this.recordScopeDrain('spectrum', records)
return records.map((record) => record.samples)
return records.map((record) => {
const length = Math.min(record.left.length, record.right.length)
const mono = new Float32Array(length)
for (let index = 0; index < length; index += 1) {
mono[index] = (record.left[index] + record.right[index]) * 0.5
}
return mono
})
}
flushPendingSpectrumStereoSamples(): { left: Float32Array; right: Float32Array }[] {
const records = this.rings.spectrum.drain()
this.recordScopeDrain('spectrum', records)
return records.map((record) => ({ left: record.left, right: record.right }))
}
flushPendingSpectrogramSamples(): Float32Array[] {
@@ -452,7 +466,13 @@ export class AudioRouter {
flushPendingWaveformSamples(): Float32Array[] {
const records = this.rings.waveform.drain()
this.recordScopeDrain('waveform', records)
return records.map((record) => record.samples)
return records.map((record) => record.left)
}
flushPendingWaveformStereoSamples(): { left: Float32Array; right: Float32Array }[] {
const records = this.rings.waveform.drain()
this.recordScopeDrain('waveform', records)
return records.map((record) => ({ left: record.left, right: record.right }))
}
getDiagnosticsSnapshot(): AudioRouterDiagnostics {
+4 -1
View File
@@ -58,7 +58,7 @@ function getScopeTheme(theme: PrismResolvedTheme, kind: ScopeKind): ScopeModuleT
return theme[kind] as ScopeModuleTheme
}
function scopeSettingsToOptions(
export function scopeSettingsToOptions(
kind: ScopeKind,
settings: ScopeSettings[ScopeKind],
theme: ScopeModuleTheme,
@@ -69,6 +69,7 @@ function scopeSettingsToOptions(
const t = theme as ResolvedSpectrumTheme
return {
lineColor: t.primary,
secondaryLineColor: t.secondary,
gradientColors: t.fillGradient,
heatColors: t.heatColors,
backgroundColor: t.background,
@@ -80,6 +81,7 @@ function scopeSettingsToOptions(
showGrid: s.showGrid,
fillGradient: s.fillGradient,
smoothing: s.smoothing,
showSideLine: s.showSideLine,
}
}
case 'oscilloscope': {
@@ -164,6 +166,7 @@ function scopeSettingsToOptions(
mid: t.midBand,
high: t.highBand,
},
mode: s.mode,
scrollSpeed: s.scrollSpeed,
gainDb: s.gainDb,
multiband: s.multiband,
@@ -20,10 +20,12 @@ function buildConsumerDemand(kind: ScopeKind): Record<ScopeKind, boolean> {
}, {} as Record<ScopeKind, boolean>)
}
function flushScopeAudioBatch(kind: ScopeKind): ScopePopoutAudioBatch {
function flushScopeAudioBatch(kind: ScopeKind, scopeSettings: ScopeSettings): ScopePopoutAudioBatch {
switch (kind) {
case 'spectrum':
return audioRouter.flushPendingSpectrumSamples()
return scopeSettings.spectrum.showSideLine
? audioRouter.flushPendingSpectrumStereoSamples()
: audioRouter.flushPendingSpectrumSamples()
case 'oscilloscope':
return audioRouter.flushPendingOscilloscopeSamples()
case 'vectorscope':
@@ -35,7 +37,9 @@ function flushScopeAudioBatch(kind: ScopeKind): ScopePopoutAudioBatch {
case 'lufsmeter':
return audioRouter.flushPendingLUFSMeterSamples()
case 'waveform':
return audioRouter.flushPendingWaveformSamples()
return scopeSettings.waveform.mode === 'stereo'
? audioRouter.flushPendingWaveformStereoSamples()
: audioRouter.flushPendingWaveformSamples()
}
}
@@ -171,7 +175,7 @@ export default function ScopePopoutBridge(): null {
}
for (const kind of activePopoutKindsRef.current) {
const batch = flushScopeAudioBatch(kind)
const batch = flushScopeAudioBatch(kind, useSettingsStore.getState().scopeSettings)
if (batch.length > 0) {
window.electronAPI.sendScopePopoutAudio(kind, batch)
}
@@ -22,7 +22,8 @@ export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind]
switch (kind) {
case 'spectrum': {
const scopeSettings = settings as ScopeSettings['spectrum']
return `${scopeSettings.heatmap ? 'Heat' : 'Fill'} · FFT ${scopeSettings.fftSize}`
const summary = `${scopeSettings.heatmap ? 'Heat' : 'Fill'} · FFT ${scopeSettings.fftSize}`
return scopeSettings.showSideLine ? `${summary} · Side` : summary
}
case 'oscilloscope': {
const scopeSettings = settings as ScopeSettings['oscilloscope']
@@ -47,9 +48,14 @@ export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind]
return 'Bar Meter'
case 'waveform': {
const scopeSettings = settings as ScopeSettings['waveform']
return scopeSettings.multiband
? `${scopeSettings.gainDb > 0 ? '+' : ''}${scopeSettings.gainDb} dB · RGB`
: `${scopeSettings.gainDb > 0 ? '+' : ''}${scopeSettings.gainDb} dB`
const summary = [`${scopeSettings.gainDb > 0 ? '+' : ''}${scopeSettings.gainDb} dB`]
if (scopeSettings.mode === 'stereo') {
summary.push('Stereo')
}
if (scopeSettings.multiband) {
summary.push('RGB')
}
return summary.join(' · ')
}
}
}
@@ -213,6 +219,11 @@ export default function ScopeSettingsSection({
active={current.showGrid}
onClick={() => onUpdate('spectrum', { showGrid: !current.showGrid })}
/>
<ToggleChip
label="Side"
active={current.showSideLine}
onClick={() => onUpdate('spectrum', { showSideLine: !current.showSideLine })}
/>
</ToggleGroup>
<RangeControl
@@ -443,6 +454,19 @@ export default function ScopeSettingsSection({
const current = settings as ScopeSettings['waveform']
return (
<>
<ToggleGroup label="Mode">
<ToggleChip
label="Mono"
active={current.mode === 'mono'}
onClick={() => onUpdate('waveform', { mode: 'mono' })}
/>
<ToggleChip
label="Stereo"
active={current.mode === 'stereo'}
onClick={() => onUpdate('waveform', { mode: 'stereo' })}
/>
</ToggleGroup>
<ToggleGroup label="Bands">
<ToggleChip
label="Multiband"
@@ -67,6 +67,34 @@ export class ScopePopoutDataSource implements AnyScopeDataSource {
return
}
if (this.scopeKind === 'spectrum' || this.scopeKind === 'waveform') {
if (isStereoBatch(batch)) {
this.monoQueue = []
this.stereoQueue.push(...batch)
if (this.scopeKind === 'spectrum') {
for (const chunk of batch) {
this.nativeVisualizerTransport.handleChunk(chunk.left, chunk.right, {
sessionId: this.sessionState.sessionId,
channelCount: this.sessionState.channelCount,
})
}
}
return
}
this.stereoQueue = []
this.monoQueue.push(...batch)
if (this.scopeKind === 'spectrum') {
for (const chunk of batch) {
this.nativeVisualizerTransport.handleChunk(chunk, chunk, {
sessionId: this.sessionState.sessionId,
channelCount: 1,
})
}
}
return
}
if (isStereoBatch(batch)) return
this.monoQueue.push(...batch)
for (const chunk of batch) {
@@ -116,6 +144,12 @@ export class ScopePopoutDataSource implements AnyScopeDataSource {
return this.scopeKind === 'spectrum' ? batch : []
}
getPendingSpectrumStereoSamples(): ScopePopoutStereoBatch {
const batch = this.stereoQueue
this.stereoQueue = []
return this.scopeKind === 'spectrum' ? batch : []
}
getPendingOscilloscopeSamples(): Float32Array[] {
const batch = this.monoQueue
this.monoQueue = []
@@ -134,6 +168,12 @@ export class ScopePopoutDataSource implements AnyScopeDataSource {
return this.scopeKind === 'waveform' ? batch : []
}
getPendingWaveformStereoSamples(): ScopePopoutStereoBatch {
const batch = this.stereoQueue
this.stereoQueue = []
return this.scopeKind === 'waveform' ? batch : []
}
getPendingVectorscopeSamples(): ScopePopoutStereoBatch {
const batch = this.stereoQueue
this.stereoQueue = []
+26
View File
@@ -215,7 +215,33 @@ type ColorStop = {
color: [number, number, number]
}
const LEGACY_DEFAULT_HEAT_COLORS: [string, string, string] = [
'rgb(15, 7, 33)',
'rgb(163, 26, 121)',
'rgb(255, 241, 209)',
]
function isLegacyDefaultHeatColors(colors: [string, string, string]): boolean {
return colors.every((color, index) => {
const left = resolveColorToRgb(color)
const right = resolveColorToRgb(LEGACY_DEFAULT_HEAT_COLORS[index])
return left.r === right.r && left.g === right.g && left.b === right.b
})
}
function buildHeatStops(colors: [string, string, string]): ColorStop[] {
if (isLegacyDefaultHeatColors(colors)) {
return [
{ at: 0, color: [0, 0, 0] },
{ at: 0.14, color: [15, 7, 33] },
{ at: 0.32, color: [61, 11, 94] },
{ at: 0.54, color: [163, 26, 121] },
{ at: 0.74, color: [255, 82, 87] },
{ at: 0.9, color: [255, 166, 63] },
{ at: 1, color: [255, 241, 209] },
]
}
const low = resolveColorToRgb(colors[0])
const mid = resolveColorToRgb(colors[1])
const high = resolveColorToRgb(colors[2])
+408 -126
View File
@@ -11,12 +11,25 @@ import {
clampSpectrumHeatmapTiltDbPerOctave,
} from '../../types/spectrum'
type SpectrumStereoChunk = {
left: Float32Array
right: Float32Array
}
type SpectrumPoint = {
x: number
y: number
heatmapIntensity: number
}
export interface SpectrumAnalyzerDataSource extends VisualizerSessionSource {
getPendingSpectrumSamples: () => Float32Array[]
getPendingSpectrumStereoSamples: () => SpectrumStereoChunk[]
}
export interface SpectrumAnalyzerOptions {
lineColor?: string
secondaryLineColor?: string
lineWidth?: number
fillGradient?: boolean
heatmapFill?: boolean
@@ -35,6 +48,7 @@ export interface SpectrumAnalyzerOptions {
heatmapTiltDbPerOctave?: number
tiltReferenceHz?: number
fftSize?: number
showSideLine?: boolean
dataSource?: SpectrumAnalyzerDataSource
frameScheduler?: FrameScheduler
}
@@ -42,12 +56,87 @@ export interface SpectrumAnalyzerOptions {
type ResolvedSpectrumAnalyzerOptions = Required<Omit<SpectrumAnalyzerOptions, 'dataSource' | 'frameScheduler'>>
type HeatStop = { at: number; color: [number, number, number] }
const LEGACY_DEFAULT_HEAT_COLORS: [string, string, string] = [
'rgb(15, 7, 33)',
'rgb(163, 26, 121)',
'rgb(255, 241, 209)',
]
const HEATMAP_GAMMA = 1.4
const FFT_SILENCE_DB = -100
const SPECTRUM_DB_FLOOR = -120
const SPECTRUM_DB_CEILING = 12
const SIDE_LINE_WIDTH_RATIO = 0.75
const hannWindowCache = new Map<number, Float32Array>()
function getHannWindow(size: number): Float32Array {
let window = hannWindowCache.get(size)
if (window) return window
window = new Float32Array(size)
for (let index = 0; index < size; index += 1) {
window[index] = 0.5 * (1 - Math.cos((2 * Math.PI * index) / (size - 1)))
}
hannWindowCache.set(size, window)
return window
}
function fft(re: Float32Array, im: Float32Array): void {
const size = re.length
if (size <= 1) return
let j = 0
for (let i = 1; i < size; i += 1) {
let bit = size >> 1
while (j & bit) {
j ^= bit
bit >>= 1
}
j ^= bit
if (i < j) {
let tmp = re[i]
re[i] = re[j]
re[j] = tmp
tmp = im[i]
im[i] = im[j]
im[j] = tmp
}
}
for (let len = 2; len <= size; len <<= 1) {
const halfLen = len >> 1
const angle = -2 * Math.PI / len
const wRe = Math.cos(angle)
const wIm = Math.sin(angle)
for (let i = 0; i < size; i += len) {
let curRe = 1
let curIm = 0
for (let k = 0; k < halfLen; k += 1) {
const evenIndex = i + k
const oddIndex = i + k + halfLen
const tRe = curRe * re[oddIndex] - curIm * im[oddIndex]
const tIm = curRe * im[oddIndex] + curIm * re[oddIndex]
re[oddIndex] = re[evenIndex] - tRe
im[oddIndex] = im[evenIndex] - tIm
re[evenIndex] += tRe
im[evenIndex] += tIm
const nextRe = curRe * wRe - curIm * wIm
curIm = curRe * wIm + curIm * wRe
curRe = nextRe
}
}
}
}
function isLegacyDefaultHeatColors(colors: [string, string, string]): boolean {
return colors.every((color, index) => {
const left = resolveColorToRgb(color)
@@ -87,28 +176,29 @@ function buildHeatStops(colors: [string, string, string]): HeatStop[] {
function buildHeatLUT(colors: [string, string, string]): Uint8Array {
const heatStops = buildHeatStops(colors)
const lut = new Uint8Array(256 * 3)
for (let i = 0; i < 256; i++) {
for (let i = 0; i < 256; i += 1) {
const t = i / 255
let s = heatStops[0]
let e = heatStops[heatStops.length - 1]
for (let si = 0; si < heatStops.length - 1; si++) {
if (t <= heatStops[si + 1].at) {
s = heatStops[si]
e = heatStops[si + 1]
let start = heatStops[0]
let end = heatStops[heatStops.length - 1]
for (let stopIndex = 0; stopIndex < heatStops.length - 1; stopIndex += 1) {
if (t <= heatStops[stopIndex + 1].at) {
start = heatStops[stopIndex]
end = heatStops[stopIndex + 1]
break
}
}
const a = Math.max(0, Math.min(1, (t - s.at) / Math.max(1e-6, e.at - s.at)))
lut[i * 3] = Math.round(s.color[0] + (e.color[0] - s.color[0]) * a)
lut[i * 3 + 1] = Math.round(s.color[1] + (e.color[1] - s.color[1]) * a)
lut[i * 3 + 2] = Math.round(s.color[2] + (e.color[2] - s.color[2]) * a)
const amount = Math.max(0, Math.min(1, (t - start.at) / Math.max(1e-6, end.at - start.at)))
lut[i * 3] = Math.round(start.color[0] + (end.color[0] - start.color[0]) * amount)
lut[i * 3 + 1] = Math.round(start.color[1] + (end.color[1] - start.color[1]) * amount)
lut[i * 3 + 2] = Math.round(start.color[2] + (end.color[2] - start.color[2]) * amount)
}
return lut
}
const HEATMAP_GAMMA = 1.4
const defaultOptions: ResolvedSpectrumAnalyzerOptions = {
lineColor: '#00ffff',
secondaryLineColor: 'rgba(0, 255, 255, 0.5)',
lineWidth: 2,
fillGradient: true,
heatmapFill: false,
@@ -127,10 +217,12 @@ const defaultOptions: ResolvedSpectrumAnalyzerOptions = {
heatmapTiltDbPerOctave: DEFAULT_SPECTRUM_HEATMAP_TILT_DB_PER_OCTAVE,
tiltReferenceHz: 1000,
fftSize: 2048,
showSideLine: false,
}
const defaultSpectrumDataSource: SpectrumAnalyzerDataSource = {
getPendingSpectrumSamples: () => audioRouter.flushPendingSpectrumSamples(),
getPendingSpectrumStereoSamples: () => audioRouter.flushPendingSpectrumStereoSamples(),
...defaultVisualizerSessionSource,
}
@@ -149,6 +241,15 @@ export class SpectrumAnalyzer {
private staticLayerKey = ''
private unsubscribeSessionChange: (() => void) | null = null
private jsMidHistory = new Float32Array(defaultOptions.fftSize)
private jsSideHistory = new Float32Array(defaultOptions.fftSize)
private jsMidMagnitudes = new Float32Array(defaultOptions.fftSize / 2)
private jsSideMagnitudes = new Float32Array(defaultOptions.fftSize / 2)
private jsFftRe = new Float32Array(defaultOptions.fftSize)
private jsFftIm = new Float32Array(defaultOptions.fftSize)
private jsBufferedSamples = 0
private jsHasSpectrumData = false
constructor(canvas: HTMLCanvasElement, options: SpectrumAnalyzerOptions = {}) {
this.canvas = canvas
const ctx = canvas.getContext('2d')
@@ -178,6 +279,7 @@ export class SpectrumAnalyzer {
if (!staticLayerCtx) throw new Error('Could not get offscreen 2D context')
this.staticLayerCtx = staticLayerCtx
this.resetJsState()
this.initNative()
this.subscribeToSessionChanges()
}
@@ -186,32 +288,61 @@ export class SpectrumAnalyzer {
if (this.unsubscribeSessionChange) {
this.unsubscribeSessionChange()
}
this.unsubscribeSessionChange = this.dataSource.subscribeToSessionChanges(() => {
this.resetState()
})
}
private initNative(): void {
this.sampleRate = Math.max(1, this.dataSource.getSampleRate())
this.lastSampleRate = 0
if (isNativeAvailable() && !this.nativeInitialized) {
this.sampleRate = Math.max(1, this.dataSource.getSampleRate())
this.lastSampleRate = 0
nativeSpectrum.setFFTSize(this.options.fftSize)
nativeSpectrum.setSampleRate(this.sampleRate)
nativeSpectrum.setSmoothing(this.getNativeSmoothing())
this.nativeInitialized = true
console.log(`SpectrumAnalyzer: Using native DSP (${this.sampleRate}Hz)`)
} else if (!isNativeAvailable()) {
} else if (!isNativeAvailable() && !this.options.showSideLine) {
console.error('SpectrumAnalyzer: Native DSP not available!')
}
}
private ensureJsStateSize(): void {
const { fftSize } = this.options
if (this.jsMidHistory.length === fftSize) {
return
}
this.jsMidHistory = new Float32Array(fftSize)
this.jsSideHistory = new Float32Array(fftSize)
this.jsMidMagnitudes = new Float32Array(fftSize / 2)
this.jsSideMagnitudes = new Float32Array(fftSize / 2)
this.jsFftRe = new Float32Array(fftSize)
this.jsFftIm = new Float32Array(fftSize)
}
private resetJsState(): void {
this.ensureJsStateSize()
this.jsMidHistory.fill(0)
this.jsSideHistory.fill(0)
this.jsMidMagnitudes.fill(FFT_SILENCE_DB)
this.jsSideMagnitudes.fill(FFT_SILENCE_DB)
this.jsFftRe.fill(0)
this.jsFftIm.fill(0)
this.jsBufferedSamples = 0
this.jsHasSpectrumData = false
}
private updateSampleRateIfNeeded(): void {
if (!isNativeAvailable()) return
const currentRate = Math.max(1, this.dataSource.getSampleRate())
if (currentRate !== this.lastSampleRate && currentRate > 0) {
this.sampleRate = currentRate
this.lastSampleRate = currentRate
nativeSpectrum.setSampleRate(currentRate)
if (isNativeAvailable()) {
nativeSpectrum.setSampleRate(currentRate)
}
console.log(`SpectrumAnalyzer: Sample rate updated to ${currentRate}Hz`)
}
}
@@ -226,6 +357,7 @@ export class SpectrumAnalyzer {
if (isNativeAvailable()) {
nativeSpectrum.reset()
}
this.resetJsState()
this.sampleRate = Math.max(1, this.dataSource.getSampleRate())
this.lastSampleRate = 0
this.invalidate()
@@ -240,12 +372,22 @@ export class SpectrumAnalyzer {
if (optionUpdates.heatmapTiltDbPerOctave !== undefined) {
nextOptions.heatmapTiltDbPerOctave = clampSpectrumHeatmapTiltDbPerOctave(optionUpdates.heatmapTiltDbPerOctave)
}
const shouldResetForOptions = (
optionUpdates.fftSize !== undefined
|| optionUpdates.smoothing !== undefined
|| optionUpdates.showSideLine !== undefined
)
this.options = nextOptions
this.heatLut = buildHeatLUT(this.options.heatColors)
let didReset = false
if (dataSource && dataSource !== this.dataSource) {
this.dataSource = dataSource
this.subscribeToSessionChanges()
this.resetState()
didReset = true
}
if (isNativeAvailable()) {
@@ -257,8 +399,15 @@ export class SpectrumAnalyzer {
}
}
if (shouldResetForOptions && !didReset) {
this.resetState()
didReset = true
}
this.staticLayerKey = ''
this.invalidate()
if (!didReset) {
this.invalidate()
}
}
start(): void {
@@ -309,7 +458,7 @@ export class SpectrumAnalyzer {
}
let peak = -Infinity
for (let i = lo; i <= hi; i++) {
for (let i = lo; i <= hi; i += 1) {
peak = Math.max(peak, data[i])
}
@@ -332,7 +481,9 @@ export class SpectrumAnalyzer {
if (pendingSpectrum.length === 1) return pendingSpectrum[0]
let totalLength = 0
for (const chunk of pendingSpectrum) totalLength += chunk.length
for (const chunk of pendingSpectrum) {
totalLength += chunk.length
}
const monoData = new Float32Array(totalLength)
let offset = 0
@@ -344,8 +495,196 @@ export class SpectrumAnalyzer {
return monoData
}
private clearPendingSpectrumQueues(): void {
this.dataSource.getPendingSpectrumSamples()
this.dataSource.getPendingSpectrumStereoSamples()
}
private pushJsSpectrumHistory(left: Float32Array, right: Float32Array, length: number): void {
const fftSize = this.options.fftSize
if (length >= fftSize) {
const start = length - fftSize
for (let index = 0; index < fftSize; index += 1) {
const leftValue = left[start + index] ?? 0
const rightValue = right[start + index] ?? leftValue
this.jsMidHistory[index] = (leftValue + rightValue) * 0.5
this.jsSideHistory[index] = (leftValue - rightValue) * 0.5
}
this.jsBufferedSamples = fftSize
return
}
this.jsMidHistory.copyWithin(0, length)
this.jsSideHistory.copyWithin(0, length)
const writeStart = fftSize - length
for (let index = 0; index < length; index += 1) {
const leftValue = left[index] ?? 0
const rightValue = right[index] ?? leftValue
this.jsMidHistory[writeStart + index] = (leftValue + rightValue) * 0.5
this.jsSideHistory[writeStart + index] = (leftValue - rightValue) * 0.5
}
this.jsBufferedSamples = Math.min(fftSize, this.jsBufferedSamples + length)
}
private updateJsMagnitudes(history: Float32Array, smoothedMagnitudes: Float32Array): void {
const fftSize = this.options.fftSize
const window = getHannWindow(fftSize)
for (let index = 0; index < fftSize; index += 1) {
this.jsFftRe[index] = history[index] * window[index]
this.jsFftIm[index] = 0
}
fft(this.jsFftRe, this.jsFftIm)
const scale = 2 / fftSize
const smoothing = Math.min(0.99, Math.max(0, this.options.smoothing))
for (let index = 0; index < smoothedMagnitudes.length; index += 1) {
const magnitude = Math.hypot(this.jsFftRe[index], this.jsFftIm[index]) * scale
let db = 20 * Math.log10(Math.max(magnitude, 1e-10))
db += 6
db = Math.min(SPECTRUM_DB_CEILING, Math.max(SPECTRUM_DB_FLOOR, db))
if (this.jsBufferedSamples < fftSize) {
smoothedMagnitudes[index] = db
continue
}
smoothedMagnitudes[index] = smoothing * smoothedMagnitudes[index] + (1 - smoothing) * db
if (!Number.isFinite(smoothedMagnitudes[index])) {
smoothedMagnitudes[index] = FFT_SILENCE_DB
}
}
}
private processJsSpectrumChunks(pendingSpectrum: SpectrumStereoChunk[]): void {
let didReceiveAudio = false
for (const chunk of pendingSpectrum) {
const length = Math.min(chunk.left.length, chunk.right.length)
if (length <= 0) {
continue
}
this.pushJsSpectrumHistory(chunk.left, chunk.right, length)
didReceiveAudio = true
}
if (!didReceiveAudio) {
return
}
this.updateJsMagnitudes(this.jsMidHistory, this.jsMidMagnitudes)
this.updateJsMagnitudes(this.jsSideHistory, this.jsSideMagnitudes)
this.jsHasSpectrumData = true
}
private buildSpectrumPoints(
frequencyData: Float32Array,
width: number,
height: number,
minFrequency: number,
maxFrequency: number,
nyquist: number,
): SpectrumPoint[] {
const bufferLength = frequencyData.length
const binWidth = nyquist / bufferLength
const points: SpectrumPoint[] = []
const numPoints = Math.max(2, Math.floor(width))
for (let index = 0; index < numPoints; index += 1) {
const t0 = index / (numPoints - 1)
const t1 = Math.min(1, (index + 1) / (numPoints - 1))
const x = t0 * width
const frequency0 = this.frequencyAtPosition(t0, minFrequency, maxFrequency)
const frequency1 = this.frequencyAtPosition(t1, minFrequency, maxFrequency)
const centerFrequency = (frequency0 + frequency1) * 0.5
const bin0 = frequency0 / binWidth
const bin1 = frequency1 / binWidth
const centerBin = (bin0 + bin1) * 0.5
const binSpan = Math.abs(bin1 - bin0)
const rawDb = binSpan <= 1
? this.getInterpolatedValue(frequencyData, Math.min(centerBin, bufferLength - 1))
: this.getPeakInRange(frequencyData, bin0, bin1)
const db = this.applyTilt(rawDb, centerFrequency)
const heatmapDb = this.applyTilt(rawDb, centerFrequency, this.options.heatmapTiltDbPerOctave)
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),
})
}
return points
}
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
const columnWidth = Math.max(1, nextX - x)
const fillHeight = height - y
if (fillHeight <= 0) {
continue
}
const lutIndex = Math.round(points[index].heatmapIntensity * 255)
const r = this.heatLut[lutIndex * 3]
const g = this.heatLut[lutIndex * 3 + 1]
const b = this.heatLut[lutIndex * 3 + 2]
this.ctx.fillStyle = `rgba(${r}, ${g}, ${b}, 0.85)`
this.ctx.fillRect(x, Math.floor(y), columnWidth, Math.ceil(fillHeight))
}
}
private renderGradientFill(points: SpectrumPoint[], width: number, height: number): void {
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.lineTo(width, height)
this.ctx.lineTo(0, height)
this.ctx.closePath()
const gradient = this.ctx.createLinearGradient(0, height, 0, 0)
const colors = this.options.gradientColors
for (let index = 0; index < colors.length; index += 1) {
gradient.addColorStop(index / (colors.length - 1), colors[index])
}
this.ctx.fillStyle = gradient
this.ctx.fill()
}
private renderStroke(points: SpectrumPoint[], color: string, lineWidth: number): void {
if (points.length === 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.lineWidth = lineWidth
this.ctx.strokeStyle = color
this.ctx.lineCap = 'round'
this.ctx.lineJoin = 'round'
this.ctx.stroke()
}
private drawFrame = (): void => {
const { canvas, ctx, options } = this
const { canvas, options } = this
const width = canvas.width
const height = canvas.height
const dpr = window.devicePixelRatio || 1
@@ -353,11 +692,6 @@ export class SpectrumAnalyzer {
return
}
if (!isNativeAvailable()) {
console.error('SpectrumAnalyzer: Native DSP required')
return
}
this.updateSampleRateIfNeeded()
const nyquist = this.sampleRate / 2
@@ -365,119 +699,66 @@ export class SpectrumAnalyzer {
const maxFrequency = Math.max(minFrequency + 1, Math.min(options.maxFrequency, nyquist))
if (!this.dataSource.isPlaying()) {
this.dataSource.getPendingSpectrumSamples()
nativeSpectrum.reset()
this.renderStaticLayer(minFrequency, maxFrequency)
return
}
const nativeTransport = this.dataSource.getNativeVisualizerTransport?.() ?? null
const pendingSpectrum = this.dataSource.getPendingSpectrumSamples()
if (!nativeTransport) {
const monoData = this.mergePendingSpectrumChunks(pendingSpectrum)
if (monoData) {
nativeSpectrum.pushSamples(monoData)
this.clearPendingSpectrumQueues()
if (isNativeAvailable()) {
nativeSpectrum.reset()
}
}
const frequencyData = nativeTransport
? nativeTransport.getLatestSpectrumMagnitudes()
: nativeSpectrum.getMagnitudes()
if (!frequencyData) {
this.resetJsState()
this.renderStaticLayer(minFrequency, maxFrequency)
return
}
const bufferLength = frequencyData.length
if (bufferLength === 0) {
let primaryData: Float32Array | null = null
let secondaryData: Float32Array | null = null
if (options.showSideLine) {
this.processJsSpectrumChunks(this.dataSource.getPendingSpectrumStereoSamples())
primaryData = this.jsHasSpectrumData ? this.jsMidMagnitudes : null
secondaryData = this.jsHasSpectrumData ? this.jsSideMagnitudes : null
} else {
if (!isNativeAvailable()) {
console.error('SpectrumAnalyzer: Native DSP required')
this.renderStaticLayer(minFrequency, maxFrequency)
return
}
const nativeTransport = this.dataSource.getNativeVisualizerTransport?.() ?? null
const pendingSpectrum = this.dataSource.getPendingSpectrumSamples()
if (!nativeTransport) {
const monoData = this.mergePendingSpectrumChunks(pendingSpectrum)
if (monoData) {
nativeSpectrum.pushSamples(monoData)
}
}
primaryData = nativeTransport
? nativeTransport.getLatestSpectrumMagnitudes()
: nativeSpectrum.getMagnitudes()
}
if (!primaryData || primaryData.length === 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
this.renderStaticLayer(minFrequency, maxFrequency)
const binWidth = nyquist / bufferLength
const points: { x: number; y: number; heatmapIntensity: number }[] = []
const numPoints = Math.max(2, Math.floor(width))
for (let i = 0; i < numPoints; i++) {
const t0 = i / (numPoints - 1)
const t1 = Math.min(1, (i + 1) / (numPoints - 1))
const x = t0 * width
const frequency0 = this.frequencyAtPosition(t0, minFrequency, maxFrequency)
const frequency1 = this.frequencyAtPosition(t1, minFrequency, maxFrequency)
const centerFrequency = (frequency0 + frequency1) * 0.5
const bin0 = frequency0 / binWidth
const bin1 = frequency1 / binWidth
const centerBin = (bin0 + bin1) * 0.5
const binSpan = Math.abs(bin1 - bin0)
const rawDb = binSpan <= 1
? this.getInterpolatedValue(frequencyData, Math.min(centerBin, bufferLength - 1))
: this.getPeakInRange(frequencyData, bin0, bin1)
const db = this.applyTilt(rawDb, centerFrequency)
const heatmapDb = this.applyTilt(rawDb, centerFrequency, options.heatmapTiltDbPerOctave)
const normalized = (db - options.minDecibels) / (options.maxDecibels - options.minDecibels)
const heatmapNormalized = (heatmapDb - options.minDecibels) / (options.maxDecibels - options.minDecibels)
const y = height - Math.max(0, Math.min(1, normalized)) * height
const heatmapIntensity = Math.pow(Math.max(0, Math.min(1, heatmapNormalized)), HEATMAP_GAMMA)
points.push({ x, y, heatmapIntensity })
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 && points.length > 0) {
for (let i = 0; i < points.length; i++) {
const x = Math.floor(points[i].x)
const y = points[i].y
const nextX = i < points.length - 1 ? Math.floor(points[i + 1].x) : width
const colWidth = Math.max(1, nextX - x)
const fillHeight = height - y
if (fillHeight <= 0) continue
const intensity = points[i].heatmapIntensity
const li = Math.round(intensity * 255)
const r = this.heatLut[li * 3]
const g = this.heatLut[li * 3 + 1]
const b = this.heatLut[li * 3 + 2]
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, 0.85)`
ctx.fillRect(x, Math.floor(y), colWidth, Math.ceil(fillHeight))
}
} else if (options.fillGradient && points.length > 0) {
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.lineTo(width, height)
ctx.lineTo(0, height)
ctx.closePath()
const gradient = ctx.createLinearGradient(0, height, 0, 0)
const colors = options.gradientColors
for (let i = 0; i < colors.length; i++) {
gradient.addColorStop(i / (colors.length - 1), colors[i])
}
ctx.fillStyle = gradient
ctx.fill()
this.renderStroke(primaryPoints, options.lineColor, options.lineWidth * dpr)
if (secondaryPoints && secondaryPoints.length > 0) {
const secondaryLineWidth = Math.max(dpr, options.lineWidth * SIDE_LINE_WIDTH_RATIO * dpr)
this.renderStroke(secondaryPoints, options.secondaryLineColor, secondaryLineWidth)
}
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.lineWidth = options.lineWidth * dpr
ctx.strokeStyle = options.lineColor
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.stroke()
}
private renderStaticLayer(minFrequency: number, maxFrequency: number): void {
@@ -584,6 +865,7 @@ export class SpectrumAnalyzer {
if (isNativeAvailable()) {
nativeSpectrum.reset()
}
this.resetJsState()
this.lastSampleRate = 0
}
}
+231 -94
View File
@@ -5,14 +5,22 @@ import { FrameScheduler } from './frameScheduler'
import { VisualizerFrameLoop } from './visualizerFrameLoop'
import {
DEFAULT_WAVEFORM_GAIN_DB,
DEFAULT_WAVEFORM_MODE,
DEFAULT_WAVEFORM_SCROLL_SPEED,
clampWaveformGainDb,
clampWaveformScrollSpeed,
type WaveformMode,
} from '../../types/waveform'
import { MultibandSplitter } from './multibandSplitter'
export interface WaveformStereoChunk {
left: Float32Array
right: Float32Array
}
export interface WaveformDataSource extends VisualizerSessionSource {
getPendingWaveformSamples: () => Float32Array[]
getPendingWaveformStereoSamples: () => WaveformStereoChunk[]
}
export interface WaveformOptions {
@@ -24,6 +32,7 @@ export interface WaveformOptions {
mid: string
high: string
}
mode?: WaveformMode
scrollSpeed?: number
gainDb?: number
multiband?: boolean
@@ -42,6 +51,7 @@ const defaultOptions: ResolvedWaveformOptions = {
mid: '#44dd44',
high: '#4488ff',
},
mode: DEFAULT_WAVEFORM_MODE,
scrollSpeed: DEFAULT_WAVEFORM_SCROLL_SPEED,
gainDb: DEFAULT_WAVEFORM_GAIN_DB,
multiband: false,
@@ -52,16 +62,15 @@ const MULTIBAND_DOMINANCE_SENSITIVITY = 5
const MULTIBAND_FOCUSED_BLEND = 0.68
const MULTIBAND_FILL_ALPHA = 0.72
const MULTIBAND_EDGE_ALPHA = 1.0
const BASE_PIXELS_PER_SECOND = 64
const DISPLAY_MARGIN = 0.95
const defaultWaveformDataSource: WaveformDataSource = {
getPendingWaveformSamples: () => audioRouter.flushPendingWaveformSamples(),
getPendingWaveformStereoSamples: () => audioRouter.flushPendingWaveformStereoSamples(),
...defaultVisualizerSessionSource,
}
// Calibrate 1.0x to the prior 8s window at roughly 512px wide,
// while keeping scroll speed independent from panel width.
const BASE_PIXELS_PER_SECOND = 64
export class Waveform {
private canvas: HTMLCanvasElement
private ctx: CanvasRenderingContext2D
@@ -69,24 +78,25 @@ export class Waveform {
private dataSource: WaveformDataSource
private frameLoop: VisualizerFrameLoop
// Offscreen canvas for scrolling content
private waterfallCanvas: HTMLCanvasElement
private waterfallCtx: CanvasRenderingContext2D
private staticLayerCanvas: HTMLCanvasElement
private staticLayerCtx: CanvasRenderingContext2D
private staticLayerKey = ''
// Sample accumulator for current pixel column
private columnAccumulator: Float32Array = new Float32Array(0)
private leftColumnAccumulator: Float32Array = new Float32Array(0)
private rightColumnAccumulator: Float32Array = new Float32Array(0)
private columnAccumulatorPos = 0
private samplesPerColumn = 0
private lastSampleRate = 0
// Multiband analysis
private splitter = new MultibandSplitter()
private bandLowAcc: Float32Array = new Float32Array(0)
private bandMidAcc: Float32Array = new Float32Array(0)
private bandHighAcc: Float32Array = new Float32Array(0)
private leftBandLowAcc: Float32Array = new Float32Array(0)
private leftBandMidAcc: Float32Array = new Float32Array(0)
private leftBandHighAcc: Float32Array = new Float32Array(0)
private rightBandLowAcc: Float32Array = new Float32Array(0)
private rightBandMidAcc: Float32Array = new Float32Array(0)
private rightBandHighAcc: Float32Array = new Float32Array(0)
private unsubscribeSessionChange: (() => void) | null = null
constructor(canvas: HTMLCanvasElement, options: WaveformOptions = {}) {
@@ -100,6 +110,7 @@ export class Waveform {
this.options = {
...defaultOptions,
...optionOverrides,
mode: optionOverrides.mode ?? defaultOptions.mode,
scrollSpeed: clampWaveformScrollSpeed(optionOverrides.scrollSpeed ?? defaultOptions.scrollSpeed),
gainDb: clampWaveformGainDb(optionOverrides.gainDb ?? defaultOptions.gainDb),
multiband: optionOverrides.multiband ?? defaultOptions.multiband,
@@ -118,6 +129,7 @@ export class Waveform {
if (!waterfallCtx) throw new Error('Could not get waterfall 2D context')
this.waterfallCtx = waterfallCtx
this.waterfallCtx.imageSmoothingEnabled = false
this.staticLayerCanvas = document.createElement('canvas')
const staticLayerCtx = this.staticLayerCanvas.getContext('2d')
if (!staticLayerCtx) throw new Error('Could not get static 2D context')
@@ -149,10 +161,14 @@ export class Waveform {
const next = Math.max(1, Math.round(sampleRate / pixelsPerSecond))
if (next !== this.samplesPerColumn) {
this.samplesPerColumn = next
this.columnAccumulator = new Float32Array(next)
this.bandLowAcc = new Float32Array(next)
this.bandMidAcc = new Float32Array(next)
this.bandHighAcc = new Float32Array(next)
this.leftColumnAccumulator = new Float32Array(next)
this.rightColumnAccumulator = new Float32Array(next)
this.leftBandLowAcc = new Float32Array(next)
this.leftBandMidAcc = new Float32Array(next)
this.leftBandHighAcc = new Float32Array(next)
this.rightBandLowAcc = new Float32Array(next)
this.rightBandMidAcc = new Float32Array(next)
this.rightBandHighAcc = new Float32Array(next)
this.columnAccumulatorPos = 0
}
this.lastSampleRate = sampleRate
@@ -164,6 +180,7 @@ export class Waveform {
const nextOptions: ResolvedWaveformOptions = {
...this.options,
...optionUpdates,
mode: optionUpdates.mode ?? this.options.mode,
lineColor: optionUpdates.lineColor ?? this.options.lineColor,
scrollSpeed: clampWaveformScrollSpeed(optionUpdates.scrollSpeed ?? this.options.scrollSpeed),
gainDb: clampWaveformGainDb(optionUpdates.gainDb ?? this.options.gainDb),
@@ -171,20 +188,26 @@ export class Waveform {
}
const speedChanged = nextOptions.scrollSpeed !== this.options.scrollSpeed
const multibandChanged = nextOptions.multiband !== this.options.multiband
const modeChanged = nextOptions.mode !== this.options.mode
const dataSourceChanged = Boolean(dataSource && dataSource !== this.dataSource)
this.options = nextOptions
if (dataSource && dataSource !== this.dataSource) {
if (dataSourceChanged && dataSource) {
this.dataSource = dataSource
this.subscribeToSessionChanges()
this.recomputeSamplesPerColumn()
this.resetDisplay()
}
if (speedChanged) {
if (dataSourceChanged || speedChanged) {
this.recomputeSamplesPerColumn()
this.resetDisplay()
}
if (multibandChanged) {
if (multibandChanged || modeChanged) {
this.splitter.reset()
}
this.staticLayerKey = ''
if (dataSourceChanged || speedChanged || multibandChanged || modeChanged) {
this.resetDisplay()
}
@@ -204,40 +227,46 @@ export class Waveform {
}
resize(): void {
// Resize handled in draw loop
this.staticLayerKey = ''
this.invalidate()
}
private computeMinMax(): { min: number; max: number } {
let min = this.columnAccumulator[0]
let max = this.columnAccumulator[0]
private computeMinMax(samples: Float32Array): { min: number; max: number } {
if (this.columnAccumulatorPos === 0) {
return { min: 0, max: 0 }
}
let min = samples[0]
let max = samples[0]
for (let i = 1; i < this.columnAccumulatorPos; i++) {
const s = this.columnAccumulator[i]
if (s < min) min = s
if (s > max) max = s
const sample = samples[i]
if (sample < min) min = sample
if (sample > max) max = sample
}
return { min, max }
}
private computeBandColor(): [number, number, number] {
private computeBandColor(
lowBandSamples: Float32Array,
midBandSamples: Float32Array,
highBandSamples: Float32Array,
): [number, number, number] {
const lowBand = this.toBandColorTuple(this.options.bandColors.low)
const midBand = this.toBandColorTuple(this.options.bandColors.mid)
const highBand = this.toBandColorTuple(this.options.bandColors.high)
const n = this.columnAccumulatorPos
if (n === 0) return midBand
// Compute RMS energy for each band
let lowSum = 0
let midSum = 0
let highSum = 0
for (let i = 0; i < n; i++) {
const l = this.bandLowAcc[i]
const m = this.bandMidAcc[i]
const h = this.bandHighAcc[i]
lowSum += l * l
midSum += m * m
highSum += h * h
const low = lowBandSamples[i]
const mid = midBandSamples[i]
const high = highBandSamples[i]
lowSum += low * low
midSum += mid * mid
highSum += high * high
}
const lowRms = Math.sqrt(lowSum / n)
@@ -290,40 +319,49 @@ export class Waveform {
return [r, g, b]
}
private shiftAndPaintColumn(min: number, max: number, width: number, height: number): void {
// Shift existing content left by 1 pixel — use 'copy' to avoid
// alpha accumulation from source-over compositing on semi-transparent pixels
private resolveColumnColor(
lowBandSamples: Float32Array,
midBandSamples: Float32Array,
highBandSamples: Float32Array,
): [number, number, number] {
if (this.options.multiband) {
return this.computeBandColor(lowBandSamples, midBandSamples, highBandSamples)
}
const lineColor = resolveColorToRgb(this.options.lineColor)
return [lineColor.r, lineColor.g, lineColor.b]
}
private shiftWaterfall(): void {
this.waterfallCtx.globalCompositeOperation = 'copy'
this.waterfallCtx.drawImage(this.waterfallCanvas, -1, 0)
this.waterfallCtx.globalCompositeOperation = 'source-over'
}
const centerY = height / 2
private paintColumn(
min: number,
max: number,
width: number,
laneTop: number,
laneHeight: number,
color: [number, number, number],
): void {
const amplitudeGain = Math.pow(10, this.options.gainDb / 20)
const scaledMin = Math.max(-1, Math.min(1, min * amplitudeGain))
const scaledMax = Math.max(-1, Math.min(1, max * amplitudeGain))
const displayMargin = 0.95 // slight margin so full-scale doesn't clip at edge
const yTop = Math.round(centerY - scaledMax * centerY * displayMargin)
const yBottom = Math.round(centerY - scaledMin * centerY * displayMargin)
const centerY = laneTop + (laneHeight / 2)
const displayHalfHeight = (laneHeight / 2) * DISPLAY_MARGIN
const yTop = Math.round(centerY - scaledMax * displayHalfHeight)
const yBottom = Math.round(centerY - scaledMin * displayHalfHeight)
const lineHeight = Math.max(1, yBottom - yTop)
let r: number, g: number, b: number
if (this.options.multiband) {
;[r, g, b] = this.computeBandColor()
} else {
const lineColor = resolveColorToRgb(this.options.lineColor)
r = lineColor.r
g = lineColor.g
b = lineColor.b
}
const fillAlpha = this.options.multiband ? MULTIBAND_FILL_ALPHA : 0.55
const edgeAlpha = this.options.multiband ? MULTIBAND_EDGE_ALPHA : 0.9
const [r, g, b] = color
// Draw the amplitude column — brighter at the edges, dimmer in the middle
this.waterfallCtx.fillStyle = `rgba(${r}, ${g}, ${b}, ${fillAlpha})`
this.waterfallCtx.fillRect(width - 1, yTop, 1, lineHeight)
// Bright edge pixels at min/max
this.waterfallCtx.fillStyle = `rgba(${r}, ${g}, ${b}, ${edgeAlpha})`
this.waterfallCtx.fillRect(width - 1, yTop, 1, 1)
if (lineHeight > 1) {
@@ -338,7 +376,7 @@ export class Waveform {
}
private ensureStaticLayer(width: number, height: number): void {
const key = `${width}:${height}`
const key = `${width}:${height}:${this.options.mode}`
if (this.staticLayerKey === key) {
return
}
@@ -351,9 +389,17 @@ export class Waveform {
}
private drawGrid(ctx: CanvasRenderingContext2D, width: number, height: number): void {
if (this.options.mode === 'stereo') {
this.drawStereoGrid(ctx, width, height)
return
}
this.drawMonoGrid(ctx, width, height)
}
private drawMonoGrid(ctx: CanvasRenderingContext2D, width: number, height: number): void {
const centerY = height / 2
// Center line (zero crossing)
ctx.strokeStyle = this.options.gridMajorColor
ctx.lineWidth = 1
ctx.beginPath()
@@ -361,7 +407,6 @@ export class Waveform {
ctx.lineTo(width, centerY)
ctx.stroke()
// ±0.5 guide lines
ctx.strokeStyle = this.options.gridMinorColor
const quarterY = centerY * 0.5
ctx.beginPath()
@@ -372,6 +417,124 @@ export class Waveform {
ctx.stroke()
}
private drawStereoGrid(ctx: CanvasRenderingContext2D, width: number, height: number): void {
const laneHeight = height / 2
ctx.strokeStyle = this.options.gridMajorColor
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(0, laneHeight * 0.5)
ctx.lineTo(width, laneHeight * 0.5)
ctx.moveTo(0, laneHeight)
ctx.lineTo(width, laneHeight)
ctx.moveTo(0, laneHeight * 1.5)
ctx.lineTo(width, laneHeight * 1.5)
ctx.stroke()
ctx.strokeStyle = this.options.gridMinorColor
ctx.beginPath()
ctx.moveTo(0, laneHeight * 0.25)
ctx.lineTo(width, laneHeight * 0.25)
ctx.moveTo(0, laneHeight * 0.75)
ctx.lineTo(width, laneHeight * 0.75)
ctx.moveTo(0, laneHeight * 1.25)
ctx.lineTo(width, laneHeight * 1.25)
ctx.moveTo(0, laneHeight * 1.75)
ctx.lineTo(width, laneHeight * 1.75)
ctx.stroke()
}
private drainPendingSamples(): void {
if (this.options.mode === 'stereo') {
this.dataSource.getPendingWaveformStereoSamples()
return
}
this.dataSource.getPendingWaveformSamples()
}
private processMonoChunk(chunk: Float32Array, width: number, height: number): void {
let lowBand: Float32Array | null = null
let midBand: Float32Array | null = null
let highBand: Float32Array | null = null
if (this.options.multiband) {
const bands = this.splitter.split(chunk, chunk)
lowBand = bands.low.left
midBand = bands.mid.left
highBand = bands.high.left
}
for (let i = 0; i < chunk.length; i++) {
this.leftColumnAccumulator[this.columnAccumulatorPos] = chunk[i]
if (lowBand && midBand && highBand) {
this.leftBandLowAcc[this.columnAccumulatorPos] = lowBand[i]
this.leftBandMidAcc[this.columnAccumulatorPos] = midBand[i]
this.leftBandHighAcc[this.columnAccumulatorPos] = highBand[i]
}
this.columnAccumulatorPos += 1
if (this.columnAccumulatorPos >= this.samplesPerColumn) {
const { min, max } = this.computeMinMax(this.leftColumnAccumulator)
const color = this.resolveColumnColor(this.leftBandLowAcc, this.leftBandMidAcc, this.leftBandHighAcc)
this.shiftWaterfall()
this.paintColumn(min, max, width, 0, height, color)
this.columnAccumulatorPos = 0
}
}
}
private processStereoChunk(chunk: WaveformStereoChunk, width: number, height: number): void {
const length = Math.min(chunk.left.length, chunk.right.length)
if (length === 0) {
return
}
const leftSamples = chunk.left.length === length ? chunk.left : chunk.left.subarray(0, length)
const rightSamples = chunk.right.length === length ? chunk.right : chunk.right.subarray(0, length)
let lowLeft: Float32Array | null = null
let midLeft: Float32Array | null = null
let highLeft: Float32Array | null = null
let lowRight: Float32Array | null = null
let midRight: Float32Array | null = null
let highRight: Float32Array | null = null
if (this.options.multiband) {
const bands = this.splitter.split(leftSamples, rightSamples)
lowLeft = bands.low.left
midLeft = bands.mid.left
highLeft = bands.high.left
lowRight = bands.low.right
midRight = bands.mid.right
highRight = bands.high.right
}
const laneHeight = height / 2
for (let i = 0; i < length; i++) {
this.leftColumnAccumulator[this.columnAccumulatorPos] = leftSamples[i]
this.rightColumnAccumulator[this.columnAccumulatorPos] = rightSamples[i]
if (lowLeft && midLeft && highLeft && lowRight && midRight && highRight) {
this.leftBandLowAcc[this.columnAccumulatorPos] = lowLeft[i]
this.leftBandMidAcc[this.columnAccumulatorPos] = midLeft[i]
this.leftBandHighAcc[this.columnAccumulatorPos] = highLeft[i]
this.rightBandLowAcc[this.columnAccumulatorPos] = lowRight[i]
this.rightBandMidAcc[this.columnAccumulatorPos] = midRight[i]
this.rightBandHighAcc[this.columnAccumulatorPos] = highRight[i]
}
this.columnAccumulatorPos += 1
if (this.columnAccumulatorPos >= this.samplesPerColumn) {
const leftMinMax = this.computeMinMax(this.leftColumnAccumulator)
const rightMinMax = this.computeMinMax(this.rightColumnAccumulator)
const leftColor = this.resolveColumnColor(this.leftBandLowAcc, this.leftBandMidAcc, this.leftBandHighAcc)
const rightColor = this.resolveColumnColor(this.rightBandLowAcc, this.rightBandMidAcc, this.rightBandHighAcc)
this.shiftWaterfall()
this.paintColumn(leftMinMax.min, leftMinMax.max, width, 0, laneHeight, leftColor)
this.paintColumn(rightMinMax.min, rightMinMax.max, width, laneHeight, laneHeight, rightColor)
this.columnAccumulatorPos = 0
}
}
}
private drawFrame = (): void => {
const width = this.canvas.width
const height = this.canvas.height
@@ -382,7 +545,6 @@ export class Waveform {
this.ctx.imageSmoothingEnabled = false
// Handle resize: preserve existing content anchored to right edge
if (this.waterfallCanvas.width !== width || this.waterfallCanvas.height !== height) {
const previousCanvas = document.createElement('canvas')
previousCanvas.width = this.waterfallCanvas.width
@@ -403,7 +565,7 @@ export class Waveform {
this.waterfallCtx.drawImage(
previousCanvas,
srcX, 0, srcW, previousCanvas.height,
dstX, 0, srcW, height
dstX, 0, srcW, height,
)
}
@@ -411,52 +573,27 @@ export class Waveform {
this.staticLayerKey = ''
}
// Handle sample rate changes
const sampleRate = this.dataSource.getSampleRate()
if (Math.abs(sampleRate - this.lastSampleRate) > 100) {
this.recomputeSamplesPerColumn()
}
if (!this.dataSource.isPlaying()) {
this.dataSource.getPendingWaveformSamples() // drain
// Freeze display — show last waveform
this.drainPendingSamples()
this.renderStaticLayer(width, height)
this.ctx.drawImage(this.waterfallCanvas, 0, 0)
return
}
const pending = this.dataSource.getPendingWaveformSamples()
const samplesPerCol = this.samplesPerColumn
const multiband = this.options.multiband
if (samplesPerCol > 0) {
if (this.options.mode === 'stereo') {
const pending = this.dataSource.getPendingWaveformStereoSamples()
for (const chunk of pending) {
// When multiband is enabled, split each chunk through the crossover filters
let lowBand: Float32Array | null = null
let midBand: Float32Array | null = null
let highBand: Float32Array | null = null
if (multiband) {
const bands = this.splitter.split(chunk, chunk)
lowBand = bands.low.left
midBand = bands.mid.left
highBand = bands.high.left
}
for (let i = 0; i < chunk.length; i++) {
this.columnAccumulator[this.columnAccumulatorPos] = chunk[i]
if (multiband && lowBand && midBand && highBand) {
this.bandLowAcc[this.columnAccumulatorPos] = lowBand[i]
this.bandMidAcc[this.columnAccumulatorPos] = midBand[i]
this.bandHighAcc[this.columnAccumulatorPos] = highBand[i]
}
this.columnAccumulatorPos++
if (this.columnAccumulatorPos >= samplesPerCol) {
const { min, max } = this.computeMinMax()
this.shiftAndPaintColumn(min, max, width, height)
this.columnAccumulatorPos = 0
}
}
this.processStereoChunk(chunk, width, height)
}
} else {
const pending = this.dataSource.getPendingWaveformSamples()
for (const chunk of pending) {
this.processMonoChunk(chunk, width, height)
}
}
+5 -2
View File
@@ -2,6 +2,7 @@ import type { VectorscopeMode } from '../renderer/visualizers/Vectorscope'
import type { SpectrogramClarityMode, SpectrogramScaleMode } from './spectrogram'
import type { VUMeterMode, VUMeterOrientation } from './vumeter'
import type { LUFSMeterMode } from './lufsmeter'
import { DEFAULT_WAVEFORM_MODE, type WaveformMode } from './waveform'
export interface ScopeSettings {
spectrum: {
@@ -12,6 +13,7 @@ export interface ScopeSettings {
showGrid: boolean
smoothing: number
fillGradient: boolean
showSideLine: boolean
}
oscilloscope: {
pitchLock: boolean
@@ -41,6 +43,7 @@ export interface ScopeSettings {
mode: LUFSMeterMode
}
waveform: {
mode: WaveformMode
scrollSpeed: number
gainDb: number
multiband: boolean
@@ -48,11 +51,11 @@ export interface ScopeSettings {
}
export const DEFAULT_SCOPE_SETTINGS: ScopeSettings = {
spectrum: { fftSize: 2048, tiltDbPerOctave: 2.0, heatmap: false, heatmapTiltDbPerOctave: 2.0, showGrid: true, smoothing: 0.9, fillGradient: true },
spectrum: { fftSize: 2048, tiltDbPerOctave: 2.0, heatmap: false, heatmapTiltDbPerOctave: 2.0, showGrid: true, smoothing: 0.9, fillGradient: true, showSideLine: false },
oscilloscope: { pitchLock: true, underfillEnabled: false, showGrid: true, lineWidth: 2 },
vectorscope: { mode: 'lissajous', multiband: false, showGrid: true, persistence: 0.10, lineWidth: 1.5 },
spectrogram: { fftSize: 2048, scrollSpeed: 2, clarityMode: 'sharper', scaleMode: 'log', colorScheme: 'heat' },
vumeter: { mode: 'bar', orientation: 'horizontal' },
lufsmeter: { mode: 'bar' },
waveform: { scrollSpeed: 1, gainDb: 0, multiband: false },
waveform: { mode: DEFAULT_WAVEFORM_MODE, scrollSpeed: 1, gainDb: 0, multiband: false },
}
+3
View File
@@ -1,3 +1,5 @@
export type WaveformMode = 'mono' | 'stereo'
export const MIN_WAVEFORM_SCROLL_SPEED = 0.5
export const MAX_WAVEFORM_SCROLL_SPEED = 8
export const WAVEFORM_SCROLL_SPEED_STEP = 0.5
@@ -6,6 +8,7 @@ export const MIN_WAVEFORM_GAIN_DB = -12
export const MAX_WAVEFORM_GAIN_DB = 18
export const WAVEFORM_GAIN_DB_STEP = 0.5
export const DEFAULT_WAVEFORM_GAIN_DB = 0
export const DEFAULT_WAVEFORM_MODE: WaveformMode = 'mono'
export function clampWaveformScrollSpeed(value: unknown): number {
const numeric = Number(value)
+63
View File
@@ -47,6 +47,69 @@ test('routes chunks only to demanded scopes and prunes queues when demand is rem
assert.equal(router.flushPendingSpectrumSamples().length, 0)
assert.equal(router.flushPendingWaveformSamples().length, 0)
assert.equal(router.flushPendingWaveformStereoSamples().length, 0)
})
test('spectrum keeps stereo chunks for the side overlay path and still exposes mono downmixes', () => {
const router = new AudioRouter()
const sessionId = router.beginSession(48000, 2, 'electron-system')
router.setVisualizerConsumerDemand('test-consumer', { spectrum: true })
router.ingestChunk(createChunk(2), createChunk(4), {
sessionId,
channelCount: 2,
sequence: 1,
capturedAt: performance.now() - 5,
})
const stereoChunks = router.flushPendingSpectrumStereoSamples()
assert.equal(stereoChunks.length, 1)
assert.deepEqual(Array.from(stereoChunks[0]?.left ?? []), [2, 2, 2, 2])
assert.deepEqual(Array.from(stereoChunks[0]?.right ?? []), [4, 4, 4, 4])
assert.equal(router.flushPendingSpectrumSamples().length, 0)
router.ingestChunk(createChunk(6), createChunk(10), {
sessionId,
channelCount: 2,
sequence: 2,
capturedAt: performance.now() - 5,
})
const monoChunks = router.flushPendingSpectrumSamples()
assert.equal(monoChunks.length, 1)
assert.deepEqual(Array.from(monoChunks[0] ?? []), [8, 8, 8, 8])
assert.equal(router.flushPendingSpectrumStereoSamples().length, 0)
})
test('waveform keeps stereo chunks for stereo mode while mono flushes still expose the left channel', () => {
const router = new AudioRouter()
const sessionId = router.beginSession(48000, 2, 'electron-system')
router.setVisualizerConsumerDemand('test-consumer', { waveform: true })
router.ingestChunk(createChunk(2), createChunk(4), {
sessionId,
channelCount: 2,
sequence: 1,
capturedAt: performance.now() - 5,
})
const stereoChunks = router.flushPendingWaveformStereoSamples()
assert.equal(stereoChunks.length, 1)
assert.deepEqual(Array.from(stereoChunks[0]?.left ?? []), [2, 2, 2, 2])
assert.deepEqual(Array.from(stereoChunks[0]?.right ?? []), [4, 4, 4, 4])
assert.equal(router.flushPendingWaveformSamples().length, 0)
router.ingestChunk(createChunk(6), createChunk(10), {
sessionId,
channelCount: 2,
sequence: 2,
capturedAt: performance.now() - 5,
})
const monoChunks = router.flushPendingWaveformSamples()
assert.equal(monoChunks.length, 1)
assert.deepEqual(Array.from(monoChunks[0] ?? []), [6, 6, 6, 6])
assert.equal(router.flushPendingWaveformStereoSamples().length, 0)
})
test('keeps the newest chunks when a fixed-capacity ring overflows', () => {
+3
View File
@@ -64,6 +64,7 @@ function createProfile(name: string): Profile {
windowBounds: { x: 120, y: 40, width: 420, height: 240 },
}
profile.windowBounds = { x: 10, y: 20, width: 840, height: 180 }
profile.scopeSettings.spectrum.showSideLine = true
profile.scopeSettings.spectrogram.colorScheme = 'mono'
return profile
}
@@ -82,6 +83,7 @@ test('profile file serialization excludes geometry and round-trips with local me
const restored = profileFileToProfile(file, extractLocalProfileMetadata(profile))
assert.deepEqual(restored.windowBounds, profile.windowBounds)
assert.deepEqual(restored.scopePopouts.spectrum.windowBounds, profile.scopePopouts.spectrum.windowBounds)
assert.equal(restored.scopeSettings.spectrum.showSideLine, true)
assert.equal(restored.scopeSettings.spectrogram.colorScheme, 'mono')
})
@@ -195,6 +197,7 @@ test('partial files normalize, unsupported versions fail, and import does not ch
const partialSnapshot = await harness.library.importProfileFromPath(partialPath)
assert.equal(partialSnapshot.activeProfileId, 'profile_partial')
assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.spectrum.showSideLine, false)
assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.spectrogram.colorScheme, 'heat')
assert.equal(partialSnapshot.profiles.profile_partial.scopePopouts.spectrogram.poppedOut, true)
assert.equal(partialSnapshot.profiles.profile_partial.widthWeights.spectrum, 1)
+65
View File
@@ -9,17 +9,21 @@ import {
import {
createDefaultProfile,
} from '../src/shared/profileState'
import { createDefaultTheme, resolveTheme } from '../src/shared/themeState'
import { usePerformanceStore } from '../src/renderer/stores/performanceStore'
import {
moveDockedScopeOrder,
useSettingsStore,
} from '../src/renderer/stores/settingsStore'
import { scopeSettingsToOptions } from '../src/renderer/components/ScopeModule'
import { scopeSummary } from '../src/renderer/components/ScopeSettingsSection'
import {
applyInputGainToStereoSamples,
inputGainDbToLinear,
} from '../src/renderer/audio/inputGain'
import { SCOPE_KINDS, type ScopeKind } from '../src/types/scope'
import type { ScopePopoutStateMap } from '../src/types/popout'
import { ScopePopoutDataSource } from '../src/renderer/popouts/ScopePopoutDataSource'
import {
VUMeterBallistics,
VU_INTEGRATION_WINDOW_MS,
@@ -499,6 +503,67 @@ test('moveDockedScopeOrder swaps a middle docked scope with its adjacent docked
])
})
test('scopeSettingsToOptions wires spectrum side overlay settings into analyzer options', () => {
const profile = createDefaultProfile('Default')
profile.scopeSettings.spectrum.showSideLine = true
const theme = resolveTheme(createDefaultTheme())
const options = scopeSettingsToOptions('spectrum', profile.scopeSettings.spectrum, theme.spectrum)
assert.equal(options.showSideLine, true)
assert.equal(options.secondaryLineColor, theme.spectrum.secondary)
assert.equal(options.lineColor, theme.spectrum.primary)
})
test('scopeSettingsToOptions wires waveform stereo mode into analyzer options', () => {
const profile = createDefaultProfile('Default')
profile.scopeSettings.waveform.mode = 'stereo'
profile.scopeSettings.waveform.multiband = true
const theme = resolveTheme(createDefaultTheme())
const options = scopeSettingsToOptions('waveform', profile.scopeSettings.waveform, theme.waveform)
assert.equal(options.mode, 'stereo')
assert.equal(options.multiband, true)
assert.equal(options.lineColor, theme.waveform.primary)
})
test('scopeSummary includes Stereo for waveform only when stereo mode is enabled', () => {
const profile = createDefaultProfile('Default')
profile.scopeSettings.waveform.gainDb = 6
assert.equal(scopeSummary('waveform', profile.scopeSettings.waveform), '+6 dB')
profile.scopeSettings.waveform.mode = 'stereo'
assert.equal(scopeSummary('waveform', profile.scopeSettings.waveform), '+6 dB · Stereo')
profile.scopeSettings.waveform.multiband = true
assert.equal(scopeSummary('waveform', profile.scopeSettings.waveform), '+6 dB · Stereo · RGB')
})
test('ScopePopoutDataSource switches waveform batches between mono and stereo queues', () => {
const dataSource = new ScopePopoutDataSource('waveform')
const monoChunk = new Float32Array([0.1, 0.2, 0.3])
const stereoLeft = new Float32Array([0.4, 0.5])
const stereoRight = new Float32Array([0.6, 0.7])
const nextMonoChunk = new Float32Array([0.8])
dataSource.pushAudioBatch([monoChunk])
assert.equal(dataSource.getPendingWaveformSamples()[0], monoChunk)
assert.equal(dataSource.getPendingWaveformStereoSamples().length, 0)
dataSource.pushAudioBatch([{ left: stereoLeft, right: stereoRight }])
assert.equal(dataSource.getPendingWaveformSamples().length, 0)
const stereoBatch = dataSource.getPendingWaveformStereoSamples()
assert.equal(stereoBatch.length, 1)
assert.equal(stereoBatch[0]?.left, stereoLeft)
assert.equal(stereoBatch[0]?.right, stereoRight)
dataSource.pushAudioBatch([nextMonoChunk])
assert.equal(dataSource.getPendingWaveformStereoSamples().length, 0)
assert.equal(dataSource.getPendingWaveformSamples()[0], nextMonoChunk)
})
test('applying a profile snapshot does not change the machine-local frame target', () => {
const previousPerformanceState = usePerformanceStore.getState()
const previousSettingsState = useSettingsStore.getState()