mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
better porting
This commit is contained in:
@@ -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>
|
||||
@@ -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
@@ -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,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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Vendored
+3
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -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
|
||||
})
|
||||
},
|
||||
}))
|
||||
@@ -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 }
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user