better porting

This commit is contained in:
Boof2015
2026-03-21 16:40:03 -04:00
parent ecf13c2857
commit 311f35f795
19 changed files with 1515 additions and 189 deletions
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
+39 -1
View File
@@ -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', () => {
+15
View File
@@ -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
+89 -99
View File
@@ -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<ReturnType<typeof setTimeout> | 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<HTMLSelectElement>): 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 => {
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 (
<div style={{ width: '100vw', height: '100vh', display: 'flex', flexDirection: 'column' }}>
<div
style={{ width: '100vw', height: '100vh', display: 'flex', flexDirection: 'column', position: 'relative' }}
onMouseEnter={showToolbar}
onMouseLeave={scheduleHide}
>
{/* Toolbar overlay — fades in on hover */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 10,
opacity: toolbarVisible ? 1 : 0,
transition: 'opacity 150ms ease',
pointerEvents: toolbarVisible ? 'auto' : 'none',
}}
>
<Toolbar onOpenSettings={handleToggleSettings} settingsOpen={settingsOpen} />
</div>
{/* Scope strip — fills all available space */}
<div style={{ flex: 1, minHeight: 0 }}>
<Strip />
</div>
{/* Temporary source picker bar — will be replaced by Toolbar + Settings in Phase 6 */}
<div
style={{
height: '32px',
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '0 8px',
backgroundColor: 'var(--bg-secondary)',
borderTop: '1px solid var(--glass-border)',
flexShrink: 0,
}}
>
{/* Signal indicator */}
<div
style={{
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: isCapturing ? '#22c55e' : '#71717a',
boxShadow: isCapturing ? '0 0 6px rgba(34, 197, 94, 0.4)' : 'none',
transition: 'all 150ms',
flexShrink: 0,
}}
/>
<select
value={captureMode === 'system' ? '__system__' : selectedDeviceId ?? ''}
onChange={handleSourceChange}
style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-secondary)',
border: '1px solid var(--glass-border)',
borderRadius: '3px',
padding: '2px 6px',
fontSize: '10px',
fontFamily: 'Inter, sans-serif',
outline: 'none',
flex: 1,
minWidth: 0,
}}
>
<option value="__system__">System Audio</option>
<optgroup label="Audio Devices">
{devices.map((device) => (
<option key={device.deviceId} value={device.deviceId}>
{device.label || `Input ${device.deviceId.slice(0, 8)}`}
</option>
))}
</optgroup>
</select>
<button
onClick={handleToggleCapture}
style={{
backgroundColor: isCapturing ? '#dc2626' : 'var(--accent)',
color: '#fff',
border: 'none',
borderRadius: '3px',
padding: '2px 10px',
fontSize: '9px',
fontFamily: 'JetBrains Mono, monospace',
textTransform: 'uppercase',
letterSpacing: '0.05em',
cursor: 'pointer',
flexShrink: 0,
}}
>
{isCapturing ? 'Stop' : 'Start'}
</button>
</div>
{/* Settings panel — expands below strip */}
{settingsOpen && <SettingsPanel onClose={handleCloseSettings} />}
</div>
)
}
+32 -9
View File
@@ -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()
+79 -10
View File
@@ -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
+83 -15
View File
@@ -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<string, unknown>): 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<string, unknown> {
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<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const visualizerRef = useRef<Visualizer | null>(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
<div
ref={containerRef}
style={{
flex: 1,
flex: widthWeight,
minWidth: 0,
height: '100%',
position: 'relative',
+451
View File
@@ -0,0 +1,451 @@
import { useEffect } from 'react'
import { useAudioStore } from '../stores/audioStore'
import { useSettingsStore, type ScopeSettings } from '../stores/settingsStore'
import { useThemeStore, PRESETS, PRESET_IDS } from '../stores/themeStore'
import type { ScopeKind } from '../../types/scope'
const PANEL_HEIGHT = 200
const SCOPE_LABELS: Record<ScopeKind, string> = {
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<void> => {
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 (
<div
style={{
height: `${PANEL_HEIGHT}px`,
backgroundColor: '#050505',
borderTop: '1px solid rgba(255, 255, 255, 0.08)',
display: 'flex',
flexDirection: 'row',
overflow: 'hidden',
flexShrink: 0,
}}
>
{/* Audio Source section */}
<div
style={{
width: '200px',
padding: '12px',
borderRight: '1px solid rgba(255, 255, 255, 0.06)',
display: 'flex',
flexDirection: 'column',
gap: '10px',
flexShrink: 0,
}}
>
<div style={{ ...labelStyle, marginBottom: 0, fontSize: '10px', color: 'rgba(255, 255, 255, 0.55)' }}>
Audio Source
</div>
<div>
<div style={labelStyle}>Source</div>
<select
value={captureMode === 'system' ? '__system__' : selectedDeviceId ?? ''}
onChange={(e) => {
void handleSourceChange(e.target.value)
}}
style={selectStyle}
>
<option value="__system__">System Audio</option>
<optgroup label="Devices">
{devices.map((d) => (
<option key={d.deviceId} value={d.deviceId}>
{d.label || `Input ${d.deviceId.slice(0, 8)}`}
</option>
))}
</optgroup>
</select>
</div>
{/* Signal indicator */}
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '10px', color: 'rgba(255, 255, 255, 0.5)' }}>
<div
style={{
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: indicatorColor,
boxShadow: isCapturing ? '0 0 6px rgba(34, 197, 94, 0.4)' : 'none',
}}
/>
{indicatorLabel}
</div>
{captureError ? (
<div style={{ fontSize: '10px', color: 'rgba(239, 68, 68, 0.8)', lineHeight: 1.4 }}>
{captureError}
</div>
) : null}
{/* Theme section */}
<div style={{ borderTop: '1px solid rgba(255, 255, 255, 0.06)', paddingTop: '10px', marginTop: '2px' }}>
<div style={{ ...labelStyle, marginBottom: '6px', fontSize: '10px', color: 'rgba(255, 255, 255, 0.55)' }}>
Theme
</div>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
{PRESET_IDS.map((id) => {
const p = PRESETS[id]
const active = presetId === id && !customAccent
return (
<button
key={id}
onClick={() => setPreset(id)}
title={p.name}
style={{
width: '18px',
height: '18px',
borderRadius: '50%',
backgroundColor: p.accent,
border: active ? '2px solid #fff' : '2px solid transparent',
cursor: 'pointer',
padding: 0,
outline: 'none',
transition: 'border-color 120ms',
}}
/>
)
})}
</div>
<div style={{ marginTop: '6px' }}>
<div style={labelStyle}>Custom</div>
<input
type="color"
value={accent}
onChange={(e) => 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',
}}
/>
</div>
</div>
</div>
{/* Per-scope settings */}
<div
style={{
flex: 1,
padding: '12px',
overflowX: 'auto',
overflowY: 'hidden',
display: 'flex',
gap: '16px',
}}
>
{visibleScopes.map((kind) => (
<ScopeSettingsColumn
key={kind}
kind={kind}
settings={scopeSettings}
onUpdate={updateScopeSettings}
accent={accent}
/>
))}
</div>
{/* Close button */}
<button
onClick={onClose}
style={{
position: 'absolute',
right: '8px',
bottom: '8px',
background: 'transparent',
border: '1px solid rgba(255, 255, 255, 0.1)',
color: 'rgba(255, 255, 255, 0.4)',
borderRadius: '3px',
padding: '2px 8px',
fontSize: '9px',
fontFamily: "'JetBrains Mono', monospace",
cursor: 'pointer',
textTransform: 'uppercase',
}}
>
Close
</button>
</div>
)
}
function ScopeSettingsColumn({ kind, settings, onUpdate, accent }: {
kind: ScopeKind
settings: ScopeSettings
onUpdate: <K extends ScopeKind>(kind: K, s: Partial<ScopeSettings[K]>) => void
accent: string
}): JSX.Element {
const s = settings[kind]
return (
<div style={{ minWidth: '140px', display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ ...labelStyle, fontSize: '10px', color: accent, marginBottom: 0 }}>
{SCOPE_LABELS[kind]}
</div>
{kind === 'spectrum' && (() => {
const ss = s as ScopeSettings['spectrum']
return (
<>
<div>
<div style={labelStyle}>FFT Size</div>
<select value={ss.fftSize} onChange={(e) => onUpdate('spectrum', { fftSize: Number(e.target.value) })} style={selectStyle}>
{[1024, 2048, 4096, 8192, 16384].map((v) => <option key={v} value={v}>{v}</option>)}
</select>
</div>
<div>
<div style={labelStyle}>Tilt (dB/oct)</div>
<input type="range" min="0" max="6" step="0.5" value={ss.tiltDbPerOctave} onChange={(e) => onUpdate('spectrum', { tiltDbPerOctave: Number(e.target.value) })} style={{ width: '100%' }} />
</div>
<label style={checkboxRowStyle}>
<input type="checkbox" checked={ss.fillGradient} onChange={(e) => onUpdate('spectrum', { fillGradient: e.target.checked })} />
Fill
</label>
<label style={checkboxRowStyle}>
<input type="checkbox" checked={ss.heatmap} onChange={(e) => onUpdate('spectrum', { heatmap: e.target.checked })} />
Heatmap
</label>
<label style={checkboxRowStyle}>
<input type="checkbox" checked={ss.showGrid} onChange={(e) => onUpdate('spectrum', { showGrid: e.target.checked })} />
Grid
</label>
</>
)
})()}
{kind === 'oscilloscope' && (() => {
const ss = s as ScopeSettings['oscilloscope']
return (
<>
<label style={checkboxRowStyle}>
<input type="checkbox" checked={ss.pitchLock} onChange={(e) => onUpdate('oscilloscope', { pitchLock: e.target.checked })} />
Pitch Lock
</label>
<label style={checkboxRowStyle}>
<input type="checkbox" checked={ss.showGrid} onChange={(e) => onUpdate('oscilloscope', { showGrid: e.target.checked })} />
Grid
</label>
<div>
<div style={labelStyle}>Line Width</div>
<input type="range" min="0.5" max="4" step="0.5" value={ss.lineWidth} onChange={(e) => onUpdate('oscilloscope', { lineWidth: Number(e.target.value) })} style={{ width: '100%' }} />
</div>
</>
)
})()}
{kind === 'vectorscope' && (() => {
const ss = s as ScopeSettings['vectorscope']
return (
<>
<div>
<div style={labelStyle}>Mode</div>
<select value={ss.mode} onChange={(e) => onUpdate('vectorscope', { mode: e.target.value as ScopeSettings['vectorscope']['mode'] })} style={selectStyle}>
<option value="lissajous">Lissajous</option>
<option value="polar-unipolar">Polar (Uni)</option>
<option value="polar-bipolar">Polar (Bi)</option>
<option value="linear-unipolar">Linear (Uni)</option>
<option value="linear-bipolar">Linear (Bi)</option>
</select>
</div>
<div>
<div style={labelStyle}>Persistence</div>
<input type="range" min="0" max="0.5" step="0.01" value={ss.persistence} onChange={(e) => onUpdate('vectorscope', { persistence: Number(e.target.value) })} style={{ width: '100%' }} />
</div>
<label style={checkboxRowStyle}>
<input type="checkbox" checked={ss.multiband} onChange={(e) => onUpdate('vectorscope', { multiband: e.target.checked })} />
Multiband
</label>
<label style={checkboxRowStyle}>
<input type="checkbox" checked={ss.showGrid} onChange={(e) => onUpdate('vectorscope', { showGrid: e.target.checked })} />
Grid
</label>
</>
)
})()}
{kind === 'spectrogram' && (() => {
const ss = s as ScopeSettings['spectrogram']
return (
<>
<div>
<div style={labelStyle}>FFT Size</div>
<select value={ss.fftSize} onChange={(e) => onUpdate('spectrogram', { fftSize: Number(e.target.value) })} style={selectStyle}>
{[512, 1024, 2048, 4096].map((v) => <option key={v} value={v}>{v}</option>)}
</select>
</div>
<div>
<div style={labelStyle}>Scale</div>
<select value={ss.scaleMode} onChange={(e) => onUpdate('spectrogram', { scaleMode: e.target.value as ScopeSettings['spectrogram']['scaleMode'] })} style={selectStyle}>
<option value="log">Log</option>
<option value="mel">Mel</option>
<option value="linear">Linear</option>
</select>
</div>
<div>
<div style={labelStyle}>Clarity</div>
<select value={ss.clarityMode} onChange={(e) => onUpdate('spectrogram', { clarityMode: e.target.value as ScopeSettings['spectrogram']['clarityMode'] })} style={selectStyle}>
<option value="classic">Classic</option>
<option value="sharp">Sharp</option>
<option value="sharper">Sharper</option>
</select>
</div>
<div>
<div style={labelStyle}>Color</div>
<select value={ss.colorScheme} onChange={(e) => onUpdate('spectrogram', { colorScheme: e.target.value as 'heat' | 'mono' })} style={selectStyle}>
<option value="heat">Heat</option>
<option value="mono">Mono</option>
</select>
</div>
<div>
<div style={labelStyle}>Speed</div>
<input type="range" min="1" max="8" step="1" value={ss.scrollSpeed} onChange={(e) => onUpdate('spectrogram', { scrollSpeed: Number(e.target.value) })} style={{ width: '100%' }} />
</div>
</>
)
})()}
{kind === 'vumeter' && (() => {
const ss = s as ScopeSettings['vumeter']
return (
<>
<div>
<div style={labelStyle}>Mode</div>
<select value={ss.mode} onChange={(e) => onUpdate('vumeter', { mode: e.target.value as ScopeSettings['vumeter']['mode'] })} style={selectStyle}>
<option value="bar">Bar</option>
<option value="needle">Needle</option>
</select>
</div>
<div>
<div style={labelStyle}>Orientation</div>
<select value={ss.orientation} onChange={(e) => onUpdate('vumeter', { orientation: e.target.value as ScopeSettings['vumeter']['orientation'] })} style={selectStyle}>
<option value="horizontal">Horizontal</option>
<option value="vertical">Vertical</option>
</select>
</div>
</>
)
})()}
{kind === 'lufsmeter' && (() => {
const ss = s as ScopeSettings['lufsmeter']
return (
<div>
<div style={labelStyle}>Mode</div>
<select value={ss.mode} onChange={(e) => onUpdate('lufsmeter', { mode: e.target.value as ScopeSettings['lufsmeter']['mode'] })} style={selectStyle}>
<option value="bar">Bar</option>
</select>
</div>
)
})()}
{kind === 'waveform' && (() => {
const ss = s as ScopeSettings['waveform']
return (
<>
<div>
<div style={labelStyle}>Gain (dB)</div>
<input type="range" min="-12" max="12" step="1" value={ss.gainDb} onChange={(e) => onUpdate('waveform', { gainDb: Number(e.target.value) })} style={{ width: '100%' }} />
</div>
<div>
<div style={labelStyle}>Speed</div>
<input type="range" min="1" max="8" step="1" value={ss.scrollSpeed} onChange={(e) => onUpdate('waveform', { scrollSpeed: Number(e.target.value) })} style={{ width: '100%' }} />
</div>
<label style={checkboxRowStyle}>
<input type="checkbox" checked={ss.multiband} onChange={(e) => onUpdate('waveform', { multiband: e.target.checked })} />
Multiband
</label>
</>
)
})()}
</div>
)
}
+80 -5
View File
@@ -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<HTMLDivElement>(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 (
<div
ref={handleRef}
onMouseDown={onMouseDown}
style={{
width: '5px',
flexShrink: 0,
cursor: 'col-resize',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 2,
}}
>
<div style={{ width: '1px', height: '100%', backgroundColor: 'rgba(255, 255, 255, 0.08)' }} />
</div>
)
}
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 (
<div
style={{
@@ -11,11 +79,18 @@ export default function Strip(): JSX.Element {
backgroundColor: 'var(--bg-primary)',
}}
>
<ScopeModule scopeKind="spectrum" lineColor="var(--accent, #38bdf8)" />
<div style={{ width: '1px', flexShrink: 0, backgroundColor: 'var(--glass-border)' }} />
<ScopeModule scopeKind="oscilloscope" lineColor="var(--accent, #38bdf8)" />
<div style={{ width: '1px', flexShrink: 0, backgroundColor: 'var(--glass-border)' }} />
<ScopeModule scopeKind="vectorscope" lineColor="var(--accent, #38bdf8)" />
{visibleScopes.map((kind, i) => (
<Fragment key={kind}>
{i > 0 && (
<ResizeHandle leftKind={visibleScopes[i - 1]} rightKind={kind} />
)}
<ScopeModule
scopeKind={kind}
lineColor={accent}
widthWeight={widthWeights[kind] ?? 1}
/>
</Fragment>
))}
</div>
)
}
+161
View File
@@ -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<ScopeKind, string> = {
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 (
<div
style={{
display: 'flex',
alignItems: 'center',
width: '100%',
height: '36px',
padding: '0 8px',
gap: '2px',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
backgroundColor: 'rgba(0, 0, 0, 0.6)',
borderBottom: '1px solid rgba(255, 255, 255, 0.06)',
}}
>
{/* Drag region */}
<div
style={{
WebkitAppRegion: 'drag',
flex: '0 0 40px',
height: '100%',
cursor: 'grab',
} as React.CSSProperties}
/>
{/* Scope toggles */}
<div style={{ display: 'flex', gap: '2px', flex: 1 }}>
{SCOPE_KINDS.map((kind) => {
const active = !hiddenScopes.has(kind)
return (
<button
key={kind}
onClick={() => toggleScope(kind)}
style={{
background: active ? accentBg : 'transparent',
border: `1px solid ${active ? accentBorder : 'rgba(255, 255, 255, 0.08)'}`,
borderRadius: '3px',
color: active ? accent : 'rgba(255, 255, 255, 0.35)',
fontSize: '9px',
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 400,
letterSpacing: '0.05em',
padding: '3px 6px',
cursor: 'pointer',
transition: 'all 120ms',
lineHeight: 1,
}}
>
{SCOPE_LABELS[kind]}
</button>
)
})}
</div>
{/* Right side: settings, pin, close */}
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
<button
onClick={onOpenSettings}
style={{
background: settingsOpen ? accentBg : 'transparent',
border: 'none',
color: settingsOpen ? accent : 'rgba(255, 255, 255, 0.5)',
fontSize: '14px',
cursor: 'pointer',
padding: '4px',
borderRadius: '3px',
lineHeight: 1,
transition: 'color 120ms',
}}
title="Settings"
>
</button>
<button
onClick={handlePin}
style={{
background: isAlwaysOnTop ? accentBg : 'transparent',
border: 'none',
color: isAlwaysOnTop ? accent : 'rgba(255, 255, 255, 0.5)',
fontSize: '12px',
cursor: 'pointer',
padding: '4px',
borderRadius: '3px',
lineHeight: 1,
transition: 'color 120ms',
}}
title={isAlwaysOnTop ? 'Unpin from top' : 'Pin to top'}
>
📌
</button>
<button
onClick={() => window.electronAPI.close()}
style={{
background: 'transparent',
border: 'none',
color: 'rgba(255, 255, 255, 0.5)',
fontSize: '12px',
cursor: 'pointer',
padding: '4px',
borderRadius: '3px',
lineHeight: 1,
transition: 'color 120ms',
}}
title="Close"
>
</button>
</div>
</div>
)
}
+3
View File
@@ -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
}
}
}
+1
View File
@@ -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
+15 -8
View File
@@ -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<void>
selectDevice: (deviceId: string) => Promise<void>
@@ -19,6 +21,8 @@ export const useAudioStore = create<AudioState>((set, get) => ({
selectedDeviceId: null,
captureMode: 'system',
isCapturing: false,
captureStatus: 'idle',
captureError: null,
sampleRate: 48000,
refreshDevices: async () => {
@@ -29,11 +33,6 @@ export const useAudioStore = create<AudioState>((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<AudioState>((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 })
},
}))
+222
View File
@@ -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<ScopeKind>
widthWeights: Record<ScopeKind, number>
scopeSettings: ScopeSettings
// Derived
visibleScopes: () => ScopeKind[]
// Actions
toggleScope: (kind: ScopeKind) => void
moveScope: (kind: ScopeKind, direction: 'left' | 'right') => void
setScopeWidthWeight: (kind: ScopeKind, weight: number) => void
updateScopeSettings: <K extends ScopeKind>(kind: K, settings: Partial<ScopeSettings[K]>) => void
}
function loadFromStorage(): Partial<{ scopeOrder: ScopeKind[]; hiddenScopes: ScopeKind[]; widthWeights: Record<ScopeKind, number>; 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<ScopeKind>()
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<ScopeSettings>
: {}
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<ScopeKind, number> = {
spectrum: 1, oscilloscope: 1, vectorscope: 1, spectrogram: 1,
vumeter: 0.5, lufsmeter: 0.5, waveform: 1,
}
export const useSettingsStore = create<SettingsState>((set, get) => ({
scopeOrder: normalizeScopeOrder(stored.scopeOrder),
hiddenScopes: new Set<ScopeKind>(
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: <K extends ScopeKind>(kind: K, settings: Partial<ScopeSettings[K]>) => {
set((state) => {
const newState = {
...state,
scopeSettings: {
...state.scopeSettings,
[kind]: { ...state.scopeSettings[kind], ...settings },
},
}
saveToStorage(newState as SettingsState)
return newState
})
},
}))
+133
View File
@@ -0,0 +1,133 @@
import { create } from 'zustand'
export interface ThemePreset {
name: string
accent: string
accentHover: string
accentGlow: string
accentRgb: string
}
const PRESETS: Record<string, ThemePreset> = {
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<ThemeState>((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 }
+18 -3
View File
@@ -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()
+29 -25
View File
@@ -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<SpectrumAnalyzerOptions>): 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,16 +303,16 @@ export class SpectrumAnalyzer {
this.updateSampleRateIfNeeded()
if (!this.dataSource.isPlaying()) {
this.dataSource.getPendingSpectrumSamples()
nativeSpectrum.reset()
// 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))
@@ -307,6 +320,9 @@ export class SpectrumAnalyzer {
this.drawGrid(minFrequency, maxFrequency)
}
if (!this.dataSource.isPlaying()) {
this.dataSource.getPendingSpectrumSamples()
nativeSpectrum.reset()
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()
+23
View File
@@ -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()