diff --git a/resources/entitlements.mac.inherit.plist b/resources/entitlements.mac.inherit.plist new file mode 100644 index 0000000..44c77d3 --- /dev/null +++ b/resources/entitlements.mac.inherit.plist @@ -0,0 +1,14 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.device.audio-input + + com.apple.security.cs.disable-library-validation + + + diff --git a/resources/entitlements.mac.plist b/resources/entitlements.mac.plist new file mode 100644 index 0000000..44c77d3 --- /dev/null +++ b/resources/entitlements.mac.plist @@ -0,0 +1,14 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.device.audio-input + + com.apple.security.cs.disable-library-validation + + + diff --git a/src/main/index.ts b/src/main/index.ts index 7c68eef..3ee63d0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -46,7 +46,7 @@ function createWindow(): void { // Auto-grant media (microphone) permission for audio capture function setupPermissions(): void { session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => { - if (permission === 'media' || permission === 'screen') { + if (permission === 'media' || permission === 'display-capture') { callback(true) } else { callback(false) @@ -97,10 +97,48 @@ function setupIPC(): void { }) } +function setupShortcuts(): void { + if (!mainWindow) return + + // Scope toggles 1-7 + const scopeKeys = ['1', '2', '3', '4', '5', '6', '7'] + scopeKeys.forEach((key) => { + mainWindow!.webContents.on('before-input-event', (_event, input) => { + if (input.type === 'keyDown' && input.key === key && !input.alt && !input.control && !input.meta && !input.shift) { + mainWindow?.webContents.send('shortcut:toggle-scope', parseInt(key) - 1) + } + }) + }) + + // T = toggle always-on-top + mainWindow.webContents.on('before-input-event', (_event, input) => { + if (input.type === 'keyDown' && input.key === 't' && !input.alt && !input.control && !input.meta && !input.shift) { + const current = mainWindow!.isAlwaysOnTop() + mainWindow!.setAlwaysOnTop(!current) + mainWindow!.webContents.send('window:always-on-top-changed', !current) + } + }) + + // Space = toggle capture + mainWindow.webContents.on('before-input-event', (_event, input) => { + if (input.type === 'keyDown' && input.key === ' ' && !input.alt && !input.control && !input.meta && !input.shift) { + mainWindow?.webContents.send('shortcut:toggle-capture') + } + }) + + // Comma (Cmd+,) = toggle settings + mainWindow.webContents.on('before-input-event', (_event, input) => { + if (input.type === 'keyDown' && input.key === ',' && input.meta && !input.alt && !input.control && !input.shift) { + mainWindow?.webContents.send('shortcut:toggle-settings') + } + }) +} + app.whenReady().then(() => { setupPermissions() setupIPC() createWindow() + setupShortcuts() }) app.on('window-all-closed', () => { diff --git a/src/preload/index.ts b/src/preload/index.ts index 9586949..e207d9f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -15,6 +15,21 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on('window:always-on-top-changed', handler) return () => ipcRenderer.removeListener('window:always-on-top-changed', handler) }, + onToggleScope: (callback: (index: number) => void) => { + const handler = (_event: Electron.IpcRendererEvent, index: number): void => callback(index) + ipcRenderer.on('shortcut:toggle-scope', handler) + return () => ipcRenderer.removeListener('shortcut:toggle-scope', handler) + }, + onToggleCapture: (callback: () => void) => { + const handler = (): void => callback() + ipcRenderer.on('shortcut:toggle-capture', handler) + return () => ipcRenderer.removeListener('shortcut:toggle-capture', handler) + }, + onToggleSettings: (callback: () => void) => { + const handler = (): void => callback() + ipcRenderer.on('shortcut:toggle-settings', handler) + return () => ipcRenderer.removeListener('shortcut:toggle-settings', handler) + }, }) // Native DSP module — load if available, gracefully degrade if not diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 5b1136a..f9f379d 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,122 +1,112 @@ -import { useEffect } from 'react' -import { useAudioStore } from './stores/audioStore' +import { useState, useRef, useCallback, useEffect } from 'react' import Strip from './components/Strip' +import Toolbar from './components/Toolbar' +import SettingsPanel from './components/SettingsPanel' +import { useSettingsStore } from './stores/settingsStore' +import { useAudioStore } from './stores/audioStore' +import { SCOPE_KINDS } from '../types/scope' + +const SETTINGS_PANEL_HEIGHT = 200 export default function App(): JSX.Element { - const { - devices, - selectedDeviceId, - captureMode, - isCapturing, - refreshDevices, - selectDevice, - setCaptureMode, - startCapture, - stopCapture, - } = useAudioStore() + const [toolbarVisible, setToolbarVisible] = useState(false) + const [settingsOpen, setSettingsOpen] = useState(false) + const hideTimeoutRef = useRef | null>(null) + const settingsExpandedRef = useRef(false) - // Enumerate devices on mount + const toggleScope = useSettingsStore((s) => s.toggleScope) + + // Auto-capture on launch useEffect(() => { - refreshDevices() - navigator.mediaDevices.addEventListener('devicechange', refreshDevices) - return () => navigator.mediaDevices.removeEventListener('devicechange', refreshDevices) - }, [refreshDevices]) + useAudioStore.getState().startCapture() + }, []) - const handleSourceChange = (e: React.ChangeEvent): void => { - const value = e.target.value - if (value === '__system__') { - setCaptureMode('system') - } else { - selectDevice(value) + // Settings panel window resize — single stable effect, no double-fire + useEffect(() => { + if (settingsOpen && !settingsExpandedRef.current) { + settingsExpandedRef.current = true + window.electronAPI.expandSettings(SETTINGS_PANEL_HEIGHT) + } else if (!settingsOpen && settingsExpandedRef.current) { + settingsExpandedRef.current = false + window.electronAPI.collapseSettings(SETTINGS_PANEL_HEIGHT) } - } + }, [settingsOpen]) - const handleToggleCapture = (): void => { - if (isCapturing) { - stopCapture() - } else { - startCapture() + const showToolbar = useCallback(() => { + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current) + hideTimeoutRef.current = null } - } + setToolbarVisible(true) + }, []) + + const scheduleHide = useCallback(() => { + if (settingsOpen) return + hideTimeoutRef.current = setTimeout(() => { + setToolbarVisible(false) + }, 400) + }, [settingsOpen]) + + const handleToggleSettings = useCallback(() => { + setSettingsOpen((prev) => !prev) + }, []) + + const handleCloseSettings = useCallback(() => { + setSettingsOpen(false) + }, []) + + // Keyboard shortcuts from main process + useEffect(() => { + const unsubs = [ + window.electronAPI.onToggleScope((index) => { + if (index >= 0 && index < SCOPE_KINDS.length) { + toggleScope(SCOPE_KINDS[index]) + } + }), + window.electronAPI.onToggleCapture(() => { + const { isCapturing, startCapture, stopCapture } = useAudioStore.getState() + if (isCapturing) { + stopCapture() + } else { + startCapture() + } + }), + window.electronAPI.onToggleSettings(() => { + setSettingsOpen((prev) => !prev) + }), + ] + return () => unsubs.forEach((unsub) => unsub()) + }, [toggleScope]) return ( -
+
+ {/* 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 */} +
+
+ {indicatorLabel} +
+ {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()