mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
add stereo to waveform scope, add side metering to spectrum
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user