+
+ {/* Toolbar overlay — fades in on hover */}
+
+
+
+
{/* Scope strip — fills all available space */}
- {/* Temporary source picker bar — will be replaced by Toolbar + Settings in Phase 6 */}
-
- {/* Signal indicator */}
-
-
-
-
-
-
+ {/* Settings panel — expands below strip */}
+ {settingsOpen &&
}
)
}
diff --git a/src/renderer/audio/AudioCapture.ts b/src/renderer/audio/AudioCapture.ts
index ef4af6f..553bdc9 100644
--- a/src/renderer/audio/AudioCapture.ts
+++ b/src/renderer/audio/AudioCapture.ts
@@ -15,6 +15,7 @@ class AudioCapture {
private workletNode: AudioWorkletNode | null = null
private selectedDeviceId: string | null = null
private captureMode: CaptureMode = 'system'
+ private sessionId: number | null = null
/**
* Start capturing system audio output via desktopCapturer (ScreenCaptureKit on macOS 13+).
@@ -52,8 +53,8 @@ class AudioCapture {
// Drop the video track immediately — we only need audio
this.stream.getVideoTracks().forEach((track) => track.stop())
- this.wireUpStream()
this.captureMode = 'system'
+ this.wireUpStream()
}
/**
@@ -80,8 +81,8 @@ class AudioCapture {
this.stream = await navigator.mediaDevices.getUserMedia(constraints)
- this.wireUpStream()
this.captureMode = 'device'
+ this.wireUpStream()
if (targetDeviceId) {
this.selectedDeviceId = targetDeviceId
@@ -108,6 +109,18 @@ class AudioCapture {
if (!this.audioContext || !this.stream) return
this.sourceNode = this.audioContext.createMediaStreamSource(this.stream)
+ const audioTrack = this.stream.getAudioTracks()[0] ?? null
+ const trackSettings = audioTrack?.getSettings()
+ const channelCount = Math.max(
+ 1,
+ Math.floor(trackSettings?.channelCount ?? this.sourceNode.channelCount ?? 2)
+ )
+ const sampleRate = Math.max(
+ 1,
+ Math.floor(trackSettings?.sampleRate ?? this.audioContext.sampleRate)
+ )
+ const sessionId = audioRouter.beginSession(sampleRate, channelCount)
+ this.sessionId = sessionId
this.workletNode = new AudioWorkletNode(this.audioContext, 'capture-processor', {
numberOfInputs: 1,
@@ -115,19 +128,29 @@ class AudioCapture {
channelCount: 2,
})
- this.workletNode.port.onmessage = (event: MessageEvent<{ left: Float32Array; right: Float32Array }>) => {
- audioRouter.ingestChunk(event.data.left, event.data.right)
+ this.workletNode.port.onmessage = (event: MessageEvent<{
+ left: Float32Array
+ right: Float32Array
+ channelCount?: number
+ }>) => {
+ audioRouter.ingestChunk(event.data.left, event.data.right, {
+ sessionId,
+ channelCount: event.data.channelCount ?? channelCount,
+ })
}
this.sourceNode.connect(this.workletNode)
-
- audioRouter.setSampleRate(this.audioContext.sampleRate)
- audioRouter.setCapturing(true)
+ console.log(
+ `AudioCapture: session ${sessionId} started (${sampleRate}Hz, ${channelCount}ch, mode=${this.captureMode})`
+ )
}
stop(): void {
- audioRouter.setCapturing(false)
- audioRouter.reset()
+ if (this.sessionId !== null) {
+ console.log(`AudioCapture: ending session ${this.sessionId}`)
+ audioRouter.endSession()
+ this.sessionId = null
+ }
if (this.workletNode) {
this.workletNode.disconnect()
diff --git a/src/renderer/audio/AudioRouter.ts b/src/renderer/audio/AudioRouter.ts
index 3c01b95..a09356d 100644
--- a/src/renderer/audio/AudioRouter.ts
+++ b/src/renderer/audio/AudioRouter.ts
@@ -7,6 +7,18 @@ const MAX_PENDING_CHUNKS = 20
const MAX_PENDING_SPECTRUM_CHUNKS = 96
const MAX_PENDING_VECTORSCOPE_CHUNKS = 20
+export interface AudioSessionState {
+ sessionId: number
+ sampleRate: number
+ channelCount: number
+ capturing: boolean
+}
+
+interface AudioChunkMeta {
+ sessionId?: number
+ channelCount?: number
+}
+
class AudioRouter {
private pendingOscilloscopeSamples: Float32Array[] = []
private pendingSpectrumSamples: Float32Array[] = []
@@ -18,6 +30,16 @@ class AudioRouter {
private _sampleRate = 48000
private _capturing = false
+ private _channelCount = 2
+ private _sessionId = 0
+ private sessionListeners = new Set<(state: AudioSessionState) => void>()
+
+ private emitSessionState(): void {
+ const state = this.getSessionState()
+ for (const listener of this.sessionListeners) {
+ listener(state)
+ }
+ }
setSampleRate(rate: number): void {
this._sampleRate = rate
@@ -35,12 +57,59 @@ class AudioRouter {
return this._capturing
}
- ingestChunk(left: Float32Array, right: Float32Array): void {
+ getChannelCount(): number {
+ return this._channelCount
+ }
+
+ getSessionState(): AudioSessionState {
+ return {
+ sessionId: this._sessionId,
+ sampleRate: this._sampleRate,
+ channelCount: this._channelCount,
+ capturing: this._capturing,
+ }
+ }
+
+ beginSession(sampleRate: number, channelCount: number): number {
+ this._sessionId += 1
+ this._sampleRate = sampleRate
+ this._channelCount = Math.max(1, Math.floor(channelCount) || 1)
+ this._capturing = true
+ this.reset()
+ this.emitSessionState()
+ return this._sessionId
+ }
+
+ endSession(): void {
+ this._sessionId += 1
+ this._capturing = false
+ this.reset()
+ this.emitSessionState()
+ }
+
+ subscribeToSessionChanges(listener: (state: AudioSessionState) => void): () => void {
+ this.sessionListeners.add(listener)
+ listener(this.getSessionState())
+ return () => {
+ this.sessionListeners.delete(listener)
+ }
+ }
+
+ ingestChunk(left: Float32Array, right: Float32Array, meta: AudioChunkMeta = {}): void {
+ if (!this._capturing) return
+ if (meta.sessionId !== undefined && meta.sessionId !== this._sessionId) return
+
+ const effectiveChannelCount = Math.max(1, Math.floor(meta.channelCount ?? this._channelCount) || 1)
+ this._channelCount = effectiveChannelCount
+ const resolvedRight = effectiveChannelCount > 1 && right.length > 0 ? right : left
+
// Compute mono
- const len = Math.min(left.length, right.length)
+ const len = Math.min(left.length, resolvedRight.length)
+ if (len === 0) return
+
const mono = new Float32Array(len)
for (let i = 0; i < len; i++) {
- mono[i] = (left[i] + right[i]) / 2
+ mono[i] = (left[i] + resolvedRight[i]) / 2
}
// Oscilloscope — uses left channel
@@ -49,7 +118,7 @@ class AudioRouter {
-Math.floor(MAX_PENDING_CHUNKS / 2)
)
}
- this.pendingOscilloscopeSamples.push(new Float32Array(left))
+ this.pendingOscilloscopeSamples.push(left.slice(0, len))
// Spectrum — uses mono
if (this.pendingSpectrumSamples.length >= MAX_PENDING_SPECTRUM_CHUNKS) {
@@ -74,8 +143,8 @@ class AudioRouter {
)
}
this.pendingVectorscopeSamples.push({
- left: new Float32Array(left),
- right: new Float32Array(right),
+ left: left.slice(0, len),
+ right: resolvedRight.slice(0, len),
})
// VU Meter — uses stereo
@@ -85,8 +154,8 @@ class AudioRouter {
)
}
this.pendingVUMeterSamples.push({
- left: new Float32Array(left),
- right: new Float32Array(right),
+ left: left.slice(0, len),
+ right: resolvedRight.slice(0, len),
})
// LUFS Meter — uses stereo
@@ -96,8 +165,8 @@ class AudioRouter {
)
}
this.pendingLUFSMeterSamples.push({
- left: new Float32Array(left),
- right: new Float32Array(right),
+ left: left.slice(0, len),
+ right: resolvedRight.slice(0, len),
})
// Waveform — uses left channel
diff --git a/src/renderer/components/ScopeModule.tsx b/src/renderer/components/ScopeModule.tsx
index ef4ab60..bf3d028 100644
--- a/src/renderer/components/ScopeModule.tsx
+++ b/src/renderer/components/ScopeModule.tsx
@@ -1,56 +1,124 @@
import { useEffect, useRef } from 'react'
import type { ScopeKind } from '../../types/scope'
+import { useSettingsStore, type ScopeSettings } from '../stores/settingsStore'
import { SpectrumAnalyzer } from '../visualizers/SpectrumAnalyzer'
import { Oscilloscope } from '../visualizers/Oscilloscope'
import { Vectorscope } from '../visualizers/Vectorscope'
+import { Spectrogram } from '../visualizers/Spectrogram'
+import { VUMeter } from '../visualizers/VUMeter'
+import { LUFSMeter } from '../visualizers/LUFSMeter'
+import { Waveform } from '../visualizers/Waveform'
interface ScopeModuleProps {
scopeKind: ScopeKind
lineColor?: string
+ widthWeight?: number
}
-type Visualizer = SpectrumAnalyzer | Oscilloscope | Vectorscope
+interface Visualizer {
+ start(): void
+ stop(): void
+ dispose(): void
+ resize(): void
+ setOptions(options: Record
): void
+}
-function createVisualizer(scopeKind: ScopeKind, canvas: HTMLCanvasElement, lineColor: string): Visualizer | null {
+/** Maps settingsStore scope settings to the visualizer's setOptions format */
+function scopeSettingsToOptions(kind: ScopeKind, settings: ScopeSettings[ScopeKind], lineColor: string): Record {
+ const base = { lineColor }
+ switch (kind) {
+ case 'spectrum': {
+ const s = settings as ScopeSettings['spectrum']
+ return { ...base, fftSize: s.fftSize, tiltDbPerOctave: s.tiltDbPerOctave, heatmapFill: s.heatmap, heatmapTiltDbPerOctave: s.heatmapTiltDbPerOctave, showGrid: s.showGrid, fillGradient: s.fillGradient }
+ }
+ case 'oscilloscope': {
+ const s = settings as ScopeSettings['oscilloscope']
+ return { ...base, pitchLock: s.pitchLock, underfillEnabled: s.underfillEnabled, showGrid: s.showGrid, lineWidth: s.lineWidth }
+ }
+ case 'vectorscope': {
+ const s = settings as ScopeSettings['vectorscope']
+ return { ...base, mode: s.mode, multiband: s.multiband, showGrid: s.showGrid, persistence: s.persistence }
+ }
+ case 'spectrogram': {
+ const s = settings as ScopeSettings['spectrogram']
+ return { ...base, fftSize: s.fftSize, scrollSpeed: s.scrollSpeed, clarityMode: s.clarityMode, scaleMode: s.scaleMode, colorScheme: s.colorScheme }
+ }
+ case 'vumeter': {
+ const s = settings as ScopeSettings['vumeter']
+ return { ...base, mode: s.mode, orientation: s.orientation }
+ }
+ case 'lufsmeter': {
+ const s = settings as ScopeSettings['lufsmeter']
+ return { ...base, mode: s.mode }
+ }
+ case 'waveform': {
+ const s = settings as ScopeSettings['waveform']
+ return { ...base, scrollSpeed: s.scrollSpeed, gainDb: s.gainDb, multiband: s.multiband }
+ }
+ default:
+ return base
+ }
+}
+
+function createVisualizer(scopeKind: ScopeKind, canvas: HTMLCanvasElement, mySettings: ScopeSettings[ScopeKind], lineColor: string): Visualizer | null {
+ const opts = scopeSettingsToOptions(scopeKind, mySettings, lineColor)
switch (scopeKind) {
case 'spectrum':
- return new SpectrumAnalyzer(canvas, { lineColor })
+ return new SpectrumAnalyzer(canvas, opts)
case 'oscilloscope':
- return new Oscilloscope(canvas, { lineColor })
+ return new Oscilloscope(canvas, opts)
case 'vectorscope':
- return new Vectorscope(canvas, { lineColor })
+ return new Vectorscope(canvas, opts)
+ case 'spectrogram':
+ return new Spectrogram(canvas, opts)
+ case 'vumeter':
+ return new VUMeter(canvas, opts)
+ case 'lufsmeter':
+ return new LUFSMeter(canvas, opts)
+ case 'waveform':
+ return new Waveform(canvas, opts)
default:
- console.warn(`Scope type "${scopeKind}" not yet implemented`)
return null
}
}
-export default function ScopeModule({ scopeKind, lineColor = '#38bdf8' }: ScopeModuleProps): JSX.Element {
+export default function ScopeModule({ scopeKind, lineColor = '#38bdf8', widthWeight = 1 }: ScopeModuleProps): JSX.Element {
const containerRef = useRef(null)
const canvasRef = useRef(null)
const visualizerRef = useRef(null)
+ const initializedRef = useRef(false)
- // Initialize and manage visualizer lifecycle
+ // Subscribe to ONLY this scope's settings — avoids triggering setOptions when other scopes change
+ const mySettings = useSettingsStore((s) => s.scopeSettings[scopeKind])
+
+ // Initialize visualizer
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
- const viz = createVisualizer(scopeKind, canvas, lineColor)
+ initializedRef.current = false
+ const viz = createVisualizer(scopeKind, canvas, mySettings, lineColor)
if (!viz) return
visualizerRef.current = viz
viz.start()
+ // Mark as initialized after a frame so the settings effect skips the first run
+ requestAnimationFrame(() => { initializedRef.current = true })
+
return () => {
viz.dispose()
visualizerRef.current = null
+ initializedRef.current = false
}
- }, [scopeKind]) // Only recreate when scope type changes
+ }, [scopeKind])
- // Update lineColor without recreating
+ // Push settings + lineColor changes to live visualizer (skip initial — constructor already handled it)
useEffect(() => {
- visualizerRef.current?.setOptions({ lineColor })
- }, [lineColor])
+ if (!visualizerRef.current || !initializedRef.current) return
+ const opts = scopeSettingsToOptions(scopeKind, mySettings, lineColor)
+ visualizerRef.current.setOptions(opts)
+ }, [mySettings, lineColor])
// ResizeObserver for DPI-aware canvas sizing
useEffect(() => {
@@ -74,7 +142,7 @@ export default function ScopeModule({ scopeKind, lineColor = '#38bdf8' }: ScopeM
const observer = new ResizeObserver(resizeCanvas)
observer.observe(container)
- resizeCanvas() // Initial size
+ resizeCanvas()
return () => observer.disconnect()
}, [])
@@ -83,7 +151,7 @@ export default function ScopeModule({ scopeKind, lineColor = '#38bdf8' }: ScopeM
= {
+ spectrum: 'Spectrum',
+ oscilloscope: 'Oscilloscope',
+ vectorscope: 'Vectorscope',
+ spectrogram: 'Spectrogram',
+ vumeter: 'VU Meter',
+ lufsmeter: 'LUFS Meter',
+ waveform: 'Waveform',
+}
+
+const labelStyle: React.CSSProperties = {
+ fontSize: '9px',
+ fontFamily: "'JetBrains Mono', monospace",
+ color: 'rgba(255, 255, 255, 0.45)',
+ textTransform: 'uppercase',
+ letterSpacing: '0.08em',
+ marginBottom: '4px',
+}
+
+const selectStyle: React.CSSProperties = {
+ backgroundColor: '#0a0a0a',
+ color: 'rgba(255, 255, 255, 0.8)',
+ border: '1px solid rgba(255, 255, 255, 0.1)',
+ borderRadius: '3px',
+ padding: '4px 6px',
+ fontSize: '11px',
+ fontFamily: 'Inter, sans-serif',
+ outline: 'none',
+ width: '100%',
+}
+
+const checkboxRowStyle: React.CSSProperties = {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '6px',
+ fontSize: '11px',
+ color: 'rgba(255, 255, 255, 0.7)',
+ fontFamily: 'Inter, sans-serif',
+ cursor: 'pointer',
+}
+
+interface SettingsPanelProps {
+ onClose: () => void
+}
+
+export default function SettingsPanel({ onClose }: SettingsPanelProps): JSX.Element {
+ const {
+ devices,
+ selectedDeviceId,
+ captureMode,
+ isCapturing,
+ captureStatus,
+ captureError,
+ refreshDevices,
+ selectDevice,
+ setCaptureMode,
+ startCapture,
+ } = useAudioStore()
+ const { scopeSettings, updateScopeSettings, hiddenScopes, scopeOrder } = useSettingsStore()
+ const { presetId, accent, setPreset, setCustomAccent, customAccent } = useThemeStore()
+
+ const visibleScopes = scopeOrder.filter((k) => !hiddenScopes.has(k))
+
+ useEffect(() => {
+ refreshDevices()
+ }, [])
+
+ const handleSourceChange = async (value: string): Promise
=> {
+ if (value === '__system__') {
+ setCaptureMode('system')
+ await startCapture()
+ } else {
+ await selectDevice(value)
+ await startCapture()
+ }
+ }
+
+ const indicatorColor = isCapturing
+ ? '#22c55e'
+ : captureStatus === 'error'
+ ? '#ef4444'
+ : '#71717a'
+ const indicatorLabel = isCapturing
+ ? 'Capturing'
+ : captureStatus === 'connecting'
+ ? 'Connecting...'
+ : captureStatus === 'error'
+ ? 'Capture Failed'
+ : 'Idle'
+
+ return (
+
+ {/* Audio Source section */}
+
+
+ Audio Source
+
+
+
+
Source
+
+
+
+ {/* Signal indicator */}
+
+ {captureError ? (
+
+ {captureError}
+
+ ) : null}
+
+ {/* Theme section */}
+
+
+ Theme
+
+
+ {PRESET_IDS.map((id) => {
+ const p = PRESETS[id]
+ const active = presetId === id && !customAccent
+ return (
+
+
+
Custom
+
setCustomAccent(e.target.value)}
+ style={{
+ width: '100%',
+ height: '24px',
+ border: '1px solid rgba(255, 255, 255, 0.1)',
+ borderRadius: '3px',
+ backgroundColor: '#0a0a0a',
+ cursor: 'pointer',
+ padding: '2px',
+ }}
+ />
+
+
+
+
+ {/* Per-scope settings */}
+
+ {visibleScopes.map((kind) => (
+
+ ))}
+
+
+ {/* Close button */}
+
+
+ )
+}
+
+function ScopeSettingsColumn({ kind, settings, onUpdate, accent }: {
+ kind: ScopeKind
+ settings: ScopeSettings
+ onUpdate: (kind: K, s: Partial) => void
+ accent: string
+}): JSX.Element {
+ const s = settings[kind]
+
+ return (
+
+
+ {SCOPE_LABELS[kind]}
+
+
+ {kind === 'spectrum' && (() => {
+ const ss = s as ScopeSettings['spectrum']
+ return (
+ <>
+
+
FFT Size
+
+
+
+
Tilt (dB/oct)
+
onUpdate('spectrum', { tiltDbPerOctave: Number(e.target.value) })} style={{ width: '100%' }} />
+
+
+
+
+ >
+ )
+ })()}
+
+ {kind === 'oscilloscope' && (() => {
+ const ss = s as ScopeSettings['oscilloscope']
+ return (
+ <>
+
+
+
+
Line Width
+
onUpdate('oscilloscope', { lineWidth: Number(e.target.value) })} style={{ width: '100%' }} />
+
+ >
+ )
+ })()}
+
+ {kind === 'vectorscope' && (() => {
+ const ss = s as ScopeSettings['vectorscope']
+ return (
+ <>
+
+
Mode
+
+
+
+
Persistence
+
onUpdate('vectorscope', { persistence: Number(e.target.value) })} style={{ width: '100%' }} />
+
+
+
+ >
+ )
+ })()}
+
+ {kind === 'spectrogram' && (() => {
+ const ss = s as ScopeSettings['spectrogram']
+ return (
+ <>
+
+
FFT Size
+
+
+
+
Scale
+
+
+
+
Clarity
+
+
+
+
Color
+
+
+
+
Speed
+
onUpdate('spectrogram', { scrollSpeed: Number(e.target.value) })} style={{ width: '100%' }} />
+
+ >
+ )
+ })()}
+
+ {kind === 'vumeter' && (() => {
+ const ss = s as ScopeSettings['vumeter']
+ return (
+ <>
+
+
Mode
+
+
+
+
Orientation
+
+
+ >
+ )
+ })()}
+
+ {kind === 'lufsmeter' && (() => {
+ const ss = s as ScopeSettings['lufsmeter']
+ return (
+
+
Mode
+
+
+ )
+ })()}
+
+ {kind === 'waveform' && (() => {
+ const ss = s as ScopeSettings['waveform']
+ return (
+ <>
+
+
Gain (dB)
+
onUpdate('waveform', { gainDb: Number(e.target.value) })} style={{ width: '100%' }} />
+
+
+
Speed
+
onUpdate('waveform', { scrollSpeed: Number(e.target.value) })} style={{ width: '100%' }} />
+
+
+ >
+ )
+ })()}
+
+ )
+}
diff --git a/src/renderer/components/Strip.tsx b/src/renderer/components/Strip.tsx
index 8da8f96..4e9aa74 100644
--- a/src/renderer/components/Strip.tsx
+++ b/src/renderer/components/Strip.tsx
@@ -1,6 +1,74 @@
+import { Fragment, useCallback, useRef } from 'react'
+import { useSettingsStore } from '../stores/settingsStore'
+import { useThemeStore } from '../stores/themeStore'
+import type { ScopeKind } from '../../types/scope'
import ScopeModule from './ScopeModule'
+function ResizeHandle({ leftKind, rightKind }: { leftKind: ScopeKind; rightKind: ScopeKind }): JSX.Element {
+ const setScopeWidthWeight = useSettingsStore((s) => s.setScopeWidthWeight)
+ const handleRef = useRef(null)
+
+ const onMouseDown = useCallback((e: React.MouseEvent) => {
+ e.preventDefault()
+ const startX = e.clientX
+ const container = handleRef.current?.parentElement
+ if (!container) return
+
+ const totalWidth = container.getBoundingClientRect().width
+ const { widthWeights } = useSettingsStore.getState()
+ const startLeftWeight = widthWeights[leftKind] ?? 1
+ const startRightWeight = widthWeights[rightKind] ?? 1
+ const totalWeight = startLeftWeight + startRightWeight
+
+ const onMouseMove = (ev: MouseEvent): void => {
+ const delta = ev.clientX - startX
+ const ratio = delta / totalWidth * totalWeight * 2
+ const newLeft = Math.max(0.15, startLeftWeight + ratio)
+ const newRight = Math.max(0.15, startRightWeight - ratio)
+ setScopeWidthWeight(leftKind, newLeft)
+ setScopeWidthWeight(rightKind, newRight)
+ }
+
+ const onMouseUp = (): void => {
+ document.removeEventListener('mousemove', onMouseMove)
+ document.removeEventListener('mouseup', onMouseUp)
+ document.body.style.cursor = ''
+ document.body.style.userSelect = ''
+ }
+
+ document.body.style.cursor = 'col-resize'
+ document.body.style.userSelect = 'none'
+ document.addEventListener('mousemove', onMouseMove)
+ document.addEventListener('mouseup', onMouseUp)
+ }, [leftKind, rightKind, setScopeWidthWeight])
+
+ return (
+
+ )
+}
+
export default function Strip(): JSX.Element {
+ const scopeOrder = useSettingsStore((s) => s.scopeOrder)
+ const hiddenScopes = useSettingsStore((s) => s.hiddenScopes)
+ const widthWeights = useSettingsStore((s) => s.widthWeights)
+ const accent = useThemeStore((s) => s.accent)
+
+ const visibleScopes = scopeOrder.filter((k) => !hiddenScopes.has(k))
+
return (
-
-
-
-
-
+ {visibleScopes.map((kind, i) => (
+
+ {i > 0 && (
+
+ )}
+
+
+ ))}
)
}
diff --git a/src/renderer/components/Toolbar.tsx b/src/renderer/components/Toolbar.tsx
new file mode 100644
index 0000000..02d59fc
--- /dev/null
+++ b/src/renderer/components/Toolbar.tsx
@@ -0,0 +1,161 @@
+import { useState, useEffect, useCallback, useMemo } from 'react'
+import type { ScopeKind } from '../../types/scope'
+import { SCOPE_KINDS } from '../../types/scope'
+import { useSettingsStore } from '../stores/settingsStore'
+import { useThemeStore } from '../stores/themeStore'
+
+const SCOPE_LABELS: Record = {
+ spectrum: 'SPEC',
+ oscilloscope: 'OSC',
+ vectorscope: 'VEC',
+ spectrogram: 'GRAM',
+ vumeter: 'VU',
+ lufsmeter: 'LUFS',
+ waveform: 'WAVE',
+}
+
+function hexToRgba(hex: string, alpha: number): string {
+ const h = hex.replace('#', '')
+ const r = parseInt(h.substring(0, 2), 16)
+ const g = parseInt(h.substring(2, 4), 16)
+ const b = parseInt(h.substring(4, 6), 16)
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`
+}
+
+interface ToolbarProps {
+ onOpenSettings: () => void
+ settingsOpen: boolean
+}
+
+export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps): JSX.Element {
+ const hiddenScopes = useSettingsStore((s) => s.hiddenScopes)
+ const toggleScope = useSettingsStore((s) => s.toggleScope)
+ const accent = useThemeStore((s) => s.accent)
+ const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(true)
+
+ const accentBg = useMemo(() => hexToRgba(accent, 0.15), [accent])
+ const accentBorder = useMemo(() => hexToRgba(accent, 0.3), [accent])
+
+ useEffect(() => {
+ window.electronAPI.isAlwaysOnTop().then(setIsAlwaysOnTop)
+ const unsub = window.electronAPI.onAlwaysOnTopChanged(setIsAlwaysOnTop)
+ return unsub
+ }, [])
+
+ const handlePin = useCallback(() => {
+ window.electronAPI.toggleAlwaysOnTop()
+ }, [])
+
+ return (
+
+ {/* Drag region */}
+
+
+ {/* Scope toggles */}
+
+ {SCOPE_KINDS.map((kind) => {
+ const active = !hiddenScopes.has(kind)
+ return (
+
+ )
+ })}
+
+
+ {/* Right side: settings, pin, close */}
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts
index 36698ec..b13f72c 100644
--- a/src/renderer/env.d.ts
+++ b/src/renderer/env.d.ts
@@ -15,6 +15,9 @@ declare global {
expandSettings: (panelHeight: number) => void
collapseSettings: (panelHeight: number) => void
onAlwaysOnTopChanged: (callback: (isOnTop: boolean) => void) => () => void
+ onToggleScope: (callback: (index: number) => void) => () => void
+ onToggleCapture: (callback: () => void) => () => void
+ onToggleSettings: (callback: () => void) => () => void
}
}
}
diff --git a/src/renderer/public/capture-worklet.js b/src/renderer/public/capture-worklet.js
index f922412..d6fa203 100644
--- a/src/renderer/public/capture-worklet.js
+++ b/src/renderer/public/capture-worklet.js
@@ -11,6 +11,7 @@ class CaptureProcessor extends AudioWorkletProcessor {
this.port.postMessage({
left: left.slice(),
right: right.slice(),
+ channelCount: Math.min(Math.max(input.length, 1), 2),
})
return true
diff --git a/src/renderer/stores/audioStore.ts b/src/renderer/stores/audioStore.ts
index 6c19b41..ea9f667 100644
--- a/src/renderer/stores/audioStore.ts
+++ b/src/renderer/stores/audioStore.ts
@@ -6,6 +6,8 @@ interface AudioState {
selectedDeviceId: string | null
captureMode: CaptureMode
isCapturing: boolean
+ captureStatus: 'idle' | 'connecting' | 'capturing' | 'error'
+ captureError: string | null
sampleRate: number
refreshDevices: () => Promise
selectDevice: (deviceId: string) => Promise
@@ -19,6 +21,8 @@ export const useAudioStore = create((set, get) => ({
selectedDeviceId: null,
captureMode: 'system',
isCapturing: false,
+ captureStatus: 'idle',
+ captureError: null,
sampleRate: 48000,
refreshDevices: async () => {
@@ -29,11 +33,6 @@ export const useAudioStore = create((set, get) => ({
selectDevice: async (deviceId: string) => {
set({ selectedDeviceId: deviceId, captureMode: 'device' })
audioCapture.setSelectedDeviceId(deviceId)
-
- // If currently capturing, restart with new device
- if (get().isCapturing) {
- await get().startCapture()
- }
},
setCaptureMode: (mode: CaptureMode) => {
@@ -41,26 +40,34 @@ export const useAudioStore = create((set, get) => ({
},
startCapture: async () => {
+ set({ captureStatus: 'connecting', captureError: null })
try {
const { captureMode, selectedDeviceId } = get()
if (captureMode === 'system') {
- await audioCapture.startSystemAudio()
+ await audioCapture.start()
} else {
await audioCapture.startDevice(selectedDeviceId ?? undefined)
}
set({
isCapturing: true,
+ captureStatus: 'capturing',
+ captureError: null,
sampleRate: audioCapture.getSampleRate(),
captureMode: audioCapture.getCaptureMode(),
})
} catch (err) {
console.error('Failed to start audio capture:', err)
- set({ isCapturing: false })
+ const message = err instanceof Error ? err.message : 'Unknown audio capture error'
+ set({
+ isCapturing: false,
+ captureStatus: 'error',
+ captureError: message,
+ })
}
},
stopCapture: () => {
audioCapture.stop()
- set({ isCapturing: false })
+ set({ isCapturing: false, captureStatus: 'idle', captureError: null })
},
}))
diff --git a/src/renderer/stores/settingsStore.ts b/src/renderer/stores/settingsStore.ts
new file mode 100644
index 0000000..c5bbbb5
--- /dev/null
+++ b/src/renderer/stores/settingsStore.ts
@@ -0,0 +1,222 @@
+import { create } from 'zustand'
+import { SCOPE_KINDS, type ScopeKind } from '../../types/scope'
+import type { VectorscopeMode } from '../visualizers/Vectorscope'
+import type { SpectrogramClarityMode, SpectrogramScaleMode } from '../../types/spectrogram'
+import type { VUMeterMode, VUMeterOrientation } from '../../types/vumeter'
+import type { LUFSMeterMode } from '../../types/lufsmeter'
+
+// Per-scope settings (mirrors Astra's AnalyzerProfileScopeSettings)
+export interface ScopeSettings {
+ spectrum: {
+ fftSize: number
+ tiltDbPerOctave: number
+ heatmap: boolean
+ heatmapTiltDbPerOctave: number
+ showGrid: boolean
+ smoothing: number
+ fillGradient: boolean
+ }
+ oscilloscope: {
+ pitchLock: boolean
+ underfillEnabled: boolean
+ showGrid: boolean
+ lineWidth: number
+ }
+ vectorscope: {
+ mode: VectorscopeMode
+ multiband: boolean
+ showGrid: boolean
+ persistence: number
+ lineWidth: number
+ }
+ spectrogram: {
+ fftSize: number
+ scrollSpeed: number
+ clarityMode: SpectrogramClarityMode
+ scaleMode: SpectrogramScaleMode
+ colorScheme: 'heat' | 'mono'
+ }
+ vumeter: {
+ mode: VUMeterMode
+ orientation: VUMeterOrientation
+ }
+ lufsmeter: {
+ mode: LUFSMeterMode
+ }
+ waveform: {
+ scrollSpeed: number
+ gainDb: number
+ multiband: boolean
+ }
+}
+
+const DEFAULT_SCOPE_SETTINGS: ScopeSettings = {
+ spectrum: { fftSize: 2048, tiltDbPerOctave: 2.0, heatmap: false, heatmapTiltDbPerOctave: 2.0, showGrid: true, smoothing: 0.9, fillGradient: true },
+ 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 },
+}
+
+const DEFAULT_VISIBLE: ScopeKind[] = ['spectrum', 'oscilloscope', 'vectorscope', 'vumeter']
+
+const STORAGE_KEY = 'prism:settings'
+
+interface SettingsState {
+ scopeOrder: ScopeKind[]
+ hiddenScopes: Set
+ widthWeights: Record
+ scopeSettings: ScopeSettings
+
+ // Derived
+ visibleScopes: () => ScopeKind[]
+
+ // Actions
+ toggleScope: (kind: ScopeKind) => void
+ moveScope: (kind: ScopeKind, direction: 'left' | 'right') => void
+ setScopeWidthWeight: (kind: ScopeKind, weight: number) => void
+ updateScopeSettings: (kind: K, settings: Partial) => void
+}
+
+function loadFromStorage(): Partial<{ scopeOrder: ScopeKind[]; hiddenScopes: ScopeKind[]; widthWeights: Record; scopeSettings: ScopeSettings }> {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY)
+ if (raw) return JSON.parse(raw)
+ } catch { /* ignore */ }
+ return {}
+}
+
+function saveToStorage(state: SettingsState): void {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({
+ scopeOrder: state.scopeOrder,
+ hiddenScopes: Array.from(state.hiddenScopes),
+ widthWeights: state.widthWeights,
+ scopeSettings: state.scopeSettings,
+ }))
+ } catch { /* ignore */ }
+}
+
+function isScopeKind(value: unknown): value is ScopeKind {
+ return typeof value === 'string' && SCOPE_KINDS.includes(value as ScopeKind)
+}
+
+function normalizeScopeOrder(raw: unknown): ScopeKind[] {
+ if (!Array.isArray(raw)) return [...SCOPE_KINDS]
+ const valid = raw.filter(isScopeKind)
+ const seen = new Set()
+ const normalized: ScopeKind[] = []
+
+ for (const kind of valid) {
+ if (seen.has(kind)) continue
+ seen.add(kind)
+ normalized.push(kind)
+ }
+
+ for (const kind of SCOPE_KINDS) {
+ if (!seen.has(kind)) {
+ normalized.push(kind)
+ }
+ }
+
+ return normalized
+}
+
+function normalizeHiddenScopes(raw: unknown): ScopeKind[] {
+ if (!Array.isArray(raw)) {
+ return SCOPE_KINDS.filter((kind) => !DEFAULT_VISIBLE.includes(kind))
+ }
+ return raw.filter(isScopeKind)
+}
+
+function mergeScopeSettings(raw: unknown): ScopeSettings {
+ const parsed = typeof raw === 'object' && raw !== null
+ ? raw as Partial
+ : {}
+
+ return {
+ spectrum: { ...DEFAULT_SCOPE_SETTINGS.spectrum, ...(parsed.spectrum ?? {}) },
+ oscilloscope: { ...DEFAULT_SCOPE_SETTINGS.oscilloscope, ...(parsed.oscilloscope ?? {}) },
+ vectorscope: { ...DEFAULT_SCOPE_SETTINGS.vectorscope, ...(parsed.vectorscope ?? {}) },
+ spectrogram: { ...DEFAULT_SCOPE_SETTINGS.spectrogram, ...(parsed.spectrogram ?? {}) },
+ vumeter: { ...DEFAULT_SCOPE_SETTINGS.vumeter, ...(parsed.vumeter ?? {}) },
+ lufsmeter: { ...DEFAULT_SCOPE_SETTINGS.lufsmeter, ...(parsed.lufsmeter ?? {}) },
+ waveform: { ...DEFAULT_SCOPE_SETTINGS.waveform, ...(parsed.waveform ?? {}) },
+ }
+}
+
+const stored = loadFromStorage()
+
+const defaultWeights: Record = {
+ spectrum: 1, oscilloscope: 1, vectorscope: 1, spectrogram: 1,
+ vumeter: 0.5, lufsmeter: 0.5, waveform: 1,
+}
+
+export const useSettingsStore = create((set, get) => ({
+ scopeOrder: normalizeScopeOrder(stored.scopeOrder),
+ hiddenScopes: new Set(
+ normalizeHiddenScopes(stored.hiddenScopes)
+ ),
+ widthWeights: stored.widthWeights ?? { ...defaultWeights },
+ scopeSettings: mergeScopeSettings(stored.scopeSettings),
+
+ visibleScopes: () => {
+ const { scopeOrder, hiddenScopes } = get()
+ return scopeOrder.filter((k) => !hiddenScopes.has(k))
+ },
+
+ toggleScope: (kind: ScopeKind) => {
+ set((state) => {
+ const next = new Set(state.hiddenScopes)
+ if (next.has(kind)) {
+ next.delete(kind)
+ } else {
+ // Don't allow hiding all scopes
+ const visibleCount = state.scopeOrder.filter((k) => !next.has(k)).length
+ if (visibleCount <= 1) return state
+ next.add(kind)
+ }
+ const newState = { ...state, hiddenScopes: next }
+ saveToStorage(newState as SettingsState)
+ return newState
+ })
+ },
+
+ moveScope: (kind: ScopeKind, direction: 'left' | 'right') => {
+ set((state) => {
+ const order = [...state.scopeOrder]
+ const idx = order.indexOf(kind)
+ if (idx === -1) return state
+ const swap = direction === 'left' ? idx - 1 : idx + 1
+ if (swap < 0 || swap >= order.length) return state
+ ;[order[idx], order[swap]] = [order[swap], order[idx]]
+ const newState = { ...state, scopeOrder: order }
+ saveToStorage(newState as SettingsState)
+ return newState
+ })
+ },
+
+ setScopeWidthWeight: (kind: ScopeKind, weight: number) => {
+ set((state) => {
+ const newState = { ...state, widthWeights: { ...state.widthWeights, [kind]: Math.max(0.1, weight) } }
+ saveToStorage(newState as SettingsState)
+ return newState
+ })
+ },
+
+ updateScopeSettings: (kind: K, settings: Partial) => {
+ set((state) => {
+ const newState = {
+ ...state,
+ scopeSettings: {
+ ...state.scopeSettings,
+ [kind]: { ...state.scopeSettings[kind], ...settings },
+ },
+ }
+ saveToStorage(newState as SettingsState)
+ return newState
+ })
+ },
+}))
diff --git a/src/renderer/stores/themeStore.ts b/src/renderer/stores/themeStore.ts
new file mode 100644
index 0000000..a5eb98e
--- /dev/null
+++ b/src/renderer/stores/themeStore.ts
@@ -0,0 +1,133 @@
+import { create } from 'zustand'
+
+export interface ThemePreset {
+ name: string
+ accent: string
+ accentHover: string
+ accentGlow: string
+ accentRgb: string
+}
+
+const PRESETS: Record = {
+ default: {
+ name: 'Cyan',
+ accent: '#38bdf8',
+ accentHover: '#7dd3fc',
+ accentGlow: 'rgba(56, 189, 248, 0.3)',
+ accentRgb: '56, 189, 248',
+ },
+ graphite: {
+ name: 'Graphite',
+ accent: '#4fc3f7',
+ accentHover: '#81d4fa',
+ accentGlow: 'rgba(79, 195, 247, 0.3)',
+ accentRgb: '79, 195, 247',
+ },
+ midnight: {
+ name: 'Midnight',
+ accent: '#4f9bff',
+ accentHover: '#7eb8ff',
+ accentGlow: 'rgba(79, 155, 255, 0.3)',
+ accentRgb: '79, 155, 255',
+ },
+ green: {
+ name: 'Green',
+ accent: '#4ade80',
+ accentHover: '#86efac',
+ accentGlow: 'rgba(74, 222, 128, 0.3)',
+ accentRgb: '74, 222, 128',
+ },
+ purple: {
+ name: 'Purple',
+ accent: '#a78bfa',
+ accentHover: '#c4b5fd',
+ accentGlow: 'rgba(167, 139, 250, 0.3)',
+ accentRgb: '167, 139, 250',
+ },
+ rose: {
+ name: 'Rose',
+ accent: '#fb7185',
+ accentHover: '#fda4af',
+ accentGlow: 'rgba(251, 113, 133, 0.3)',
+ accentRgb: '251, 113, 133',
+ },
+}
+
+export const PRESET_IDS = Object.keys(PRESETS)
+
+const STORAGE_KEY = 'prism:theme'
+
+function hexToRgb(hex: string): string {
+ const h = hex.replace('#', '')
+ const r = parseInt(h.substring(0, 2), 16)
+ const g = parseInt(h.substring(2, 4), 16)
+ const b = parseInt(h.substring(4, 6), 16)
+ return `${r}, ${g}, ${b}`
+}
+
+function lightenHex(hex: string, amount: number): string {
+ const h = hex.replace('#', '')
+ const r = Math.min(255, parseInt(h.substring(0, 2), 16) + amount)
+ const g = Math.min(255, parseInt(h.substring(2, 4), 16) + amount)
+ const b = Math.min(255, parseInt(h.substring(4, 6), 16) + amount)
+ return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
+}
+
+interface ThemeState {
+ presetId: string
+ customAccent: string | null // null = use preset accent
+ accent: string // resolved accent hex
+
+ setPreset: (id: string) => void
+ setCustomAccent: (hex: string | null) => void
+}
+
+function loadTheme(): { presetId: string; customAccent: string | null } {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY)
+ if (raw) return JSON.parse(raw)
+ } catch { /* ignore */ }
+ return { presetId: 'default', customAccent: null }
+}
+
+function applyToDOM(accent: string): void {
+ const rgb = hexToRgb(accent)
+ const root = document.documentElement
+ root.style.setProperty('--accent', accent)
+ root.style.setProperty('--accent-hover', lightenHex(accent, 50))
+ root.style.setProperty('--accent-glow', `rgba(${rgb}, 0.3)`)
+ root.style.setProperty('--accent-rgb', rgb)
+}
+
+const stored = loadTheme()
+const initialPreset = PRESETS[stored.presetId] ?? PRESETS.default
+const initialAccent = stored.customAccent ?? initialPreset.accent
+
+// Apply immediately on load
+applyToDOM(initialAccent)
+
+export const useThemeStore = create((set) => ({
+ presetId: stored.presetId,
+ customAccent: stored.customAccent,
+ accent: initialAccent,
+
+ setPreset: (id: string) => {
+ const preset = PRESETS[id] ?? PRESETS.default
+ applyToDOM(preset.accent)
+ const state = { presetId: id, customAccent: null, accent: preset.accent }
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ presetId: id, customAccent: null }))
+ set(state)
+ },
+
+ setCustomAccent: (hex: string | null) => {
+ set((prev) => {
+ const preset = PRESETS[prev.presetId] ?? PRESETS.default
+ const accent = hex ?? preset.accent
+ applyToDOM(accent)
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ presetId: prev.presetId, customAccent: hex }))
+ return { ...prev, customAccent: hex, accent }
+ })
+ },
+}))
+
+export { PRESETS }
diff --git a/src/renderer/visualizers/Oscilloscope.ts b/src/renderer/visualizers/Oscilloscope.ts
index 9276b9f..0dbb26b 100644
--- a/src/renderer/visualizers/Oscilloscope.ts
+++ b/src/renderer/visualizers/Oscilloscope.ts
@@ -96,6 +96,7 @@ export class Oscilloscope {
private nativeInitialized: boolean = false
private samplesReceived: number = 0
private lastSampleRate: number = 0
+ private unsubscribeSessionChange: (() => void) | null = null
private static readonly WARMUP_SAMPLES = 4096 // Need ~4K samples before pitch detection is reliable
constructor(canvas: HTMLCanvasElement, options: OscilloscopeOptions = {}) {
@@ -107,17 +108,21 @@ export class Oscilloscope {
// Initialize native module
this.initNative()
+ this.unsubscribeSessionChange = audioRouter.subscribeToSessionChanges(() => {
+ this.reset()
+ })
}
private initNative(): void {
if (isNativeAvailable() && !this.nativeInitialized) {
- // Get actual sample rate from AudioEngine (defaults to 48000 if context not ready)
+ // Initialize with current sample rate, but set lastSampleRate to 0 so
+ // updateSampleRateIfNeeded() always fires once the real capture rate is known.
+ // This prevents stale-rate issues when capture starts after initialization.
const sampleRate = audioRouter.getSampleRate()
- this.lastSampleRate = sampleRate
+ this.lastSampleRate = 0
nativeOscilloscope.setSampleRate(sampleRate)
nativeOscilloscope.setPitchLock(this.options.pitchLock)
nativeOscilloscope.setDisplaySamples(getNormalizedOscilloscopeDisplaySamples(sampleRate))
- // Note: Filter is now pitch-adaptive FIR bandpass (auto-configured in native code)
this.nativeInitialized = true
console.log(`Oscilloscope: Using native DSP with AudioWorklet (${sampleRate}Hz)`)
} else if (!isNativeAvailable()) {
@@ -191,6 +196,11 @@ export class Oscilloscope {
// Check if sample rate needs updating (AudioContext may have initialized after us)
this.updateSampleRateIfNeeded()
+ if (!audioRouter.isCapturing()) {
+ this.animationId = requestAnimationFrame(this.draw)
+ return
+ }
+
// Flush ALL pending samples to native C++ (prevents sample loss)
const pendingSamples = audioRouter.flushPendingOscilloscopeSamples()
for (const chunk of pendingSamples) {
@@ -332,6 +342,11 @@ export class Oscilloscope {
dispose(): void {
this.stop()
+ if (this.unsubscribeSessionChange) {
+ this.unsubscribeSessionChange()
+ this.unsubscribeSessionChange = null
+ }
+
// Reset native module state
if (isNativeAvailable()) {
nativeOscilloscope.reset()
diff --git a/src/renderer/visualizers/SpectrumAnalyzer.ts b/src/renderer/visualizers/SpectrumAnalyzer.ts
index ffbdfd1..a09b608 100644
--- a/src/renderer/visualizers/SpectrumAnalyzer.ts
+++ b/src/renderer/visualizers/SpectrumAnalyzer.ts
@@ -104,6 +104,7 @@ export class SpectrumAnalyzer {
private nativeInitialized: boolean = false
private sampleRate: number = 48000
private lastSampleRate: number = 0
+ private unsubscribeSessionChange: (() => void) | null = null
constructor(canvas: HTMLCanvasElement, options: SpectrumAnalyzerOptions = {}) {
this.canvas = canvas
@@ -125,12 +126,15 @@ export class SpectrumAnalyzer {
// Initialize native module
this.initNative()
+ this.unsubscribeSessionChange = audioRouter.subscribeToSessionChanges(() => {
+ this.resetState()
+ })
}
private initNative(): void {
if (isNativeAvailable() && !this.nativeInitialized) {
this.sampleRate = Math.max(1, this.dataSource.getSampleRate())
- this.lastSampleRate = this.sampleRate
+ this.lastSampleRate = 0 // Force updateSampleRateIfNeeded() to fire once real rate is known
nativeSpectrum.setFFTSize(this.options.fftSize)
nativeSpectrum.setSampleRate(this.sampleRate)
nativeSpectrum.setSmoothing(this.getNativeSmoothing())
@@ -158,8 +162,17 @@ export class SpectrumAnalyzer {
return Math.min(0.99, Math.max(0, Math.pow(base, fftRatio)))
}
+ private resetState(): void {
+ if (isNativeAvailable()) {
+ nativeSpectrum.reset()
+ }
+ this.sampleRate = Math.max(1, this.dataSource.getSampleRate())
+ this.lastSampleRate = 0
+ }
+
setOptions(options: Partial): void {
const { dataSource, ...optionUpdates } = options
+ const prevFftSize = this.options.fftSize
const nextOptions = { ...this.options, ...optionUpdates }
if (optionUpdates.tiltDbPerOctave !== undefined) {
nextOptions.tiltDbPerOctave = clampSpectrumTiltDbPerOctave(optionUpdates.tiltDbPerOctave)
@@ -172,12 +185,12 @@ export class SpectrumAnalyzer {
this.dataSource = dataSource
}
- // Update native module settings
+ // Update native module settings — only when values actually change to avoid buffer resets
if (isNativeAvailable()) {
- if (options.fftSize !== undefined) {
+ if (options.fftSize !== undefined && options.fftSize !== prevFftSize) {
nativeSpectrum.setFFTSize(options.fftSize)
}
- if (options.smoothing !== undefined || options.fftSize !== undefined) {
+ if (options.smoothing !== undefined || (options.fftSize !== undefined && options.fftSize !== prevFftSize)) {
nativeSpectrum.setSmoothing(this.getNativeSmoothing())
}
}
@@ -290,23 +303,26 @@ export class SpectrumAnalyzer {
this.updateSampleRateIfNeeded()
+ // Clear canvas
+ ctx.clearRect(0, 0, width, height)
+
+ // Draw background if not transparent
+ if (options.backgroundColor !== 'transparent') {
+ ctx.fillStyle = options.backgroundColor
+ ctx.fillRect(0, 0, width, height)
+ }
+
+ // Draw grid
+ const nyquist = this.sampleRate / 2
+ const minFrequency = Math.max(1, Math.min(options.minFrequency, nyquist))
+ const maxFrequency = Math.max(minFrequency + 1, Math.min(options.maxFrequency, nyquist))
+ if (options.showGrid) {
+ this.drawGrid(minFrequency, maxFrequency)
+ }
+
if (!this.dataSource.isPlaying()) {
this.dataSource.getPendingSpectrumSamples()
nativeSpectrum.reset()
-
- ctx.clearRect(0, 0, width, height)
- if (options.backgroundColor !== 'transparent') {
- ctx.fillStyle = options.backgroundColor
- ctx.fillRect(0, 0, width, height)
- }
-
- const nyquist = this.sampleRate / 2
- const minFrequency = Math.max(1, Math.min(options.minFrequency, nyquist))
- const maxFrequency = Math.max(minFrequency + 1, Math.min(options.maxFrequency, nyquist))
- if (options.showGrid) {
- this.drawGrid(minFrequency, maxFrequency)
- }
-
this.animationId = requestAnimationFrame(this.draw)
return
}
@@ -332,23 +348,6 @@ export class SpectrumAnalyzer {
return
}
- // Clear canvas
- ctx.clearRect(0, 0, width, height)
-
- // Draw background if not transparent
- if (options.backgroundColor !== 'transparent') {
- ctx.fillStyle = options.backgroundColor
- ctx.fillRect(0, 0, width, height)
- }
-
- // Draw grid
- const nyquist = this.sampleRate / 2
- const minFrequency = Math.max(1, Math.min(options.minFrequency, nyquist))
- const maxFrequency = Math.max(minFrequency + 1, Math.min(options.maxFrequency, nyquist))
- if (options.showGrid) {
- this.drawGrid(minFrequency, maxFrequency)
- }
-
// Calculate frequency mapping
const binWidth = nyquist / bufferLength
@@ -505,6 +504,11 @@ export class SpectrumAnalyzer {
dispose(): void {
this.stop()
+ if (this.unsubscribeSessionChange) {
+ this.unsubscribeSessionChange()
+ this.unsubscribeSessionChange = null
+ }
+
// Reset native module state
if (isNativeAvailable()) {
nativeSpectrum.reset()
diff --git a/src/renderer/visualizers/Vectorscope.ts b/src/renderer/visualizers/Vectorscope.ts
index e21b743..941f987 100644
--- a/src/renderer/visualizers/Vectorscope.ts
+++ b/src/renderer/visualizers/Vectorscope.ts
@@ -41,6 +41,7 @@ export class Vectorscope {
private isRunning: boolean = false
private nativeInitialized: boolean = false
private lastSampleRate: number = 0
+ private unsubscribeSessionChange: (() => void) | null = null
private splitter: MultibandSplitter = new MultibandSplitter()
private multibandBuffer: MultibandBuffer = new MultibandBuffer()
@@ -61,6 +62,9 @@ export class Vectorscope {
// Initialize native module if available
this.initNative()
+ this.unsubscribeSessionChange = audioRouter.subscribeToSessionChanges(() => {
+ this.resetDisplay()
+ })
}
private initNative(): void {
@@ -140,6 +144,20 @@ export class Vectorscope {
// Update sample rate if changed
this.updateSampleRateIfNeeded()
+ if (!audioRouter.isCapturing()) {
+ ctx.clearRect(0, 0, width, height)
+ if (options.backgroundColor !== 'transparent') {
+ ctx.fillStyle = options.backgroundColor
+ ctx.fillRect(0, 0, width, height)
+ }
+ if (options.showGrid) {
+ const dpr = window.devicePixelRatio || 1
+ drawVectorscopeGridForMode(ctx, width, height, options.gridColor, options.mode, dpr)
+ }
+ this.animationId = requestAnimationFrame(this.draw)
+ return
+ }
+
// ---- PERSISTENCE FADE ----
offscreenCtx.globalCompositeOperation = 'destination-in'
offscreenCtx.fillStyle = `rgba(255, 255, 255, ${options.persistence})`
@@ -328,6 +346,11 @@ export class Vectorscope {
dispose(): void {
this.stop()
+ if (this.unsubscribeSessionChange) {
+ this.unsubscribeSessionChange()
+ this.unsubscribeSessionChange = null
+ }
+
// Reset native module state
if (isNativeAvailable()) {
nativeVectorscope.reset()