UI/UX improvements, scope popouts

This commit is contained in:
Boof2015
2026-03-27 23:01:46 -04:00
parent e126a264cd
commit 85c0cecfd2
28 changed files with 2396 additions and 1000 deletions
+468 -81
View File
@@ -1,6 +1,16 @@
import { app, BrowserWindow, desktopCapturer, ipcMain, screen, session } from 'electron'
import { app, BrowserWindow, desktopCapturer, ipcMain, Menu, screen, session } from 'electron'
import type { BrowserWindowConstructorOptions, MenuItemConstructorOptions, WebContents } from 'electron'
import { join } from 'path'
import type { CaptureBackendSupport, CaptureBackendSupportEntry } from '../types/capture'
import type {
ScopePopoutAudioBatch,
ScopePopoutSessionState,
ScopePopoutSnapshot,
ScopePopoutSyncStateMap,
WindowBounds,
} from '../types/popout'
import type { ProfileMenuRequest } from '../types/profileMenu'
import { SCOPE_KINDS, type ScopeKind } from '../types/scope'
let mainWindow: BrowserWindow | null = null
let currentSettingsHeight = 0
@@ -8,6 +18,10 @@ let moveInterval: ReturnType<typeof setInterval> | null = null
let moveStartCursor: { x: number; y: number } | null = null
let moveStartPosition: number[] | null = null
const scopePopoutWindows = new Map<ScopeKind, BrowserWindow>()
const scopePopoutCloseAllowed = new Set<ScopeKind>()
const popoutBoundsTimers = new Map<ScopeKind, ReturnType<typeof setTimeout>>()
const WINDOW_DEFAULTS = {
width: 900,
height: 180,
@@ -15,7 +29,145 @@ const WINDOW_DEFAULTS = {
minHeight: 100,
}
function createWindow(): void {
const POPOUT_DEFAULTS = {
width: 360,
height: 240,
minWidth: 220,
minHeight: 160,
}
function isScopeKind(value: unknown): value is ScopeKind {
return typeof value === 'string' && SCOPE_KINDS.includes(value as ScopeKind)
}
function getWindowFromSender(sender: WebContents): BrowserWindow | null {
return BrowserWindow.fromWebContents(sender)
}
function isMainRendererWindow(window: BrowserWindow | null): boolean {
return window !== null && window === mainWindow
}
function sendToRenderer(sender: WebContents, channel: string, ...args: unknown[]): void {
if (!sender.isDestroyed()) {
sender.send(channel, ...args)
}
}
function normalizeProfileMenuRequest(raw: unknown): ProfileMenuRequest | null {
if (typeof raw !== 'object' || raw === null) return null
const candidate = raw as Partial<ProfileMenuRequest>
if (
typeof candidate.x !== 'number'
|| typeof candidate.y !== 'number'
|| !Array.isArray(candidate.profiles)
) {
return null
}
const profiles = candidate.profiles
.filter((profile): profile is ProfileMenuRequest['profiles'][number] => {
return typeof profile?.id === 'string'
&& typeof profile?.name === 'string'
&& typeof profile?.isDefault === 'boolean'
})
.map((profile) => ({
id: profile.id,
name: profile.name,
isDefault: profile.isDefault,
}))
return {
x: Math.round(candidate.x),
y: Math.round(candidate.y),
profiles,
activeProfileId: typeof candidate.activeProfileId === 'string' ? candidate.activeProfileId : null,
}
}
function buildProfileMenuTemplate(
request: ProfileMenuRequest,
sender: WebContents,
): MenuItemConstructorOptions[] {
const activeProfile = request.activeProfileId
? request.profiles.find((profile) => profile.id === request.activeProfileId) ?? null
: null
const template: MenuItemConstructorOptions[] = [
{ label: 'Presets', enabled: false },
...request.profiles.map((profile) => ({
type: 'checkbox' as const,
checked: profile.id === request.activeProfileId,
label: profile.name,
click: () => sendToRenderer(sender, 'profile-menu:load', profile.id),
})),
{ type: 'separator' },
{
label: 'Save as New Preset',
click: () => sendToRenderer(sender, 'profile-menu:save-new'),
},
]
if (activeProfile) {
template.push({
label: `Save to "${activeProfile.name}"`,
click: () => sendToRenderer(sender, 'profile-menu:save-overwrite'),
})
}
if (activeProfile && !activeProfile.isDefault) {
template.push(
{ type: 'separator' },
{
label: `Rename "${activeProfile.name}"...`,
click: () => sendToRenderer(sender, 'profile-menu:rename-active', activeProfile.id),
},
{
label: `Delete "${activeProfile.name}"`,
click: () => sendToRenderer(sender, 'profile-menu:delete-active', activeProfile.id),
},
)
}
return template
}
function normalizeBounds(raw: unknown, fallback: WindowBounds): WindowBounds {
if (typeof raw !== 'object' || raw === null) return fallback
const candidate = raw as Partial<WindowBounds>
if (
typeof candidate.x !== 'number'
|| typeof candidate.y !== 'number'
|| typeof candidate.width !== 'number'
|| typeof candidate.height !== 'number'
) {
return fallback
}
return {
x: Math.round(candidate.x),
y: Math.round(candidate.y),
width: Math.max(POPOUT_DEFAULTS.minWidth, Math.round(candidate.width)),
height: Math.max(POPOUT_DEFAULTS.minHeight, Math.round(candidate.height)),
}
}
function loadRendererTarget(window: BrowserWindow, query: Record<string, string>): void {
if (process.env.ELECTRON_RENDERER_URL) {
const url = new URL(process.env.ELECTRON_RENDERER_URL)
for (const [key, value] of Object.entries(query)) {
url.searchParams.set(key, value)
}
void window.loadURL(url.toString())
return
}
void window.loadFile(join(__dirname, '../renderer/index.html'), { query })
}
function createMainWindow(): void {
mainWindow = new BrowserWindow({
...WINDOW_DEFAULTS,
frame: false,
@@ -39,13 +191,165 @@ function createWindow(): void {
mainWindow.on('closed', () => {
mainWindow = null
currentSettingsHeight = 0
for (const kind of SCOPE_KINDS) {
destroyScopePopoutWindow(kind)
}
})
// Load the renderer
if (process.env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
loadRendererTarget(mainWindow, { window: 'main' })
}
function setAllWindowsAlwaysOnTop(next: boolean): void {
mainWindow?.setAlwaysOnTop(next)
for (const window of scopePopoutWindows.values()) {
window.setAlwaysOnTop(next)
}
}
function emitPopoutBoundsChanged(kind: ScopeKind, window: BrowserWindow): void {
if (!mainWindow || mainWindow.isDestroyed() || window.isDestroyed()) return
const existingTimer = popoutBoundsTimers.get(kind)
if (existingTimer) {
clearTimeout(existingTimer)
}
const timer = setTimeout(() => {
popoutBoundsTimers.delete(kind)
if (!mainWindow || mainWindow.isDestroyed() || window.isDestroyed()) return
const bounds = window.getBounds()
mainWindow.webContents.send('scope-popout:bounds-changed', kind, bounds)
}, 80)
popoutBoundsTimers.set(kind, timer)
}
function destroyScopePopoutWindow(kind: ScopeKind): void {
const window = scopePopoutWindows.get(kind)
if (!window) return
const pendingTimer = popoutBoundsTimers.get(kind)
if (pendingTimer) {
clearTimeout(pendingTimer)
popoutBoundsTimers.delete(kind)
}
scopePopoutCloseAllowed.add(kind)
scopePopoutWindows.delete(kind)
if (!window.isDestroyed()) {
window.close()
}
scopePopoutCloseAllowed.delete(kind)
}
function createScopePopoutWindow(kind: ScopeKind, rawBounds?: WindowBounds): BrowserWindow | null {
if (!mainWindow) return null
const existing = scopePopoutWindows.get(kind)
if (existing && !existing.isDestroyed()) {
return existing
}
const mainBounds = mainWindow.getBounds()
const fallbackBounds: WindowBounds = {
x: mainBounds.x + 40,
y: mainBounds.y + 40,
width: POPOUT_DEFAULTS.width,
height: POPOUT_DEFAULTS.height,
}
const bounds = normalizeBounds(rawBounds, fallbackBounds)
const options: BrowserWindowConstructorOptions = {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
minWidth: POPOUT_DEFAULTS.minWidth,
minHeight: POPOUT_DEFAULTS.minHeight,
frame: false,
transparent: false,
backgroundColor: '#000000',
resizable: true,
fullscreenable: false,
maximizable: false,
minimizable: true,
skipTaskbar: true,
autoHideMenuBar: true,
title: `Prism ${kind}`,
parent: mainWindow,
alwaysOnTop: mainWindow.isAlwaysOnTop(),
show: false,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false,
backgroundThrottling: false,
},
}
const popoutWindow = new BrowserWindow(options)
scopePopoutWindows.set(kind, popoutWindow)
popoutWindow.once('ready-to-show', () => {
if (!popoutWindow.isDestroyed()) {
popoutWindow.show()
}
})
popoutWindow.on('close', (event) => {
if (scopePopoutCloseAllowed.has(kind) || !mainWindow || mainWindow.isDestroyed()) {
return
}
event.preventDefault()
mainWindow.webContents.send('scope-popout:close-requested', kind)
})
popoutWindow.on('closed', () => {
scopePopoutWindows.delete(kind)
scopePopoutCloseAllowed.delete(kind)
const pendingTimer = popoutBoundsTimers.get(kind)
if (pendingTimer) {
clearTimeout(pendingTimer)
popoutBoundsTimers.delete(kind)
}
})
popoutWindow.on('move', () => emitPopoutBoundsChanged(kind, popoutWindow))
popoutWindow.on('resize', () => emitPopoutBoundsChanged(kind, popoutWindow))
loadRendererTarget(popoutWindow, { window: 'scope-popout', scope: kind })
return popoutWindow
}
function syncScopePopouts(nextState: ScopePopoutSyncStateMap): void {
for (const kind of SCOPE_KINDS) {
const desired = nextState[kind]
if (!desired?.shouldBeOpen) {
destroyScopePopoutWindow(kind)
continue
}
const popoutWindow = createScopePopoutWindow(kind, desired.bounds)
if (!popoutWindow || popoutWindow.isDestroyed()) continue
const currentBounds = popoutWindow.getBounds()
const nextBounds = normalizeBounds(desired.bounds, currentBounds)
const hasBoundsDelta =
currentBounds.x !== nextBounds.x
|| currentBounds.y !== nextBounds.y
|| currentBounds.width !== nextBounds.width
|| currentBounds.height !== nextBounds.height
if (hasBoundsDelta) {
popoutWindow.setBounds(nextBounds)
}
}
}
@@ -90,7 +394,6 @@ function getCaptureBackendSupport(): CaptureBackendSupport {
}
}
// Auto-grant media (microphone) permission for audio capture
function setupPermissions(): void {
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
if (permission === 'media' || permission === 'display-capture') {
@@ -101,25 +404,26 @@ function setupPermissions(): void {
})
}
// IPC handlers
function setupIPC(): void {
ipcMain.on('window:minimize', () => {
mainWindow?.minimize()
ipcMain.on('window:minimize', (event) => {
getWindowFromSender(event.sender)?.minimize()
})
ipcMain.on('window:start-move', () => {
if (!mainWindow) return
ipcMain.on('window:start-move', (event) => {
const targetWindow = getWindowFromSender(event.sender)
if (!targetWindow) return
const cursor = screen.getCursorScreenPoint()
moveStartCursor = { x: cursor.x, y: cursor.y }
moveStartPosition = mainWindow.getPosition()
moveStartPosition = targetWindow.getPosition()
if (moveInterval) clearInterval(moveInterval)
moveInterval = setInterval(() => {
if (!mainWindow || !moveStartCursor || !moveStartPosition) return
if (!targetWindow || targetWindow.isDestroyed() || !moveStartCursor || !moveStartPosition) return
const current = screen.getCursorScreenPoint()
const dx = current.x - moveStartCursor.x
const dy = current.y - moveStartCursor.y
mainWindow.setPosition(moveStartPosition[0] + dx, moveStartPosition[1] + dy)
targetWindow.setPosition(moveStartPosition[0] + dx, moveStartPosition[1] + dy)
}, 16)
})
@@ -132,19 +436,27 @@ function setupIPC(): void {
moveStartPosition = null
})
ipcMain.on('window:close', () => {
mainWindow?.close()
ipcMain.on('window:close', (event) => {
getWindowFromSender(event.sender)?.close()
})
ipcMain.on('window:toggle-always-on-top', () => {
if (!mainWindow) return
const current = mainWindow.isAlwaysOnTop()
mainWindow.setAlwaysOnTop(!current)
mainWindow.webContents.send('window:always-on-top-changed', !current)
ipcMain.on('window:toggle-always-on-top', (event) => {
const targetWindow = getWindowFromSender(event.sender)
if (!targetWindow) return
const current = targetWindow.isAlwaysOnTop()
const next = !current
if (isMainRendererWindow(targetWindow)) {
setAllWindowsAlwaysOnTop(next)
mainWindow?.webContents.send('window:always-on-top-changed', next)
return
}
targetWindow.setAlwaysOnTop(next)
})
ipcMain.handle('window:is-always-on-top', () => {
return mainWindow?.isAlwaysOnTop() ?? true
ipcMain.handle('window:is-always-on-top', (event) => {
return getWindowFromSender(event.sender)?.isAlwaysOnTop() ?? true
})
ipcMain.handle('audio:get-desktop-sources', async () => {
@@ -156,126 +468,203 @@ function setupIPC(): void {
return getCaptureBackendSupport()
})
ipcMain.on('window:set-bounds', (_event, bounds: { x: number; y: number; width: number; height: number }) => {
if (!mainWindow) return
// Saved bounds are base (without settings). Add back current settings height so scopes stay the same size.
mainWindow.setBounds({
...bounds,
height: bounds.height + currentSettingsHeight,
ipcMain.on('profile-menu:open', (event, rawRequest: unknown) => {
const request = normalizeProfileMenuRequest(rawRequest)
if (!request) return
const targetWindow = getWindowFromSender(event.sender)
const menu = Menu.buildFromTemplate(buildProfileMenuTemplate(request, event.sender))
menu.popup({
window: targetWindow ?? undefined,
x: request.x,
y: request.y,
callback: () => sendToRenderer(event.sender, 'profile-menu:closed'),
})
})
ipcMain.handle('window:get-bounds', () => {
if (!mainWindow) return null
const bounds = mainWindow.getBounds()
// Strip settings height so we always save base bounds
ipcMain.on('window:set-bounds', (event, bounds: WindowBounds) => {
const targetWindow = getWindowFromSender(event.sender)
if (!targetWindow) return
if (isMainRendererWindow(targetWindow)) {
targetWindow.setBounds({
...bounds,
height: bounds.height + currentSettingsHeight,
})
return
}
targetWindow.setBounds(bounds)
})
ipcMain.handle('window:get-bounds', (event) => {
const targetWindow = getWindowFromSender(event.sender)
if (!targetWindow) return null
const bounds = targetWindow.getBounds()
if (!isMainRendererWindow(targetWindow)) {
return bounds
}
return {
...bounds,
height: bounds.height - currentSettingsHeight,
}
})
ipcMain.on('window:reposition', (_event, position: 'top' | 'bottom') => {
if (!mainWindow) return
const display = screen.getDisplayMatching(mainWindow.getBounds())
ipcMain.on('window:reposition', (event, position: 'top' | 'bottom') => {
const targetWindow = getWindowFromSender(event.sender)
if (!targetWindow) return
const display = screen.getDisplayMatching(targetWindow.getBounds())
const workArea = display.workArea
const [, height] = mainWindow.getSize()
const [, height] = targetWindow.getSize()
if (position === 'top') {
mainWindow.setPosition(workArea.x, workArea.y)
targetWindow.setPosition(workArea.x, workArea.y)
} else {
mainWindow.setPosition(workArea.x, workArea.y + workArea.height - height)
targetWindow.setPosition(workArea.x, workArea.y + workArea.height - height)
}
mainWindow.setSize(workArea.width, height)
targetWindow.setSize(workArea.width, height)
})
ipcMain.on('window:expand-settings', (_event, panelHeight: number) => {
if (!mainWindow) return
const bounds = mainWindow.getBounds()
const [minW] = mainWindow.getMinimumSize()
const newHeight = bounds.height + panelHeight
mainWindow.setMinimumSize(minW, WINDOW_DEFAULTS.minHeight + panelHeight)
ipcMain.on('window:expand-settings', (event, panelHeight: number) => {
const targetWindow = getWindowFromSender(event.sender)
if (!targetWindow || !isMainRendererWindow(targetWindow)) return
const bounds = targetWindow.getBounds()
const [minW] = targetWindow.getMinimumSize()
const newHeight = bounds.height + panelHeight
targetWindow.setMinimumSize(minW, WINDOW_DEFAULTS.minHeight + panelHeight)
// Check if expanding would push window off screen bottom
const display = screen.getDisplayMatching(bounds)
const workArea = display.workArea
const bottomEdge = bounds.y + newHeight
if (bottomEdge > workArea.y + workArea.height) {
const newY = Math.max(workArea.y, workArea.y + workArea.height - newHeight)
mainWindow.setBounds({ x: bounds.x, y: newY, width: bounds.width, height: newHeight })
targetWindow.setBounds({ x: bounds.x, y: newY, width: bounds.width, height: newHeight })
} else {
mainWindow.setSize(bounds.width, newHeight, true)
targetWindow.setSize(bounds.width, newHeight, true)
}
currentSettingsHeight = Math.max(0, currentSettingsHeight + Math.round(panelHeight))
})
ipcMain.on('window:collapse-settings', (_event, panelHeight: number) => {
if (!mainWindow) return
const bounds = mainWindow.getBounds()
const [minW] = mainWindow.getMinimumSize()
const newHeight = Math.max(WINDOW_DEFAULTS.minHeight, bounds.height - panelHeight)
mainWindow.setMinimumSize(minW, WINDOW_DEFAULTS.minHeight)
ipcMain.on('window:collapse-settings', (event, panelHeight: number) => {
const targetWindow = getWindowFromSender(event.sender)
if (!targetWindow || !isMainRendererWindow(targetWindow)) return
const bounds = targetWindow.getBounds()
const [minW] = targetWindow.getMinimumSize()
const newHeight = Math.max(WINDOW_DEFAULTS.minHeight, bounds.height - panelHeight)
targetWindow.setMinimumSize(minW, WINDOW_DEFAULTS.minHeight)
// If window was pushed up when expanding, push it back down
const display = screen.getDisplayMatching(bounds)
const workArea = display.workArea
const wasAtBottom = bounds.y + bounds.height >= workArea.y + workArea.height - 10
if (wasAtBottom) {
const newY = Math.min(bounds.y + panelHeight, workArea.y + workArea.height - newHeight)
mainWindow.setBounds({ x: bounds.x, y: newY, width: bounds.width, height: newHeight })
targetWindow.setBounds({ x: bounds.x, y: newY, width: bounds.width, height: newHeight })
} else {
mainWindow.setSize(bounds.width, newHeight, true)
targetWindow.setSize(bounds.width, newHeight, true)
}
currentSettingsHeight = Math.max(0, currentSettingsHeight - Math.round(panelHeight))
})
ipcMain.on('window:set-settings-height', (_event, panelHeight: number) => {
if (!mainWindow) return
ipcMain.on('window:set-settings-height', (event, panelHeight: number) => {
const targetWindow = getWindowFromSender(event.sender)
if (!targetWindow || !isMainRendererWindow(targetWindow)) return
const nextHeight = Math.max(0, Math.round(panelHeight))
const delta = nextHeight - currentSettingsHeight
const [width, height] = mainWindow.getSize()
const [minW] = mainWindow.getMinimumSize()
const [width, height] = targetWindow.getSize()
const [minW] = targetWindow.getMinimumSize()
mainWindow.setMinimumSize(minW, WINDOW_DEFAULTS.minHeight + nextHeight)
targetWindow.setMinimumSize(minW, WINDOW_DEFAULTS.minHeight + nextHeight)
if (delta !== 0) {
const newHeight = Math.max(WINDOW_DEFAULTS.minHeight, height + delta)
// If expanding near the bottom of the screen, move the window up so it doesn't go off-screen
const bounds = mainWindow.getBounds()
const bounds = targetWindow.getBounds()
const display = screen.getDisplayMatching(bounds)
const workArea = display.workArea
const bottomEdge = bounds.y + newHeight
if (delta > 0 && bottomEdge > workArea.y + workArea.height) {
const newY = Math.max(workArea.y, workArea.y + workArea.height - newHeight)
mainWindow.setBounds({ x: bounds.x, y: newY, width, height: newHeight })
targetWindow.setBounds({ x: bounds.x, y: newY, width, height: newHeight })
} else if (delta < 0) {
// Collapsing: if we moved the window up previously, move it back down
const baseHeight = newHeight - nextHeight
const naturalBottom = bounds.y + baseHeight
if (naturalBottom < workArea.y + workArea.height) {
// Push window down so it stays near the bottom
const maxY = workArea.y + workArea.height - newHeight
if (bounds.y < maxY) {
mainWindow.setSize(width, newHeight, true)
targetWindow.setSize(width, newHeight, true)
} else {
mainWindow.setBounds({ x: bounds.x, y: maxY, width, height: newHeight })
targetWindow.setBounds({ x: bounds.x, y: maxY, width, height: newHeight })
}
} else {
mainWindow.setSize(width, newHeight, true)
targetWindow.setSize(width, newHeight, true)
}
} else {
mainWindow.setSize(width, newHeight, true)
targetWindow.setSize(width, newHeight, true)
}
}
currentSettingsHeight = nextHeight
})
ipcMain.on('scope-popout:sync', (event, state: ScopePopoutSyncStateMap) => {
const targetWindow = getWindowFromSender(event.sender)
if (!isMainRendererWindow(targetWindow)) return
syncScopePopouts(state)
})
ipcMain.on('scope-popout:snapshot', (event, snapshot: ScopePopoutSnapshot) => {
const targetWindow = getWindowFromSender(event.sender)
if (!isMainRendererWindow(targetWindow) || !isScopeKind(snapshot?.kind)) return
const popoutWindow = scopePopoutWindows.get(snapshot.kind)
if (!popoutWindow || popoutWindow.isDestroyed()) return
popoutWindow.webContents.send('scope-popout:snapshot', snapshot)
})
ipcMain.on('scope-popout:audio', (event, kind: ScopeKind, batch: ScopePopoutAudioBatch) => {
const targetWindow = getWindowFromSender(event.sender)
if (!isMainRendererWindow(targetWindow) || !isScopeKind(kind)) return
const popoutWindow = scopePopoutWindows.get(kind)
if (!popoutWindow || popoutWindow.isDestroyed()) return
popoutWindow.webContents.send('scope-popout:audio', kind, batch)
})
ipcMain.on('scope-popout:session', (event, kind: ScopeKind, sessionState: ScopePopoutSessionState) => {
const targetWindow = getWindowFromSender(event.sender)
if (!isMainRendererWindow(targetWindow) || !isScopeKind(kind)) return
const popoutWindow = scopePopoutWindows.get(kind)
if (!popoutWindow || popoutWindow.isDestroyed()) return
popoutWindow.webContents.send('scope-popout:session', kind, sessionState)
})
ipcMain.on('scope-popout:ready', (event, kind: ScopeKind) => {
const targetWindow = getWindowFromSender(event.sender)
if (!targetWindow || isMainRendererWindow(targetWindow) || !isScopeKind(kind)) return
mainWindow?.webContents.send('scope-popout:ready', kind)
})
ipcMain.on('scope-popout:request-pop-in', (event, kind: ScopeKind) => {
const targetWindow = getWindowFromSender(event.sender)
if (!targetWindow || isMainRendererWindow(targetWindow) || !isScopeKind(kind)) return
mainWindow?.webContents.send('scope-popout:close-requested', kind)
})
ipcMain.on('scope-popout:settings-update', (event, kind: ScopeKind, partial: unknown) => {
const targetWindow = getWindowFromSender(event.sender)
if (!targetWindow || isMainRendererWindow(targetWindow) || !isScopeKind(kind)) return
mainWindow?.webContents.send('scope-popout:settings-update', kind, partial)
})
}
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) => {
@@ -285,23 +674,21 @@ function setupShortcuts(): void {
})
})
// 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)
const next = !current
setAllWindowsAlwaysOnTop(next)
mainWindow!.webContents.send('window:always-on-top-changed', next)
}
})
// 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')
@@ -312,7 +699,7 @@ function setupShortcuts(): void {
app.whenReady().then(() => {
setupPermissions()
setupIPC()
createWindow()
createMainWindow()
setupShortcuts()
})
+84 -2
View File
@@ -1,6 +1,15 @@
import { contextBridge, ipcRenderer } from 'electron'
import type { CaptureBackendSupport, CaptureBackendSupportEntry } from '../types/capture'
import type { NativeCaptureAPI } from '../types/nativeCapture'
import type {
ScopePopoutAudioBatch,
ScopePopoutSessionState,
ScopePopoutSnapshot,
ScopePopoutSyncStateMap,
WindowBounds,
} from '../types/popout'
import type { ProfileMenuRequest } from '../types/profileMenu'
import type { ScopeKind } from '../types/scope'
import type { VisualizerDSP } from '../renderer/audio/native/visualizer-dsp'
type NativeAddonModule = VisualizerDSP & NativeCaptureAPI
@@ -12,8 +21,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
close: () => ipcRenderer.send('window:close'),
startWindowMove: () => ipcRenderer.send('window:start-move'),
stopWindowMove: () => ipcRenderer.send('window:stop-move'),
setWindowBounds: (bounds: { x: number; y: number; width: number; height: number }) => ipcRenderer.send('window:set-bounds', bounds),
getWindowBounds: () => ipcRenderer.invoke('window:get-bounds') as Promise<{ x: number; y: number; width: number; height: number } | null>,
setWindowBounds: (bounds: WindowBounds) => ipcRenderer.send('window:set-bounds', bounds),
getWindowBounds: () => ipcRenderer.invoke('window:get-bounds') as Promise<WindowBounds | null>,
repositionWindow: (position: 'top' | 'bottom') => ipcRenderer.send('window:reposition', position),
toggleAlwaysOnTop: () => ipcRenderer.send('window:toggle-always-on-top'),
isAlwaysOnTop: () => ipcRenderer.invoke('window:is-always-on-top'),
@@ -28,6 +37,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
expandSettings: (panelHeight: number) => ipcRenderer.send('window:expand-settings', panelHeight),
collapseSettings: (panelHeight: number) => ipcRenderer.send('window:collapse-settings', panelHeight),
setSettingsHeight: (panelHeight: number) => ipcRenderer.send('window:set-settings-height', panelHeight),
openProfileMenu: (request: ProfileMenuRequest) => ipcRenderer.send('profile-menu:open', request),
syncScopePopouts: (state: ScopePopoutSyncStateMap) => ipcRenderer.send('scope-popout:sync', state),
sendScopePopoutSnapshot: (snapshot: ScopePopoutSnapshot) => ipcRenderer.send('scope-popout:snapshot', snapshot),
sendScopePopoutAudio: (kind: ScopeKind, batch: ScopePopoutAudioBatch) => ipcRenderer.send('scope-popout:audio', kind, batch),
sendScopePopoutSession: (kind: ScopeKind, session: ScopePopoutSessionState) => ipcRenderer.send('scope-popout:session', kind, session),
notifyScopePopoutReady: (kind: ScopeKind) => ipcRenderer.send('scope-popout:ready', kind),
requestScopePopIn: (kind: ScopeKind) => ipcRenderer.send('scope-popout:request-pop-in', kind),
sendScopePopoutSettingsUpdate: (kind: ScopeKind, partial: unknown) => ipcRenderer.send('scope-popout:settings-update', kind, partial),
onAlwaysOnTopChanged: (callback: (isOnTop: boolean) => void) => {
const handler = (_event: Electron.IpcRendererEvent, isOnTop: boolean): void => callback(isOnTop)
ipcRenderer.on('window:always-on-top-changed', handler)
@@ -48,6 +65,71 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.on('shortcut:toggle-settings', handler)
return () => ipcRenderer.removeListener('shortcut:toggle-settings', handler)
},
onProfileMenuClosed: (callback: () => void) => {
const handler = (): void => callback()
ipcRenderer.on('profile-menu:closed', handler)
return () => ipcRenderer.removeListener('profile-menu:closed', handler)
},
onProfileMenuLoad: (callback: (id: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, id: string): void => callback(id)
ipcRenderer.on('profile-menu:load', handler)
return () => ipcRenderer.removeListener('profile-menu:load', handler)
},
onProfileMenuSaveNew: (callback: () => void) => {
const handler = (): void => callback()
ipcRenderer.on('profile-menu:save-new', handler)
return () => ipcRenderer.removeListener('profile-menu:save-new', handler)
},
onProfileMenuSaveOverwrite: (callback: () => void) => {
const handler = (): void => callback()
ipcRenderer.on('profile-menu:save-overwrite', handler)
return () => ipcRenderer.removeListener('profile-menu:save-overwrite', handler)
},
onProfileMenuRenameActive: (callback: (id: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, id: string): void => callback(id)
ipcRenderer.on('profile-menu:rename-active', handler)
return () => ipcRenderer.removeListener('profile-menu:rename-active', handler)
},
onProfileMenuDeleteActive: (callback: (id: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, id: string): void => callback(id)
ipcRenderer.on('profile-menu:delete-active', handler)
return () => ipcRenderer.removeListener('profile-menu:delete-active', handler)
},
onScopePopoutReady: (callback: (kind: ScopeKind) => void) => {
const handler = (_event: Electron.IpcRendererEvent, kind: ScopeKind): void => callback(kind)
ipcRenderer.on('scope-popout:ready', handler)
return () => ipcRenderer.removeListener('scope-popout:ready', handler)
},
onScopePopoutCloseRequested: (callback: (kind: ScopeKind) => void) => {
const handler = (_event: Electron.IpcRendererEvent, kind: ScopeKind): void => callback(kind)
ipcRenderer.on('scope-popout:close-requested', handler)
return () => ipcRenderer.removeListener('scope-popout:close-requested', handler)
},
onScopePopoutBoundsChanged: (callback: (kind: ScopeKind, bounds: WindowBounds) => void) => {
const handler = (_event: Electron.IpcRendererEvent, kind: ScopeKind, bounds: WindowBounds): void => callback(kind, bounds)
ipcRenderer.on('scope-popout:bounds-changed', handler)
return () => ipcRenderer.removeListener('scope-popout:bounds-changed', handler)
},
onScopePopoutSettingsUpdate: (callback: (kind: ScopeKind, partial: unknown) => void) => {
const handler = (_event: Electron.IpcRendererEvent, kind: ScopeKind, partial: unknown): void => callback(kind, partial)
ipcRenderer.on('scope-popout:settings-update', handler)
return () => ipcRenderer.removeListener('scope-popout:settings-update', handler)
},
onScopePopoutSnapshot: (callback: (snapshot: ScopePopoutSnapshot) => void) => {
const handler = (_event: Electron.IpcRendererEvent, snapshot: ScopePopoutSnapshot): void => callback(snapshot)
ipcRenderer.on('scope-popout:snapshot', handler)
return () => ipcRenderer.removeListener('scope-popout:snapshot', handler)
},
onScopePopoutAudio: (callback: (kind: ScopeKind, batch: ScopePopoutAudioBatch) => void) => {
const handler = (_event: Electron.IpcRendererEvent, kind: ScopeKind, batch: ScopePopoutAudioBatch): void => callback(kind, batch)
ipcRenderer.on('scope-popout:audio', handler)
return () => ipcRenderer.removeListener('scope-popout:audio', handler)
},
onScopePopoutSession: (callback: (kind: ScopeKind, session: ScopePopoutSessionState) => void) => {
const handler = (_event: Electron.IpcRendererEvent, kind: ScopeKind, session: ScopePopoutSessionState): void => callback(kind, session)
ipcRenderer.on('scope-popout:session', handler)
return () => ipcRenderer.removeListener('scope-popout:session', handler)
},
})
// Native DSP module — load if available, gracefully degrade if not
+18 -1
View File
@@ -1,8 +1,9 @@
import { useState, useRef, useCallback, useEffect, type JSX } from 'react'
import { useState, useRef, useCallback, useEffect, useLayoutEffect, type JSX } from 'react'
import Strip from './components/Strip'
import Toolbar from './components/Toolbar'
import SettingsPanel from './components/SettingsPanel'
import BottomBar from './components/BottomBar'
import ScopePopoutBridge from './components/ScopePopoutBridge'
import { useSettingsStore } from './stores/settingsStore'
import { useAudioStore } from './stores/audioStore'
import { SCOPE_KINDS } from '../types/scope'
@@ -14,8 +15,22 @@ export default function App(): JSX.Element {
const [settingsOpen, setSettingsOpen] = useState(false)
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const prevSettingsOpenRef = useRef(false)
const startupBoundsAppliedRef = useRef(false)
const toggleScope = useSettingsStore((s) => s.toggleScope)
const profiles = useSettingsStore((s) => s.profiles)
const activeProfileId = useSettingsStore((s) => s.activeProfileId)
useLayoutEffect(() => {
if (startupBoundsAppliedRef.current) return
const profile = activeProfileId ? profiles[activeProfileId] : null
startupBoundsAppliedRef.current = true
if (profile?.windowBounds) {
window.electronAPI.setWindowBounds(profile.windowBounds)
}
}, [activeProfileId, profiles])
// Auto-capture on launch
useEffect(() => {
@@ -110,6 +125,8 @@ export default function App(): JSX.Element {
<Strip />
</div>
<ScopePopoutBridge />
{settingsOpen && (
<div className="prism-settings-region" style={{ height: SETTINGS_EXPAND_HEIGHT }}>
<SettingsPanel />
+65 -23
View File
@@ -1,17 +1,27 @@
import { useEffect, useRef, type JSX } 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'
import type { ScopeSettings } from '../../types/settings'
import { useSettingsStore } from '../stores/settingsStore'
import { SpectrumAnalyzer, type SpectrumAnalyzerDataSource } from '../visualizers/SpectrumAnalyzer'
import { Oscilloscope, type OscilloscopeDataSource } from '../visualizers/Oscilloscope'
import { Vectorscope, type VectorscopeDataSource } from '../visualizers/Vectorscope'
import { Spectrogram, type SpectrogramDataSource } from '../visualizers/Spectrogram'
import { VUMeter, type VUMeterDataSource } from '../visualizers/VUMeter'
import { LUFSMeter, type LUFSMeterDataSource } from '../visualizers/LUFSMeter'
import { Waveform, type WaveformDataSource } from '../visualizers/Waveform'
interface ScopeModuleProps {
scopeKind: ScopeKind
lineColor?: string
settings?: ScopeSettings[ScopeKind]
dataSource?:
| SpectrumAnalyzerDataSource
| OscilloscopeDataSource
| VectorscopeDataSource
| SpectrogramDataSource
| VUMeterDataSource
| LUFSMeterDataSource
| WaveformDataSource
}
interface Visualizer {
@@ -75,36 +85,68 @@ function scopeSettingsToOptions(kind: ScopeKind, settings: ScopeSettings[ScopeKi
}
}
function createVisualizer(scopeKind: ScopeKind, canvas: HTMLCanvasElement, mySettings: ScopeSettings[ScopeKind], lineColor: string): Visualizer | null {
function createVisualizer(
scopeKind: ScopeKind,
canvas: HTMLCanvasElement,
mySettings: ScopeSettings[ScopeKind],
lineColor: string,
dataSource?: ScopeModuleProps['dataSource'],
): Visualizer | null {
const opts = scopeSettingsToOptions(scopeKind, mySettings, lineColor)
switch (scopeKind) {
case 'spectrum':
return new SpectrumAnalyzer(canvas, opts)
return new SpectrumAnalyzer(canvas, {
...opts,
...(dataSource ? { dataSource: dataSource as SpectrumAnalyzerDataSource } : {}),
})
case 'oscilloscope':
return new Oscilloscope(canvas, opts)
return new Oscilloscope(canvas, {
...opts,
...(dataSource ? { dataSource: dataSource as OscilloscopeDataSource } : {}),
})
case 'vectorscope':
return new Vectorscope(canvas, opts)
return new Vectorscope(canvas, {
...opts,
...(dataSource ? { dataSource: dataSource as VectorscopeDataSource } : {}),
})
case 'spectrogram':
return new Spectrogram(canvas, opts)
return new Spectrogram(canvas, {
...opts,
...(dataSource ? { dataSource: dataSource as SpectrogramDataSource } : {}),
})
case 'vumeter':
return new VUMeter(canvas, opts)
return new VUMeter(canvas, {
...opts,
...(dataSource ? { dataSource: dataSource as VUMeterDataSource } : {}),
})
case 'lufsmeter':
return new LUFSMeter(canvas, opts)
return new LUFSMeter(canvas, {
...opts,
...(dataSource ? { dataSource: dataSource as LUFSMeterDataSource } : {}),
})
case 'waveform':
return new Waveform(canvas, opts)
return new Waveform(canvas, {
...opts,
...(dataSource ? { dataSource: dataSource as WaveformDataSource } : {}),
})
default:
return null
}
}
export default function ScopeModule({ scopeKind, lineColor = '#38bdf8' }: ScopeModuleProps): JSX.Element {
export default function ScopeModule({
scopeKind,
lineColor = '#38bdf8',
settings,
dataSource,
}: ScopeModuleProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const visualizerRef = useRef<Visualizer | null>(null)
const initializedRef = useRef(false)
// Subscribe to ONLY this scope's settings — avoids triggering setOptions when other scopes change
const mySettings = useSettingsStore((s) => s.scopeSettings[scopeKind])
const storeSettings = useSettingsStore((s) => s.scopeSettings[scopeKind])
const mySettings = settings ?? storeSettings
// Initialize visualizer
useEffect(() => {
@@ -112,7 +154,7 @@ export default function ScopeModule({ scopeKind, lineColor = '#38bdf8' }: ScopeM
if (!canvas) return
initializedRef.current = false
const viz = createVisualizer(scopeKind, canvas, mySettings, lineColor)
const viz = createVisualizer(scopeKind, canvas, mySettings, lineColor, dataSource)
if (!viz) return
visualizerRef.current = viz
@@ -126,14 +168,14 @@ export default function ScopeModule({ scopeKind, lineColor = '#38bdf8' }: ScopeM
visualizerRef.current = null
initializedRef.current = false
}
}, [scopeKind])
}, [dataSource, scopeKind])
// Push settings + lineColor changes to live visualizer (skip initial — constructor already handled it)
useEffect(() => {
if (!visualizerRef.current || !initializedRef.current) return
const opts = scopeSettingsToOptions(scopeKind, mySettings, lineColor)
const opts = { ...scopeSettingsToOptions(scopeKind, mySettings, lineColor), ...(dataSource ? { dataSource } : {}) }
visualizerRef.current.setOptions(opts)
}, [mySettings, lineColor])
}, [dataSource, lineColor, mySettings, scopeKind])
// ResizeObserver for DPI-aware canvas sizing
useEffect(() => {
@@ -0,0 +1,188 @@
import { useEffect, useMemo, useRef } from 'react'
import { audioRouter } from '../audio/AudioRouter'
import { useSettingsStore } from '../stores/settingsStore'
import { useThemeStore } from '../stores/themeStore'
import type {
ScopePopoutAudioBatch,
ScopePopoutSessionState,
ScopePopoutSnapshot,
ScopePopoutSyncStateMap,
} from '../../types/popout'
import { SCOPE_KINDS, SCOPE_LABELS, type ScopeKind } from '../../types/scope'
import type { ScopeSettings } from '../../types/settings'
function buildConsumerDemand(kind: ScopeKind): Record<ScopeKind, boolean> {
return SCOPE_KINDS.reduce((acc, currentKind) => {
acc[currentKind] = currentKind === kind
return acc
}, {} as Record<ScopeKind, boolean>)
}
function flushScopeAudioBatch(kind: ScopeKind): ScopePopoutAudioBatch {
switch (kind) {
case 'spectrum':
return audioRouter.flushPendingSpectrumSamples()
case 'oscilloscope':
return audioRouter.flushPendingOscilloscopeSamples()
case 'vectorscope':
return audioRouter.flushPendingVectorscopeSamples()
case 'spectrogram':
return audioRouter.flushPendingSpectrogramSamples()
case 'vumeter':
return audioRouter.flushPendingVUMeterSamples()
case 'lufsmeter':
return audioRouter.flushPendingLUFSMeterSamples()
case 'waveform':
return audioRouter.flushPendingWaveformSamples()
}
}
function toPopoutSessionState(state: ScopePopoutSessionState): ScopePopoutSessionState {
return {
sessionId: state.sessionId,
sampleRate: state.sampleRate,
channelCount: state.channelCount,
capturing: state.capturing,
backendKind: state.backendKind,
}
}
function isPartialSettings(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
export default function ScopePopoutBridge(): null {
const hiddenScopes = useSettingsStore((s) => s.hiddenScopes)
const scopePopouts = useSettingsStore((s) => s.scopePopouts)
const scopeSettings = useSettingsStore((s) => s.scopeSettings)
const popInScope = useSettingsStore((s) => s.popInScope)
const updatePopoutBounds = useSettingsStore((s) => s.updatePopoutBounds)
const updateScopeSettings = useSettingsStore((s) => s.updateScopeSettings)
const accent = useThemeStore((s) => s.accent)
const activePopoutKinds = useMemo(
() => SCOPE_KINDS.filter((kind) => scopePopouts[kind]?.poppedOut && !hiddenScopes.has(kind)),
[hiddenScopes, scopePopouts],
)
const activePopoutKindsRef = useRef<ScopeKind[]>(activePopoutKinds)
useEffect(() => {
activePopoutKindsRef.current = activePopoutKinds
}, [activePopoutKinds])
useEffect(() => {
const syncState = SCOPE_KINDS.reduce((acc, kind) => {
acc[kind] = {
shouldBeOpen: scopePopouts[kind]?.poppedOut === true && !hiddenScopes.has(kind),
bounds: scopePopouts[kind]?.windowBounds,
}
return acc
}, {} as ScopePopoutSyncStateMap)
window.electronAPI.syncScopePopouts(syncState)
}, [hiddenScopes, scopePopouts])
useEffect(() => {
for (const kind of activePopoutKinds) {
const snapshot: ScopePopoutSnapshot = {
kind,
label: SCOPE_LABELS[kind],
accent,
settings: scopeSettings[kind],
}
window.electronAPI.sendScopePopoutSnapshot(snapshot)
}
}, [accent, activePopoutKinds, scopeSettings])
useEffect(() => {
const sessionState = toPopoutSessionState(audioRouter.getSessionState())
for (const kind of activePopoutKinds) {
window.electronAPI.sendScopePopoutSession(kind, sessionState)
}
}, [activePopoutKinds])
useEffect(() => {
const unsubscribeCloseRequested = window.electronAPI.onScopePopoutCloseRequested((kind) => {
popInScope(kind)
})
const unsubscribeBoundsChanged = window.electronAPI.onScopePopoutBoundsChanged((kind, bounds) => {
updatePopoutBounds(kind, bounds)
})
const unsubscribeSettingsUpdate = window.electronAPI.onScopePopoutSettingsUpdate((kind, partial) => {
if (!isPartialSettings(partial)) return
updateScopeSettings(kind, partial as Partial<ScopeSettings[typeof kind]>)
})
const unsubscribeReady = window.electronAPI.onScopePopoutReady((kind) => {
const nextHiddenScopes = useSettingsStore.getState().hiddenScopes
const nextPopouts = useSettingsStore.getState().scopePopouts
if (!nextPopouts[kind]?.poppedOut || nextHiddenScopes.has(kind)) return
window.electronAPI.sendScopePopoutSnapshot({
kind,
label: SCOPE_LABELS[kind],
accent: useThemeStore.getState().accent,
settings: useSettingsStore.getState().scopeSettings[kind],
})
window.electronAPI.sendScopePopoutSession(kind, toPopoutSessionState(audioRouter.getSessionState()))
})
return () => {
unsubscribeCloseRequested()
unsubscribeBoundsChanged()
unsubscribeSettingsUpdate()
unsubscribeReady()
}
}, [popInScope, updatePopoutBounds, updateScopeSettings])
useEffect(() => {
for (const kind of SCOPE_KINDS) {
const consumerId = `popout:${kind}`
if (activePopoutKinds.includes(kind)) {
audioRouter.setVisualizerConsumerDemand(consumerId, buildConsumerDemand(kind))
} else {
audioRouter.clearVisualizerConsumerDemand(consumerId)
}
}
return () => {
for (const kind of SCOPE_KINDS) {
audioRouter.clearVisualizerConsumerDemand(`popout:${kind}`)
}
}
}, [activePopoutKinds])
useEffect(() => {
let frameId = 0
const flushFrame = (): void => {
for (const kind of activePopoutKindsRef.current) {
const batch = flushScopeAudioBatch(kind)
if (batch.length > 0) {
window.electronAPI.sendScopePopoutAudio(kind, batch)
}
}
frameId = window.requestAnimationFrame(flushFrame)
}
if (activePopoutKinds.length > 0) {
frameId = window.requestAnimationFrame(flushFrame)
}
return () => {
if (frameId) {
window.cancelAnimationFrame(frameId)
}
}
}, [activePopoutKinds])
useEffect(() => {
return audioRouter.subscribeToSessionChanges((state) => {
const nextSessionState = toPopoutSessionState(state)
for (const kind of activePopoutKindsRef.current) {
window.electronAPI.sendScopePopoutSession(kind, nextSessionState)
}
})
}, [])
return null
}
@@ -0,0 +1,459 @@
import type { JSX, ReactNode } from 'react'
import type { ScopeKind } from '../../types/scope'
import { SCOPE_LABELS } from '../../types/scope'
import type { ScopeSettings } from '../../types/settings'
function vectorscopeModeLabel(mode: ScopeSettings['vectorscope']['mode']): string {
switch (mode) {
case 'lissajous':
return 'Lissajous'
case 'polar-unipolar':
return 'Polar Uni'
case 'polar-bipolar':
return 'Polar Bi'
case 'linear-unipolar':
return 'Linear Uni'
case 'linear-bipolar':
return 'Linear Bi'
}
}
export function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind]): string {
switch (kind) {
case 'spectrum': {
const scopeSettings = settings as ScopeSettings['spectrum']
return `${scopeSettings.heatmap ? 'Heat' : 'Fill'} · FFT ${scopeSettings.fftSize}`
}
case 'oscilloscope': {
const scopeSettings = settings as ScopeSettings['oscilloscope']
const mode = scopeSettings.pitchLock ? 'Pitch Lock' : 'Free Run'
return scopeSettings.underfillEnabled ? `${mode} · Fill` : mode
}
case 'vectorscope': {
const scopeSettings = settings as ScopeSettings['vectorscope']
return scopeSettings.multiband
? `${vectorscopeModeLabel(scopeSettings.mode)} · RGB`
: vectorscopeModeLabel(scopeSettings.mode)
}
case 'spectrogram': {
const scopeSettings = settings as ScopeSettings['spectrogram']
return `${scopeSettings.scaleMode.toUpperCase()} · ${scopeSettings.clarityMode}`
}
case 'vumeter': {
const scopeSettings = settings as ScopeSettings['vumeter']
return `${scopeSettings.mode.toUpperCase()} · ${scopeSettings.orientation.toUpperCase()}`
}
case 'lufsmeter':
return 'Bar Meter'
case 'waveform': {
const scopeSettings = settings as ScopeSettings['waveform']
return scopeSettings.multiband
? `${scopeSettings.gainDb > 0 ? '+' : ''}${scopeSettings.gainDb} dB · RGB`
: `${scopeSettings.gainDb > 0 ? '+' : ''}${scopeSettings.gainDb} dB`
}
}
}
function ToggleChip({
label,
active,
onClick,
}: {
label: string
active: boolean
onClick: () => void
}): JSX.Element {
return (
<button
type="button"
className={`settings-chip ${active ? 'is-active' : ''}`.trim()}
onClick={onClick}
>
{label}
</button>
)
}
function SelectControl({
label,
value,
children,
onChange,
}: {
label: string
value: string | number
children: ReactNode
onChange: (value: string) => void
}): JSX.Element {
return (
<label className="settings-control">
<span className="settings-control__label">{label}</span>
<select
className="settings-control__select"
value={value}
onChange={(event) => onChange(event.target.value)}
>
{children}
</select>
</label>
)
}
function RangeControl({
label,
value,
valueLabel,
min,
max,
step,
fullWidth = true,
disabled = false,
onChange,
}: {
label: string
value: number
valueLabel: string
min: number
max: number
step: number
fullWidth?: boolean
disabled?: boolean
onChange: (value: number) => void
}): JSX.Element {
return (
<label className={`settings-control ${fullWidth ? 'settings-control--full' : ''} ${disabled ? 'is-disabled' : ''}`.trim()}>
<span className="settings-control__label">
{label}
<span className="settings-control__value">{valueLabel}</span>
</span>
<input
className="settings-control__range"
type="range"
min={min}
max={max}
step={step}
value={value}
disabled={disabled}
onChange={(event) => onChange(Number(event.target.value))}
/>
</label>
)
}
interface ScopeSettingsSectionProps {
kind: ScopeKind
settings: ScopeSettings[ScopeKind]
onUpdate: <K extends ScopeKind>(kind: K, partial: Partial<ScopeSettings[K]>) => void
}
export default function ScopeSettingsSection({
kind,
settings,
onUpdate,
}: ScopeSettingsSectionProps): JSX.Element {
return (
<section className="settings-scope-section">
<div className="settings-scope-section__header">
<div className="settings-scope-section__title">{SCOPE_LABELS[kind]}</div>
<div className="settings-scope-section__summary">{scopeSummary(kind, settings)}</div>
</div>
<div className="settings-scope-section__controls">
{kind === 'spectrum' && (() => {
const current = settings as ScopeSettings['spectrum']
return (
<>
<SelectControl
label="FFT Size"
value={current.fftSize}
onChange={(value) => onUpdate('spectrum', { fftSize: Number(value) })}
>
{[1024, 2048, 4096, 8192, 16384].map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</SelectControl>
<div className="settings-chip-row settings-control--full">
<ToggleChip
label="Fill"
active={current.fillGradient}
onClick={() => onUpdate('spectrum', { fillGradient: !current.fillGradient })}
/>
<ToggleChip
label="Heatmap"
active={current.heatmap}
onClick={() => onUpdate('spectrum', { heatmap: !current.heatmap })}
/>
<ToggleChip
label="Grid"
active={current.showGrid}
onClick={() => onUpdate('spectrum', { showGrid: !current.showGrid })}
/>
</div>
<RangeControl
label="Tilt"
value={current.tiltDbPerOctave}
valueLabel={`${current.tiltDbPerOctave.toFixed(1)} dB/oct`}
min={0}
max={6}
step={0.5}
fullWidth={false}
onChange={(value) => onUpdate('spectrum', { tiltDbPerOctave: value })}
/>
<RangeControl
label="Heat Tilt"
value={current.heatmapTiltDbPerOctave}
valueLabel={`${current.heatmapTiltDbPerOctave.toFixed(1)} dB/oct`}
min={0}
max={6}
step={0.5}
fullWidth={false}
disabled={!current.heatmap}
onChange={(value) => onUpdate('spectrum', { heatmapTiltDbPerOctave: value })}
/>
<RangeControl
label="Smoothing"
value={current.smoothing}
valueLabel={current.smoothing.toFixed(2)}
min={0}
max={0.99}
step={0.01}
fullWidth={false}
onChange={(value) => onUpdate('spectrum', { smoothing: value })}
/>
</>
)
})()}
{kind === 'oscilloscope' && (() => {
const current = settings as ScopeSettings['oscilloscope']
return (
<>
<div className="settings-chip-row settings-control--full">
<ToggleChip
label="Pitch Lock"
active={current.pitchLock}
onClick={() => onUpdate('oscilloscope', { pitchLock: !current.pitchLock })}
/>
<ToggleChip
label="Underfill"
active={current.underfillEnabled}
onClick={() => onUpdate('oscilloscope', { underfillEnabled: !current.underfillEnabled })}
/>
<ToggleChip
label="Grid"
active={current.showGrid}
onClick={() => onUpdate('oscilloscope', { showGrid: !current.showGrid })}
/>
</div>
<RangeControl
label="Line Width"
value={current.lineWidth}
valueLabel={`${current.lineWidth.toFixed(1)} px`}
min={0.5}
max={4}
step={0.5}
fullWidth={false}
onChange={(value) => onUpdate('oscilloscope', { lineWidth: value })}
/>
</>
)
})()}
{kind === 'vectorscope' && (() => {
const current = settings as ScopeSettings['vectorscope']
return (
<>
<SelectControl
label="Mode"
value={current.mode}
onChange={(value) => onUpdate('vectorscope', { mode: value as ScopeSettings['vectorscope']['mode'] })}
>
<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>
</SelectControl>
<div className="settings-chip-row settings-control--full">
<ToggleChip
label="RGB"
active={current.multiband}
onClick={() => onUpdate('vectorscope', { multiband: !current.multiband })}
/>
<ToggleChip
label="Grid"
active={current.showGrid}
onClick={() => onUpdate('vectorscope', { showGrid: !current.showGrid })}
/>
</div>
<RangeControl
label="Persistence"
value={current.persistence}
valueLabel={current.persistence.toFixed(2)}
min={0}
max={0.5}
step={0.01}
fullWidth={false}
onChange={(value) => onUpdate('vectorscope', { persistence: value })}
/>
<RangeControl
label="Line Width"
value={current.lineWidth}
valueLabel={`${current.lineWidth.toFixed(1)} px`}
min={0.5}
max={4}
step={0.5}
fullWidth={false}
onChange={(value) => onUpdate('vectorscope', { lineWidth: value })}
/>
</>
)
})()}
{kind === 'spectrogram' && (() => {
const current = settings as ScopeSettings['spectrogram']
return (
<>
<SelectControl
label="FFT Size"
value={current.fftSize}
onChange={(value) => onUpdate('spectrogram', { fftSize: Number(value) })}
>
{[512, 1024, 2048, 4096].map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</SelectControl>
<SelectControl
label="Scale"
value={current.scaleMode}
onChange={(value) => onUpdate('spectrogram', { scaleMode: value as ScopeSettings['spectrogram']['scaleMode'] })}
>
<option value="log">Log</option>
<option value="mel">Mel</option>
<option value="linear">Linear</option>
</SelectControl>
<SelectControl
label="Clarity"
value={current.clarityMode}
onChange={(value) => onUpdate('spectrogram', { clarityMode: value as ScopeSettings['spectrogram']['clarityMode'] })}
>
<option value="classic">Classic</option>
<option value="sharp">Sharp</option>
<option value="sharper">Sharper</option>
</SelectControl>
<SelectControl
label="Color"
value={current.colorScheme}
onChange={(value) => onUpdate('spectrogram', { colorScheme: value as ScopeSettings['spectrogram']['colorScheme'] })}
>
<option value="heat">Heat</option>
<option value="mono">Mono</option>
</SelectControl>
<RangeControl
label="Speed"
value={current.scrollSpeed}
valueLabel={`x${current.scrollSpeed.toFixed(0)}`}
min={1}
max={8}
step={1}
fullWidth={false}
onChange={(value) => onUpdate('spectrogram', { scrollSpeed: value })}
/>
</>
)
})()}
{kind === 'vumeter' && (() => {
const current = settings as ScopeSettings['vumeter']
return (
<>
<SelectControl
label="Mode"
value={current.mode}
onChange={(value) => onUpdate('vumeter', { mode: value as ScopeSettings['vumeter']['mode'] })}
>
<option value="bar">Bar</option>
<option value="needle">Needle</option>
</SelectControl>
<SelectControl
label="Orientation"
value={current.orientation}
onChange={(value) => onUpdate('vumeter', { orientation: value as ScopeSettings['vumeter']['orientation'] })}
>
<option value="horizontal">Horizontal</option>
<option value="vertical">Vertical</option>
</SelectControl>
</>
)
})()}
{kind === 'lufsmeter' && (() => {
const current = settings as ScopeSettings['lufsmeter']
return (
<SelectControl
label="Mode"
value={current.mode}
onChange={(value) => onUpdate('lufsmeter', { mode: value as ScopeSettings['lufsmeter']['mode'] })}
>
<option value="bar">Bar</option>
</SelectControl>
)
})()}
{kind === 'waveform' && (() => {
const current = settings as ScopeSettings['waveform']
return (
<>
<div className="settings-chip-row settings-control--full">
<ToggleChip
label="Multiband"
active={current.multiband}
onClick={() => onUpdate('waveform', { multiband: !current.multiband })}
/>
</div>
<RangeControl
label="Gain"
value={current.gainDb}
valueLabel={`${current.gainDb > 0 ? '+' : ''}${current.gainDb.toFixed(0)} dB`}
min={-12}
max={12}
step={1}
fullWidth={false}
onChange={(value) => onUpdate('waveform', { gainDb: value })}
/>
<RangeControl
label="Speed"
value={current.scrollSpeed}
valueLabel={`x${current.scrollSpeed.toFixed(0)}`}
min={1}
max={8}
step={1}
fullWidth={false}
onChange={(value) => onUpdate('waveform', { scrollSpeed: value })}
/>
</>
)
})()}
</div>
</section>
)
}
+11 -476
View File
@@ -1,494 +1,29 @@
import { useMemo, type CSSProperties, type JSX, type ReactNode } from 'react'
import { useSettingsStore, type ScopeSettings } from '../stores/settingsStore'
import type { ScopeKind } from '../../types/scope'
import { useMemo, type CSSProperties, type JSX } from 'react'
import { buildAnalyzerGridTemplateColumns } from '../analyzerLayout'
const SCOPE_LABELS: Record<ScopeKind, string> = {
spectrum: 'Spectrum',
oscilloscope: 'Oscilloscope',
vectorscope: 'Vectorscope',
spectrogram: 'Spectrogram',
vumeter: 'VU Meter',
lufsmeter: 'LUFS Meter',
waveform: 'Waveform',
}
function vectorscopeModeLabel(mode: ScopeSettings['vectorscope']['mode']): string {
switch (mode) {
case 'lissajous':
return 'Lissajous'
case 'polar-unipolar':
return 'Polar Uni'
case 'polar-bipolar':
return 'Polar Bi'
case 'linear-unipolar':
return 'Linear Uni'
case 'linear-bipolar':
return 'Linear Bi'
}
}
function scopeSummary(kind: ScopeKind, settings: ScopeSettings[ScopeKind]): string {
switch (kind) {
case 'spectrum': {
const scopeSettings = settings as ScopeSettings['spectrum']
return `${scopeSettings.heatmap ? 'Heat' : 'Fill'} · FFT ${scopeSettings.fftSize}`
}
case 'oscilloscope': {
const scopeSettings = settings as ScopeSettings['oscilloscope']
const mode = scopeSettings.pitchLock ? 'Pitch Lock' : 'Free Run'
return scopeSettings.underfillEnabled ? `${mode} · Fill` : mode
}
case 'vectorscope': {
const scopeSettings = settings as ScopeSettings['vectorscope']
return scopeSettings.multiband
? `${vectorscopeModeLabel(scopeSettings.mode)} · RGB`
: vectorscopeModeLabel(scopeSettings.mode)
}
case 'spectrogram': {
const scopeSettings = settings as ScopeSettings['spectrogram']
return `${scopeSettings.scaleMode.toUpperCase()} · ${scopeSettings.clarityMode}`
}
case 'vumeter': {
const scopeSettings = settings as ScopeSettings['vumeter']
return `${scopeSettings.mode.toUpperCase()} · ${scopeSettings.orientation.toUpperCase()}`
}
case 'lufsmeter':
return 'Bar Meter'
case 'waveform': {
const scopeSettings = settings as ScopeSettings['waveform']
return scopeSettings.multiband
? `${scopeSettings.gainDb > 0 ? '+' : ''}${scopeSettings.gainDb} dB · RGB`
: `${scopeSettings.gainDb > 0 ? '+' : ''}${scopeSettings.gainDb} dB`
}
}
}
function ToggleChip({
label,
active,
onClick,
}: {
label: string
active: boolean
onClick: () => void
}): JSX.Element {
return (
<button
type="button"
className={`settings-chip ${active ? 'is-active' : ''}`.trim()}
onClick={onClick}
>
{label}
</button>
)
}
function SelectControl({
label,
value,
children,
onChange,
}: {
label: string
value: string | number
children: ReactNode
onChange: (value: string) => void
}): JSX.Element {
return (
<label className="settings-control">
<span className="settings-control__label">{label}</span>
<select
className="settings-control__select"
value={value}
onChange={(event) => onChange(event.target.value)}
>
{children}
</select>
</label>
)
}
function RangeControl({
label,
value,
valueLabel,
min,
max,
step,
fullWidth = true,
disabled = false,
onChange,
}: {
label: string
value: number
valueLabel: string
min: number
max: number
step: number
fullWidth?: boolean
disabled?: boolean
onChange: (value: number) => void
}): JSX.Element {
return (
<label className={`settings-control ${fullWidth ? 'settings-control--full' : ''} ${disabled ? 'is-disabled' : ''}`.trim()}>
<span className="settings-control__label">
{label}
<span className="settings-control__value">{valueLabel}</span>
</span>
<input
className="settings-control__range"
type="range"
min={min}
max={max}
step={step}
value={value}
disabled={disabled}
onChange={(event) => onChange(Number(event.target.value))}
/>
</label>
)
}
function ScopeSettingsSection({
kind,
settings,
onUpdate,
}: {
kind: ScopeKind
settings: ScopeSettings
onUpdate: <K extends ScopeKind>(kind: K, partial: Partial<ScopeSettings[K]>) => void
}): JSX.Element {
const scopeSettings = settings[kind]
return (
<section className="settings-scope-section">
<div className="settings-scope-section__header">
<div className="settings-scope-section__title">{SCOPE_LABELS[kind]}</div>
<div className="settings-scope-section__summary">{scopeSummary(kind, scopeSettings)}</div>
</div>
<div className="settings-scope-section__controls">
{kind === 'spectrum' && (() => {
const current = scopeSettings as ScopeSettings['spectrum']
return (
<>
<SelectControl
label="FFT Size"
value={current.fftSize}
onChange={(value) => onUpdate('spectrum', { fftSize: Number(value) })}
>
{[1024, 2048, 4096, 8192, 16384].map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</SelectControl>
<div className="settings-chip-row settings-control--full">
<ToggleChip
label="Fill"
active={current.fillGradient}
onClick={() => onUpdate('spectrum', { fillGradient: !current.fillGradient })}
/>
<ToggleChip
label="Heatmap"
active={current.heatmap}
onClick={() => onUpdate('spectrum', { heatmap: !current.heatmap })}
/>
<ToggleChip
label="Grid"
active={current.showGrid}
onClick={() => onUpdate('spectrum', { showGrid: !current.showGrid })}
/>
</div>
<RangeControl
label="Tilt"
value={current.tiltDbPerOctave}
valueLabel={`${current.tiltDbPerOctave.toFixed(1)} dB/oct`}
min={0}
max={6}
step={0.5}
fullWidth={false}
onChange={(value) => onUpdate('spectrum', { tiltDbPerOctave: value })}
/>
<RangeControl
label="Heat Tilt"
value={current.heatmapTiltDbPerOctave}
valueLabel={`${current.heatmapTiltDbPerOctave.toFixed(1)} dB/oct`}
min={0}
max={6}
step={0.5}
fullWidth={false}
disabled={!current.heatmap}
onChange={(value) => onUpdate('spectrum', { heatmapTiltDbPerOctave: value })}
/>
<RangeControl
label="Smoothing"
value={current.smoothing}
valueLabel={current.smoothing.toFixed(2)}
min={0}
max={0.99}
step={0.01}
fullWidth={false}
onChange={(value) => onUpdate('spectrum', { smoothing: value })}
/>
</>
)
})()}
{kind === 'oscilloscope' && (() => {
const current = scopeSettings as ScopeSettings['oscilloscope']
return (
<>
<div className="settings-chip-row settings-control--full">
<ToggleChip
label="Pitch Lock"
active={current.pitchLock}
onClick={() => onUpdate('oscilloscope', { pitchLock: !current.pitchLock })}
/>
<ToggleChip
label="Underfill"
active={current.underfillEnabled}
onClick={() => onUpdate('oscilloscope', { underfillEnabled: !current.underfillEnabled })}
/>
<ToggleChip
label="Grid"
active={current.showGrid}
onClick={() => onUpdate('oscilloscope', { showGrid: !current.showGrid })}
/>
</div>
<RangeControl
label="Line Width"
value={current.lineWidth}
valueLabel={`${current.lineWidth.toFixed(1)} px`}
min={0.5}
max={4}
step={0.5}
fullWidth={false}
onChange={(value) => onUpdate('oscilloscope', { lineWidth: value })}
/>
</>
)
})()}
{kind === 'vectorscope' && (() => {
const current = scopeSettings as ScopeSettings['vectorscope']
return (
<>
<SelectControl
label="Mode"
value={current.mode}
onChange={(value) => onUpdate('vectorscope', { mode: value as ScopeSettings['vectorscope']['mode'] })}
>
<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>
</SelectControl>
<div className="settings-chip-row settings-control--full">
<ToggleChip
label="RGB"
active={current.multiband}
onClick={() => onUpdate('vectorscope', { multiband: !current.multiband })}
/>
<ToggleChip
label="Grid"
active={current.showGrid}
onClick={() => onUpdate('vectorscope', { showGrid: !current.showGrid })}
/>
</div>
<RangeControl
label="Persistence"
value={current.persistence}
valueLabel={current.persistence.toFixed(2)}
min={0}
max={0.5}
step={0.01}
fullWidth={false}
onChange={(value) => onUpdate('vectorscope', { persistence: value })}
/>
<RangeControl
label="Line Width"
value={current.lineWidth}
valueLabel={`${current.lineWidth.toFixed(1)} px`}
min={0.5}
max={4}
step={0.5}
fullWidth={false}
onChange={(value) => onUpdate('vectorscope', { lineWidth: value })}
/>
</>
)
})()}
{kind === 'spectrogram' && (() => {
const current = scopeSettings as ScopeSettings['spectrogram']
return (
<>
<SelectControl
label="FFT Size"
value={current.fftSize}
onChange={(value) => onUpdate('spectrogram', { fftSize: Number(value) })}
>
{[512, 1024, 2048, 4096].map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</SelectControl>
<SelectControl
label="Scale"
value={current.scaleMode}
onChange={(value) => onUpdate('spectrogram', { scaleMode: value as ScopeSettings['spectrogram']['scaleMode'] })}
>
<option value="log">Log</option>
<option value="mel">Mel</option>
<option value="linear">Linear</option>
</SelectControl>
<SelectControl
label="Clarity"
value={current.clarityMode}
onChange={(value) => onUpdate('spectrogram', { clarityMode: value as ScopeSettings['spectrogram']['clarityMode'] })}
>
<option value="classic">Classic</option>
<option value="sharp">Sharp</option>
<option value="sharper">Sharper</option>
</SelectControl>
<SelectControl
label="Color"
value={current.colorScheme}
onChange={(value) => onUpdate('spectrogram', { colorScheme: value as ScopeSettings['spectrogram']['colorScheme'] })}
>
<option value="heat">Heat</option>
<option value="mono">Mono</option>
</SelectControl>
<RangeControl
label="Speed"
value={current.scrollSpeed}
valueLabel={`x${current.scrollSpeed.toFixed(0)}`}
min={1}
max={8}
step={1}
fullWidth={false}
onChange={(value) => onUpdate('spectrogram', { scrollSpeed: value })}
/>
</>
)
})()}
{kind === 'vumeter' && (() => {
const current = scopeSettings as ScopeSettings['vumeter']
return (
<>
<SelectControl
label="Mode"
value={current.mode}
onChange={(value) => onUpdate('vumeter', { mode: value as ScopeSettings['vumeter']['mode'] })}
>
<option value="bar">Bar</option>
<option value="needle">Needle</option>
</SelectControl>
<SelectControl
label="Orientation"
value={current.orientation}
onChange={(value) => onUpdate('vumeter', { orientation: value as ScopeSettings['vumeter']['orientation'] })}
>
<option value="horizontal">Horizontal</option>
<option value="vertical">Vertical</option>
</SelectControl>
</>
)
})()}
{kind === 'lufsmeter' && (() => {
const current = scopeSettings as ScopeSettings['lufsmeter']
return (
<SelectControl
label="Mode"
value={current.mode}
onChange={(value) => onUpdate('lufsmeter', { mode: value as ScopeSettings['lufsmeter']['mode'] })}
>
<option value="bar">Bar</option>
</SelectControl>
)
})()}
{kind === 'waveform' && (() => {
const current = scopeSettings as ScopeSettings['waveform']
return (
<>
<div className="settings-chip-row settings-control--full">
<ToggleChip
label="Multiband"
active={current.multiband}
onClick={() => onUpdate('waveform', { multiband: !current.multiband })}
/>
</div>
<RangeControl
label="Gain"
value={current.gainDb}
valueLabel={`${current.gainDb > 0 ? '+' : ''}${current.gainDb.toFixed(0)} dB`}
min={-12}
max={12}
step={1}
fullWidth={false}
onChange={(value) => onUpdate('waveform', { gainDb: value })}
/>
<RangeControl
label="Speed"
value={current.scrollSpeed}
valueLabel={`x${current.scrollSpeed.toFixed(0)}`}
min={1}
max={8}
step={1}
fullWidth={false}
onChange={(value) => onUpdate('waveform', { scrollSpeed: value })}
/>
</>
)
})()}
</div>
</section>
)
}
import ScopeSettingsSection from './ScopeSettingsSection'
import { useSettingsStore } from '../stores/settingsStore'
export default function SettingsPanel(): JSX.Element {
const { scopeSettings, updateScopeSettings, hiddenScopes, scopeOrder, widthWeights } = useSettingsStore()
const { scopeSettings, updateScopeSettings, hiddenScopes, scopeOrder, widthWeights, scopePopouts } = useSettingsStore()
const visibleScopes = useMemo(
() => scopeOrder.filter((kind) => !hiddenScopes.has(kind)),
[scopeOrder, hiddenScopes],
const dockedScopes = useMemo(
() => scopeOrder.filter((kind) => !hiddenScopes.has(kind) && !scopePopouts[kind]?.poppedOut),
[hiddenScopes, scopeOrder, scopePopouts],
)
const scopeTrackStyle = useMemo(() => {
const gridTemplateColumns = buildAnalyzerGridTemplateColumns(visibleScopes, widthWeights)
const gridTemplateColumns = buildAnalyzerGridTemplateColumns(dockedScopes, widthWeights)
if (!gridTemplateColumns) return undefined
return { gridTemplateColumns } as CSSProperties
}, [visibleScopes, widthWeights])
}, [dockedScopes, widthWeights])
return (
<div className="settings-panel">
<div className="settings-panel__scope-track" style={scopeTrackStyle}>
{visibleScopes.map((kind) => (
{dockedScopes.map((kind) => (
<ScopeSettingsSection
key={kind}
kind={kind}
settings={scopeSettings}
settings={scopeSettings[kind]}
onUpdate={updateScopeSettings}
/>
))}
+56 -22
View File
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type JSX } from 'react'
import { useSettingsStore } from '../stores/settingsStore'
import { useThemeStore } from '../stores/themeStore'
import type { ScopeKind } from '../../types/scope'
import { SCOPE_LABELS, type ScopeKind } from '../../types/scope'
import type { WindowBounds } from '../../types/popout'
import ScopeModule from './ScopeModule'
import { buildAnalyzerGridTemplateColumns } from '../analyzerLayout'
import { audioRouter } from '../audio/AudioRouter'
@@ -9,44 +10,46 @@ import { audioRouter } from '../audio/AudioRouter'
export default function Strip(): JSX.Element {
const scopeOrder = useSettingsStore((s) => s.scopeOrder)
const hiddenScopes = useSettingsStore((s) => s.hiddenScopes)
const scopePopouts = useSettingsStore((s) => s.scopePopouts)
const widthWeights = useSettingsStore((s) => s.widthWeights)
const setScopeWidthWeight = useSettingsStore((s) => s.setScopeWidthWeight)
const popOutScope = useSettingsStore((s) => s.popOutScope)
const accent = useThemeStore((s) => s.accent)
const stripRef = useRef<HTMLDivElement>(null)
const gridRef = useRef<HTMLDivElement>(null)
const scopeRefs = useRef<Partial<Record<ScopeKind, HTMLDivElement | null>>>({})
const [handleOffsets, setHandleOffsets] = useState<number[]>([])
const visibleScopes = useMemo(
() => scopeOrder.filter((k) => !hiddenScopes.has(k)),
[hiddenScopes, scopeOrder],
const dockedScopes = useMemo(
() => scopeOrder.filter((k) => !hiddenScopes.has(k) && !scopePopouts[k]?.poppedOut),
[hiddenScopes, scopeOrder, scopePopouts],
)
const visibleScopeKey = useMemo(() => visibleScopes.join('|'), [visibleScopes])
const visibleScopeKey = useMemo(() => dockedScopes.join('|'), [dockedScopes])
const gridTemplateColumns = useMemo(() => {
return buildAnalyzerGridTemplateColumns(visibleScopes, widthWeights)
}, [visibleScopes, widthWeights])
return buildAnalyzerGridTemplateColumns(dockedScopes, widthWeights)
}, [dockedScopes, widthWeights])
const gridStyle = useMemo(() => {
if (!gridTemplateColumns) return undefined
return { gridTemplateColumns } as CSSProperties
}, [gridTemplateColumns])
const updateHandleOffsets = useCallback((): void => {
if (visibleScopes.length < 2) {
if (dockedScopes.length < 2) {
setHandleOffsets([])
return
}
const nextOffsets: number[] = []
for (let index = 0; index < visibleScopes.length - 1; index += 1) {
const leftElement = scopeRefs.current[visibleScopes[index]]
for (let index = 0; index < dockedScopes.length - 1; index += 1) {
const leftElement = scopeRefs.current[dockedScopes[index]]
if (!leftElement) continue
nextOffsets.push(leftElement.offsetLeft + leftElement.offsetWidth)
}
setHandleOffsets(nextOffsets)
}, [visibleScopes])
}, [dockedScopes])
useEffect(() => {
const visibleScopeSet = new Set(visibleScopes)
const visibleScopeSet = new Set(dockedScopes)
audioRouter.setVisualizerConsumerDemand('docked-strip', {
spectrum: visibleScopeSet.has('spectrum'),
oscilloscope: visibleScopeSet.has('oscilloscope'),
@@ -60,7 +63,7 @@ export default function Strip(): JSX.Element {
return () => {
audioRouter.clearVisualizerConsumerDemand('docked-strip')
}
}, [visibleScopeKey, visibleScopes])
}, [visibleScopeKey, dockedScopes])
useEffect(() => {
const strip = stripRef.current
@@ -97,7 +100,7 @@ export default function Strip(): JSX.Element {
observer?.observe(gridRef.current)
}
for (const scope of visibleScopes) {
for (const scope of dockedScopes) {
const element = scopeRefs.current[scope]
if (element) {
observer?.observe(element)
@@ -110,11 +113,11 @@ export default function Strip(): JSX.Element {
observer?.disconnect()
window.removeEventListener('resize', updateHandleOffsets)
}
}, [gridTemplateColumns, updateHandleOffsets, visibleScopes])
}, [dockedScopes, gridTemplateColumns, updateHandleOffsets])
const startResizeDrag = useCallback((handleIndex: number, event: React.MouseEvent<HTMLButtonElement>) => {
const leftKind = visibleScopes[handleIndex]
const rightKind = visibleScopes[handleIndex + 1]
const leftKind = dockedScopes[handleIndex]
const rightKind = dockedScopes[handleIndex + 1]
if (!leftKind || !rightKind) return
const leftElement = scopeRefs.current[leftKind]
@@ -164,12 +167,30 @@ export default function Strip(): JSX.Element {
document.body.style.userSelect = 'none'
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}, [setScopeWidthWeight, visibleScopes])
}, [dockedScopes, setScopeWidthWeight])
const handlePopoutScope = useCallback(async (kind: ScopeKind): Promise<void> => {
const element = scopeRefs.current[kind]
const rect = element?.getBoundingClientRect()
const windowBounds = await window.electronAPI.getWindowBounds()
let nextBounds: WindowBounds | undefined
if (rect && windowBounds) {
nextBounds = {
x: Math.round(windowBounds.x + rect.left),
y: Math.round(windowBounds.y + rect.top),
width: Math.max(220, Math.round(rect.width)),
height: Math.max(160, Math.round(rect.height)),
}
}
popOutScope(kind, nextBounds)
}, [popOutScope])
return (
<div ref={stripRef} className="scope-strip">
<div ref={gridRef} className="scope-strip__grid" style={gridStyle}>
{visibleScopes.map((kind) => (
{dockedScopes.map((kind) => (
<div
key={kind}
ref={(element) => {
@@ -177,6 +198,19 @@ export default function Strip(): JSX.Element {
}}
className="scope-strip__cell"
>
<button
type="button"
className="scope-strip__popout-button"
onClick={() => {
void handlePopoutScope(kind)
}}
aria-label={`Pop out ${SCOPE_LABELS[kind]}`}
title={`Pop out ${SCOPE_LABELS[kind]}`}
>
<span className="scope-strip__popout-icon" aria-hidden="true">
&#8599;
</span>
</button>
<ScopeModule
scopeKind={kind}
lineColor={accent}
@@ -185,14 +219,14 @@ export default function Strip(): JSX.Element {
))}
</div>
{visibleScopes.length > 1 && handleOffsets.map((offset, index) => (
{dockedScopes.length > 1 && handleOffsets.map((offset, index) => (
<button
key={`${visibleScopes[index]}:${visibleScopes[index + 1]}`}
key={`${dockedScopes[index]}:${dockedScopes[index + 1]}`}
type="button"
className="scope-strip__resize-handle"
style={{ left: `${offset}px` }}
onMouseDown={(event) => startResizeDrag(index, event)}
aria-label={`Resize between ${visibleScopes[index]} and ${visibleScopes[index + 1]}`}
aria-label={`Resize between ${dockedScopes[index]} and ${dockedScopes[index + 1]}`}
>
<span className="scope-strip__resize-handle-grip" aria-hidden="true" />
</button>
+85 -137
View File
@@ -68,6 +68,8 @@ interface ToolbarProps {
settingsOpen: boolean
}
const DEFAULT_PROFILE_ID = 'profile_default'
export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps): JSX.Element {
const profiles = useSettingsStore((s) => s.profiles)
const activeProfileId = useSettingsStore((s) => s.activeProfileId)
@@ -78,11 +80,8 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
const updateActiveProfile = useSettingsStore((s) => s.updateActiveProfile)
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(true)
const [showReposition, setShowReposition] = useState(false)
const [showProfileMenu, setShowProfileMenu] = useState(false)
const [renamingId, setRenamingId] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
const profileMenuRef = useRef<HTMLDivElement>(null)
const renameInputRef = useRef<HTMLInputElement>(null)
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false)
const profileButtonRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
window.electronAPI.isAlwaysOnTop().then(setIsAlwaysOnTop)
@@ -90,26 +89,69 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
return unsubscribe
}, [])
// Close profile menu on outside click
useEffect(() => {
if (!showProfileMenu) return
const handleClick = (e: MouseEvent): void => {
if (profileMenuRef.current && !profileMenuRef.current.contains(e.target as Node)) {
setShowProfileMenu(false)
setRenamingId(null)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [showProfileMenu])
const handleSaveNew = useCallback(() => {
const count = Object.keys(useSettingsStore.getState().profiles).length
saveProfile(`Profile ${count}`)
setIsProfileMenuOpen(false)
}, [saveProfile])
// Focus rename input when it appears
useEffect(() => {
if (renamingId && renameInputRef.current) {
renameInputRef.current.focus()
renameInputRef.current.select()
const handleSaveOverwrite = useCallback(() => {
updateActiveProfile()
setIsProfileMenuOpen(false)
}, [updateActiveProfile])
const handleRenameActive = useCallback((id: string) => {
const profile = useSettingsStore.getState().profiles[id]
if (!profile || id === DEFAULT_PROFILE_ID) {
setIsProfileMenuOpen(false)
return
}
}, [renamingId])
const nextName = window.prompt('Rename preset', profile.name)?.trim()
if (nextName) {
renameProfile(id, nextName)
}
setIsProfileMenuOpen(false)
}, [renameProfile])
const handleDeleteActive = useCallback((id: string) => {
const profile = useSettingsStore.getState().profiles[id]
if (!profile || id === DEFAULT_PROFILE_ID) {
setIsProfileMenuOpen(false)
return
}
if (!window.confirm(`Delete "${profile.name}"?`)) {
setIsProfileMenuOpen(false)
return
}
deleteProfile(id)
setIsProfileMenuOpen(false)
}, [deleteProfile])
useEffect(() => {
const offClosed = window.electronAPI.onProfileMenuClosed(() => {
setIsProfileMenuOpen(false)
})
const offLoad = window.electronAPI.onProfileMenuLoad((id) => {
loadProfile(id)
setIsProfileMenuOpen(false)
})
const offSaveNew = window.electronAPI.onProfileMenuSaveNew(handleSaveNew)
const offSaveOverwrite = window.electronAPI.onProfileMenuSaveOverwrite(handleSaveOverwrite)
const offRename = window.electronAPI.onProfileMenuRenameActive(handleRenameActive)
const offDelete = window.electronAPI.onProfileMenuDeleteActive(handleDeleteActive)
return () => {
offClosed()
offLoad()
offSaveNew()
offSaveOverwrite()
offRename()
offDelete()
}
}, [handleDeleteActive, handleRenameActive, handleSaveNew, handleSaveOverwrite, loadProfile])
const handlePin = useCallback(() => {
window.electronAPI.toggleAlwaysOnTop()
@@ -120,30 +162,24 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
setShowReposition(false)
}, [])
const handleSaveNew = useCallback(() => {
const count = Object.keys(profiles).length
saveProfile(`Profile ${count}`)
setShowProfileMenu(false)
}, [profiles, saveProfile])
const handleOpenProfileMenu = useCallback(() => {
const buttonRect = profileButtonRef.current?.getBoundingClientRect()
if (!buttonRect) return
const handleSaveOverwrite = useCallback(() => {
updateActiveProfile()
setShowProfileMenu(false)
}, [updateActiveProfile])
setShowReposition(false)
setIsProfileMenuOpen(true)
window.electronAPI.openProfileMenu({
x: Math.round(buttonRect.left),
y: Math.round(buttonRect.bottom + 4),
activeProfileId,
profiles: Object.entries(profiles).map(([id, profile]) => ({
id,
name: profile.name,
isDefault: id === DEFAULT_PROFILE_ID,
})),
})
}, [activeProfileId, profiles])
const handleStartRename = useCallback((id: string, currentName: string) => {
setRenamingId(id)
setRenameValue(currentName)
}, [])
const handleFinishRename = useCallback(() => {
if (renamingId && renameValue.trim()) {
renameProfile(renamingId, renameValue.trim())
}
setRenamingId(null)
}, [renamingId, renameValue, renameProfile])
const profileIds = Object.keys(profiles)
const activeProfile = activeProfileId ? profiles[activeProfileId] : null
return (
@@ -164,11 +200,12 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
<span className="toolbar__brand-text">Prism</span>
</div>
<div className="toolbar__profile" ref={profileMenuRef}>
<div className="toolbar__profile">
<button
ref={profileButtonRef}
type="button"
className={`toolbar__profile-button ${showProfileMenu ? 'is-active' : ''}`.trim()}
onClick={() => setShowProfileMenu((prev) => !prev)}
className={`toolbar__profile-button ${isProfileMenuOpen ? 'is-active' : ''}`.trim()}
onClick={handleOpenProfileMenu}
title="Presets"
>
<span className="toolbar__profile-name">
@@ -176,95 +213,6 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
</span>
<ChevronIcon />
</button>
{showProfileMenu && (
<div className="toolbar__profile-menu">
<div className="toolbar__profile-menu-section">
<div className="toolbar__profile-menu-label">Presets</div>
{profileIds.map((id) => {
const profile = profiles[id]
const isActive = id === activeProfileId
const isDefault = id === 'profile_default'
if (renamingId === id) {
return (
<div key={id} className="toolbar__profile-menu-item">
<input
ref={renameInputRef}
className="toolbar__profile-rename-input"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={handleFinishRename}
onKeyDown={(e) => {
if (e.key === 'Enter') handleFinishRename()
if (e.key === 'Escape') setRenamingId(null)
}}
/>
</div>
)
}
return (
<div
key={id}
className={`toolbar__profile-menu-item ${isActive ? 'is-active' : ''}`.trim()}
>
<button
type="button"
className="toolbar__profile-menu-item-name"
onClick={() => {
loadProfile(id)
setShowProfileMenu(false)
}}
>
{isActive && <span className="toolbar__profile-check">&#10003;</span>}
{profile.name}
</button>
{!isDefault && (
<div className="toolbar__profile-menu-item-actions">
<button
type="button"
className="toolbar__profile-menu-action"
onClick={() => handleStartRename(id, profile.name)}
title="Rename"
>
&#9998;
</button>
<button
type="button"
className="toolbar__profile-menu-action toolbar__profile-menu-action--danger"
onClick={() => deleteProfile(id)}
title="Delete"
>
&times;
</button>
</div>
)}
</div>
)
})}
</div>
<div className="toolbar__profile-menu-divider" />
<button
type="button"
className="toolbar__profile-menu-action-row"
onClick={handleSaveNew}
>
Save as New Preset
</button>
{activeProfileId && (
<button
type="button"
className="toolbar__profile-menu-action-row"
onClick={handleSaveOverwrite}
>
Save to "{activeProfile?.name}"
</button>
)}
</div>
)}
</div>
<div
+32 -2
View File
@@ -3,6 +3,15 @@
import type { VisualizerDSP } from './audio/native/visualizer-dsp'
import type { CaptureBackendSupport } from '../types/capture'
import type { NativeCaptureAPI } from '../types/nativeCapture'
import type {
ScopePopoutAudioBatch,
ScopePopoutSessionState,
ScopePopoutSnapshot,
ScopePopoutSyncStateMap,
WindowBounds,
} from '../types/popout'
import type { ProfileMenuRequest } from '../types/profileMenu'
import type { ScopeKind } from '../types/scope'
declare global {
interface Window {
@@ -14,8 +23,8 @@ declare global {
close: () => void
startWindowMove: () => void
stopWindowMove: () => void
setWindowBounds: (bounds: { x: number; y: number; width: number; height: number }) => void
getWindowBounds: () => Promise<{ x: number; y: number; width: number; height: number } | null>
setWindowBounds: (bounds: WindowBounds) => void
getWindowBounds: () => Promise<WindowBounds | null>
repositionWindow: (position: 'top' | 'bottom') => void
toggleAlwaysOnTop: () => void
isAlwaysOnTop: () => Promise<boolean>
@@ -24,10 +33,31 @@ declare global {
expandSettings: (panelHeight: number) => void
collapseSettings: (panelHeight: number) => void
setSettingsHeight: (panelHeight: number) => void
openProfileMenu: (request: ProfileMenuRequest) => void
syncScopePopouts: (state: ScopePopoutSyncStateMap) => void
sendScopePopoutSnapshot: (snapshot: ScopePopoutSnapshot) => void
sendScopePopoutAudio: (kind: ScopeKind, batch: ScopePopoutAudioBatch) => void
sendScopePopoutSession: (kind: ScopeKind, session: ScopePopoutSessionState) => void
notifyScopePopoutReady: (kind: ScopeKind) => void
requestScopePopIn: (kind: ScopeKind) => void
sendScopePopoutSettingsUpdate: (kind: ScopeKind, partial: unknown) => void
onAlwaysOnTopChanged: (callback: (isOnTop: boolean) => void) => () => void
onToggleScope: (callback: (index: number) => void) => () => void
onToggleCapture: (callback: () => void) => () => void
onToggleSettings: (callback: () => void) => () => void
onProfileMenuClosed: (callback: () => void) => () => void
onProfileMenuLoad: (callback: (id: string) => void) => () => void
onProfileMenuSaveNew: (callback: () => void) => () => void
onProfileMenuSaveOverwrite: (callback: () => void) => () => void
onProfileMenuRenameActive: (callback: (id: string) => void) => () => void
onProfileMenuDeleteActive: (callback: (id: string) => void) => () => void
onScopePopoutReady: (callback: (kind: ScopeKind) => void) => () => void
onScopePopoutCloseRequested: (callback: (kind: ScopeKind) => void) => () => void
onScopePopoutBoundsChanged: (callback: (kind: ScopeKind, bounds: WindowBounds) => void) => () => void
onScopePopoutSettingsUpdate: (callback: (kind: ScopeKind, partial: unknown) => void) => () => void
onScopePopoutSnapshot: (callback: (snapshot: ScopePopoutSnapshot) => void) => () => void
onScopePopoutAudio: (callback: (kind: ScopeKind, batch: ScopePopoutAudioBatch) => void) => () => void
onScopePopoutSession: (callback: (kind: ScopeKind, session: ScopePopoutSessionState) => void) => () => void
}
}
}
+17 -1
View File
@@ -1,14 +1,30 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import ScopePopoutWindow from './popouts/ScopePopoutWindow'
import './styles/globals.css'
import '@fontsource/inter/400.css'
import '@fontsource/inter/500.css'
import '@fontsource/inter/600.css'
import '@fontsource/jetbrains-mono/400.css'
import { SCOPE_KINDS, type ScopeKind } from '../types/scope'
function isScopeKind(value: string | null): value is ScopeKind {
return value !== null && SCOPE_KINDS.includes(value as ScopeKind)
}
const params = new URLSearchParams(window.location.search)
const windowRole = params.get('window')
const scopeKind = params.get('scope')
const root = windowRole === 'scope-popout'
? isScopeKind(scopeKind)
? <ScopePopoutWindow scopeKind={scopeKind} />
: <div>Invalid scope popout</div>
: <App />
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
{root}
</React.StrictMode>
)
@@ -0,0 +1,128 @@
import type {
ScopePopoutAudioBatch,
ScopePopoutSessionState,
ScopePopoutStereoBatch,
} from '../../types/popout'
import type { ScopeKind } from '../../types/scope'
import type { LUFSMeterDataSource } from '../visualizers/LUFSMeter'
import type { OscilloscopeDataSource } from '../visualizers/Oscilloscope'
import type { SpectrogramDataSource } from '../visualizers/Spectrogram'
import type { SpectrumAnalyzerDataSource } from '../visualizers/SpectrumAnalyzer'
import type { VectorscopeDataSource } from '../visualizers/Vectorscope'
import type { VUMeterDataSource } from '../visualizers/VUMeter'
import type { WaveformDataSource } from '../visualizers/Waveform'
type AnyScopeDataSource =
& SpectrumAnalyzerDataSource
& OscilloscopeDataSource
& VectorscopeDataSource
& SpectrogramDataSource
& VUMeterDataSource
& LUFSMeterDataSource
& WaveformDataSource
const INITIAL_SESSION_STATE: ScopePopoutSessionState = {
sessionId: 0,
sampleRate: 48000,
channelCount: 2,
capturing: false,
backendKind: null,
}
function isStereoBatch(batch: ScopePopoutAudioBatch): batch is ScopePopoutStereoBatch {
return batch.length > 0 && typeof batch[0] === 'object' && batch[0] !== null && 'left' in batch[0] && 'right' in batch[0]
}
function isStereoScope(kind: ScopeKind): boolean {
return kind === 'vectorscope' || kind === 'vumeter' || kind === 'lufsmeter'
}
export class ScopePopoutDataSource implements AnyScopeDataSource {
private monoQueue: Float32Array[] = []
private stereoQueue: ScopePopoutStereoBatch = []
private sessionState: ScopePopoutSessionState = INITIAL_SESSION_STATE
private readonly listeners = new Set<(state: ScopePopoutSessionState) => void>()
constructor(private readonly scopeKind: ScopeKind) {}
pushAudioBatch(batch: ScopePopoutAudioBatch): void {
if (isStereoScope(this.scopeKind)) {
if (!isStereoBatch(batch)) return
this.stereoQueue.push(...batch)
return
}
if (isStereoBatch(batch)) return
this.monoQueue.push(...batch)
}
setSessionState(nextState: ScopePopoutSessionState): void {
this.sessionState = nextState
if (!nextState.capturing) {
this.monoQueue = []
this.stereoQueue = []
}
for (const listener of this.listeners) {
listener(this.sessionState)
}
}
getSampleRate(): number {
return this.sessionState.sampleRate
}
isPlaying(): boolean {
return this.sessionState.capturing
}
subscribeToSessionChanges(listener: (state: ScopePopoutSessionState) => void): () => void {
this.listeners.add(listener)
listener(this.sessionState)
return () => {
this.listeners.delete(listener)
}
}
getPendingSpectrumSamples(): Float32Array[] {
const batch = this.monoQueue
this.monoQueue = []
return this.scopeKind === 'spectrum' ? batch : []
}
getPendingOscilloscopeSamples(): Float32Array[] {
const batch = this.monoQueue
this.monoQueue = []
return this.scopeKind === 'oscilloscope' ? batch : []
}
getPendingSpectrogramSamples(): Float32Array[] {
const batch = this.monoQueue
this.monoQueue = []
return this.scopeKind === 'spectrogram' ? batch : []
}
getPendingWaveformSamples(): Float32Array[] {
const batch = this.monoQueue
this.monoQueue = []
return this.scopeKind === 'waveform' ? batch : []
}
getPendingVectorscopeSamples(): ScopePopoutStereoBatch {
const batch = this.stereoQueue
this.stereoQueue = []
return this.scopeKind === 'vectorscope' ? batch : []
}
getPendingVUMeterSamples(): ScopePopoutStereoBatch {
const batch = this.stereoQueue
this.stereoQueue = []
return this.scopeKind === 'vumeter' ? batch : []
}
getPendingLUFSMeterSamples(): ScopePopoutStereoBatch {
const batch = this.stereoQueue
this.stereoQueue = []
return this.scopeKind === 'lufsmeter' ? batch : []
}
}
+231
View File
@@ -0,0 +1,231 @@
import { useCallback, useEffect, useMemo, useRef, useState, type JSX, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent } from 'react'
import type { ScopePopoutSnapshot } from '../../types/popout'
import { SCOPE_LABELS, type ScopeKind } from '../../types/scope'
import { DEFAULT_SCOPE_SETTINGS, type ScopeSettings } from '../../types/settings'
import ScopeModule from '../components/ScopeModule'
import ScopeSettingsSection from '../components/ScopeSettingsSection'
import { applyAccentToDOM } from '../stores/themeStore'
import { ScopePopoutDataSource } from './ScopePopoutDataSource'
function PopInIcon(): JSX.Element {
return (
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M12.5 3.5h-4M12.5 3.5v4M11.8 4.2 8.6 7.4M3.5 8.5v4h4M4.2 11.8 7.6 8.4" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
function SettingsIcon(): JSX.Element {
return (
<svg viewBox="0 0 16 16" aria-hidden="true">
<circle cx="8" cy="8" r="2.1" fill="none" stroke="currentColor" strokeWidth="1.2" />
<path d="M8 1.8v1.7M8 12.5v1.7M14.2 8h-1.7M3.5 8H1.8M12.2 3.8l-1.2 1.2M5 11l-1.2 1.2M12.2 12.2 11 11M5 5 3.8 3.8" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</svg>
)
}
function GripIcon(): JSX.Element {
return (
<svg viewBox="0 0 16 16" aria-hidden="true">
<circle cx="5.5" cy="4" r="1.1" fill="currentColor" />
<circle cx="10.5" cy="4" r="1.1" fill="currentColor" />
<circle cx="5.5" cy="8" r="1.1" fill="currentColor" />
<circle cx="10.5" cy="8" r="1.1" fill="currentColor" />
<circle cx="5.5" cy="12" r="1.1" fill="currentColor" />
<circle cx="10.5" cy="12" r="1.1" fill="currentColor" />
</svg>
)
}
interface ScopePopoutWindowProps {
scopeKind: ScopeKind
}
export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps): JSX.Element {
const [snapshot, setSnapshot] = useState<ScopePopoutSnapshot<ScopeKind> | null>(null)
const [miniSettingsOpen, setMiniSettingsOpen] = useState(false)
const [chromeHeight, setChromeHeight] = useState(0)
const chromeRef = useRef<HTMLDivElement>(null)
const dataSource = useMemo(() => new ScopePopoutDataSource(scopeKind), [scopeKind])
useEffect(() => {
const unsubscribeSnapshot = window.electronAPI.onScopePopoutSnapshot((nextSnapshot) => {
if (nextSnapshot.kind !== scopeKind) return
setSnapshot(nextSnapshot)
applyAccentToDOM(nextSnapshot.accent)
})
const unsubscribeAudio = window.electronAPI.onScopePopoutAudio((kind, batch) => {
if (kind !== scopeKind) return
dataSource.pushAudioBatch(batch)
})
const unsubscribeSession = window.electronAPI.onScopePopoutSession((kind, sessionState) => {
if (kind !== scopeKind) return
dataSource.setSessionState(sessionState)
})
window.electronAPI.notifyScopePopoutReady(scopeKind)
return () => {
unsubscribeSnapshot()
unsubscribeAudio()
unsubscribeSession()
}
}, [dataSource, scopeKind])
const effectiveAccent = snapshot?.accent ?? '#38bdf8'
const effectiveSettings = (snapshot?.settings ?? DEFAULT_SCOPE_SETTINGS[scopeKind]) as ScopeSettings[ScopeKind]
const handleUpdateScopeSettings = <K extends ScopeKind>(kind: K, partial: Partial<ScopeSettings[K]>): void => {
if (kind !== scopeKind) return
setSnapshot((prev) => {
if (!prev) return prev
return {
...prev,
settings: {
...prev.settings,
...partial,
} as ScopeSettings[K],
}
})
window.electronAPI.sendScopePopoutSettingsUpdate(kind, partial)
}
const handleDragStart = useCallback((event: ReactPointerEvent<HTMLButtonElement>): void => {
if (event.button !== 0) return
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
window.electronAPI.startWindowMove()
}, [])
const handleDragEnd = useCallback((event: ReactPointerEvent<HTMLButtonElement>): void => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
window.electronAPI.stopWindowMove()
}, [])
const handleAltDragStart = useCallback((event: ReactMouseEvent<HTMLDivElement>): void => {
if (!event.altKey || event.button !== 0) return
const target = event.target
if (target instanceof Element && target.closest('.scope-popout__drag-handle')) {
return
}
event.preventDefault()
window.electronAPI.startWindowMove()
}, [])
const handleAltDragEnd = useCallback((): void => {
window.electronAPI.stopWindowMove()
}, [])
useEffect(() => {
const chrome = chromeRef.current
if (!chrome) return
const updateHeight = (): void => {
setChromeHeight(chrome.scrollHeight)
}
updateHeight()
const observer = typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver(() => updateHeight())
observer?.observe(chrome)
return () => observer?.disconnect()
}, [miniSettingsOpen, snapshot])
useEffect(() => {
return () => {
window.electronAPI.stopWindowMove()
}
}, [])
return (
<div
className="scope-popout"
onMouseDown={handleAltDragStart}
onMouseUp={handleAltDragEnd}
>
<div
ref={chromeRef}
className={[
'scope-popout__chrome',
miniSettingsOpen ? 'is-expanded' : '',
].join(' ').trim()}
>
<header className="scope-popout__header">
<div className="scope-popout__drag">
<button
type="button"
className="scope-popout__drag-handle"
onPointerDown={handleDragStart}
onPointerUp={handleDragEnd}
onPointerCancel={handleDragEnd}
onLostPointerCapture={handleDragEnd}
aria-label="Drag window"
title="Drag window"
>
<span className="scope-popout__drag-icon" aria-hidden="true">
<GripIcon />
</span>
</button>
<div className="scope-popout__title-group">
<span className="scope-popout__title">{snapshot?.label ?? SCOPE_LABELS[scopeKind]}</span>
<span className="scope-popout__subtitle">Detached Scope</span>
</div>
</div>
<div className="scope-popout__actions">
<button
type="button"
className={`scope-popout__button ${miniSettingsOpen ? 'is-active' : ''}`.trim()}
onClick={() => setMiniSettingsOpen((prev) => !prev)}
aria-label="Toggle mini settings"
title="Mini settings"
>
<SettingsIcon />
</button>
<button
type="button"
className="scope-popout__button"
onClick={() => window.electronAPI.requestScopePopIn(scopeKind)}
aria-label={`Pop in ${SCOPE_LABELS[scopeKind]}`}
title={`Pop in ${SCOPE_LABELS[scopeKind]}`}
>
<PopInIcon />
</button>
</div>
</header>
{miniSettingsOpen && (
<div className="scope-popout__settings-panel">
<ScopeSettingsSection
kind={scopeKind}
settings={effectiveSettings}
onUpdate={handleUpdateScopeSettings}
/>
</div>
)}
</div>
<div
className="scope-popout__content"
style={miniSettingsOpen ? { paddingTop: `${chromeHeight}px` } : undefined}
>
<div className="scope-popout__canvas-region">
<ScopeModule
scopeKind={scopeKind}
lineColor={effectiveAccent}
settings={effectiveSettings}
dataSource={dataSource}
/>
</div>
</div>
</div>
)
}
+118 -63
View File
@@ -1,64 +1,9 @@
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'
import type { ScopePopoutStateMap, WindowBounds } from '../../types/popout'
import { DEFAULT_SCOPE_SETTINGS, type ScopeSettings } from '../../types/settings'
// 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 },
}
export type { ScopeSettings } from '../../types/settings'
const DEFAULT_VISIBLE: ScopeKind[] = ['spectrum', 'oscilloscope', 'vectorscope', 'vumeter']
@@ -73,7 +18,8 @@ export interface Profile {
hiddenScopes: ScopeKind[]
widthWeights: Record<ScopeKind, number>
scopeSettings: ScopeSettings
windowBounds?: { x: number; y: number; width: number; height: number }
scopePopouts: ScopePopoutStateMap
windowBounds?: WindowBounds
}
function loadProfiles(): Record<string, Profile> {
@@ -111,6 +57,7 @@ interface SettingsState {
hiddenScopes: Set<ScopeKind>
widthWeights: Record<ScopeKind, number>
scopeSettings: ScopeSettings
scopePopouts: ScopePopoutStateMap
// Derived
visibleScopes: () => ScopeKind[]
@@ -124,6 +71,9 @@ interface SettingsState {
moveScope: (kind: ScopeKind, direction: 'left' | 'right') => void
setScopeWidthWeight: (kind: ScopeKind, weight: number) => void
updateScopeSettings: <K extends ScopeKind>(kind: K, settings: Partial<ScopeSettings[K]>) => void
popOutScope: (kind: ScopeKind, bounds?: WindowBounds) => void
popInScope: (kind: ScopeKind) => void
updatePopoutBounds: (kind: ScopeKind, bounds: WindowBounds) => void
saveProfile: (name: string) => string
saveProfileAs: (name: string) => string
updateActiveProfile: () => void
@@ -132,7 +82,13 @@ interface SettingsState {
renameProfile: (id: string, name: string) => void
}
function loadFromStorage(): Partial<{ scopeOrder: ScopeKind[]; hiddenScopes: ScopeKind[]; widthWeights: Record<ScopeKind, number>; scopeSettings: ScopeSettings }> {
function loadFromStorage(): Partial<{
scopeOrder: ScopeKind[]
hiddenScopes: ScopeKind[]
widthWeights: Record<ScopeKind, number>
scopeSettings: ScopeSettings
scopePopouts: ScopePopoutStateMap
}> {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) return JSON.parse(raw)
@@ -147,6 +103,7 @@ function saveToStorage(state: SettingsState): void {
hiddenScopes: Array.from(state.hiddenScopes),
widthWeights: state.widthWeights,
scopeSettings: state.scopeSettings,
scopePopouts: state.scopePopouts,
}))
} catch { /* ignore */ }
}
@@ -199,6 +156,53 @@ function mergeScopeSettings(raw: unknown): ScopeSettings {
}
}
function createDefaultScopePopouts(): ScopePopoutStateMap {
return SCOPE_KINDS.reduce((acc, kind) => {
acc[kind] = { poppedOut: false }
return acc
}, {} as ScopePopoutStateMap)
}
function normalizeWindowBounds(raw: unknown): WindowBounds | undefined {
if (typeof raw !== 'object' || raw === null) return undefined
const candidate = raw as Partial<WindowBounds>
if (
typeof candidate.x !== 'number'
|| typeof candidate.y !== 'number'
|| typeof candidate.width !== 'number'
|| typeof candidate.height !== 'number'
) {
return undefined
}
return {
x: Math.round(candidate.x),
y: Math.round(candidate.y),
width: Math.max(120, Math.round(candidate.width)),
height: Math.max(80, Math.round(candidate.height)),
}
}
function normalizeScopePopouts(raw: unknown): ScopePopoutStateMap {
const defaults = createDefaultScopePopouts()
const parsed = typeof raw === 'object' && raw !== null
? raw as Partial<Record<ScopeKind, Partial<ScopePopoutStateMap[ScopeKind]>>>
: {}
for (const kind of SCOPE_KINDS) {
const value = parsed[kind]
defaults[kind] = {
poppedOut: Boolean(value?.poppedOut),
windowBounds: normalizeWindowBounds(value?.windowBounds),
}
}
return defaults
}
function cloneScopeSettings(settings: ScopeSettings): ScopeSettings {
return JSON.parse(JSON.stringify(settings)) as ScopeSettings
}
const stored = loadFromStorage()
const defaultWeights: Record<ScopeKind, number> = {
@@ -214,7 +218,8 @@ function ensureDefaultProfile(profiles: Record<string, Profile>): Record<string,
scopeOrder: [...SCOPE_KINDS],
hiddenScopes: SCOPE_KINDS.filter((kind) => !DEFAULT_VISIBLE.includes(kind)),
widthWeights: { ...defaultWeights },
scopeSettings: JSON.parse(JSON.stringify(DEFAULT_SCOPE_SETTINGS)),
scopeSettings: cloneScopeSettings(DEFAULT_SCOPE_SETTINGS),
scopePopouts: createDefaultScopePopouts(),
}
const updated = { [DEFAULT_PROFILE_ID]: defaultProfile, ...profiles }
saveProfiles(updated)
@@ -231,6 +236,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
),
widthWeights: stored.widthWeights ?? { ...defaultWeights },
scopeSettings: mergeScopeSettings(stored.scopeSettings),
scopePopouts: normalizeScopePopouts(stored.scopePopouts),
profiles: initialProfiles,
activeProfileId: initialActiveProfileId,
@@ -292,6 +298,51 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
})
},
popOutScope: (kind: ScopeKind, bounds?: WindowBounds) => {
set((state) => {
const nextPopout = {
...state.scopePopouts,
[kind]: {
poppedOut: true,
windowBounds: bounds ?? state.scopePopouts[kind]?.windowBounds,
},
}
const newState = { ...state, scopePopouts: nextPopout }
saveToStorage(newState as SettingsState)
return newState
})
},
popInScope: (kind: ScopeKind) => {
set((state) => {
const nextPopout = {
...state.scopePopouts,
[kind]: {
...state.scopePopouts[kind],
poppedOut: false,
},
}
const newState = { ...state, scopePopouts: nextPopout }
saveToStorage(newState as SettingsState)
return newState
})
},
updatePopoutBounds: (kind: ScopeKind, bounds: WindowBounds) => {
set((state) => {
const nextPopout = {
...state.scopePopouts,
[kind]: {
...state.scopePopouts[kind],
windowBounds: normalizeWindowBounds(bounds),
},
}
const newState = { ...state, scopePopouts: nextPopout }
saveToStorage(newState as SettingsState)
return newState
})
},
saveProfile: (name: string) => {
const state = get()
const id = `profile_${Date.now()}`
@@ -300,7 +351,8 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
scopeOrder: [...state.scopeOrder],
hiddenScopes: Array.from(state.hiddenScopes),
widthWeights: { ...state.widthWeights },
scopeSettings: JSON.parse(JSON.stringify(state.scopeSettings)),
scopeSettings: cloneScopeSettings(state.scopeSettings),
scopePopouts: normalizeScopePopouts(state.scopePopouts),
}
// Capture window bounds asynchronously
window.electronAPI.getWindowBounds().then((bounds) => {
@@ -332,7 +384,8 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
scopeOrder: [...state.scopeOrder],
hiddenScopes: Array.from(state.hiddenScopes),
widthWeights: { ...state.widthWeights },
scopeSettings: JSON.parse(JSON.stringify(state.scopeSettings)),
scopeSettings: cloneScopeSettings(state.scopeSettings),
scopePopouts: normalizeScopePopouts(state.scopePopouts),
}
// Capture window bounds asynchronously
window.electronAPI.getWindowBounds().then((bounds) => {
@@ -358,6 +411,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
hiddenScopes: new Set<ScopeKind>(normalizeHiddenScopes(profile.hiddenScopes)),
widthWeights: profile.widthWeights ?? { ...defaultWeights },
scopeSettings: mergeScopeSettings(profile.scopeSettings),
scopePopouts: normalizeScopePopouts(profile.scopePopouts),
activeProfileId: id,
}
saveToStorage(newState as SettingsState)
@@ -403,5 +457,6 @@ if (initialActiveProfileId && initialProfiles[initialActiveProfileId]) {
hiddenScopes: new Set<ScopeKind>(normalizeHiddenScopes(profile.hiddenScopes)),
widthWeights: profile.widthWeights ?? { ...defaultWeights },
scopeSettings: mergeScopeSettings(profile.scopeSettings),
scopePopouts: normalizeScopePopouts(profile.scopePopouts),
})
}
+4 -4
View File
@@ -90,7 +90,7 @@ function loadTheme(): { presetId: string; customAccent: string | null } {
return { presetId: 'default', customAccent: null }
}
function applyToDOM(accent: string): void {
export function applyAccentToDOM(accent: string): void {
const rgb = hexToRgb(accent)
const root = document.documentElement
root.style.setProperty('--accent', accent)
@@ -104,7 +104,7 @@ const initialPreset = PRESETS[stored.presetId] ?? PRESETS.default
const initialAccent = stored.customAccent ?? initialPreset.accent
// Apply immediately on load
applyToDOM(initialAccent)
applyAccentToDOM(initialAccent)
export const useThemeStore = create<ThemeState>((set) => ({
presetId: stored.presetId,
@@ -113,7 +113,7 @@ export const useThemeStore = create<ThemeState>((set) => ({
setPreset: (id: string) => {
const preset = PRESETS[id] ?? PRESETS.default
applyToDOM(preset.accent)
applyAccentToDOM(preset.accent)
const state = { presetId: id, customAccent: null, accent: preset.accent }
localStorage.setItem(STORAGE_KEY, JSON.stringify({ presetId: id, customAccent: null }))
set(state)
@@ -123,7 +123,7 @@ export const useThemeStore = create<ThemeState>((set) => ({
set((prev) => {
const preset = PRESETS[prev.presetId] ?? PRESETS.default
const accent = hex ?? preset.accent
applyToDOM(accent)
applyAccentToDOM(accent)
localStorage.setItem(STORAGE_KEY, JSON.stringify({ presetId: prev.presetId, customAccent: hex }))
return { ...prev, customAccent: hex, accent }
})
+214 -142
View File
@@ -203,148 +203,6 @@ select {
white-space: nowrap;
}
.toolbar__profile-menu {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
min-width: 200px;
background: rgba(10, 10, 12, 0.96);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: 4px 0;
z-index: 1000;
backdrop-filter: blur(12px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
}
.toolbar__profile-menu-section {
padding: 2px 0;
}
.toolbar__profile-menu-label {
padding: 4px 12px;
font-family: 'Inter', sans-serif;
font-size: 9px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.3);
}
.toolbar__profile-menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 4px 0 0;
}
.toolbar__profile-menu-item.is-active .toolbar__profile-menu-item-name {
color: rgba(255, 255, 255, 0.9);
}
.toolbar__profile-menu-item-name {
flex: 1;
min-width: 0;
padding: 5px 12px;
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
font-family: 'Inter', sans-serif;
font-size: 11px;
text-align: left;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.toolbar__profile-menu-item-name:hover {
color: rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.04);
}
.toolbar__profile-check {
margin-right: 6px;
font-size: 10px;
color: var(--accent);
}
.toolbar__profile-menu-item-actions {
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 100ms ease;
}
.toolbar__profile-menu-item:hover .toolbar__profile-menu-item-actions {
opacity: 1;
}
.toolbar__profile-menu-action {
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
border-radius: 4px;
color: rgba(255, 255, 255, 0.4);
font-size: 12px;
cursor: pointer;
}
.toolbar__profile-menu-action:hover {
color: rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.06);
}
.toolbar__profile-menu-action--danger:hover {
color: #ff5f57;
background: rgba(255, 95, 87, 0.1);
}
.toolbar__profile-rename-input {
flex: 1;
margin: 2px 8px;
padding: 3px 8px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
color: rgba(255, 255, 255, 0.9);
font-family: 'Inter', sans-serif;
font-size: 11px;
outline: none;
}
.toolbar__profile-rename-input:focus {
border-color: var(--accent);
}
.toolbar__profile-menu-divider {
height: 1px;
margin: 4px 0;
background: rgba(255, 255, 255, 0.06);
}
.toolbar__profile-menu-action-row {
display: block;
width: 100%;
padding: 6px 12px;
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
font-family: 'Inter', sans-serif;
font-size: 11px;
text-align: left;
cursor: pointer;
}
.toolbar__profile-menu-action-row:hover {
color: rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.04);
}
.toolbar__spacer {
flex: 1;
min-width: 0;
@@ -486,6 +344,46 @@ select {
overflow: hidden;
}
.scope-strip__popout-button {
position: absolute;
bottom: 8px;
right: 8px;
z-index: 3;
width: 28px;
height: 28px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(8, 12, 18, 0.74);
color: rgba(255, 255, 255, 0.76);
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
cursor: pointer;
opacity: 0;
transform: translateY(-2px);
transition: opacity 120ms ease, transform 120ms ease, border-color 120ms ease, color 120ms ease;
backdrop-filter: blur(14px) saturate(1.04);
-webkit-backdrop-filter: blur(14px) saturate(1.04);
}
.scope-strip__cell:hover .scope-strip__popout-button,
.scope-strip__popout-button:focus-visible {
opacity: 1;
transform: translateY(0);
}
.scope-strip__popout-button:hover {
color: var(--accent);
border-color: rgba(var(--accent-rgb), 0.28);
}
.scope-strip__popout-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.scope-strip__resize-handle {
position: absolute;
top: 0;
@@ -906,6 +804,180 @@ select {
margin-right: 14px;
}
.scope-popout {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
background:
radial-gradient(circle at top, rgba(var(--accent-rgb), 0.1), transparent 26%),
linear-gradient(180deg, rgba(3, 4, 6, 0.98), rgba(0, 0, 0, 1));
color: var(--text-primary);
}
.scope-popout__chrome {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
max-height: 0;
opacity: 0;
overflow: hidden;
pointer-events: none;
transform: translateY(-8px);
transition: max-height 160ms ease, opacity 120ms ease, transform 160ms ease;
}
.scope-popout:hover .scope-popout__chrome,
.scope-popout__chrome:hover,
.scope-popout__chrome.is-expanded {
max-height: 58px;
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.scope-popout__chrome.is-expanded {
max-height: 420px;
}
.scope-popout__header {
display: flex;
align-items: center;
gap: 10px;
min-height: 42px;
padding: 8px 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(7, 10, 14, 0.92);
}
.scope-popout__drag {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
}
.scope-popout__drag-handle {
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0;
border: 0;
border-radius: 999px;
background: transparent;
color: inherit;
cursor: grab;
-webkit-app-region: no-drag;
}
.scope-popout__drag-handle:active {
cursor: grabbing;
}
.scope-popout__drag-icon {
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.32);
}
.scope-popout__drag-icon svg {
width: 14px;
height: 14px;
}
.scope-popout__title-group {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.scope-popout__title {
font-family: 'JetBrains Mono', monospace;
letter-spacing: 0.12em;
text-transform: uppercase;
font-size: 10px;
color: var(--accent);
}
.scope-popout__subtitle {
font-size: 10px;
color: var(--text-tertiary);
}
.scope-popout__actions {
display: flex;
align-items: center;
gap: 6px;
-webkit-app-region: no-drag;
}
.scope-popout__button {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.03);
color: rgba(255, 255, 255, 0.64);
cursor: pointer;
transition: color 120ms ease, border-color 120ms ease, background-color 120ms ease, transform 120ms ease;
}
.scope-popout__button svg {
width: 14px;
height: 14px;
}
.scope-popout__button:hover,
.scope-popout__button.is-active {
color: var(--accent);
border-color: rgba(var(--accent-rgb), 0.28);
background: rgba(var(--accent-rgb), 0.1);
transform: translateY(-1px);
}
.scope-popout__settings-panel {
flex-shrink: 0;
overflow-y: auto;
overflow-x: hidden;
background: linear-gradient(180deg, rgba(6, 8, 11, 0.98), rgba(4, 5, 7, 0.98));
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
padding: 12px 0 8px;
}
.scope-popout__settings-panel .settings-scope-section {
border-left: 0;
padding: 0 14px 12px;
}
.scope-popout__content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
transition: padding-top 160ms ease;
}
.scope-popout__canvas-region {
flex: 1;
min-height: 0;
position: relative;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
+3 -5
View File
@@ -1,10 +1,9 @@
import { audioRouter } from '../audio/AudioRouter'
import type { LUFSMeterMode } from '../../types/lufsmeter'
import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource'
export interface LUFSMeterDataSource {
export interface LUFSMeterDataSource extends VisualizerSessionSource {
getPendingLUFSMeterSamples: () => Array<{ left: Float32Array; right: Float32Array }>
getSampleRate: () => number
isPlaying: () => boolean
}
export interface LUFSMeterOptions {
@@ -22,8 +21,7 @@ const defaultOptions: ResolvedLUFSMeterOptions = {
const defaultLUFSMeterDataSource: LUFSMeterDataSource = {
getPendingLUFSMeterSamples: () => audioRouter.flushPendingLUFSMeterSamples(),
getSampleRate: () => audioRouter.getSampleRate(),
isPlaying: () => audioRouter.isCapturing(),
...defaultVisualizerSessionSource,
}
// ---- Constants ----
+29 -9
View File
@@ -5,6 +5,11 @@ import {
isNativeAvailable
} from '../audio/native'
import { getNormalizedOscilloscopeDisplaySamples } from '../audio/native/oscilloscopeDisplaySamples'
import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource'
export interface OscilloscopeDataSource extends VisualizerSessionSource {
getPendingOscilloscopeSamples: () => Float32Array[]
}
export interface OscilloscopeOptions {
lineColor?: string
@@ -14,9 +19,12 @@ export interface OscilloscopeOptions {
gridColor?: string
pitchLock?: boolean
underfillEnabled?: boolean
dataSource?: OscilloscopeDataSource
}
const defaultOptions: Required<OscilloscopeOptions> = {
type ResolvedOscilloscopeOptions = Required<Omit<OscilloscopeOptions, 'dataSource'>>
const defaultOptions: ResolvedOscilloscopeOptions = {
lineColor: '#00ffff',
lineWidth: 2,
backgroundColor: 'transparent',
@@ -26,6 +34,11 @@ const defaultOptions: Required<OscilloscopeOptions> = {
underfillEnabled: false
}
const defaultOscilloscopeDataSource: OscilloscopeDataSource = {
getPendingOscilloscopeSamples: () => audioRouter.flushPendingOscilloscopeSamples(),
...defaultVisualizerSessionSource,
}
function parseRgbChannels(color: string): string | null {
const normalized = color.trim()
@@ -90,7 +103,8 @@ function highContrastUnderfillColor(accentColor: string, alpha: number): string
export class Oscilloscope {
private canvas: HTMLCanvasElement
private ctx: CanvasRenderingContext2D
private options: Required<OscilloscopeOptions>
private options: ResolvedOscilloscopeOptions
private dataSource: OscilloscopeDataSource
private animationId: number | null = null
private isRunning: boolean = false
private nativeInitialized: boolean = false
@@ -104,11 +118,13 @@ export class Oscilloscope {
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not get 2D context')
this.ctx = ctx
this.options = { ...defaultOptions, ...options }
const { dataSource, ...optionOverrides } = options
this.options = { ...defaultOptions, ...optionOverrides }
this.dataSource = dataSource ?? defaultOscilloscopeDataSource
// Initialize native module
this.initNative()
this.unsubscribeSessionChange = audioRouter.subscribeToSessionChanges(() => {
this.unsubscribeSessionChange = this.dataSource.subscribeToSessionChanges(() => {
this.reset()
})
}
@@ -118,7 +134,7 @@ export class Oscilloscope {
// 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()
const sampleRate = this.dataSource.getSampleRate()
this.lastSampleRate = 0
nativeOscilloscope.setSampleRate(sampleRate)
nativeOscilloscope.setPitchLock(this.options.pitchLock)
@@ -133,7 +149,7 @@ export class Oscilloscope {
// Update sample rate if AudioContext changes (called from draw loop)
private updateSampleRateIfNeeded(): void {
if (!isNativeAvailable()) return
const currentRate = audioRouter.getSampleRate()
const currentRate = this.dataSource.getSampleRate()
if (currentRate !== this.lastSampleRate && currentRate > 0) {
this.lastSampleRate = currentRate
nativeOscilloscope.setSampleRate(currentRate)
@@ -143,7 +159,11 @@ export class Oscilloscope {
}
setOptions(options: Partial<OscilloscopeOptions>): void {
this.options = { ...this.options, ...options }
const { dataSource, ...optionUpdates } = options
this.options = { ...this.options, ...optionUpdates }
if (dataSource) {
this.dataSource = dataSource
}
// Update native module settings
if (isNativeAvailable() && options.pitchLock !== undefined) {
@@ -196,13 +216,13 @@ export class Oscilloscope {
// Check if sample rate needs updating (AudioContext may have initialized after us)
this.updateSampleRateIfNeeded()
if (!audioRouter.isCapturing()) {
if (!this.dataSource.isPlaying()) {
this.animationId = requestAnimationFrame(this.draw)
return
}
// Flush ALL pending samples to native C++ (prevents sample loss)
const pendingSamples = audioRouter.flushPendingOscilloscopeSamples()
const pendingSamples = this.dataSource.getPendingOscilloscopeSamples()
for (const chunk of pendingSamples) {
nativeOscilloscope.pushSamples(chunk)
this.samplesReceived += chunk.length
+3 -5
View File
@@ -1,4 +1,5 @@
import { audioRouter } from '../audio/AudioRouter'
import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource'
import {
DEFAULT_SPECTROGRAM_CLARITY_MODE,
DEFAULT_SPECTROGRAM_SCALE_MODE,
@@ -10,10 +11,8 @@ import {
type SpectrogramScaleMode,
} from '../../types/spectrogram'
export interface SpectrogramDataSource {
export interface SpectrogramDataSource extends VisualizerSessionSource {
getPendingSpectrogramSamples: () => Float32Array[]
getSampleRate: () => number
isPlaying: () => boolean
}
export interface SpectrogramOptions {
@@ -53,8 +52,7 @@ const defaultOptions: ResolvedSpectrogramOptions = {
const defaultSpectrogramDataSource: SpectrogramDataSource = {
getPendingSpectrogramSamples: () => audioRouter.flushPendingSpectrogramSamples(),
getSampleRate: () => audioRouter.getSampleRate(),
isPlaying: () => audioRouter.isCapturing(),
...defaultVisualizerSessionSource,
}
function getClarityProfile(mode: SpectrogramClarityMode): SpectrogramClarityProfile {
+4 -6
View File
@@ -1,5 +1,6 @@
import { audioRouter } from '../audio/AudioRouter'
import { spectrum as nativeSpectrum, isNativeAvailable } from '../audio/native'
import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource'
import {
DEFAULT_SPECTRUM_TILT_DB_PER_OCTAVE,
DEFAULT_SPECTRUM_HEATMAP_TILT_DB_PER_OCTAVE,
@@ -7,10 +8,8 @@ import {
clampSpectrumHeatmapTiltDbPerOctave,
} from '../../types/spectrum'
export interface SpectrumAnalyzerDataSource {
export interface SpectrumAnalyzerDataSource extends VisualizerSessionSource {
getPendingSpectrumSamples: () => Float32Array[]
getSampleRate: () => number
isPlaying: () => boolean
}
export interface SpectrumAnalyzerOptions {
@@ -90,8 +89,7 @@ const defaultOptions: ResolvedSpectrumAnalyzerOptions = {
const defaultSpectrumDataSource: SpectrumAnalyzerDataSource = {
getPendingSpectrumSamples: () => audioRouter.flushPendingSpectrumSamples(),
getSampleRate: () => audioRouter.getSampleRate(),
isPlaying: () => audioRouter.isCapturing(),
...defaultVisualizerSessionSource,
}
export class SpectrumAnalyzer {
@@ -127,7 +125,7 @@ export class SpectrumAnalyzer {
// Initialize native module
this.initNative()
this.unsubscribeSessionChange = audioRouter.subscribeToSessionChanges(() => {
this.unsubscribeSessionChange = this.dataSource.subscribeToSessionChanges(() => {
this.resetState()
})
}
+4 -6
View File
@@ -1,14 +1,13 @@
import { audioRouter } from '../audio/AudioRouter'
import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource'
import {
DEFAULT_VU_METER_ORIENTATION,
type VUMeterMode,
type VUMeterOrientation,
} from '../../types/vumeter'
export interface VUMeterDataSource {
export interface VUMeterDataSource extends VisualizerSessionSource {
getPendingVUMeterSamples: () => Array<{ left: Float32Array; right: Float32Array }>
getSampleRate: () => number
isPlaying: () => boolean
}
export interface VUMeterOptions {
@@ -28,8 +27,7 @@ const defaultOptions: ResolvedVUMeterOptions = {
const defaultVUMeterDataSource: VUMeterDataSource = {
getPendingVUMeterSamples: () => audioRouter.flushPendingVUMeterSamples(),
getSampleRate: () => audioRouter.getSampleRate(),
isPlaying: () => audioRouter.isCapturing(),
...defaultVisualizerSessionSource,
}
// ---- Meter constants ----
@@ -85,7 +83,7 @@ export class VUMeter {
const { dataSource, ...optionOverrides } = options
this.options = { ...defaultOptions, ...optionOverrides }
this.dataSource = dataSource ?? defaultVUMeterDataSource
this.unsubscribeSessionChange = audioRouter.subscribeToSessionChanges(() => {
this.unsubscribeSessionChange = this.dataSource.subscribeToSessionChanges(() => {
this.resetMeters()
})
}
+30 -10
View File
@@ -2,9 +2,14 @@ import { audioRouter } from '../audio/AudioRouter'
import { vectorscope as nativeVectorscope, isNativeAvailable } from '../audio/native'
import { transformPoint, drawVectorscopeGridForMode, getVectorscopeLayout } from './vectorscopeGrids'
import { MultibandSplitter, MultibandBuffer, BAND_COLORS } from './multibandSplitter'
import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource'
export type VectorscopeMode = 'lissajous' | 'polar-unipolar' | 'polar-bipolar' | 'linear-unipolar' | 'linear-bipolar'
export interface VectorscopeDataSource extends VisualizerSessionSource {
getPendingVectorscopeSamples: () => Array<{ left: Float32Array; right: Float32Array }>
}
export interface VectorscopeOptions {
lineColor?: string
lineWidth?: number
@@ -15,9 +20,12 @@ export interface VectorscopeOptions {
displayPoints?: number // how many points to request from native, default 4096
mode?: VectorscopeMode
multiband?: boolean
dataSource?: VectorscopeDataSource
}
const defaultOptions: Required<VectorscopeOptions> = {
type ResolvedVectorscopeOptions = Required<Omit<VectorscopeOptions, 'dataSource'>>
const defaultOptions: ResolvedVectorscopeOptions = {
lineColor: '#00ffff',
lineWidth: 1.5,
backgroundColor: 'transparent',
@@ -29,6 +37,11 @@ const defaultOptions: Required<VectorscopeOptions> = {
multiband: false,
}
const defaultVectorscopeDataSource: VectorscopeDataSource = {
getPendingVectorscopeSamples: () => audioRouter.flushPendingVectorscopeSamples(),
...defaultVisualizerSessionSource,
}
const BAND_ORDER = ['low', 'mid', 'high'] as const
export class Vectorscope {
@@ -36,7 +49,8 @@ export class Vectorscope {
private ctx: CanvasRenderingContext2D
private offscreenCanvas: HTMLCanvasElement
private offscreenCtx: CanvasRenderingContext2D
private options: Required<VectorscopeOptions>
private options: ResolvedVectorscopeOptions
private dataSource: VectorscopeDataSource
private animationId: number | null = null
private isRunning: boolean = false
private nativeInitialized: boolean = false
@@ -50,7 +64,9 @@ export class Vectorscope {
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not get 2D context')
this.ctx = ctx
this.options = { ...defaultOptions, ...options }
const { dataSource, ...optionOverrides } = options
this.options = { ...defaultOptions, ...optionOverrides }
this.dataSource = dataSource ?? defaultVectorscopeDataSource
// Create offscreen canvas for persistence/fade
this.offscreenCanvas = document.createElement('canvas')
@@ -62,14 +78,14 @@ export class Vectorscope {
// Initialize native module if available
this.initNative()
this.unsubscribeSessionChange = audioRouter.subscribeToSessionChanges(() => {
this.unsubscribeSessionChange = this.dataSource.subscribeToSessionChanges(() => {
this.resetDisplay()
})
}
private initNative(): void {
if (isNativeAvailable() && !this.nativeInitialized) {
const sampleRate = audioRouter.getSampleRate()
const sampleRate = this.dataSource.getSampleRate()
this.lastSampleRate = sampleRate
nativeVectorscope.setSampleRate(sampleRate)
this.nativeInitialized = true
@@ -80,7 +96,7 @@ export class Vectorscope {
}
private updateSampleRateIfNeeded(): void {
const currentRate = audioRouter.getSampleRate()
const currentRate = this.dataSource.getSampleRate()
if (currentRate !== this.lastSampleRate && currentRate > 0) {
this.lastSampleRate = currentRate
if (isNativeAvailable()) {
@@ -101,7 +117,11 @@ export class Vectorscope {
}
setOptions(options: Partial<VectorscopeOptions>): void {
this.options = { ...this.options, ...options }
const { dataSource, ...optionUpdates } = options
this.options = { ...this.options, ...optionUpdates }
if (dataSource) {
this.dataSource = dataSource
}
}
start(): void {
@@ -144,7 +164,7 @@ export class Vectorscope {
// Update sample rate if changed
this.updateSampleRateIfNeeded()
if (!audioRouter.isCapturing()) {
if (!this.dataSource.isPlaying()) {
ctx.clearRect(0, 0, width, height)
if (options.backgroundColor !== 'transparent') {
ctx.fillStyle = options.backgroundColor
@@ -165,7 +185,7 @@ export class Vectorscope {
offscreenCtx.globalCompositeOperation = 'source-over'
// ---- FLUSH SAMPLES ----
const pendingSamples = audioRouter.flushPendingVectorscopeSamples()
const pendingSamples = this.dataSource.getPendingVectorscopeSamples()
if (options.multiband) {
// Multiband path: split into 3 bands, render each with its own color
@@ -293,7 +313,7 @@ export class Vectorscope {
const dotSize = options.lineWidth * dpr
// Ensure splitter is configured
const sampleRate = audioRouter.getSampleRate()
const sampleRate = this.dataSource.getSampleRate()
if (sampleRate > 0) {
this.splitter.configure(sampleRate)
}
+3 -5
View File
@@ -1,4 +1,5 @@
import { audioRouter } from '../audio/AudioRouter'
import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource'
import {
DEFAULT_WAVEFORM_GAIN_DB,
DEFAULT_WAVEFORM_SCROLL_SPEED,
@@ -7,10 +8,8 @@ import {
} from '../../types/waveform'
import { MultibandSplitter } from './multibandSplitter'
export interface WaveformDataSource {
export interface WaveformDataSource extends VisualizerSessionSource {
getPendingWaveformSamples: () => Float32Array[]
getSampleRate: () => number
isPlaying: () => boolean
}
export interface WaveformOptions {
@@ -42,8 +41,7 @@ const MULTIBAND_EDGE_ALPHA = 1.0
const defaultWaveformDataSource: WaveformDataSource = {
getPendingWaveformSamples: () => audioRouter.flushPendingWaveformSamples(),
getSampleRate: () => audioRouter.getSampleRate(),
isPlaying: () => audioRouter.isCapturing(),
...defaultVisualizerSessionSource,
}
// Calibrate 1.0x to the prior 8s window at roughly 512px wide,
+14
View File
@@ -0,0 +1,14 @@
import { audioRouter } from '../audio/AudioRouter'
import type { ScopePopoutSessionState } from '../../types/popout'
export interface VisualizerSessionSource {
getSampleRate: () => number
isPlaying: () => boolean
subscribeToSessionChanges: (listener: (state: ScopePopoutSessionState) => void) => () => void
}
export const defaultVisualizerSessionSource: VisualizerSessionSource = {
getSampleRate: () => audioRouter.getSampleRate(),
isPlaying: () => audioRouter.isCapturing(),
subscribeToSessionChanges: (listener) => audioRouter.subscribeToSessionChanges((state) => listener(state)),
}
+48
View File
@@ -0,0 +1,48 @@
import type { CaptureBackendKind } from './capture'
import type { ScopeKind } from './scope'
import type { ScopeSettings } from './settings'
export interface WindowBounds {
x: number
y: number
width: number
height: number
}
export interface ScopePopoutState {
poppedOut: boolean
windowBounds?: WindowBounds
}
export type ScopePopoutStateMap = Record<ScopeKind, ScopePopoutState>
export interface ScopePopoutSyncState {
shouldBeOpen: boolean
bounds?: WindowBounds
}
export type ScopePopoutSyncStateMap = Record<ScopeKind, ScopePopoutSyncState>
export interface ScopePopoutSessionState {
sessionId: number
sampleRate: number
channelCount: number
capturing: boolean
backendKind: CaptureBackendKind | null
}
export interface ScopePopoutStereoChunk {
left: Float32Array
right: Float32Array
}
export type ScopePopoutMonoBatch = Float32Array[]
export type ScopePopoutStereoBatch = ScopePopoutStereoChunk[]
export type ScopePopoutAudioBatch = ScopePopoutMonoBatch | ScopePopoutStereoBatch
export interface ScopePopoutSnapshot<K extends ScopeKind = ScopeKind> {
kind: K
label: string
accent: string
settings: ScopeSettings[K]
}
+12
View File
@@ -0,0 +1,12 @@
export interface ProfileMenuProfileSummary {
id: string
name: string
isDefault: boolean
}
export interface ProfileMenuRequest {
x: number
y: number
profiles: ProfileMenuProfileSummary[]
activeProfileId: string | null
}
+10
View File
@@ -9,3 +9,13 @@ export const SCOPE_KINDS: ScopeKind[] = [
'lufsmeter',
'waveform',
]
export const SCOPE_LABELS: Record<ScopeKind, string> = {
spectrum: 'Spectrum',
oscilloscope: 'Oscilloscope',
vectorscope: 'Vectorscope',
spectrogram: 'Spectrogram',
vumeter: 'VU Meter',
lufsmeter: 'LUFS Meter',
waveform: 'Waveform',
}
+58
View File
@@ -0,0 +1,58 @@
import type { VectorscopeMode } from '../renderer/visualizers/Vectorscope'
import type { SpectrogramClarityMode, SpectrogramScaleMode } from './spectrogram'
import type { VUMeterMode, VUMeterOrientation } from './vumeter'
import type { LUFSMeterMode } from './lufsmeter'
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
}
}
export 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 },
}