From 8bf255082963796cf0c9b5de923089dc9597176a Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Sat, 22 Aug 2026 12:59:58 -0400 Subject: [PATCH 01/27] linked analysis --- README.md | 3 +- src/main/index.ts | 64 +++++ src/main/profileLibrary.ts | 1 + src/preload/index.ts | 7 + src/renderer/App.tsx | 10 +- src/renderer/components/BottomBar.tsx | 21 ++ .../components/ScopeMeasurementOverlay.tsx | 128 +++++++++- src/renderer/components/ScopeModule.tsx | 82 ++++++- src/renderer/components/ScopePopoutBridge.tsx | 5 +- src/renderer/components/Strip.tsx | 26 +- src/renderer/env.d.ts | 3 + src/renderer/popouts/ScopePopoutWindow.tsx | 6 + src/renderer/scopeMeasurement.ts | 155 +++++++++++- src/renderer/stores/profileDraft.ts | 3 + src/renderer/stores/settingsStore.ts | 27 +++ src/renderer/styles/globals.css | 56 +++++ src/renderer/useLinkedAnalysis.ts | 107 +++++++++ src/renderer/visualizers/Oscilloscope.ts | 6 + src/renderer/visualizers/Spectrogram.ts | 14 ++ src/renderer/visualizers/SpectrumAnalyzer.ts | 11 + src/renderer/visualizers/Waveform.ts | 10 + src/shared/profileState.ts | 19 +- src/types/analysis.ts | 124 ++++++++++ src/types/popout.ts | 2 + src/types/profile.ts | 20 +- test/profile-library.test.ts | 20 ++ test/renderer-helpers.test.ts | 223 +++++++++++++++++- 27 files changed, 1132 insertions(+), 21 deletions(-) create mode 100644 src/renderer/useLinkedAnalysis.ts create mode 100644 src/types/analysis.ts diff --git a/README.md b/README.md index c961a37..9d59ade 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Every scope can be configured independently. Resize and rearrange them into a ra ![Prism customize](assets/prism-showcase-layout-readme.gif) -Spectrum, Spectrogram, Oscilloscope, and Waveform also include interactive measurement overlays for inspecting frequency, level, pitch, amplitude, or time directly from the display. +Spectrum, Spectrogram, Oscilloscope, and Waveform also include interactive measurement overlays for inspecting frequency, level, pitch, amplitude, or time directly from the display. Optional Linked Analysis mirrors compatible frequency, history-time, and amplitude guides across docked and detached scopes.
Stats for nerds @@ -170,6 +170,7 @@ Most of Prism's analysis runs in native C++, with the same DSP implementations r * Desktop visualizers can target 10, 30, 60, 120, or 144 FPS, or synchronize to the display refresh rate * Spectrum, Spectrogram, Oscilloscope, and Waveform support interactive measurement overlays * Measurement coordinates account for scope rotation and mirroring, so displayed readouts continue to correspond to the underlying signal after transforming a scope +* Linked Analysis maps only compatible semantic axes between scopes and hides guides when an exact value is outside the target's visible range
diff --git a/src/main/index.ts b/src/main/index.ts index 8308df4..6293727 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -16,6 +16,12 @@ import type { import type { ProfileMenuRequest } from '../types/profileMenu' import type { LegacyProfileMigrationPayload, Profile } from '../types/profile' import { SCOPE_KINDS, SCOPE_LABELS, type ScopeKind } from '../types/scope' +import { + isLinkedAnalysisCompatibleScopeKind, + normalizeLinkedAnalysisMessage, + type LinkedAnalysisMessage, + type LinkedAnalysisProbe, +} from '../types/analysis' import type { LegacyThemeMigrationPayload, ThemeLibrarySnapshot, @@ -107,6 +113,8 @@ const scopePopoutWindows = new Map() const scopePopoutCloseAllowed = new Set() const popoutBoundsTimers = new Map>() const suppressNextPopoutBoundsEvents = new Set() +const linkedAnalysisSources = new Map() +const linkedAnalysisCleanupRegistered = new Set() let nowPlayingConfigWindow: BrowserWindow | null = null let nowPlayingConfigBoundsTimer: ReturnType | null = null const windowSettingsHeights = new Map() @@ -1088,6 +1096,36 @@ function getScopeKindForWindow(window: BrowserWindow | null): ScopeKind | null { return null } +function broadcastLinkedAnalysisMessage(message: LinkedAnalysisMessage): void { + const targets = new Set() + if (mainWindow) targets.add(mainWindow) + for (const popoutWindow of scopePopoutWindows.values()) targets.add(popoutWindow) + + for (const target of targets) { + if (!target.isDestroyed() && !target.webContents.isDestroyed()) { + target.webContents.send('linked-analysis:update', message) + } + } +} + +function registerLinkedAnalysisSourceCleanup(sender: WebContents): void { + if (linkedAnalysisCleanupRegistered.has(sender.id)) return + const senderId = sender.id + linkedAnalysisCleanupRegistered.add(senderId) + sender.once('destroyed', () => { + linkedAnalysisCleanupRegistered.delete(senderId) + const activeProbe = linkedAnalysisSources.get(senderId) + linkedAnalysisSources.delete(senderId) + if (activeProbe) { + broadcastLinkedAnalysisMessage({ + active: false, + interactionId: activeProbe.interactionId, + sourceKind: activeProbe.sourceKind, + }) + } + }) +} + function describeWindow(window: BrowserWindow): string { if (isMainRendererWindow(window)) { return 'main window' @@ -2699,6 +2737,32 @@ function setupIPC(): void { if (!targetWindow || isMainRendererWindow(targetWindow) || !isScopeKind(kind)) return mainWindow?.webContents.send('scope-popout:settings-update', kind, partial) }) + + ipcMain.on('linked-analysis:update', (event, rawMessage: unknown) => { + const targetWindow = getWindowFromSender(event.sender) + const message = normalizeLinkedAnalysisMessage(rawMessage) + if (!targetWindow || !message) return + + const popoutKind = getScopeKindForWindow(targetWindow) + if (!isMainRendererWindow(targetWindow)) { + if (!popoutKind || !isLinkedAnalysisCompatibleScopeKind(popoutKind) || message.sourceKind !== popoutKind) { + return + } + } + + const senderId = event.sender.id + registerLinkedAnalysisSourceCleanup(event.sender) + if (message.active) { + linkedAnalysisSources.set(senderId, message) + broadcastLinkedAnalysisMessage(message) + return + } + + const activeProbe = linkedAnalysisSources.get(senderId) + if (!activeProbe || activeProbe.interactionId !== message.interactionId) return + linkedAnalysisSources.delete(senderId) + broadcastLinkedAnalysisMessage(message) + }) } app.commandLine.appendSwitch('enable-features', 'Metal') diff --git a/src/main/profileLibrary.ts b/src/main/profileLibrary.ts index e1f976f..24b2e72 100644 --- a/src/main/profileLibrary.ts +++ b/src/main/profileLibrary.ts @@ -355,6 +355,7 @@ export class FileBackedProfileLibrary { candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3 + && candidate.version !== 4 && candidate.version !== PROFILE_FILE_VERSION ) { throw new Error(`Unsupported profile version in ${basename(filePath)}.`) diff --git a/src/preload/index.ts b/src/preload/index.ts index aea8c8f..23a89c6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -24,6 +24,7 @@ import type { ProfileLibrarySnapshot, } from '../types/profile' import type { ScopeKind } from '../types/scope' +import type { LinkedAnalysisMessage } from '../types/analysis' import type { LegacyThemeMigrationPayload, LegacyThemeMigrationResult, @@ -167,6 +168,12 @@ contextBridge.exposeInMainWorld('electronAPI', { 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), + sendLinkedAnalysisMessage: (message: LinkedAnalysisMessage) => ipcRenderer.send('linked-analysis:update', message), + onLinkedAnalysisMessage: (callback: (message: LinkedAnalysisMessage) => void) => { + const handler = (_event: Electron.IpcRendererEvent, message: LinkedAnalysisMessage): void => callback(message) + ipcRenderer.on('linked-analysis:update', handler) + return () => ipcRenderer.removeListener('linked-analysis:update', handler) + }, onAlwaysOnTopChanged: (callback: (isOnTop: boolean) => void) => { const handler = (_event: Electron.IpcRendererEvent, isOnTop: boolean): void => callback(isOnTop) ipcRenderer.on('window:always-on-top-changed', handler) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 66ab258..2df5997 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -17,6 +17,7 @@ import { useWindowBackgroundStore } from './stores/windowBackgroundStore' import { useUpdateStore } from './stores/updateStore' import { useDesktopIntegrationStore } from './stores/desktopIntegrationStore' import { getRendererWindowCapabilities } from './windowCapabilities' +import { useLinkedAnalysis } from './useLinkedAnalysis' export default function App(): JSX.Element { const [toolbarVisible, setToolbarVisible] = useState(false) @@ -40,6 +41,7 @@ export default function App(): JSX.Element { const scopeOrder = useSettingsStore((s) => s.scopeOrder) const hiddenScopes = useSettingsStore((s) => s.hiddenScopes) const scopePopouts = useSettingsStore((s) => s.scopePopouts) + const linkedAnalysisEnabled = useSettingsStore((s) => s.analysisSettings.linkedAnalysis) const settingsOpen = useUiStore((s) => s.settingsOpen) const toggleSettings = useUiStore((s) => s.toggleSettings) const setSettingsOpen = useUiStore((s) => s.setSettingsOpen) @@ -52,6 +54,7 @@ export default function App(): JSX.Element { const isNowPlayingVisible = !hiddenScopes.has('nowPlaying') && (scopeOrder.includes('nowPlaying') || scopePopouts.nowPlaying?.poppedOut === true) + const linkedAnalysis = useLinkedAnalysis(linkedAnalysisEnabled) // Auto-capture on launch useEffect(() => { @@ -309,7 +312,12 @@ export default function App(): JSX.Element {
- +
diff --git a/src/renderer/components/BottomBar.tsx b/src/renderer/components/BottomBar.tsx index a8ef112..17e983a 100644 --- a/src/renderer/components/BottomBar.tsx +++ b/src/renderer/components/BottomBar.tsx @@ -153,6 +153,8 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): const hiddenScopes = useSettingsStore((s) => s.hiddenScopes) const scopeOrder = useSettingsStore((s) => s.scopeOrder) const toggleScope = useSettingsStore((s) => s.toggleScope) + const linkedAnalysis = useSettingsStore((s) => s.analysisSettings.linkedAnalysis) + const updateAnalysisSettings = useSettingsStore((s) => s.updateAnalysisSettings) const frameTarget = usePerformanceStore((s) => s.frameTarget) const dockedRenderFps = usePerformanceStore((s) => s.dockedRenderFps) const setFrameTarget = usePerformanceStore((s) => s.setFrameTarget) @@ -456,6 +458,25 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
+
+
Analysis
+
+
+ +
+
+
+ +
+
Theme
diff --git a/src/renderer/components/ScopeMeasurementOverlay.tsx b/src/renderer/components/ScopeMeasurementOverlay.tsx index 6b1afa5..c6fec03 100644 --- a/src/renderer/components/ScopeMeasurementOverlay.tsx +++ b/src/renderer/components/ScopeMeasurementOverlay.tsx @@ -10,12 +10,14 @@ import { type RefObject, } from 'react' import type { ScopeDisplayRotation } from '../../types/scopeTransform' +import type { LinkedAnalysisProjection } from '../../types/analysis' import { resolveMeasurementReadoutPosition, resolveMeasurementSourcePoint, type ActiveScopeMeasurement, type ScopeMeasurementSource, } from '../scopeMeasurement' +import { transformNormalizedScopePoint } from '../scopeCanvasTransform' interface ScopeMeasurementControllerOptions { containerRef: RefObject @@ -24,6 +26,12 @@ interface ScopeMeasurementControllerOptions { rotation: ScopeDisplayRotation mirrorHorizontal: boolean onActiveChange?: (active: boolean) => void + onMeasurementChange?: (change: ScopeMeasurementChange) => void +} + +export interface ScopeMeasurementChange { + interactionId: string + measurement: ActiveScopeMeasurement | null } interface ScopeMeasurementPointerBindings { @@ -47,14 +55,18 @@ export function useScopeMeasurement({ rotation, mirrorHorizontal, onActiveChange, + onMeasurementChange, }: ScopeMeasurementControllerOptions): ScopeMeasurementController { const [measurement, setMeasurement] = useState(null) const activePointerIdRef = useRef(null) const getSourceRef = useRef(getSource) const onActiveChangeRef = useRef(onActiveChange) + const onMeasurementChangeRef = useRef(onMeasurementChange) + const interactionIdRef = useRef(null) const transformRef = useRef({ rotation, mirrorHorizontal }) getSourceRef.current = getSource onActiveChangeRef.current = onActiveChange + onMeasurementChangeRef.current = onMeasurementChange transformRef.current = { rotation, mirrorHorizontal } const setActive = useCallback((active: boolean): void => { @@ -66,11 +78,16 @@ export function useScopeMeasurement({ const pointerId = activePointerIdRef.current if (pointerId === null) return activePointerIdRef.current = null + const interactionId = interactionIdRef.current + interactionIdRef.current = null const container = containerRef.current if (container?.hasPointerCapture(pointerId)) { container.releasePointerCapture(pointerId) } setMeasurement(null) + if (interactionId) { + onMeasurementChangeRef.current?.({ interactionId, measurement: null }) + } setActive(false) }, [containerRef, setActive]) @@ -89,18 +106,25 @@ export function useScopeMeasurement({ transformRef.current.rotation, transformRef.current.mirrorHorizontal, ) - setMeasurement({ + const interactionId = interactionIdRef.current + if (!interactionId) return + const nextMeasurement: ActiveScopeMeasurement = { pointerId: event.pointerId, viewportPoint, sourcePoint, measurement: source.getMeasurementAt(sourcePoint), - }) + } + setMeasurement(nextMeasurement) + onMeasurementChangeRef.current?.({ interactionId, measurement: nextMeasurement }) }, [containerRef]) const handlePointerDown = useCallback((event: ReactPointerEvent): void => { if (!enabled || event.altKey || event.button !== 0 || !event.isPrimary || activePointerIdRef.current !== null) return if (!getSourceRef.current()) return event.preventDefault() + interactionIdRef.current = typeof globalThis.crypto?.randomUUID === 'function' + ? globalThis.crypto.randomUUID() + : `${Date.now().toString(36)}-${event.pointerId.toString(36)}-${Math.random().toString(36).slice(2)}` activePointerIdRef.current = event.pointerId event.currentTarget.setPointerCapture(event.pointerId) setActive(true) @@ -219,3 +243,103 @@ export function ScopeMeasurementOverlay({
) } + +interface ScopeLinkedAnalysisOverlayProps { + containerRef: RefObject + projection: LinkedAnalysisProjection | null + rotation: ScopeDisplayRotation + mirrorHorizontal: boolean +} + +interface ViewportGuide { + axis: 'horizontal' | 'vertical' + position: number +} + +function transformLinkedGuide( + guide: LinkedAnalysisProjection['guides'][number], + rotation: ScopeDisplayRotation, + mirrorHorizontal: boolean, +): ViewportGuide { + const from = transformNormalizedScopePoint(guide.from, rotation, mirrorHorizontal) + const to = transformNormalizedScopePoint(guide.to, rotation, mirrorHorizontal) + if (Math.abs(from.x - to.x) <= Math.abs(from.y - to.y)) { + return { axis: 'vertical', position: (from.x + to.x) / 2 } + } + return { axis: 'horizontal', position: (from.y + to.y) / 2 } +} + +export function ScopeLinkedAnalysisOverlay({ + containerRef, + projection, + rotation, + mirrorHorizontal, +}: ScopeLinkedAnalysisOverlayProps): JSX.Element | null { + const labelRef = useRef(null) + const [labelSize, setLabelSize] = useState({ width: 90, height: 26 }) + const [viewportSize, setViewportSize] = useState({ width: 1, height: 1 }) + const active = projection !== null + + useLayoutEffect(() => { + if (!active) return + const label = labelRef.current + const container = containerRef.current + if (!label || !container) return + const measure = (): void => { + const nextLabelSize = { width: label.offsetWidth, height: label.offsetHeight } + setLabelSize((previous) => previous.width === nextLabelSize.width + && previous.height === nextLabelSize.height ? previous : nextLabelSize) + const rect = container.getBoundingClientRect() + const nextViewportSize = { width: rect.width, height: rect.height } + setViewportSize((previous) => previous.width === nextViewportSize.width + && previous.height === nextViewportSize.height ? previous : nextViewportSize) + } + measure() + const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure) + observer?.observe(label) + observer?.observe(container) + return () => observer?.disconnect() + }, [active, containerRef]) + + if (!projection) return null + const guides = projection.guides.map((guide) => transformLinkedGuide( + guide, + rotation, + mirrorHorizontal, + )) + const anchor = guides.reduce((sum, guide) => { + if (guide.axis === 'vertical') return { x: sum.x + guide.position, y: sum.y + 0.5 } + return { x: sum.x + 0.5, y: sum.y + guide.position } + }, { x: 0, y: 0 }) + const divisor = Math.max(1, guides.length) + const pointer = { + x: (anchor.x / divisor) * viewportSize.width, + y: (anchor.y / divisor) * viewportSize.height, + } + const labelPosition = resolveMeasurementReadoutPosition( + pointer, + viewportSize, + labelSize, + ) + + return ( + + ) +} diff --git a/src/renderer/components/ScopeModule.tsx b/src/renderer/components/ScopeModule.tsx index ccd9796..ab88e7c 100644 --- a/src/renderer/components/ScopeModule.tsx +++ b/src/renderer/components/ScopeModule.tsx @@ -1,5 +1,11 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type JSX } from 'react' import { isTransformableScopeKind, type ScopeKind } from '../../types/scope' +import { + isLinkedAnalysisCompatibleScopeKind, + type LinkedAnalysisMessage, + type LinkedAnalysisProbe, + type LinkedAnalysisProjection, +} from '../../types/analysis' import type { ScopeSettings } from '../../types/settings' import { nominalFrequencyBoundsForRange } from '../../types/frequencyScale' import type { ScopeDisplayRotation } from '../../types/scopeTransform' @@ -28,10 +34,11 @@ import { LUFSMeter, type LUFSMeterDataSource } from '../visualizers/LUFSMeter' import { Waveform, type WaveformDataSource } from '../visualizers/Waveform' import type { FrameScheduler } from '../visualizers/frameScheduler' import { + ScopeLinkedAnalysisOverlay, ScopeMeasurementOverlay, useScopeMeasurement, } from './ScopeMeasurementOverlay' -import type { ScopeMeasurementSource } from '../scopeMeasurement' +import type { ActiveScopeMeasurement, ScopeMeasurementSource } from '../scopeMeasurement' import { getScopeCanvasTransformStyle, isSameScopeCanvasLayout, @@ -56,6 +63,9 @@ interface ScopeModuleProps { settings?: ScopeSettings[ScopeKind] frameScheduler?: FrameScheduler onMeasurementActiveChange?: (active: boolean) => void + linkedAnalysisEnabled?: boolean + linkedAnalysisProbe?: LinkedAnalysisProbe | null + onLinkedAnalysisMessage?: (message: LinkedAnalysisMessage) => void dataSource?: | SpectrumAnalyzerDataSource | OscilloscopeDataSource @@ -74,6 +84,7 @@ interface Visualizer { setOptions(options: Record): void getMeasurementAt?: ScopeMeasurementSource['getMeasurementAt'] setMeasurementActive?: ScopeMeasurementSource['setMeasurementActive'] + getLinkedAnalysisProjection?: (probe: LinkedAnalysisProbe) => LinkedAnalysisProjection | null } const SPECTRUM_PEAK_OVERLAY_MARGIN_PX = 10 @@ -404,6 +415,9 @@ export default function ScopeModule({ settings, frameScheduler, onMeasurementActiveChange, + linkedAnalysisEnabled = false, + linkedAnalysisProbe = null, + onLinkedAnalysisMessage, dataSource, }: ScopeModuleProps): JSX.Element { const containerRef = useRef(null) @@ -417,6 +431,12 @@ export default function ScopeModule({ const peakOverlayRef = useRef(null) const [spectrumPeakInfo, setSpectrumPeakInfo] = useState(null) const [peakOverlaySize, setPeakOverlaySize] = useState(null) + const [linkedProjection, setLinkedProjection] = useState(null) + const linkedAnalysisProbeRef = useRef(linkedAnalysisProbe) + const refreshLinkedProjectionRef = useRef<() => void>(() => {}) + const onMeasurementActiveChangeRef = useRef(onMeasurementActiveChange) + linkedAnalysisProbeRef.current = linkedAnalysisProbe + onMeasurementActiveChangeRef.current = onMeasurementActiveChange const storeSettings = useSettingsStore((s) => s.scopeSettings[scopeKind]) const activeTheme = useThemeStore((s) => s.activeTheme) @@ -440,6 +460,26 @@ export default function ScopeModule({ || scopeKind === 'spectrogram' || scopeKind === 'oscilloscope' || scopeKind === 'waveform' + const handleMeasurementChange = useCallback((change: { + interactionId: string + measurement: ActiveScopeMeasurement | null + }): void => { + if (!linkedAnalysisEnabled || !isLinkedAnalysisCompatibleScopeKind(scopeKind)) return + if (change.measurement) { + onLinkedAnalysisMessage?.({ + active: true, + interactionId: change.interactionId, + sourceKind: scopeKind, + dimensions: change.measurement.measurement.dimensions, + }) + return + } + onLinkedAnalysisMessage?.({ + active: false, + interactionId: change.interactionId, + sourceKind: scopeKind, + }) + }, [linkedAnalysisEnabled, onLinkedAnalysisMessage, scopeKind]) const measurementController = useScopeMeasurement({ containerRef, enabled: measurementEnabled, @@ -451,8 +491,28 @@ export default function ScopeModule({ ? visualizer as ScopeMeasurementSource : null }, - onActiveChange: onMeasurementActiveChange, + onMeasurementChange: handleMeasurementChange, }) + const refreshLinkedProjection = useCallback((): void => { + const probe = linkedAnalysisProbeRef.current + const visualizer = visualizerRef.current + const nextProjection = linkedAnalysisEnabled + && probe + && probe.sourceKind !== scopeKind + && visualizer?.getLinkedAnalysisProjection + ? visualizer.getLinkedAnalysisProjection(probe) + : null + setLinkedProjection(nextProjection) + }, [linkedAnalysisEnabled, scopeKind]) + refreshLinkedProjectionRef.current = refreshLinkedProjection + const analysisActive = measurementController.active || linkedProjection !== null + + useEffect(() => { + onMeasurementActiveChangeRef.current?.(analysisActive) + return () => { + if (analysisActive) onMeasurementActiveChangeRef.current?.(false) + } + }, [analysisActive]) useEffect(() => { if (!captureSpectrumPeakInfo) { @@ -527,6 +587,7 @@ export default function ScopeModule({ visualizerRef.current = viz viz.start() + refreshLinkedProjectionRef.current() requestAnimationFrame(() => { initializedRef.current = true @@ -561,6 +622,10 @@ export default function ScopeModule({ visualizerRef.current.setOptions(opts) }, [captureSpectrumPeakInfo, dataSource, frameScheduler, handleSpectrumPeakInfo, mySettings, myTheme, scopeKind, windowBgAlpha]) + useEffect(() => { + refreshLinkedProjection() + }, [linkedAnalysisProbe, mySettings, refreshLinkedProjection]) + useLayoutEffect(() => { const container = containerRef.current const canvas = canvasRef.current @@ -614,6 +679,7 @@ export default function ScopeModule({ appliedResizeRef.current = nextResize visualizerRef.current?.resize() + refreshLinkedProjectionRef.current() } const scheduleResize = (): void => { @@ -664,7 +730,7 @@ export default function ScopeModule({ className={[ 'scope-module', measurementEnabled ? 'scope-measurement-surface' : '', - measurementController.active ? 'is-measuring' : '', + analysisActive ? 'is-measuring' : '', ].filter(Boolean).join(' ')} ref={containerRef} {...measurementController.pointerBindings} @@ -685,7 +751,15 @@ export default function ScopeModule({ containerRef={containerRef} measurement={measurementController.measurement} /> - {!measurementController.active && scopeKind === 'spectrum' && spectrumPeakMode !== 'off' && spectrumPeakInfo && ( + {!measurementController.active && ( + + )} + {!analysisActive && scopeKind === 'spectrum' && spectrumPeakMode !== 'off' && spectrumPeakInfo && (
s.hiddenScopes) const scopePopouts = useSettingsStore((s) => s.scopePopouts) const scopeSettings = useSettingsStore((s) => s.scopeSettings) + const analysisSettings = useSettingsStore((s) => s.analysisSettings) const popInScope = useSettingsStore((s) => s.popInScope) const updatePopoutBounds = useSettingsStore((s) => s.updatePopoutBounds) const updateScopeSettings = useSettingsStore((s) => s.updateScopeSettings) @@ -114,10 +115,11 @@ export default function ScopePopoutBridge(): null { interfaceTheme: activeTheme.interface, scopeTheme: activeTheme[kind], settings: scopeSettings[kind], + analysisSettings, } window.electronAPI.sendScopePopoutSnapshot(snapshot) } - }, [activePopoutKinds, activeTheme, scopeSettings]) + }, [activePopoutKinds, activeTheme, analysisSettings, scopeSettings]) useEffect(() => { const sessionState = toPopoutSessionState(audioRouter.getSessionState()) @@ -150,6 +152,7 @@ export default function ScopePopoutBridge(): null { interfaceTheme: useThemeStore.getState().activeTheme.interface, scopeTheme: useThemeStore.getState().activeTheme[kind], settings: useSettingsStore.getState().scopeSettings[kind], + analysisSettings: useSettingsStore.getState().analysisSettings, }) if (isAudioScopeKind(kind)) { window.electronAPI.sendScopePopoutSession(kind, toPopoutSessionState(audioRouter.getSessionState())) diff --git a/src/renderer/components/Strip.tsx b/src/renderer/components/Strip.tsx index 0d99415..0bc8bbc 100644 --- a/src/renderer/components/Strip.tsx +++ b/src/renderer/components/Strip.tsx @@ -9,12 +9,21 @@ import { usePerformanceStore } from '../stores/performanceStore' import { FrameScheduler } from '../visualizers/frameScheduler' import { getRendererWindowCapabilities } from '../windowCapabilities' import { getScopePopoutMinWidth } from '../../shared/scopeSizing' +import type { LinkedAnalysisMessage, LinkedAnalysisProbe } from '../../types/analysis' interface StripProps { onMeasurementActiveChange?: (active: boolean) => void + linkedAnalysisEnabled?: boolean + linkedAnalysisProbe?: LinkedAnalysisProbe | null + onLinkedAnalysisMessage?: (message: LinkedAnalysisMessage) => void } -export default function Strip({ onMeasurementActiveChange }: StripProps): JSX.Element { +export default function Strip({ + onMeasurementActiveChange, + linkedAnalysisEnabled = false, + linkedAnalysisProbe = null, + onLinkedAnalysisMessage, +}: StripProps): JSX.Element { const scopeOrder = useSettingsStore((s) => s.scopeOrder) const hiddenScopes = useSettingsStore((s) => s.hiddenScopes) const scopePopouts = useSettingsStore((s) => s.scopePopouts) @@ -28,6 +37,7 @@ export default function Strip({ onMeasurementActiveChange }: StripProps): JSX.El const stripRef = useRef(null) const gridRef = useRef(null) const scopeRefs = useRef>>({}) + const activeMeasurementScopesRef = useRef(new Set()) const [handleOffsets, setHandleOffsets] = useState([]) const supportsGeometryPersistence = getRendererWindowCapabilities().supportsGeometryPersistence const dockedScopes = useMemo( @@ -218,6 +228,15 @@ export default function Strip({ onMeasurementActiveChange }: StripProps): JSX.El popOutScope(kind, nextBounds) }, [popOutScope, supportsGeometryPersistence]) + const handleScopeMeasurementActiveChange = useCallback((kind: ScopeKind, active: boolean): void => { + if (active) { + activeMeasurementScopesRef.current.add(kind) + } else { + activeMeasurementScopesRef.current.delete(kind) + } + onMeasurementActiveChange?.(activeMeasurementScopesRef.current.size > 0) + }, [onMeasurementActiveChange]) + return (
@@ -273,7 +292,10 @@ export default function Strip({ onMeasurementActiveChange }: StripProps): JSX.El handleScopeMeasurementActiveChange(kind, active)} + linkedAnalysisEnabled={linkedAnalysisEnabled} + linkedAnalysisProbe={linkedAnalysisProbe} + onLinkedAnalysisMessage={onLinkedAnalysisMessage} />
))} diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index db54f7e..e0376ad 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -27,6 +27,7 @@ import type { ProfileLibrarySnapshot, } from '../types/profile' import type { ScopeKind } from '../types/scope' +import type { LinkedAnalysisMessage } from '../types/analysis' import type { LegacyThemeMigrationPayload, LegacyThemeMigrationResult, @@ -129,6 +130,8 @@ declare global { notifyScopePopoutReady: (kind: ScopeKind) => void requestScopePopIn: (kind: ScopeKind) => void sendScopePopoutSettingsUpdate: (kind: ScopeKind, partial: unknown) => void + sendLinkedAnalysisMessage: (message: LinkedAnalysisMessage) => void + onLinkedAnalysisMessage: (callback: (message: LinkedAnalysisMessage) => void) => () => void onAlwaysOnTopChanged: (callback: (isOnTop: boolean) => void) => () => void onWindowBackgroundChanged: (callback: (snapshot: WindowBackgroundSnapshot) => void) => () => void onMainWindowBoundsChanged: (callback: (bounds: WindowBounds) => void) => () => void diff --git a/src/renderer/popouts/ScopePopoutWindow.tsx b/src/renderer/popouts/ScopePopoutWindow.tsx index de42051..81bf7e4 100644 --- a/src/renderer/popouts/ScopePopoutWindow.tsx +++ b/src/renderer/popouts/ScopePopoutWindow.tsx @@ -12,6 +12,7 @@ import { useWindowBackgroundStore } from '../stores/windowBackgroundStore' import { getRendererWindowCapabilities } from '../windowCapabilities' import { ScopePopoutDataSource } from './ScopePopoutDataSource' import { FrameScheduler } from '../visualizers/frameScheduler' +import { useLinkedAnalysis } from '../useLinkedAnalysis' function PopInIcon(): JSX.Element { return ( @@ -138,6 +139,8 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps) const effectiveSettings = (snapshot?.settings ?? DEFAULT_SCOPE_SETTINGS[scopeKind]) as ScopeSettings[ScopeKind] const effectiveScopeTheme = snapshot?.scopeTheme ?? defaultTheme[scopeKind] + const linkedAnalysisEnabled = snapshot?.analysisSettings.linkedAnalysis ?? false + const linkedAnalysis = useLinkedAnalysis(linkedAnalysisEnabled) const settingsHeight = miniSettingsOpen ? POPOUT_SETTINGS_EXPAND_HEIGHT : 0 const handleUpdateScopeSettings = (kind: K, partial: Partial): void => { @@ -298,6 +301,9 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps) frameScheduler={frameScheduler} dataSource={dataSource} onMeasurementActiveChange={setMeasurementActive} + linkedAnalysisEnabled={linkedAnalysisEnabled} + linkedAnalysisProbe={linkedAnalysis.probe} + onLinkedAnalysisMessage={linkedAnalysis.publish} />
diff --git a/src/renderer/scopeMeasurement.ts b/src/renderer/scopeMeasurement.ts index 8c37de6..8b9dcff 100644 --- a/src/renderer/scopeMeasurement.ts +++ b/src/renderer/scopeMeasurement.ts @@ -7,9 +7,15 @@ import type { SpectrogramScaleMode } from '../types/spectrogram' import { clampFrequencyRangeToNyquist, frequencyAtNormalizedPosition, + normalizedPositionAtFrequency, type FrequencyScaleMode, } from '../types/frequencyScale' import type { WaveformMode } from '../types/waveform' +import type { + LinkedAnalysisProbe, + LinkedAnalysisProjection, + ScopeMeasurementDimensions, +} from '../types/analysis' import { inverseTransformNormalizedScopePoint, type NormalizedScopePoint, @@ -19,6 +25,7 @@ export type MeasurableScopeKind = 'spectrum' | 'spectrogram' | 'oscilloscope' | export interface ScopeMeasurement { values: string[] + dimensions: ScopeMeasurementDimensions } export interface ScopeMeasurementSource { @@ -104,6 +111,10 @@ export function resolveSpectrumMeasurement( formatMeasurementFrequency(frequencyHz), formatMeasurementPitch(frequencyHz), ], + dimensions: { + frequencyHz, + spectralLevelDb: db, + }, } } @@ -136,6 +147,10 @@ export function resolveSpectrogramMeasurement( formatMeasurementFrequency(frequencyHz), formatMeasurementPitch(frequencyHz), ], + dimensions: { + frequencyHz, + historySecondsAgo: secondsAgo, + }, } } @@ -152,6 +167,10 @@ export function resolveOscilloscopeMeasurement( formatMeasurementAmplitude(amplitude), formatMeasurementDb(amplitudeToDbfs(amplitude), 'dBFS'), ], + dimensions: { + frameTimeSeconds: timeSeconds, + signedAmplitude: amplitude, + }, } } @@ -177,7 +196,141 @@ export function resolveWaveformMeasurement( formatMeasurementDb(amplitudeToDbfs(amplitude), 'dBFS'), ] if (channel) values.unshift(channel) - return { values } + return { + values, + dimensions: { + historySecondsAgo: secondsAgo, + signedAmplitude: amplitude, + ...(channel ? { channel } : {}), + }, + } +} + +function isWithinInclusiveRange(value: number, min: number, max: number): boolean { + return Number.isFinite(value) && value >= min && value <= max +} + +function verticalGuide(x: number): LinkedAnalysisProjection['guides'][number] { + return { from: { x, y: 0 }, to: { x, y: 1 } } +} + +function horizontalGuide(y: number): LinkedAnalysisProjection['guides'][number] { + return { from: { x: 0, y }, to: { x: 1, y } } +} + +export function resolveSpectrumLinkedAnalysisProjection( + probe: LinkedAnalysisProbe, + options: { + sampleRate: number + minFrequency: number + maxFrequency: number + scaleType: FrequencyScaleMode + }, +): LinkedAnalysisProjection | null { + if (probe.sourceKind !== 'spectrogram') return null + const frequencyHz = probe.dimensions.frequencyHz + if (frequencyHz === undefined) return null + const range = clampFrequencyRangeToNyquist(options.sampleRate, options.minFrequency, options.maxFrequency) + if (!isWithinInclusiveRange(frequencyHz, range.minFrequency, range.maxFrequency)) return null + + return { + guides: [verticalGuide(normalizedPositionAtFrequency( + frequencyHz, + range.minFrequency, + range.maxFrequency, + options.scaleType, + ))], + label: formatMeasurementFrequency(frequencyHz), + } +} + +export function resolveSpectrogramLinkedAnalysisProjection( + probe: LinkedAnalysisProbe, + options: { + sampleRate: number + minFrequency: number + maxFrequency: number + scaleMode: SpectrogramScaleMode + fftSize: number + scrollSpeed: number + canvasPixelWidth: number + }, +): LinkedAnalysisProjection | null { + if (probe.sourceKind === 'spectrum') { + const frequencyHz = probe.dimensions.frequencyHz + if (frequencyHz === undefined) return null + const range = clampFrequencyRangeToNyquist(options.sampleRate, options.minFrequency, options.maxFrequency) + if (!isWithinInclusiveRange(frequencyHz, range.minFrequency, range.maxFrequency)) return null + const normalizedFrequency = normalizedPositionAtFrequency( + frequencyHz, + range.minFrequency, + range.maxFrequency, + options.scaleMode, + ) + return { + guides: [horizontalGuide(1 - normalizedFrequency)], + label: formatMeasurementFrequency(frequencyHz), + } + } + + if (probe.sourceKind !== 'waveform') return null + const secondsAgo = probe.dimensions.historySecondsAgo + if (secondsAgo === undefined) return null + const hopDivisor = Math.max(2, Math.min(64, Math.round(8 * options.scrollSpeed))) + const hopSize = Math.max(1, Math.floor(options.fftSize / hopDivisor)) + const maxHistorySeconds = (Math.max(0, options.canvasPixelWidth - 1) * hopSize) + / Math.max(1, options.sampleRate) + if (!isWithinInclusiveRange(secondsAgo, 0, maxHistorySeconds) || maxHistorySeconds <= 0) return null + return { + guides: [verticalGuide(1 - secondsAgo / maxHistorySeconds)], + label: formatMeasurementTime(secondsAgo, true), + } +} + +export function resolveOscilloscopeLinkedAnalysisProjection( + probe: LinkedAnalysisProbe, +): LinkedAnalysisProjection | null { + if (probe.sourceKind !== 'waveform') return null + const amplitude = probe.dimensions.signedAmplitude + if (amplitude === undefined || !isWithinInclusiveRange(amplitude, -1, 1)) return null + return { + guides: [horizontalGuide((1 - amplitude) / 2)], + label: formatMeasurementAmplitude(amplitude), + } +} + +export function resolveWaveformLinkedAnalysisProjection( + probe: LinkedAnalysisProbe, + options: { + mode: WaveformMode + scrollSpeed: number + canvasPixelWidth: number + }, +): LinkedAnalysisProjection | null { + if (probe.sourceKind === 'spectrogram') { + const secondsAgo = probe.dimensions.historySecondsAgo + if (secondsAgo === undefined) return null + const maxHistorySeconds = Math.max(0, options.canvasPixelWidth - 1) + / (WAVEFORM_BASE_PIXELS_PER_SECOND * Math.max(0.01, options.scrollSpeed)) + if (!isWithinInclusiveRange(secondsAgo, 0, maxHistorySeconds) || maxHistorySeconds <= 0) return null + return { + guides: [verticalGuide(1 - secondsAgo / maxHistorySeconds)], + label: formatMeasurementTime(secondsAgo, true), + } + } + + if (probe.sourceKind !== 'oscilloscope') return null + const amplitude = probe.dimensions.signedAmplitude + const maxAmplitude = 1 / WAVEFORM_DISPLAY_MARGIN + if (amplitude === undefined || !isWithinInclusiveRange(amplitude, -maxAmplitude, maxAmplitude)) return null + const laneY = 0.5 - amplitude * 0.5 * WAVEFORM_DISPLAY_MARGIN + const guides = options.mode === 'stereo' + ? [horizontalGuide(laneY / 2), horizontalGuide(0.5 + laneY / 2)] + : [horizontalGuide(laneY)] + return { + guides, + label: formatMeasurementAmplitude(amplitude), + } } export function resolveMeasurementSourcePoint( diff --git a/src/renderer/stores/profileDraft.ts b/src/renderer/stores/profileDraft.ts index d647c27..ef3395e 100644 --- a/src/renderer/stores/profileDraft.ts +++ b/src/renderer/stores/profileDraft.ts @@ -2,6 +2,7 @@ import type { ScopePopoutStateMap, WindowBounds } from '../../types/popout' import type { Profile } from '../../types/profile' import type { ScopeKind } from '../../types/scope' import type { ScopeSettings } from '../../types/settings' +import type { AnalysisSettings } from '../../types/analysis' import { cloneScopeSettings, normalizeProfile, @@ -13,6 +14,7 @@ export interface ProfileDraftSource { hiddenScopes: Iterable widthWeights: Record scopeSettings: ScopeSettings + analysisSettings: AnalysisSettings scopePopouts: ScopePopoutStateMap windowBounds?: WindowBounds } @@ -27,6 +29,7 @@ export function buildProfileDraft( hiddenScopes: Array.from(source.hiddenScopes), widthWeights: { ...source.widthWeights }, scopeSettings: cloneScopeSettings(source.scopeSettings), + analysisSettings: { ...source.analysisSettings }, scopePopouts: normalizeScopePopouts(source.scopePopouts), windowBounds: source.windowBounds, }, name) diff --git a/src/renderer/stores/settingsStore.ts b/src/renderer/stores/settingsStore.ts index 65f6835..79e73de 100644 --- a/src/renderer/stores/settingsStore.ts +++ b/src/renderer/stores/settingsStore.ts @@ -9,6 +9,11 @@ import { } from '../../types/profile' import { SCOPE_KINDS, type ScopeKind } from '../../types/scope' import type { ScopeSettings } from '../../types/settings' +import { + DEFAULT_ANALYSIS_SETTINGS, + normalizeAnalysisSettings, + type AnalysisSettings, +} from '../../types/analysis' import { createDefaultProfile, mergeScopeSettings, @@ -36,6 +41,7 @@ interface PersistedSettingsState { hiddenScopes: ScopeKind[] widthWeights: Record scopeSettings: ScopeSettings + analysisSettings: AnalysisSettings scopePopouts: ScopePopoutStateMap windowBounds?: WindowBounds } @@ -45,6 +51,7 @@ interface WorkingSettingsState { hiddenScopes: Set widthWeights: Record scopeSettings: ScopeSettings + analysisSettings: AnalysisSettings scopePopouts: ScopePopoutStateMap windowBounds?: WindowBounds } @@ -62,6 +69,7 @@ interface SettingsState extends WorkingSettingsState { moveDockedScope: (kind: ScopeKind, direction: 'left' | 'right') => void setScopeWidthWeight: (kind: ScopeKind, weight: number) => void updateScopeSettings: (kind: K, settings: Partial) => void + updateAnalysisSettings: (settings: Partial) => void popOutScope: (kind: ScopeKind, bounds?: WindowBounds) => void popInScope: (kind: ScopeKind) => void updatePopoutBounds: (kind: ScopeKind, bounds: WindowBounds) => void @@ -133,6 +141,7 @@ function buildProfileBaselineSignature(profile: Profile | null): string | undefi hiddenScopes: normalizeHiddenScopes(normalizedProfile.hiddenScopes), widthWeights: normalizeWidthWeights(normalizedProfile.widthWeights), scopeSettings: mergeScopeSettings(normalizedProfile.scopeSettings), + analysisSettings: normalizeAnalysisSettings(normalizedProfile.analysisSettings), scopePopouts, }) } @@ -154,6 +163,7 @@ function normalizePersistedProfileBaselineSignature(raw: string): string | null hiddenScopes: normalizeHiddenScopes(candidate.hiddenScopes), widthWeights: normalizeWidthWeights(candidate.widthWeights), scopeSettings: mergeScopeSettings(candidate.scopeSettings), + analysisSettings: normalizeAnalysisSettings(candidate.analysisSettings), scopePopouts, }) } catch { @@ -258,6 +268,7 @@ function saveToStorage( hiddenScopes: Array.from(state.hiddenScopes), widthWeights: state.widthWeights, scopeSettings: state.scopeSettings, + analysisSettings: state.analysisSettings, scopePopouts, windowBounds, })) @@ -277,6 +288,7 @@ function hasPersistedWorkingState(state: Partial): boole || 'hiddenScopes' in state || 'widthWeights' in state || 'scopeSettings' in state + || 'analysisSettings' in state || 'scopePopouts' in state || 'windowBounds' in state } @@ -341,6 +353,7 @@ function createWorkingStateFromProfile(profile: Profile): WorkingSettingsState { hiddenScopes: new Set(normalizeHiddenScopes(normalizedProfile.hiddenScopes)), widthWeights: normalizeWidthWeights(normalizedProfile.widthWeights), scopeSettings: mergeScopeSettings(normalizedProfile.scopeSettings), + analysisSettings: normalizeAnalysisSettings(normalizedProfile.analysisSettings), scopePopouts: normalizeScopePopouts(normalizedProfile.scopePopouts), windowBounds: normalizedProfile.windowBounds, } @@ -352,6 +365,9 @@ function createWorkingStateFromPersistedState(state: Partial(normalizeHiddenScopes(state.hiddenScopes)), widthWeights: normalizeWidthWeights(state.widthWeights), scopeSettings: mergeScopeSettings(state.scopeSettings), + analysisSettings: state.analysisSettings === undefined + ? { ...DEFAULT_ANALYSIS_SETTINGS } + : normalizeAnalysisSettings(state.analysisSettings), scopePopouts: buildPersistedScopePopouts(normalizeScopePopouts(state.scopePopouts)), windowBounds: supportsWindowGeometryPersistence() ? state.windowBounds @@ -752,6 +768,17 @@ export const useSettingsStore = create((set, get) => ({ }) }, + updateAnalysisSettings: (settings: Partial) => { + set((state) => { + return commitWorkingState(state, { + analysisSettings: normalizeAnalysisSettings({ + ...state.analysisSettings, + ...settings, + }), + }) + }) + }, + popOutScope: (kind: ScopeKind, bounds?: WindowBounds) => { set((state) => { return commitWorkingState(state, { diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index 08e918a..edf5174 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -935,6 +935,62 @@ button.toolbar__version:hover { opacity: 0.4; } +.scope-linked-analysis { + position: absolute; + inset: 0; + z-index: 2; + overflow: hidden; + pointer-events: none; +} + +.scope-linked-analysis__line { + position: absolute; + display: block; + opacity: 0.46; + filter: drop-shadow(0 0 0.5px rgba(0, 0, 0, 0.55)); +} + +.scope-linked-analysis__line--vertical { + top: 0; + bottom: 0; + width: 1px; + transform: translateX(-0.5px); + background: repeating-linear-gradient( + to bottom, + var(--scope-overlay-text) 0 4px, + transparent 4px 8px + ); +} + +.scope-linked-analysis__line--horizontal { + left: 0; + right: 0; + height: 1px; + transform: translateY(-0.5px); + background: repeating-linear-gradient( + to right, + var(--scope-overlay-text) 0 4px, + transparent 4px 8px + ); +} + +.scope-linked-analysis__label { + position: absolute; + max-width: calc(100% - 16px); + padding: 5px 8px; + border: 1px dashed var(--scope-overlay-border); + border-radius: 5px; + background: var(--scope-overlay-bg); + color: var(--scope-overlay-text); + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.18); + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + font-weight: 600; + line-height: 1; + letter-spacing: 0.01em; + white-space: nowrap; +} + .scope-module__peak-info { position: absolute; z-index: 3; diff --git a/src/renderer/useLinkedAnalysis.ts b/src/renderer/useLinkedAnalysis.ts new file mode 100644 index 0000000..23774e3 --- /dev/null +++ b/src/renderer/useLinkedAnalysis.ts @@ -0,0 +1,107 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { + LinkedAnalysisEnd, + LinkedAnalysisMessage, + LinkedAnalysisProbe, +} from '../types/analysis' + +export function reduceLinkedAnalysisProbe( + current: LinkedAnalysisProbe | null, + message: LinkedAnalysisMessage, +): LinkedAnalysisProbe | null { + if (message.active) return message + return current?.interactionId === message.interactionId ? null : current +} + +export interface LinkedAnalysisSession { + probe: LinkedAnalysisProbe | null + publish: (message: LinkedAnalysisMessage) => void +} + +export function useLinkedAnalysis(enabled: boolean): LinkedAnalysisSession { + const [probe, setProbe] = useState(null) + const enabledRef = useRef(enabled) + const localProbeRef = useRef(null) + const pendingProbeRef = useRef(null) + const sendFrameRef = useRef(null) + enabledRef.current = enabled + + const cancelPendingFrame = useCallback((): void => { + if (sendFrameRef.current !== null) { + window.cancelAnimationFrame(sendFrameRef.current) + sendFrameRef.current = null + } + pendingProbeRef.current = null + }, []) + + const publish = useCallback((message: LinkedAnalysisMessage): void => { + if (!enabledRef.current) return + setProbe((current) => reduceLinkedAnalysisProbe(current, message)) + + if (!message.active) { + if (localProbeRef.current?.interactionId !== message.interactionId) return + cancelPendingFrame() + localProbeRef.current = null + window.electronAPI.sendLinkedAnalysisMessage(message) + return + } + + const isNewInteraction = localProbeRef.current?.interactionId !== message.interactionId + localProbeRef.current = message + if (isNewInteraction) { + cancelPendingFrame() + window.electronAPI.sendLinkedAnalysisMessage(message) + return + } + + pendingProbeRef.current = message + if (sendFrameRef.current !== null) return + sendFrameRef.current = window.requestAnimationFrame(() => { + sendFrameRef.current = null + const pending = pendingProbeRef.current + pendingProbeRef.current = null + if (pending && enabledRef.current) { + window.electronAPI.sendLinkedAnalysisMessage(pending) + } + }) + }, [cancelPendingFrame]) + + useEffect(() => { + return window.electronAPI.onLinkedAnalysisMessage((message) => { + if (!enabledRef.current) return + setProbe((current) => reduceLinkedAnalysisProbe(current, message)) + }) + }, []) + + useEffect(() => { + if (enabled) return + const localProbe = localProbeRef.current + cancelPendingFrame() + localProbeRef.current = null + setProbe(null) + if (localProbe) { + const end: LinkedAnalysisEnd = { + active: false, + interactionId: localProbe.interactionId, + sourceKind: localProbe.sourceKind, + } + window.electronAPI.sendLinkedAnalysisMessage(end) + } + }, [cancelPendingFrame, enabled]) + + useEffect(() => { + return () => { + const localProbe = localProbeRef.current + cancelPendingFrame() + if (localProbe) { + window.electronAPI.sendLinkedAnalysisMessage({ + active: false, + interactionId: localProbe.interactionId, + sourceKind: localProbe.sourceKind, + }) + } + } + }, [cancelPendingFrame]) + + return { probe: enabled ? probe : null, publish } +} diff --git a/src/renderer/visualizers/Oscilloscope.ts b/src/renderer/visualizers/Oscilloscope.ts index 9381b01..eb0bea1 100644 --- a/src/renderer/visualizers/Oscilloscope.ts +++ b/src/renderer/visualizers/Oscilloscope.ts @@ -10,9 +10,11 @@ import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './ import { FrameScheduler } from './frameScheduler' import { VisualizerFrameLoop } from './visualizerFrameLoop' import { + resolveOscilloscopeLinkedAnalysisProjection, resolveOscilloscopeMeasurement, type ScopeMeasurement, } from '../scopeMeasurement' +import type { LinkedAnalysisProbe, LinkedAnalysisProjection } from '../../types/analysis' import type { NormalizedScopePoint } from '../scopeCanvasTransform' export interface OscilloscopeDataSource extends VisualizerSessionSource { @@ -201,6 +203,10 @@ export class Oscilloscope { ) } + getLinkedAnalysisProjection(probe: LinkedAnalysisProbe): LinkedAnalysisProjection | null { + return resolveOscilloscopeLinkedAnalysisProjection(probe) + } + private ensureRenderBuffer(size: number): Float32Array { if (this.renderBuffer.length !== size) { this.renderBuffer = new Float32Array(size) diff --git a/src/renderer/visualizers/Spectrogram.ts b/src/renderer/visualizers/Spectrogram.ts index 9c88a57..19c8bac 100644 --- a/src/renderer/visualizers/Spectrogram.ts +++ b/src/renderer/visualizers/Spectrogram.ts @@ -32,9 +32,11 @@ import { normalizeHeatDb, } from './heatScale' import { + resolveSpectrogramLinkedAnalysisProjection, resolveSpectrogramMeasurement, type ScopeMeasurement, } from '../scopeMeasurement' +import type { LinkedAnalysisProbe, LinkedAnalysisProjection } from '../../types/analysis' import type { NormalizedScopePoint } from '../scopeCanvasTransform' import { buildFrequencyGuides, @@ -363,6 +365,18 @@ export class Spectrogram { }) } + getLinkedAnalysisProjection(probe: LinkedAnalysisProbe): LinkedAnalysisProjection | null { + return resolveSpectrogramLinkedAnalysisProjection(probe, { + sampleRate: Math.max(1, this.dataSource.getSampleRate()), + minFrequency: this.options.minFrequency, + maxFrequency: this.options.maxFrequency, + scaleMode: this.options.scaleMode, + fftSize: this.options.fftSize, + scrollSpeed: this.options.scrollSpeed, + canvasPixelWidth: this.canvas.width, + }) + } + private getFrequencyPixelCount(width: number, height: number): number { return this.options.orientation === 'vertical' ? width : height } diff --git a/src/renderer/visualizers/SpectrumAnalyzer.ts b/src/renderer/visualizers/SpectrumAnalyzer.ts index 79627f5..9f14565 100644 --- a/src/renderer/visualizers/SpectrumAnalyzer.ts +++ b/src/renderer/visualizers/SpectrumAnalyzer.ts @@ -20,9 +20,11 @@ import { } from './heatScale' import { SPECTRUM_MEASUREMENT_SMOOTHING, + resolveSpectrumLinkedAnalysisProjection, resolveSpectrumMeasurement, type ScopeMeasurement, } from '../scopeMeasurement' +import type { LinkedAnalysisProbe, LinkedAnalysisProjection } from '../../types/analysis' import type { NormalizedScopePoint } from '../scopeCanvasTransform' import { buildFrequencyGuides, @@ -387,6 +389,15 @@ export class SpectrumAnalyzer { }) } + getLinkedAnalysisProjection(probe: LinkedAnalysisProbe): LinkedAnalysisProjection | null { + return resolveSpectrumLinkedAnalysisProjection(probe, { + sampleRate: this.sampleRate, + minFrequency: this.options.minFrequency, + maxFrequency: this.options.maxFrequency, + scaleType: this.options.scaleType, + }) + } + setMeasurementActive(active: boolean): void { if (this.measurementActive === active) return this.measurementActive = active diff --git a/src/renderer/visualizers/Waveform.ts b/src/renderer/visualizers/Waveform.ts index 6f32b64..3b38f3f 100644 --- a/src/renderer/visualizers/Waveform.ts +++ b/src/renderer/visualizers/Waveform.ts @@ -14,9 +14,11 @@ import { MultibandSplitter, createMultibandChunk, type MultibandChunk } from './ import { WAVEFORM_BASE_PIXELS_PER_SECOND, WAVEFORM_DISPLAY_MARGIN, + resolveWaveformLinkedAnalysisProjection, resolveWaveformMeasurement, type ScopeMeasurement, } from '../scopeMeasurement' +import type { LinkedAnalysisProbe, LinkedAnalysisProjection } from '../../types/analysis' import type { NormalizedScopePoint } from '../scopeCanvasTransform' export interface WaveformStereoChunk { @@ -263,6 +265,14 @@ export class Waveform { }) } + getLinkedAnalysisProjection(probe: LinkedAnalysisProbe): LinkedAnalysisProjection | null { + return resolveWaveformLinkedAnalysisProjection(probe, { + mode: this.options.mode, + scrollSpeed: this.options.scrollSpeed, + canvasPixelWidth: this.canvas.width, + }) + } + private computeMinMax(samples: Float32Array): { min: number; max: number } { if (this.columnAccumulatorPos === 0) { return { min: 0, max: 0 } diff --git a/src/shared/profileState.ts b/src/shared/profileState.ts index e36c67c..707aa3d 100644 --- a/src/shared/profileState.ts +++ b/src/shared/profileState.ts @@ -9,7 +9,7 @@ import { type ProfileLocalMetadata, type PrismProfileFile, type PrismProfileFileScopePopoutMap, - type PrismProfileFileV4, + type PrismProfileFileV5, type PrismProfileLocalStateV1, } from '../types/profile' import { AUDIO_SCOPE_KINDS, SCOPE_KINDS, normalizeScopeKind, type ScopeKind } from '../types/scope' @@ -34,6 +34,10 @@ import { type ScopeDisplayRotation, } from '../types/scopeTransform' import { normalizeVectorscopeZoomDb } from '../types/vectorscope' +import { + DEFAULT_ANALYSIS_SETTINGS, + normalizeAnalysisSettings, +} from '../types/analysis' export const DEFAULT_VISIBLE: ScopeKind[] = ['spectrum', 'oscilloscope', 'vectorscope', 'vumeter'] export const DEFAULT_SCOPE_ORDER: ScopeKind[] = [...AUDIO_SCOPE_KINDS] @@ -303,6 +307,7 @@ export function createDefaultProfile(name = DEFAULT_PROFILE_NAME): Profile { hiddenScopes: SCOPE_KINDS.filter((kind) => !DEFAULT_VISIBLE.includes(kind)), widthWeights: { ...DEFAULT_SCOPE_WIDTH_WEIGHTS }, scopeSettings: cloneScopeSettings(DEFAULT_SCOPE_SETTINGS), + analysisSettings: { ...DEFAULT_ANALYSIS_SETTINGS }, scopePopouts: createDefaultScopePopouts(), } } @@ -318,6 +323,7 @@ export function normalizeProfile(raw: unknown, fallbackName = DEFAULT_PROFILE_NA hiddenScopes: normalizeHiddenScopes(parsed.hiddenScopes), widthWeights: normalizeWidthWeights(parsed.widthWeights), scopeSettings: mergeScopeSettings(parsed.scopeSettings), + analysisSettings: normalizeAnalysisSettings(parsed.analysisSettings), scopePopouts: normalizeScopePopouts(parsed.scopePopouts), windowBounds: normalizeWindowBounds(parsed.windowBounds), } @@ -340,9 +346,9 @@ export function normalizeProfileFile( raw: unknown, fallbackId: string, fallbackName = DEFAULT_PROFILE_NAME, -) : PrismProfileFileV4 { +) : PrismProfileFileV5 { const parsed = typeof raw === 'object' && raw !== null - ? raw as Partial + ? raw as Omit, 'version'> & { version?: unknown } : {} const id = typeof parsed.id === 'string' && parsed.id.trim() @@ -362,11 +368,12 @@ export function normalizeProfileFile( scopeSettings: mergeScopeSettings(parsed.scopeSettings, { legacyProfileFileScale: isLegacyProfileFileVersion(parsed.version), }), + analysisSettings: normalizeAnalysisSettings(parsed.analysisSettings), scopePopouts: normalizeProfileFileScopePopouts(parsed.scopePopouts), } } -export function profileToFileData(id: string, profile: Profile): PrismProfileFileV4 { +export function profileToFileData(id: string, profile: Profile): PrismProfileFileV5 { const normalized = normalizeProfile(profile, profile.name) return { @@ -378,6 +385,7 @@ export function profileToFileData(id: string, profile: Profile): PrismProfileFil hiddenScopes: [...normalized.hiddenScopes], widthWeights: { ...normalized.widthWeights }, scopeSettings: cloneScopeSettings(normalized.scopeSettings), + analysisSettings: { ...normalized.analysisSettings }, scopePopouts: SCOPE_KINDS.reduce((acc, kind) => { acc[kind] = { poppedOut: normalized.scopePopouts[kind]?.poppedOut === true } return acc @@ -475,6 +483,9 @@ export function profileFileToProfile( scopeSettings: mergeScopeSettings(file.scopeSettings, { legacyProfileFileScale: isLegacyProfileFileVersion(file.version), }), + analysisSettings: normalizeAnalysisSettings( + 'analysisSettings' in file ? file.analysisSettings : undefined, + ), scopePopouts: SCOPE_KINDS.reduce((acc, kind) => { acc[kind] = { poppedOut: Boolean(file.scopePopouts[kind]?.poppedOut), diff --git a/src/types/analysis.ts b/src/types/analysis.ts new file mode 100644 index 0000000..944a55f --- /dev/null +++ b/src/types/analysis.ts @@ -0,0 +1,124 @@ +import type { ScopeKind } from './scope' + +export const LINKED_ANALYSIS_SCOPE_KINDS = [ + 'spectrum', + 'spectrogram', + 'oscilloscope', + 'waveform', +] as const + +export type LinkedAnalysisScopeKind = typeof LINKED_ANALYSIS_SCOPE_KINDS[number] + +export interface AnalysisSettings { + linkedAnalysis: boolean +} + +export const DEFAULT_ANALYSIS_SETTINGS: AnalysisSettings = { + linkedAnalysis: false, +} + +export interface ScopeMeasurementDimensions { + frequencyHz?: number + spectralLevelDb?: number + historySecondsAgo?: number + frameTimeSeconds?: number + signedAmplitude?: number + channel?: 'L' | 'R' +} + +export interface LinkedAnalysisProbe { + active: true + interactionId: string + sourceKind: LinkedAnalysisScopeKind + dimensions: ScopeMeasurementDimensions +} + +export interface LinkedAnalysisEnd { + active: false + interactionId: string + sourceKind: LinkedAnalysisScopeKind +} + +export type LinkedAnalysisMessage = LinkedAnalysisProbe | LinkedAnalysisEnd + +export interface LinkedAnalysisGuide { + from: { x: number; y: number } + to: { x: number; y: number } +} + +export interface LinkedAnalysisProjection { + guides: LinkedAnalysisGuide[] + label: string +} + +export function isLinkedAnalysisScopeKind(value: unknown): value is LinkedAnalysisScopeKind { + return typeof value === 'string' + && LINKED_ANALYSIS_SCOPE_KINDS.includes(value as LinkedAnalysisScopeKind) +} + +export function isLinkedAnalysisCompatibleScopeKind(value: ScopeKind): value is LinkedAnalysisScopeKind { + return isLinkedAnalysisScopeKind(value) +} + +export function normalizeAnalysisSettings(raw: unknown): AnalysisSettings { + const parsed = typeof raw === 'object' && raw !== null + ? raw as Partial + : {} + return { + linkedAnalysis: parsed.linkedAnalysis === true, + } +} + +function normalizeFiniteDimension(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +export function normalizeScopeMeasurementDimensions(raw: unknown): ScopeMeasurementDimensions { + const parsed = typeof raw === 'object' && raw !== null + ? raw as Partial> + : {} + const dimensions: ScopeMeasurementDimensions = {} + const frequencyHz = normalizeFiniteDimension(parsed.frequencyHz) + const spectralLevelDb = normalizeFiniteDimension(parsed.spectralLevelDb) + const historySecondsAgo = normalizeFiniteDimension(parsed.historySecondsAgo) + const frameTimeSeconds = normalizeFiniteDimension(parsed.frameTimeSeconds) + const signedAmplitude = normalizeFiniteDimension(parsed.signedAmplitude) + + if (frequencyHz !== undefined && frequencyHz > 0) dimensions.frequencyHz = frequencyHz + if (spectralLevelDb !== undefined) dimensions.spectralLevelDb = spectralLevelDb + if (historySecondsAgo !== undefined && historySecondsAgo >= 0) dimensions.historySecondsAgo = historySecondsAgo + if (frameTimeSeconds !== undefined && frameTimeSeconds >= 0) dimensions.frameTimeSeconds = frameTimeSeconds + if (signedAmplitude !== undefined) dimensions.signedAmplitude = signedAmplitude + if (parsed.channel === 'L' || parsed.channel === 'R') dimensions.channel = parsed.channel + + return dimensions +} + +export function normalizeLinkedAnalysisMessage(raw: unknown): LinkedAnalysisMessage | null { + if (typeof raw !== 'object' || raw === null) return null + const parsed = raw as Partial + if ( + typeof parsed.interactionId !== 'string' + || parsed.interactionId.length === 0 + || parsed.interactionId.length > 160 + || !isLinkedAnalysisScopeKind(parsed.sourceKind) + ) { + return null + } + + if (parsed.active === false) { + return { + active: false, + interactionId: parsed.interactionId, + sourceKind: parsed.sourceKind, + } + } + if (parsed.active !== true) return null + + return { + active: true, + interactionId: parsed.interactionId, + sourceKind: parsed.sourceKind, + dimensions: normalizeScopeMeasurementDimensions(parsed.dimensions), + } +} diff --git a/src/types/popout.ts b/src/types/popout.ts index 3980add..667f66b 100644 --- a/src/types/popout.ts +++ b/src/types/popout.ts @@ -1,6 +1,7 @@ import type { CaptureBackendKind } from './capture' import type { ScopeKind } from './scope' import type { ScopeSettings } from './settings' +import type { AnalysisSettings } from './analysis' import type { ResolvedAstraTheme, ResolvedInterfaceTheme, @@ -67,4 +68,5 @@ export interface ScopePopoutSnapshot { interfaceTheme: ResolvedInterfaceTheme scopeTheme: ScopePopoutResolvedScopeTheme settings: ScopeSettings[K] + analysisSettings: AnalysisSettings } diff --git a/src/types/profile.ts b/src/types/profile.ts index 66c9b93..9e13d9d 100644 --- a/src/types/profile.ts +++ b/src/types/profile.ts @@ -1,9 +1,10 @@ import type { ScopePopoutStateMap, WindowBounds } from './popout' import type { ScopeKind } from './scope' import type { ScopeSettings } from './settings' +import type { AnalysisSettings } from './analysis' export const PROFILE_FILE_FORMAT = 'prism-profile' -export const PROFILE_FILE_VERSION = 4 +export const PROFILE_FILE_VERSION = 5 export const PROFILE_LOCAL_STATE_FORMAT = 'prism-profile-local' export const PROFILE_LOCAL_STATE_VERSION = 1 export const LEGACY_PROFILE_MIGRATION_VERSION = 1 @@ -16,6 +17,7 @@ export interface Profile { hiddenScopes: ScopeKind[] widthWeights: Record scopeSettings: ScopeSettings + analysisSettings: AnalysisSettings scopePopouts: ScopePopoutStateMap windowBounds?: WindowBounds } @@ -65,6 +67,19 @@ export interface PrismProfileFileV3 { } export interface PrismProfileFileV4 { + format: typeof PROFILE_FILE_FORMAT + version: 4 + id: string + name: string + themeId?: string | null + scopeOrder: ScopeKind[] + hiddenScopes: ScopeKind[] + widthWeights: Record + scopeSettings: ScopeSettings + scopePopouts: PrismProfileFileScopePopoutMap +} + +export interface PrismProfileFileV5 { format: typeof PROFILE_FILE_FORMAT version: typeof PROFILE_FILE_VERSION id: string @@ -74,10 +89,11 @@ export interface PrismProfileFileV4 { hiddenScopes: ScopeKind[] widthWeights: Record scopeSettings: ScopeSettings + analysisSettings: AnalysisSettings scopePopouts: PrismProfileFileScopePopoutMap } -export type PrismProfileFile = PrismProfileFileV1 | PrismProfileFileV2 | PrismProfileFileV3 | PrismProfileFileV4 +export type PrismProfileFile = PrismProfileFileV1 | PrismProfileFileV2 | PrismProfileFileV3 | PrismProfileFileV4 | PrismProfileFileV5 export interface ProfileLocalMetadata { windowBounds?: WindowBounds diff --git a/test/profile-library.test.ts b/test/profile-library.test.ts index b0d82d5..d56831b 100644 --- a/test/profile-library.test.ts +++ b/test/profile-library.test.ts @@ -60,6 +60,7 @@ function createProfile(name: string): Profile { profile.scopeSettings.spectrogram.showGrid = true profile.scopeSettings.spectrogram.rotation = 90 profile.scopeSettings.spectrogram.mirrorHorizontal = true + profile.analysisSettings.linkedAnalysis = true return profile } @@ -88,6 +89,7 @@ test('profile file serialization excludes geometry and round-trips with local me assert.equal(file.scopeOrder.includes('nowPlaying'), false) assert.equal(file.hiddenScopes.includes('nowPlaying'), true) assert.equal(file.widthWeights.nowPlaying, 1) + assert.equal(file.analysisSettings.linkedAnalysis, true) const restored = profileFileToProfile(file, extractLocalProfileMetadata(profile)) assert.deepEqual(restored.windowBounds, profile.windowBounds) @@ -105,6 +107,7 @@ test('profile file serialization excludes geometry and round-trips with local me assert.equal(restored.scopeSettings.spectrogram.rotation, 90) assert.equal(restored.scopeSettings.spectrogram.mirrorHorizontal, true) assert.equal(restored.scopeSettings.nowPlaying.showControls, true) + assert.equal(restored.analysisSettings.linkedAnalysis, true) }) test('profile file waveform speed migration preserves legacy scroll feel', () => { @@ -406,6 +409,7 @@ test('partial files normalize, unsupported versions fail, and import does not ch assert.equal(Object.hasOwn(partialSnapshot.profiles.profile_partial.scopeSettings.waveform, 'gainDb'), false) assert.equal(partialSnapshot.profiles.profile_partial.scopePopouts.spectrogram.poppedOut, true) assert.equal(partialSnapshot.profiles.profile_partial.widthWeights.spectrum, 1) + assert.equal(partialSnapshot.profiles.profile_partial.analysisSettings.linkedAnalysis, false) const badVersionPath = join(harness.rootDir, 'unsupported.prsm') await writeFile(badVersionPath, `${JSON.stringify({ @@ -506,6 +510,22 @@ test('version 3 profiles remain importable and migrate vertical spectrograms to } }) +test('version 4 profiles remain importable with linked analysis disabled', async () => { + const harness = await createHarness() + + try { + const base = profileToFileData('profile_v4', createDefaultProfile('Version 4')) + const { analysisSettings: _analysisSettings, ...legacy } = base + const legacyPath = join(harness.rootDir, 'version-4.prsm') + await writeFile(legacyPath, `${JSON.stringify({ ...legacy, version: 4 }, null, 2)}\n`, 'utf8') + + const snapshot = await harness.library.importProfileFromPath(legacyPath) + assert.equal(snapshot.profiles.profile_v4.analysisSettings.linkedAnalysis, false) + } finally { + await harness.cleanup() + } +}) + test('legacy migration writes managed files, preserves active profile, and stores local-only geometry', async () => { const harness = await createHarness() diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index b6eadc8..664ca70 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -65,10 +65,19 @@ import { frequencyAtNormalizedPosition, resolveMeasurementReadoutPosition, resolveOscilloscopeMeasurement, + resolveOscilloscopeLinkedAnalysisProjection, resolveSpectrogramMeasurement, + resolveSpectrogramLinkedAnalysisProjection, resolveSpectrumMeasurement, + resolveSpectrumLinkedAnalysisProjection, resolveWaveformMeasurement, + resolveWaveformLinkedAnalysisProjection, } from '../src/renderer/scopeMeasurement' +import { + normalizeLinkedAnalysisMessage, + type LinkedAnalysisProbe, +} from '../src/types/analysis' +import { reduceLinkedAnalysisProbe } from '../src/renderer/useLinkedAnalysis' import { applyInputGainToStereoSamples, inputGainDbToLinear, @@ -467,6 +476,7 @@ function seedProfileDraftState(profile: Profile): void { hiddenScopes: new Set(profile.hiddenScopes), widthWeights: { ...profile.widthWeights }, scopeSettings: JSON.parse(JSON.stringify(profile.scopeSettings)) as Profile['scopeSettings'], + analysisSettings: { ...profile.analysisSettings }, scopePopouts: JSON.parse(JSON.stringify(profile.scopePopouts)) as Profile['scopePopouts'], windowBounds: profile.windowBounds, profiles: { @@ -1893,6 +1903,162 @@ test('spectrogram measurement follows scale mode and rendered history speed', () } }) +test('linked analysis projects exact semantic values through each target scale', () => { + const spectrumProbe: LinkedAnalysisProbe = { + active: true, + interactionId: 'spectrum-probe', + sourceKind: 'spectrum', + dimensions: { frequencyHz: 1000, spectralLevelDb: -42 }, + } + const spectrumToSpectrogram = resolveSpectrogramLinkedAnalysisProjection(spectrumProbe, { + sampleRate: 48000, + minFrequency: 20, + maxFrequency: 20000, + scaleMode: 'log', + fftSize: 4096, + scrollSpeed: 2, + canvasPixelWidth: 121, + }) + assert.ok(spectrumToSpectrogram) + assert.equal(spectrumToSpectrogram.label, '1.00kHz') + assertAlmostEqual( + spectrumToSpectrogram.guides[0].from.y, + 1 - Math.log10(1000 / 20) / Math.log10(20000 / 20), + 1e-12, + 'spectrogram frequency guide', + ) + assert.equal(spectrumToSpectrogram.guides[0].from.x, 0) + assert.equal(spectrumToSpectrogram.guides[0].to.x, 1) + + const spectrogramProbe: LinkedAnalysisProbe = { + active: true, + interactionId: 'spectrogram-probe', + sourceKind: 'spectrogram', + dimensions: { frequencyHz: 1000, historySecondsAgo: 0.25 }, + } + const spectrogramToSpectrum = resolveSpectrumLinkedAnalysisProjection(spectrogramProbe, { + sampleRate: 48000, + minFrequency: 20, + maxFrequency: 20000, + scaleType: 'linear', + }) + assert.ok(spectrogramToSpectrum) + assertAlmostEqual( + spectrogramToSpectrum.guides[0].from.x, + (1000 - 20) / (20000 - 20), + 1e-12, + 'linear spectrum frequency guide', + ) + + const spectrogramToWaveform = resolveWaveformLinkedAnalysisProjection(spectrogramProbe, { + mode: 'mono', + scrollSpeed: 1, + canvasPixelWidth: 129, + }) + assert.ok(spectrogramToWaveform) + assertAlmostEqual(spectrogramToWaveform.guides[0].from.x, 0.75, 1e-12, 'waveform history guide') + + const waveformProbe: LinkedAnalysisProbe = { + active: true, + interactionId: 'waveform-probe', + sourceKind: 'waveform', + dimensions: { historySecondsAgo: 0.32, signedAmplitude: 0.5, channel: 'R' }, + } + const waveformToSpectrogram = resolveSpectrogramLinkedAnalysisProjection(waveformProbe, { + sampleRate: 48000, + minFrequency: 20, + maxFrequency: 20000, + scaleMode: 'mel', + fftSize: 4096, + scrollSpeed: 4, + canvasPixelWidth: 121, + }) + assert.ok(waveformToSpectrogram) + assertAlmostEqual(waveformToSpectrogram.guides[0].from.x, 0, 1e-12, 'spectrogram history guide') + + const waveformToOscilloscope = resolveOscilloscopeLinkedAnalysisProjection(waveformProbe) + assert.ok(waveformToOscilloscope) + assertAlmostEqual(waveformToOscilloscope.guides[0].from.y, 0.25, 1e-12, 'oscilloscope amplitude guide') +}) + +test('linked analysis hides out-of-range values and duplicates unchanneled amplitude across stereo waveform lanes', () => { + const outOfRangeFrequency: LinkedAnalysisProbe = { + active: true, + interactionId: 'frequency-outside', + sourceKind: 'spectrogram', + dimensions: { frequencyHz: 22000 }, + } + assert.equal(resolveSpectrumLinkedAnalysisProjection(outOfRangeFrequency, { + sampleRate: 48000, + minFrequency: 20, + maxFrequency: 20000, + scaleType: 'log', + }), null) + + const outOfRangeHistory: LinkedAnalysisProbe = { + active: true, + interactionId: 'history-outside', + sourceKind: 'spectrogram', + dimensions: { historySecondsAgo: 1.01 }, + } + assert.equal(resolveWaveformLinkedAnalysisProjection(outOfRangeHistory, { + mode: 'mono', + scrollSpeed: 1, + canvasPixelWidth: 129, + }), null) + + const oscilloscopeProbe: LinkedAnalysisProbe = { + active: true, + interactionId: 'oscilloscope-probe', + sourceKind: 'oscilloscope', + dimensions: { signedAmplitude: 0.5, frameTimeSeconds: 0.01 }, + } + const stereoProjection = resolveWaveformLinkedAnalysisProjection(oscilloscopeProbe, { + mode: 'stereo', + scrollSpeed: 1, + canvasPixelWidth: 129, + }) + assert.ok(stereoProjection) + assert.equal(stereoProjection.guides.length, 2) + assertAlmostEqual(stereoProjection.guides[0].from.y, 0.13125, 1e-12, 'left waveform lane') + assertAlmostEqual(stereoProjection.guides[1].from.y, 0.63125, 1e-12, 'right waveform lane') + assert.equal(stereoProjection.label, '+0.500') +}) + +test('linked analysis messages normalize payloads and stale endings cannot clear newer probes', () => { + const first = normalizeLinkedAnalysisMessage({ + active: true, + interactionId: 'first', + sourceKind: 'spectrum', + dimensions: { frequencyHz: 62, spectralLevelDb: -40, signedAmplitude: Number.NaN }, + }) + assert.ok(first?.active) + assert.deepEqual(first.dimensions, { frequencyHz: 62, spectralLevelDb: -40 }) + assert.equal(normalizeLinkedAnalysisMessage({ + active: true, + interactionId: '', + sourceKind: 'spectrum', + dimensions: {}, + }), null) + + const second: LinkedAnalysisProbe = { + active: true, + interactionId: 'second', + sourceKind: 'waveform', + dimensions: { signedAmplitude: 0.25 }, + } + assert.equal(reduceLinkedAnalysisProbe(second, { + active: false, + interactionId: 'first', + sourceKind: 'spectrum', + }), second) + assert.equal(reduceLinkedAnalysisProbe(second, { + active: false, + interactionId: 'second', + sourceKind: 'waveform', + }), null) +}) + test('frequency scale transforms are monotonic, invertible, and Nyquist-safe', () => { const minFrequency = 20 const maxFrequency = 20000 @@ -4199,6 +4365,7 @@ test('profile draft comparisons return to clean after reverting a change', () => hiddenScopes: baselineProfile.hiddenScopes, widthWeights: baselineProfile.widthWeights, scopeSettings: baselineProfile.scopeSettings, + analysisSettings: baselineProfile.analysisSettings, scopePopouts: baselineProfile.scopePopouts, windowBounds: baselineProfile.windowBounds, }, baselineProfile.name) @@ -4214,6 +4381,7 @@ test('profile draft comparisons return to clean after reverting a change', () => scrollSpeed: baselineProfile.scopeSettings.waveform.scrollSpeed + 1, }, }, + analysisSettings: baselineProfile.analysisSettings, scopePopouts: baselineProfile.scopePopouts, windowBounds: baselineProfile.windowBounds, }, baselineProfile.name) @@ -4223,12 +4391,42 @@ test('profile draft comparisons return to clean after reverting a change', () => hiddenScopes: baselineProfile.hiddenScopes, widthWeights: baselineProfile.widthWeights, scopeSettings: baselineProfile.scopeSettings, + analysisSettings: baselineProfile.analysisSettings, scopePopouts: baselineProfile.scopePopouts, windowBounds: baselineProfile.windowBounds, }, baselineProfile.name) assert.equal(profilesMatch(baselineDraft, changedDraft), false) assert.equal(profilesMatch(baselineDraft, revertedDraft), true) + + const linkedAnalysisDraft = buildProfileDraft({ + scopeOrder: baselineProfile.scopeOrder, + hiddenScopes: baselineProfile.hiddenScopes, + widthWeights: baselineProfile.widthWeights, + scopeSettings: baselineProfile.scopeSettings, + analysisSettings: { linkedAnalysis: true }, + scopePopouts: baselineProfile.scopePopouts, + windowBounds: baselineProfile.windowBounds, + }, baselineProfile.name) + assert.equal(profilesMatch(baselineDraft, linkedAnalysisDraft), false) +}) + +test('linked analysis is a profile-scoped setting that dirties and cleans with its baseline', () => { + const previousSettingsState = useSettingsStore.getState() + try { + const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) + seedProfileDraftState(profile) + + useSettingsStore.getState().updateAnalysisSettings({ linkedAnalysis: true }) + assert.equal(useSettingsStore.getState().analysisSettings.linkedAnalysis, true) + assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, true) + + useSettingsStore.getState().updateAnalysisSettings({ linkedAnalysis: false }) + assert.equal(useSettingsStore.getState().analysisSettings.linkedAnalysis, false) + assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false) + } finally { + useSettingsStore.setState(previousSettingsState) + } }) test('buildProfileDraft omits theme metadata from the runtime draft', () => { @@ -4239,6 +4437,7 @@ test('buildProfileDraft omits theme metadata from the runtime draft', () => { hiddenScopes: profile.hiddenScopes, widthWeights: profile.widthWeights, scopeSettings: profile.scopeSettings, + analysisSettings: profile.analysisSettings, scopePopouts: profile.scopePopouts, windowBounds: profile.windowBounds, }, profile.name) @@ -4690,7 +4889,7 @@ test('detached scope interactions accept the first macOS click and hide chrome d assert.match(mainSource, /function createScopePopoutWindow\([\s\S]*?process\.platform === 'darwin' \? \{ acceptFirstMouse: true \} : \{\}/) assert.doesNotMatch(mainWindowFactorySource, /acceptFirstMouse/) assert.match(scopeModuleSource, /onMeasurementActiveChange\?: \(active: boolean\) => void/) - assert.match(scopeModuleSource, /onActiveChange: onMeasurementActiveChange/) + assert.match(scopeModuleSource, /onMeasurementActiveChangeRef\.current\?\.\(analysisActive\)/) assert.match(popoutSource, /measurementActive \? 'is-measuring' : ''/) assert.match(popoutSource, /onMeasurementActiveChange=\{setMeasurementActive\}/) assert.match(stylesSource, /\.scope-popout__chrome:has\(:focus-visible\)/) @@ -4703,10 +4902,28 @@ test('main window hides its toolbar while a docked scope measurement is active', const stripSource = await readFile(join(process.cwd(), 'src', 'renderer', 'components', 'Strip.tsx'), 'utf8') assert.match(stripSource, /onMeasurementActiveChange\?: \(active: boolean\) => void/) - assert.match(stripSource, /onMeasurementActiveChange=\{onMeasurementActiveChange\}/) + assert.match(stripSource, /handleScopeMeasurementActiveChange/) assert.match(appSource, /const \[measurementActive, setMeasurementActive\] = useState\(false\)/) assert.match(appSource, /toolbarVisible && !measurementActive \? 'is-visible' : ''/) - assert.match(appSource, //) + assert.match(appSource, / { + const mainSource = await readFile(join(process.cwd(), 'src', 'main', 'index.ts'), 'utf8') + const preloadSource = await readFile(join(process.cwd(), 'src', 'preload', 'index.ts'), 'utf8') + const hookSource = await readFile(join(process.cwd(), 'src', 'renderer', 'useLinkedAnalysis.ts'), 'utf8') + const bridgeSource = await readFile(join(process.cwd(), 'src', 'renderer', 'components', 'ScopePopoutBridge.tsx'), 'utf8') + + assert.match(mainSource, /ipcMain\.on\('linked-analysis:update'/) + assert.match(mainSource, /normalizeLinkedAnalysisMessage\(rawMessage\)/) + assert.match(mainSource, /message\.sourceKind !== popoutKind/) + assert.match(mainSource, /sender\.once\('destroyed',[\s\S]*?broadcastLinkedAnalysisMessage/) + assert.match(preloadSource, /sendLinkedAnalysisMessage:[\s\S]*?linked-analysis:update/) + assert.match(preloadSource, /onLinkedAnalysisMessage:[\s\S]*?removeListener\('linked-analysis:update'/) + assert.match(hookSource, /window\.requestAnimationFrame/) + assert.match(hookSource, /const isNewInteraction[\s\S]*?sendLinkedAnalysisMessage\(message\)/) + assert.match(hookSource, /cancelPendingFrame\(\)[\s\S]*?sendLinkedAnalysisMessage\(message\)/) + assert.match(bridgeSource, /analysisSettings: useSettingsStore\.getState\(\)\.analysisSettings/) }) test('programmatic top/bottom reposition flushes fresh bounds through persistence channels', async () => { From b5bb8503e388fbdf84fa0a8b36507f3f85661080 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:53:53 -0400 Subject: [PATCH 02/27] prism bridge daw server --- .github/workflows/main.yml | 14 +- .github/workflows/release.yml | 18 +- README.md | 12 + docs/daw-bridge.md | 65 ++ package.json | 1 + plugin/CMakeLists.txt | 65 +- plugin/README.md | 21 +- plugin/Source/BridgeEditor.cpp | 77 +++ plugin/Source/BridgeEditor.h | 30 + plugin/Source/BridgeProcessor.cpp | 586 ++++++++++++++++++ plugin/Source/BridgeProcessor.h | 147 +++++ plugin/Tests/BridgeProcessorTests.cpp | 131 ++++ .../linux/install-vst3-from-package.sh | 1 + resources/installer/linux/install-vst3.sh | 1 + .../linux/remove-vst3-from-system.sh | 1 + resources/installer/windows-vst3.nsh | 3 +- scripts/run-daw-bridge-tests.mjs | 31 + scripts/run-tests.mjs | 1 + src/main/index.ts | 52 ++ src/main/profileLibrary.ts | 1 + src/main/services/dawBridgeService.ts | 458 ++++++++++++++ src/main/services/trayMenu.ts | 12 +- src/plugin-ui/PluginWebViewDataSource.ts | 5 + src/preload/captureSupport.ts | 5 + src/preload/index.ts | 34 +- src/renderer/audio/AudioCapture.ts | 250 +++++++- src/renderer/audio/AudioRouter.ts | 51 +- src/renderer/components/BottomBar.tsx | 52 +- src/renderer/components/ScopeModule.tsx | 3 + src/renderer/components/ScopePopoutBridge.tsx | 5 +- .../components/ScopeSettingsSection.tsx | 20 + src/renderer/components/TrayControlBridge.tsx | 26 + src/renderer/env.d.ts | 2 + src/renderer/popouts/ScopePopoutDataSource.ts | 13 +- src/renderer/stores/audioStore.ts | 78 ++- src/renderer/styles/globals.css | 9 + src/renderer/visualizers/Spectrogram.ts | 48 +- src/renderer/visualizers/Waveform.ts | 58 +- src/renderer/visualizers/dataSource.ts | 3 + src/renderer/visualizers/scrollingTimeline.ts | 298 +++++++++ src/shared/profileState.ts | 15 +- src/types/capture.ts | 6 +- src/types/dawBridge.ts | 106 ++++ src/types/desktopIntegration.ts | 9 +- src/types/popout.ts | 3 + src/types/profile.ts | 10 +- src/types/settings.ts | 7 +- test/audio-router.test.ts | 20 + test/audio-store.test.ts | 54 ++ test/capture-support.test.ts | 5 + test/daw-bridge-service.test.ts | 178 ++++++ test/profile-library.test.ts | 5 + test/renderer-helpers.test.ts | 99 +++ 53 files changed, 3136 insertions(+), 69 deletions(-) create mode 100644 docs/daw-bridge.md create mode 100644 plugin/Source/BridgeEditor.cpp create mode 100644 plugin/Source/BridgeEditor.h create mode 100644 plugin/Source/BridgeProcessor.cpp create mode 100644 plugin/Source/BridgeProcessor.h create mode 100644 plugin/Tests/BridgeProcessorTests.cpp create mode 100644 scripts/run-daw-bridge-tests.mjs create mode 100644 src/main/services/dawBridgeService.ts create mode 100644 src/renderer/visualizers/scrollingTimeline.ts create mode 100644 src/types/dawBridge.ts create mode 100644 test/daw-bridge-service.test.ts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fb3928c..b37a0d1 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -269,6 +269,14 @@ jobs: for /f "usebackq tokens=*" %%i in (`vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set VSINSTALL=%%i call "%VSINSTALL%\VC\Auxiliary\Build\vcvars64.bat" cmake --build plugin/build --target PrismInstallerPlugins --config Release --parallel 4 + cmake --build plugin/build --target PrismBridgeTests --config Release --parallel 4 + ctest --test-dir plugin/build -C Release --output-on-failure -R PrismBridgeProcessorTests + + - name: Test Prism Bridge processor + if: runner.os != 'Windows' + run: | + cmake --build plugin/build --target PrismBridgeTests --config Release --parallel 4 + ctest --test-dir plugin/build -C Release --output-on-failure -R PrismBridgeProcessorTests - name: Stage plugins for Windows installer if: runner.os == 'Windows' @@ -276,13 +284,13 @@ jobs: run: | rm -rf plugin/dist-installer mkdir -p plugin/dist-installer/VST3 - for art in PrismSpectrum PrismOscilloscope PrismVUMeter PrismLUFSMeter PrismVectorscope PrismSpectrogram PrismWaveform; do + for art in PrismSpectrum PrismOscilloscope PrismVUMeter PrismLUFSMeter PrismVectorscope PrismSpectrogram PrismWaveform PrismBridge; do vst3_dir="plugin/build/${art}_artefacts/Release/VST3" [ -d "$vst3_dir" ] && find "$vst3_dir" -maxdepth 1 -name "*.vst3" -exec cp -R {} plugin/dist-installer/VST3/ \; done vst3_count="$(find plugin/dist-installer/VST3 -maxdepth 1 -name "*.vst3" | wc -l | tr -d ' ')" - if [ "$vst3_count" -ne 7 ]; then - echo "ERROR: expected 7 VST3 plugins, found $vst3_count" + if [ "$vst3_count" -ne 8 ]; then + echo "ERROR: expected 8 VST3 plugins, found $vst3_count" exit 1 fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8cf3513..94ef743 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -214,6 +214,14 @@ jobs: for /f "usebackq tokens=*" %%i in (`vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set VSINSTALL=%%i call "%VSINSTALL%\VC\Auxiliary\Build\vcvars64.bat" cmake --build plugin/build --target PrismInstallerPlugins --config Release --parallel 4 + cmake --build plugin/build --target PrismBridgeTests --config Release --parallel 4 + ctest --test-dir plugin/build -C Release --output-on-failure -R PrismBridgeProcessorTests + + - name: Test Prism Bridge processor + if: runner.os != 'Windows' + run: | + cmake --build plugin/build --target PrismBridgeTests --config Release --parallel 4 + ctest --test-dir plugin/build -C Release --output-on-failure -R PrismBridgeProcessorTests - name: Show sccache stats if: runner.os != 'Linux' @@ -226,7 +234,7 @@ jobs: mkdir -p plugin/dist-installer mkdir -p plugin/dist-installer/VST3 [ "$RUNNER_OS" = "macOS" ] && mkdir -p plugin/dist-installer/AU - for art in PrismSpectrum PrismOscilloscope PrismVUMeter PrismLUFSMeter PrismVectorscope PrismSpectrogram PrismWaveform; do + for art in PrismSpectrum PrismOscilloscope PrismVUMeter PrismLUFSMeter PrismVectorscope PrismSpectrogram PrismWaveform PrismBridge; do vst3_dir="plugin/build/${art}_artefacts/Release/VST3" [ -d "$vst3_dir" ] && find "$vst3_dir" -maxdepth 1 -name "*.vst3" -exec cp -R {} plugin/dist-installer/VST3/ \; if [ "$RUNNER_OS" = "macOS" ]; then @@ -237,8 +245,8 @@ jobs: echo "--- staged VST3 ---" ls -la plugin/dist-installer/VST3 vst3_count="$(find plugin/dist-installer/VST3 -maxdepth 1 -name "*.vst3" | wc -l | tr -d ' ')" - if [ "$vst3_count" -ne 7 ]; then - echo "ERROR: expected 7 VST3 plugins, found $vst3_count" + if [ "$vst3_count" -ne 8 ]; then + echo "ERROR: expected 8 VST3 plugins, found $vst3_count" exit 1 fi if [ "$RUNNER_OS" = "Linux" ]; then @@ -255,8 +263,8 @@ jobs: echo "--- staged AU ---" ls -la plugin/dist-installer/AU au_count="$(find plugin/dist-installer/AU -maxdepth 1 -name "*.component" | wc -l | tr -d ' ')" - if [ "$au_count" -ne 7 ]; then - echo "ERROR: expected 7 AU plugins, found $au_count" + if [ "$au_count" -ne 8 ]; then + echo "ERROR: expected 8 AU plugins, found $au_count" exit 1 fi fi diff --git a/README.md b/README.md index 9d59ade..c8f5b8d 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,7 @@ Most of Prism's analysis runs in native C++, with the same DSP implementations r ### DAW plugins * All seven Prism analyzers are built from the same native DSP source used by the desktop application +* Prism Bridge streams one DAW track or bus into the standalone app without changing the track audio * VST3 on Windows, macOS, and Linux * AU on macOS * Mono and stereo host layouts are supported @@ -203,6 +204,17 @@ Drop a **Spectrum**, **Oscilloscope**, **Vectorscope**, **Spectrogram**, **VU Me * Plugins follow your Prism themes and profiles * Audio passes through untouched +Prism also installs **Prism Bridge**, a lightweight pass-through plugin for sending +one DAW track or bus to the standalone application. Keep Prism open, insert Bridge +on the source you want to inspect, then choose it under **DAW Bridges** in Prism's +audio selector. Multiple Bridge instances can be available at once; only the one +you select streams audio. Waveform and Spectrogram can optionally show host-derived +Bars + Beats or Seconds rulers, including loop, seek, and packet-gap seams. + +Bridge is available as VST3 on Windows, macOS, and Linux and as AU on macOS. The AU +is intentionally not marked sandbox-safe because it requires localhost access. +Protocol details and host-validation notes are in [docs/daw-bridge.md](docs/daw-bridge.md). + Tested in Ableton Live, FL Studio, and Reaper. The plugins install alongside Prism, so there is no separate download. diff --git a/docs/daw-bridge.md b/docs/daw-bridge.md new file mode 100644 index 0000000..0d888ac --- /dev/null +++ b/docs/daw-bridge.md @@ -0,0 +1,65 @@ +# Prism Bridge protocol and host notes + +Prism Bridge is a mono/stereo pass-through effect that sends one selected DAW +track or bus to the standalone Prism application. Prism owns the server and +binds only `127.0.0.1:51789`; Bridge instances reconnect with exponential +backoff from 250 ms to 5 seconds. + +## Using Bridge + +1. Open the standalone Prism application. +2. Insert **Prism Bridge** on each track or bus you may want to inspect. +3. Optionally give each instance a custom source name in its native editor. +4. In Prism's audio selector, choose an instance under **DAW Bridges**. + +Only the selected instance transmits audio. If it disconnects, Prism stays in +DAW mode and displays **Waiting** without falling back to another input. A +same-format reconnect preserves scope history; a sample-rate or channel-layout +change begins a new analysis session. + +Waveform and Spectrogram have a **Timeline** setting with Off, Bars + Beats, and +Seconds choices. The ruler is drawn only for DAW Bridge input. Bars + Beats uses +the host PPQ, tempo, meter, and bar anchor; when beat metadata is missing it +shows a labeled seconds fallback. Loop, seek, and packet discontinuities are +kept in history as dashed Loop, Jump, and Gap seams. + +## Wire format (version 1) + +Every frame starts with a 12-byte little-endian header: + +| Offset | Type | Meaning | +| ---: | --- | --- | +| 0 | `uint32` | Magic `0x4d535250` | +| 4 | `uint16` | Protocol version (`1`) | +| 6 | `uint16` | Message type | +| 8 | `uint32` | Payload byte length | + +Hello (`1`) and source update (`3`) payloads are UTF-8 JSON. Heartbeat (`2`) +has no payload. Prism sends a JSON subscription (`10`) containing `selected`. +Audio (`20`) uses a 76-byte binary metadata header followed by little-endian +planar Float32 samples (all left samples, then all right samples). Metadata +includes sequence, frame count, channels, sample rate, sample/seconds/PPQ +position, tempo, last-bar PPQ, loop points, time signature, and play, record, +and loop flags. + +Payloads larger than 256 KiB and unknown or mismatched protocol versions are +rejected. Prism caps live connections, batches renderer IPC, and expires clients +after three seconds without traffic. Bridge packetizes into preallocated +512-frame records on the audio thread and keeps only the newest approximately +50 ms before transmission. Socket work, JSON, allocation, and locking stay on +the background or UI threads. + +The stable source UUID and custom name are stored in plug-in state. If a DAW +duplicates that state, Prism assigns distinct live keys to all duplicates and +requires an explicit choice instead of guessing which instance was intended. + +## Formats and validation + +Bridge ships as VST3 on macOS, Windows, and Linux, and as AU on macOS. It has no +Standalone target. `AU_SANDBOX_SAFE FALSE` is intentional because the AU needs +loopback networking. Logic may load Audio Units in `AUHostingServiceXPC`, so a +successful build or `auval` scan is not enough: live Logic-to-Prism loopback is +a release blocker. + +See Apple's [out-of-process Audio Unit debugging documentation](https://developer.apple.com/documentation/audiotoolbox/debugging-out-of-process-audio-units-on-apple-silicon?language=objc) +and [Audio Unit sandboxing guide](https://developer.apple.com/library/archive/technotes/tn2312/_index.html). diff --git a/package.json b/package.json index be920d7..2a4ca27 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "test:audio-store": "node scripts/run-audio-store-tests.mjs", "test:astra": "node scripts/run-astra-integration-tests.mjs", "test:capture-support": "node scripts/run-capture-support-tests.mjs", + "test:daw-bridge": "node scripts/run-daw-bridge-tests.mjs", "test:spotify-provider": "node scripts/run-mac-spotify-provider-tests.mjs", "test:now-playing": "node scripts/run-now-playing-manager-tests.mjs", "test:profiles": "node scripts/run-profile-library-tests.mjs", diff --git a/plugin/CMakeLists.txt b/plugin/CMakeLists.txt index 27a061b..6b72411 100644 --- a/plugin/CMakeLists.txt +++ b/plugin/CMakeLists.txt @@ -11,6 +11,8 @@ endif() project(PrismPlugins VERSION 0.1.0 LANGUAGES C CXX) +include(CTest) + set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -197,6 +199,66 @@ add_prism_scope(PrismVectorscope "Prism Vectorscope" Pvct "PRISM_SCOPE_VECTORS add_prism_scope(PrismSpectrogram "Prism Spectrogram" Pspg "PRISM_SCOPE_SPECTROGRAM=1") add_prism_scope(PrismWaveform "Prism Waveform" Pwav "PRISM_SCOPE_WAVEFORM=1") +# Prism Bridge is intentionally a small native-only pass-through plug-in. It has +# no standalone target and never creates a webview or performs networking on the +# audio thread. +set(PRISM_BRIDGE_FORMATS ${PRISM_PLUGIN_FORMATS}) +list(REMOVE_ITEM PRISM_BRIDGE_FORMATS Standalone) + +juce_add_plugin(PrismBridge + PRODUCT_NAME "Prism Bridge" + COMPANY_NAME "Prism" + BUNDLE_ID com.astra.prism.PrismBridge + PLUGIN_MANUFACTURER_CODE Prsm + PLUGIN_CODE Pbrg + FORMATS ${PRISM_BRIDGE_FORMATS} + IS_SYNTH FALSE + NEEDS_MIDI_INPUT FALSE + NEEDS_MIDI_OUTPUT FALSE + IS_MIDI_EFFECT FALSE + AU_SANDBOX_SAFE FALSE + COPY_PLUGIN_AFTER_BUILD ${PRISM_COPY_PLUGIN_AFTER_BUILD}) + +target_sources(PrismBridge PRIVATE + Source/BridgeProcessor.cpp + Source/BridgeEditor.cpp) + +target_include_directories(PrismBridge PRIVATE Source) +target_compile_definitions(PrismBridge PRIVATE + JUCE_USE_CURL=0 + JUCE_VST3_CAN_REPLACE_VST2=0) +target_link_libraries(PrismBridge PRIVATE + juce::juce_audio_utils + juce::juce_recommended_config_flags + juce::juce_recommended_lto_flags + juce::juce_recommended_warning_flags) + +if(APPLE AND TARGET PrismBridge_AU) + # Keep local/CI AU bundles structurally valid before the release pipeline + # replaces this ad-hoc signature with the distribution identity. + add_custom_command(TARGET PrismBridge_AU POST_BUILD + COMMAND codesign --force --deep --sign - "$" + VERBATIM) +endif() + +if(BUILD_TESTING) + juce_add_console_app(PrismBridgeTests PRODUCT_NAME "Prism Bridge Tests") + target_sources(PrismBridgeTests PRIVATE + Tests/BridgeProcessorTests.cpp + Source/BridgeProcessor.cpp + Source/BridgeEditor.cpp) + target_include_directories(PrismBridgeTests PRIVATE Source) + target_compile_definitions(PrismBridgeTests PRIVATE + PRISM_BRIDGE_TESTING=1 + JucePlugin_Name="Prism Bridge" + JUCE_USE_CURL=0) + target_link_libraries(PrismBridgeTests PRIVATE + juce::juce_audio_utils + juce::juce_recommended_config_flags + juce::juce_recommended_warning_flags) + add_test(NAME PrismBridgeProcessorTests COMMAND PrismBridgeTests) +endif() + set(PRISM_SCOPE_TARGETS PrismSpectrum PrismOscilloscope @@ -204,7 +266,8 @@ set(PRISM_SCOPE_TARGETS PrismLUFSMeter PrismVectorscope PrismSpectrogram - PrismWaveform) + PrismWaveform + PrismBridge) add_custom_target(PrismInstallerPlugins) foreach(PRISM_SCOPE_TARGET IN LISTS PRISM_SCOPE_TARGETS) diff --git a/plugin/README.md b/plugin/README.md index d6efe80..17dc52d 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -2,7 +2,8 @@ JUCE 8 plugins (VST3 / AU / Standalone) that render Prism's scopes inside a DAW — one plugin per scope: **spectrum, oscilloscope, vectorscope, spectrogram, VU meter, -loudness meter, waveform**. They **reuse Prism's existing C++ DSP** (`native/src/*.cpp`) +loudness meter, waveform** — plus the native **Prism Bridge** pass-through plugin. +The analyzers **reuse Prism's existing C++ DSP** (`native/src/*.cpp`) and the **existing React canvas UI** (`src/plugin-ui`, importing the unchanged visualizers from `src/renderer/visualizers/`). VST3 is built for macOS, Windows, and Linux; AU is macOS-only. @@ -35,13 +36,14 @@ cmake -B plugin/build -S plugin -DCMAKE_BUILD_TYPE=Release # embeds the bundle ( cmake --build plugin/build --config Release ``` -`COPY_PLUGIN_AFTER_BUILD` installs all seven plugins into your user plugin folders: +`COPY_PLUGIN_AFTER_BUILD` installs all seven analyzers plus Prism Bridge into your user plugin folders: - AU: `~/Library/Audio/Plug-Ins/Components/Prism *.component` - VST3: `~/Library/Audio/Plug-Ins/VST3/Prism *.vst3` Load any `Prism *` AU / VST3 on a track in Ableton / FL / Logic / Reaper (or run the matching **Standalone** from `plugin/build/Prism*_artefacts/Release/Standalone/`), play audio, and the scope animates. +(Prism Bridge has AU/VST3 targets only and no Standalone target.) (To use a local JUCE checkout instead of fetching: add `-DJUCE_PATH=/path/to/JUCE`.) ### UI development: dev-server mode (hot reload) @@ -100,20 +102,31 @@ cmake --build plugin/build --target PrismInstallerPlugins --parallel ``` Built bundles land under `plugin/build/Prism*_artefacts/Release/VST3/`. Copy the -seven `Prism *.vst3` directories to one of the standard Linux VST3 scan paths: +eight `Prism *.vst3` directories to one of the standard Linux VST3 scan paths: - User-local: `$HOME/.vst3` - System-wide: `/usr/lib/vst3` - System-wide local: `/usr/local/lib/vst3` The Linux `.deb` and `.rpm` release packages install Prism's VST3 bundles to -`/usr/lib/vst3` and remove only those seven bundles on package removal. The Linux +`/usr/lib/vst3` and remove only those eight bundles on package removal. The Linux `tar.gz` release includes `resources/plugins/install-vst3.sh`, which installs to `$HOME/.vst3` by default or `/usr/lib/vst3` with `--system`. The AppImage is portable app-only and does not install DAW plugins. ## Notes +- **Prism Bridge:** start the standalone Prism application, insert Bridge on a + mono or stereo track/bus, then select that instance in Prism's **DAW Bridges** + input group. Bridge is bit-transparent, ignores offline rendering, and sends + audio and transport metadata only over `127.0.0.1:51789`. Its audio callback + only packetizes into a bounded lock-free queue; a background thread owns the + socket. The source UUID and custom name are saved in DAW state. +- **Logic/AU validation:** `AU_SANDBOX_SAFE FALSE` is explicit because Bridge needs + loopback networking. Apple may host Audio Units out of process; AU loopback in + Logic is therefore a release-blocking host validation, not something the build + alone can prove. + - **Refresh rate (macOS):** frames are emitted on `juce::VBlankAttachment` (synced to the display, adapts to 60/120/144 Hz) and the webview renders via the `display-sync` FrameScheduler. WKWebView otherwise throttles `requestAnimationFrame` to 60 fps regardless diff --git a/plugin/Source/BridgeEditor.cpp b/plugin/Source/BridgeEditor.cpp new file mode 100644 index 0000000..3493c27 --- /dev/null +++ b/plugin/Source/BridgeEditor.cpp @@ -0,0 +1,77 @@ +#include "BridgeEditor.h" +#include "BridgeProcessor.h" + +PrismBridgeEditor::PrismBridgeEditor(PrismBridgeProcessor& p) + : juce::AudioProcessorEditor(&p), bridgeProcessor(p) +{ + title.setText("PRISM BRIDGE", juce::dontSendNotification); + title.setFont(juce::Font(juce::FontOptions(22.0f, juce::Font::bold))); + title.setColour(juce::Label::textColourId, juce::Colour(0xfff1f5f9)); + addAndMakeVisible(title); + + nameLabel.setText("Source name", juce::dontSendNotification); + nameLabel.setColour(juce::Label::textColourId, juce::Colour(0xff94a3b8)); + addAndMakeVisible(nameLabel); + + nameEditor.setText(bridgeProcessor.getCustomName(), false); + nameEditor.setTextToShowWhenEmpty(bridgeProcessor.getDisplayName(), juce::Colour(0xff64748b)); + nameEditor.setColour(juce::TextEditor::backgroundColourId, juce::Colour(0xff111827)); + nameEditor.setColour(juce::TextEditor::textColourId, juce::Colour(0xfff8fafc)); + nameEditor.setColour(juce::TextEditor::outlineColourId, juce::Colour(0xff334155)); + nameEditor.onReturnKey = [this] { commitName(); }; + nameEditor.onFocusLost = [this] { commitName(); }; + addAndMakeVisible(nameEditor); + + for (auto* label : { &sourceInfo, &connectionInfo, &droppedInfo }) + { + label->setColour(juce::Label::textColourId, juce::Colour(0xffcbd5e1)); + addAndMakeVisible(*label); + } + connectionInfo.setColour(juce::Label::textColourId, juce::Colour(0xff38bdf8)); + + setResizable(false, false); + setSize(440, 220); + timerCallback(); + startTimerHz(4); +} + +void PrismBridgeEditor::paint(juce::Graphics& g) +{ + g.fillAll(juce::Colour(0xff080d18)); + g.setColour(juce::Colour(0xff1e293b)); + g.drawRoundedRectangle(getLocalBounds().toFloat().reduced(10.0f), 10.0f, 1.0f); +} + +void PrismBridgeEditor::resized() +{ + auto bounds = getLocalBounds().reduced(24); + title.setBounds(bounds.removeFromTop(34)); + bounds.removeFromTop(8); + nameLabel.setBounds(bounds.removeFromTop(20)); + nameEditor.setBounds(bounds.removeFromTop(32)); + bounds.removeFromTop(12); + sourceInfo.setBounds(bounds.removeFromTop(24)); + connectionInfo.setBounds(bounds.removeFromTop(24)); + droppedInfo.setBounds(bounds.removeFromTop(24)); +} + +void PrismBridgeEditor::timerCallback() +{ + const auto track = bridgeProcessor.getTrackName(); + const auto host = bridgeProcessor.getHostName(); + sourceInfo.setText( + "Host: " + (host.isNotEmpty() ? host : "Unknown") + + " Track: " + (track.isNotEmpty() ? track : "Not provided"), + juce::dontSendNotification); + connectionInfo.setText(bridgeProcessor.getConnectionDescription(), juce::dontSendNotification); + droppedInfo.setText( + "Dropped frames: " + juce::String(bridgeProcessor.getDroppedFrameCount()), + juce::dontSendNotification); +} + +void PrismBridgeEditor::commitName() +{ + bridgeProcessor.setCustomName(nameEditor.getText()); + nameEditor.setText(bridgeProcessor.getCustomName(), false); + nameEditor.setTextToShowWhenEmpty(bridgeProcessor.getDisplayName(), juce::Colour(0xff64748b)); +} diff --git a/plugin/Source/BridgeEditor.h b/plugin/Source/BridgeEditor.h new file mode 100644 index 0000000..1b9f5e3 --- /dev/null +++ b/plugin/Source/BridgeEditor.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +class PrismBridgeProcessor; + +class PrismBridgeEditor final : public juce::AudioProcessorEditor, + private juce::Timer +{ +public: + explicit PrismBridgeEditor(PrismBridgeProcessor&); + ~PrismBridgeEditor() override = default; + void paint(juce::Graphics&) override; + void resized() override; + +private: + void timerCallback() override; + void commitName(); + + PrismBridgeProcessor& bridgeProcessor; + juce::Label title; + juce::Label nameLabel; + juce::TextEditor nameEditor; + juce::Label sourceInfo; + juce::Label connectionInfo; + juce::Label droppedInfo; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(PrismBridgeEditor) +}; diff --git a/plugin/Source/BridgeProcessor.cpp b/plugin/Source/BridgeProcessor.cpp new file mode 100644 index 0000000..2c770f9 --- /dev/null +++ b/plugin/Source/BridgeProcessor.cpp @@ -0,0 +1,586 @@ +#include "BridgeProcessor.h" +#include "BridgeEditor.h" +#include +#include +#include +#include + +namespace +{ +constexpr uint32_t frameMagic = 0x4d535250; +constexpr uint16_t protocolVersion = 1; +constexpr uint16_t messageHello = 1; +constexpr uint16_t messageHeartbeat = 2; +constexpr uint16_t messageSourceUpdate = 3; +constexpr uint16_t messageSubscribe = 10; +constexpr uint16_t messageAudio = 20; +constexpr size_t frameHeaderBytes = 12; +constexpr size_t audioHeaderBytes = 76; +constexpr size_t maxPayloadBytes = 256 * 1024; +constexpr int serverPort = 51789; + +template +void writeLittleEndian(uint8_t* destination, Integer value) noexcept +{ + const auto little = juce::ByteOrder::swapIfBigEndian(value); + std::memcpy(destination, &little, sizeof(little)); +} + +void writeDoubleLittleEndian(uint8_t* destination, double value) noexcept +{ + uint64_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + writeLittleEndian(destination, bits); +} + +uint16_t readU16(const uint8_t* source) noexcept +{ + uint16_t value = 0; + std::memcpy(&value, source, sizeof(value)); + return juce::ByteOrder::swapIfBigEndian(value); +} + +uint32_t readU32(const uint8_t* source) noexcept +{ + uint32_t value = 0; + std::memcpy(&value, source, sizeof(value)); + return juce::ByteOrder::swapIfBigEndian(value); +} +} + +PrismBridgeProcessor::PrismBridgeProcessor() + : juce::AudioProcessor(BusesProperties() + .withInput("Input", juce::AudioChannelSet::stereo(), true) + .withOutput("Output", juce::AudioChannelSet::stereo(), true)), + juce::Thread("Prism Bridge network"), + sourceId(juce::Uuid().toString()), + instanceId(juce::Uuid().toString()), + hostName(juce::PluginHostType().getHostDescription()) +{ +} + +PrismBridgeProcessor::~PrismBridgeProcessor() +{ + signalThreadShouldExit(); + stopThread(1500); + closeSocket(); +} + +void PrismBridgeProcessor::prepareToPlay(double sampleRate, int) +{ + currentSampleRate.store(sampleRate > 0.0 ? sampleRate : 48000.0); + nextSequence.store(0); + fifo.reset(); +#if ! defined(PRISM_BRIDGE_TESTING) || ! PRISM_BRIDGE_TESTING + if (!isThreadRunning()) + startThread(juce::Thread::Priority::normal); +#endif + identityDirty.store(true); +} + +void PrismBridgeProcessor::releaseResources() +{ + signalThreadShouldExit(); + stopThread(1000); + closeSocket(); + connected.store(false); + selected.store(false); + discardQueuedPackets(); +} + +bool PrismBridgeProcessor::isBusesLayoutSupported(const BusesLayout& layouts) const +{ + const auto input = layouts.getMainInputChannelSet(); + const auto output = layouts.getMainOutputChannelSet(); + return input == output + && (input == juce::AudioChannelSet::mono() || input == juce::AudioChannelSet::stereo()); +} + +PrismBridgeProcessor::TransportData PrismBridgeProcessor::readTransport() const noexcept +{ + TransportData result; + if (const auto* playHead = getPlayHead()) + { + if (const auto position = playHead->getPosition()) + { + result.isPlaying = position->getIsPlaying(); + result.isRecording = position->getIsRecording(); + result.isLooping = position->getIsLooping(); + if (const auto value = position->getTimeInSamples()) + { + result.timeInSamples = *value; + result.hasTimeInSamples = true; + } + if (const auto value = position->getTimeInSeconds()) + { + result.timeInSeconds = *value; + result.hasTimeInSeconds = true; + } + if (const auto value = position->getPpqPosition()) + { + result.ppqPosition = *value; + result.hasPpq = true; + } + if (const auto value = position->getBpm()) + { + result.bpm = *value; + result.hasBpm = std::isfinite(result.bpm) && result.bpm > 0.0; + } + if (const auto value = position->getPpqPositionOfLastBarStart()) + result.lastBarPpq = *value; + else + result.lastBarPpq = std::numeric_limits::quiet_NaN(); + if (const auto value = position->getTimeSignature()) + { + result.numerator = static_cast(value->numerator); + result.denominator = static_cast(value->denominator); + result.hasTimeSignature = value->numerator > 0 && value->denominator > 0; + } + if (const auto value = position->getLoopPoints()) + { + result.loopStartPpq = value->ppqStart; + result.loopEndPpq = value->ppqEnd; + } + else + { + result.loopStartPpq = std::numeric_limits::quiet_NaN(); + result.loopEndPpq = std::numeric_limits::quiet_NaN(); + } + } + } + return result; +} + +PrismBridgeProcessor::TransportData PrismBridgeProcessor::offsetTransport( + const TransportData& base, + int sampleOffset) const noexcept +{ + auto result = base; + const double sampleRate = std::max(1.0, currentSampleRate.load()); + const double seconds = static_cast(sampleOffset) / sampleRate; + if (result.hasTimeInSamples) result.timeInSamples += sampleOffset; + if (result.hasTimeInSeconds) result.timeInSeconds += seconds; + if (result.hasPpq && result.hasBpm) result.ppqPosition += seconds * result.bpm / 60.0; + return result; +} + +void PrismBridgeProcessor::processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer&) +{ + juce::ScopedNoDenormals noDenormals; + if (isNonRealtime() || !selected.load(std::memory_order_relaxed)) + return; + + const int frameCount = buffer.getNumSamples(); + const int channels = buffer.getNumChannels(); + if (frameCount <= 0 || channels <= 0) + return; + + const auto transport = readTransport(); + const float* left = buffer.getReadPointer(0); + const float* right = channels > 1 ? buffer.getReadPointer(1) : left; + for (int offset = 0; offset < frameCount; offset += packetFrames) + { + const int count = std::min(packetFrames, frameCount - offset); + enqueuePacket(left + offset, right + offset, count, channels, offsetTransport(transport, offset)); + } +} + +void PrismBridgeProcessor::enqueuePacket( + const float* left, + const float* right, + int frameCount, + int channelCount, + const TransportData& transport) noexcept +{ + int start1 = 0, size1 = 0, start2 = 0, size2 = 0; + fifo.prepareToWrite(1, start1, size1, start2, size2); + if (size1 == 0) + { + droppedFrames.fetch_add(static_cast(frameCount), std::memory_order_relaxed); + return; + } + + auto& packet = packets[static_cast(start1)]; + std::memcpy(packet.left.data(), left, static_cast(frameCount) * sizeof(float)); + std::memcpy(packet.right.data(), right, static_cast(frameCount) * sizeof(float)); + packet.transport = transport; + packet.sequence = nextSequence.fetch_add(1, std::memory_order_relaxed) + 1; + packet.frameCount = static_cast(frameCount); + packet.channelCount = static_cast(channelCount > 1 ? 2 : 1); + packet.sampleRate = currentSampleRate.load(std::memory_order_relaxed); + fifo.finishedWrite(1); +} + +bool PrismBridgeProcessor::popPacket(AudioPacket& destination) noexcept +{ + int start1 = 0, size1 = 0, start2 = 0, size2 = 0; + fifo.prepareToRead(1, start1, size1, start2, size2); + if (size1 == 0) return false; + destination = packets[static_cast(start1)]; + fifo.finishedRead(1); + return true; +} + +void PrismBridgeProcessor::discardQueuedPackets() noexcept +{ + AudioPacket scratch; + while (popPacket(scratch)) {} +} + +void PrismBridgeProcessor::run() +{ + int reconnectDelayMs = 250; + int64_t lastHeartbeat = 0; + AudioPacket packet; + + while (!threadShouldExit()) + { + if (!socket || !socket->isConnected()) + { + connected.store(false); + selected.store(false); + discardQueuedPackets(); + if (!connectSocket()) + { + wait(reconnectDelayMs); + reconnectDelayMs = std::min(5000, reconnectDelayMs * 2); + continue; + } + reconnectDelayMs = 250; + lastHeartbeat = static_cast(juce::Time::getMillisecondCounterHiRes()); + } + + if (!readControlMessages()) + { + closeSocket(); + continue; + } + + const auto currentDroppedFrames = droppedFrames.load(); + if ((identityDirty.exchange(false) || currentDroppedFrames != reportedDroppedFrames) + && !sendHello(messageSourceUpdate)) + { + closeSocket(); + continue; + } + reportedDroppedFrames = currentDroppedFrames; + + const auto now = static_cast(juce::Time::getMillisecondCounterHiRes()); + if (now - lastHeartbeat >= 1000) + { + if (!sendHeartbeat()) + { + closeSocket(); + continue; + } + lastHeartbeat = now; + } + + if (!selected.load()) + { + discardQueuedPackets(); + wait(5); + continue; + } + + const auto maximumLivePackets = std::max( + 1, + static_cast(std::floor(currentSampleRate.load() * 0.05 / packetFrames))); + if (fifo.getNumReady() > maximumLivePackets) + { + while (fifo.getNumReady() > maximumLivePackets && popPacket(packet)) + droppedFrames.fetch_add(packet.frameCount, std::memory_order_relaxed); + } + + if (popPacket(packet)) + { + if (!sendAudioPacket(packet)) + closeSocket(); + } + else + { + wait(2); + } + } + + closeSocket(); +} + +bool PrismBridgeProcessor::connectSocket() +{ + socket = std::make_unique(); + if (!socket->connect("127.0.0.1", serverPort, 250)) + { + socket.reset(); + return false; + } + connected.store(true); + controlBuffer.setSize(0); + if (!sendHello(messageHello)) + { + closeSocket(); + return false; + } + reportedDroppedFrames = droppedFrames.load(); + return true; +} + +void PrismBridgeProcessor::closeSocket() +{ + connected.store(false); + selected.store(false); + if (socket) socket->close(); + socket.reset(); +} + +juce::String PrismBridgeProcessor::buildIdentityJson() const +{ + const juce::ScopedLock lock(identityLock); + auto object = new juce::DynamicObject(); + object->setProperty("sourceId", sourceId); + object->setProperty("instanceId", instanceId); + object->setProperty("customName", customName); + object->setProperty("trackName", trackName); + object->setProperty("hostName", hostName); + object->setProperty("sampleRate", currentSampleRate.load()); + object->setProperty("channelCount", getTotalNumInputChannels()); + object->setProperty("droppedFrames", static_cast(droppedFrames.load())); + return juce::JSON::toString(juce::var(object), true); +} + +bool PrismBridgeProcessor::sendHello(uint16_t messageType) +{ + const auto json = buildIdentityJson(); + return sendFrame(messageType, json.toRawUTF8(), static_cast(json.getNumBytesAsUTF8())); +} + +bool PrismBridgeProcessor::sendHeartbeat() +{ + return sendFrame(messageHeartbeat, nullptr, 0); +} + +bool PrismBridgeProcessor::sendAudioPacket(const AudioPacket& packet) +{ + std::array payload {}; + const auto payloadSize = encodeAudioPayload(packet, payload.data(), payload.size()); + return payloadSize > 0 && sendFrame(messageAudio, payload.data(), payloadSize); +} + +size_t PrismBridgeProcessor::encodeAudioPayload( + const AudioPacket& packet, + uint8_t* payload, + size_t capacity) const noexcept +{ + const auto requiredSize = audioHeaderBytes + + static_cast(packet.frameCount) * packet.channelCount * sizeof(float); + if (!payload || packet.frameCount == 0 || packet.frameCount > packetFrames + || (packet.channelCount != 1 && packet.channelCount != 2) || capacity < requiredSize) + return 0; + + std::memset(payload, 0, requiredSize); + writeLittleEndian(payload + 0, packet.sequence); + writeLittleEndian(payload + 4, packet.frameCount); + payload[6] = packet.channelCount; + uint8_t flags = 0; + if (packet.transport.isPlaying) flags |= 1 << 0; + if (packet.transport.isRecording) flags |= 1 << 1; + if (packet.transport.isLooping) flags |= 1 << 2; + if (packet.transport.hasTimeInSamples) flags |= 1 << 3; + if (packet.transport.hasTimeInSeconds) flags |= 1 << 4; + if (packet.transport.hasPpq) flags |= 1 << 5; + if (packet.transport.hasBpm) flags |= 1 << 6; + if (packet.transport.hasTimeSignature) flags |= 1 << 7; + payload[7] = flags; + writeDoubleLittleEndian(payload + 8, packet.sampleRate); + writeLittleEndian(payload + 16, packet.transport.timeInSamples); + writeDoubleLittleEndian(payload + 24, packet.transport.timeInSeconds); + writeDoubleLittleEndian(payload + 32, packet.transport.ppqPosition); + writeDoubleLittleEndian(payload + 40, packet.transport.bpm); + writeDoubleLittleEndian(payload + 48, packet.transport.lastBarPpq); + writeDoubleLittleEndian(payload + 56, packet.transport.loopStartPpq); + writeDoubleLittleEndian(payload + 64, packet.transport.loopEndPpq); + writeLittleEndian(payload + 72, packet.transport.numerator); + writeLittleEndian(payload + 74, packet.transport.denominator); + + size_t offset = audioHeaderBytes; + for (size_t index = 0; index < packet.frameCount; ++index) + { + uint32_t bits = 0; + std::memcpy(&bits, &packet.left[index], sizeof(bits)); + writeLittleEndian(payload + offset, bits); + offset += sizeof(float); + } + if (packet.channelCount > 1) + { + for (size_t index = 0; index < packet.frameCount; ++index) + { + uint32_t bits = 0; + std::memcpy(&bits, &packet.right[index], sizeof(bits)); + writeLittleEndian(payload + offset, bits); + offset += sizeof(float); + } + } + return offset; +} + +bool PrismBridgeProcessor::sendFrame(uint16_t messageType, const void* payload, size_t payloadSize) +{ + if (payloadSize > maxPayloadBytes) return false; + std::array header {}; + writeLittleEndian(header.data() + 0, frameMagic); + writeLittleEndian(header.data() + 4, protocolVersion); + writeLittleEndian(header.data() + 6, messageType); + writeLittleEndian(header.data() + 8, static_cast(payloadSize)); + return writeAll(header.data(), header.size()) + && (payloadSize == 0 || writeAll(payload, payloadSize)); +} + +bool PrismBridgeProcessor::writeAll(const void* source, size_t byteCount) +{ + if (!socket || !socket->isConnected()) return false; + const auto* bytes = static_cast(source); + size_t written = 0; + while (written < byteCount && !threadShouldExit()) + { + if (socket->waitUntilReady(false, 50) < 1) return false; + const int result = socket->write(bytes + written, static_cast(byteCount - written)); + if (result <= 0) return false; + written += static_cast(result); + } + return written == byteCount; +} + +bool PrismBridgeProcessor::readControlMessages() +{ + if (!socket || !socket->isConnected()) return false; + while (socket->waitUntilReady(true, 0) > 0) + { + std::array buffer {}; + const int bytesRead = socket->read(buffer.data(), static_cast(buffer.size()), false); + if (bytesRead <= 0) return false; + controlBuffer.append(buffer.data(), static_cast(bytesRead)); + if (controlBuffer.getSize() > maxPayloadBytes + frameHeaderBytes) return false; + parseControlBuffer(); + } + return true; +} + +void PrismBridgeProcessor::parseControlBuffer() +{ + while (controlBuffer.getSize() >= frameHeaderBytes) + { + const auto* data = static_cast(controlBuffer.getData()); + const uint32_t magic = readU32(data + 0); + const uint16_t version = readU16(data + 4); + const uint16_t messageType = readU16(data + 6); + const uint32_t payloadSize = readU32(data + 8); + if (magic != frameMagic || version != protocolVersion || payloadSize > maxPayloadBytes) + { + closeSocket(); + return; + } + const size_t frameSize = frameHeaderBytes + payloadSize; + if (controlBuffer.getSize() < frameSize) return; + + if (messageType == messageSubscribe) + { + const auto json = juce::String::fromUTF8( + reinterpret_cast(data + frameHeaderBytes), + static_cast(payloadSize)); + const auto parsed = juce::JSON::parse(json); + if (const auto* object = parsed.getDynamicObject()) + selected.store(static_cast(object->getProperty("selected"))); + } + + juce::MemoryBlock remainder; + if (controlBuffer.getSize() > frameSize) + remainder.append(data + frameSize, controlBuffer.getSize() - frameSize); + controlBuffer.swapWith(remainder); + } +} + +void PrismBridgeProcessor::updateTrackProperties(const TrackProperties& properties) +{ + { + const juce::ScopedLock lock(identityLock); + trackName = properties.name; + } + identityDirty.store(true); +} + +void PrismBridgeProcessor::setCustomName(const juce::String& value) +{ + { + const juce::ScopedLock lock(identityLock); + customName = value.trim().substring(0, 160); + } + identityDirty.store(true); + updateHostDisplay(ChangeDetails().withNonParameterStateChanged(true)); +} + +juce::String PrismBridgeProcessor::getCustomName() const +{ + const juce::ScopedLock lock(identityLock); + return customName; +} + +juce::String PrismBridgeProcessor::getDisplayName() const +{ + const juce::ScopedLock lock(identityLock); + if (customName.isNotEmpty()) return customName; + if (trackName.isNotEmpty()) return trackName; + return "Prism Bridge " + sourceId.substring(0, 8); +} + +juce::String PrismBridgeProcessor::getHostName() const +{ + const juce::ScopedLock lock(identityLock); + return hostName; +} + +juce::String PrismBridgeProcessor::getTrackName() const +{ + const juce::ScopedLock lock(identityLock); + return trackName; +} + +juce::String PrismBridgeProcessor::getConnectionDescription() const +{ + if (!connected.load()) return "Waiting for Prism"; + return selected.load() ? "Connected · selected in Prism" : "Connected · available in Prism"; +} + +void PrismBridgeProcessor::getStateInformation(juce::MemoryBlock& destination) +{ + const juce::ScopedLock lock(identityLock); + auto object = new juce::DynamicObject(); + object->setProperty("sourceId", sourceId); + object->setProperty("customName", customName); + const auto json = juce::JSON::toString(juce::var(object)); + destination.setSize(0); + destination.append(json.toRawUTF8(), static_cast(json.getNumBytesAsUTF8())); +} + +void PrismBridgeProcessor::setStateInformation(const void* data, int sizeInBytes) +{ + if (!data || sizeInBytes <= 0) return; + const auto parsed = juce::JSON::parse(juce::String::fromUTF8(static_cast(data), sizeInBytes)); + if (const auto* object = parsed.getDynamicObject()) + { + const juce::ScopedLock lock(identityLock); + const auto restoredId = object->getProperty("sourceId").toString(); + if (restoredId.isNotEmpty()) sourceId = restoredId; + customName = object->getProperty("customName").toString().trim().substring(0, 160); + } + identityDirty.store(true); +} + +juce::AudioProcessorEditor* PrismBridgeProcessor::createEditor() +{ + return new PrismBridgeEditor(*this); +} + +juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() +{ + return new PrismBridgeProcessor(); +} diff --git a/plugin/Source/BridgeProcessor.h b/plugin/Source/BridgeProcessor.h new file mode 100644 index 0000000..bbb5ae9 --- /dev/null +++ b/plugin/Source/BridgeProcessor.h @@ -0,0 +1,147 @@ +#pragma once + +#include +#include +#include +#include +#include + +class PrismBridgeEditor; + +class PrismBridgeProcessor final : public juce::AudioProcessor, + private juce::Thread +{ +public: + static constexpr int packetFrames = 512; + static constexpr int packetCapacity = 128; + + PrismBridgeProcessor(); + ~PrismBridgeProcessor() override; + + void prepareToPlay(double sampleRate, int samplesPerBlock) override; + void releaseResources() override; + bool isBusesLayoutSupported(const BusesLayout& layouts) const override; + void processBlock(juce::AudioBuffer&, juce::MidiBuffer&) override; + void updateTrackProperties(const TrackProperties&) override; + + juce::AudioProcessorEditor* createEditor() override; + bool hasEditor() const override { return true; } + const juce::String getName() const override { return JucePlugin_Name; } + bool acceptsMidi() const override { return false; } + bool producesMidi() const override { return false; } + bool isMidiEffect() const override { return false; } + double getTailLengthSeconds() const override { return 0.0; } + int getNumPrograms() override { return 1; } + int getCurrentProgram() override { return 0; } + void setCurrentProgram(int) override {} + const juce::String getProgramName(int) override { return {}; } + void changeProgramName(int, const juce::String&) override {} + + void getStateInformation(juce::MemoryBlock&) override; + void setStateInformation(const void*, int) override; + + void setCustomName(const juce::String&); + juce::String getCustomName() const; + juce::String getDisplayName() const; + juce::String getHostName() const; + juce::String getTrackName() const; + juce::String getConnectionDescription() const; + uint64_t getDroppedFrameCount() const noexcept { return droppedFrames.load(); } + +#if defined(PRISM_BRIDGE_TESTING) && PRISM_BRIDGE_TESTING + void setSelectedForTesting(bool value) noexcept { selected.store(value); } + int getQueuedPacketCountForTesting() const noexcept { return fifo.getNumReady(); } + void clearQueuedPacketsForTesting() noexcept { discardQueuedPackets(); } +#endif + +private: +#if defined(PRISM_BRIDGE_TESTING) && PRISM_BRIDGE_TESTING +public: +#endif + struct TransportData + { + int64_t timeInSamples = 0; + double timeInSeconds = 0.0; + double ppqPosition = 0.0; + double bpm = 0.0; + double lastBarPpq = 0.0; + double loopStartPpq = 0.0; + double loopEndPpq = 0.0; + int16_t numerator = 4; + int16_t denominator = 4; + bool hasTimeInSamples = false; + bool hasTimeInSeconds = false; + bool hasPpq = false; + bool hasBpm = false; + bool hasTimeSignature = false; + bool isPlaying = false; + bool isRecording = false; + bool isLooping = false; + }; + + struct AudioPacket + { + std::array left {}; + std::array right {}; + TransportData transport; + uint32_t sequence = 0; + uint16_t frameCount = 0; + uint8_t channelCount = 2; + double sampleRate = 48000.0; + }; + +#if defined(PRISM_BRIDGE_TESTING) && PRISM_BRIDGE_TESTING + TransportData offsetTransportForTesting(const TransportData& value, int offset) const noexcept + { + return offsetTransport(value, offset); + } + size_t encodeAudioPayloadForTesting(const AudioPacket& packet, uint8_t* destination, size_t capacity) const noexcept + { + return encodeAudioPayload(packet, destination, capacity); + } +#endif + +private: + + void run() override; + void enqueuePacket(const float*, const float*, int, int, const TransportData&) noexcept; + bool popPacket(AudioPacket&) noexcept; + void discardQueuedPackets() noexcept; + TransportData readTransport() const noexcept; + TransportData offsetTransport(const TransportData&, int sampleOffset) const noexcept; + bool connectSocket(); + void closeSocket(); + bool sendHello(uint16_t messageType = 1); + bool sendHeartbeat(); + bool sendAudioPacket(const AudioPacket&); + size_t encodeAudioPayload(const AudioPacket&, uint8_t*, size_t) const noexcept; + bool readControlMessages(); + bool sendFrame(uint16_t messageType, const void*, size_t); + bool writeAll(const void*, size_t); + void parseControlBuffer(); + juce::String buildIdentityJson() const; + + // AbstractFifo reserves one sentinel slot, so allocate one extra element to + // make packetCapacity the actual writable packet count. + juce::AbstractFifo fifo { packetCapacity + 1 }; + std::array packets; + std::atomic currentSampleRate { 48000.0 }; + std::atomic nextSequence { 0 }; + std::atomic droppedFrames { 0 }; + std::atomic connected { false }; + std::atomic selected { false }; + std::atomic identityDirty { true }; + + mutable juce::CriticalSection identityLock; + juce::String sourceId; + const juce::String instanceId; + juce::String customName; + juce::String trackName; + juce::String hostName; + + std::unique_ptr socket; + juce::MemoryBlock controlBuffer; + uint64_t reportedDroppedFrames = 0; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(PrismBridgeProcessor) +}; diff --git a/plugin/Tests/BridgeProcessorTests.cpp b/plugin/Tests/BridgeProcessorTests.cpp new file mode 100644 index 0000000..d4a882e --- /dev/null +++ b/plugin/Tests/BridgeProcessorTests.cpp @@ -0,0 +1,131 @@ +#include "BridgeProcessor.h" +#include +#include +#include + +namespace +{ +int failures = 0; + +void expect(bool condition, const char* message) +{ + if (condition) return; + std::cerr << "FAIL: " << message << '\n'; + ++failures; +} + +void fillTestSignal(juce::AudioBuffer& buffer) +{ + for (int channel = 0; channel < buffer.getNumChannels(); ++channel) + for (int sample = 0; sample < buffer.getNumSamples(); ++sample) + buffer.setSample(channel, sample, std::sin(static_cast(sample + channel * 7) * 0.013f)); +} + +void expectBitIdentical(const juce::AudioBuffer& actual, + const juce::AudioBuffer& expected, + const char* message) +{ + bool equal = actual.getNumChannels() == expected.getNumChannels() + && actual.getNumSamples() == expected.getNumSamples(); + for (int channel = 0; equal && channel < actual.getNumChannels(); ++channel) + equal = std::memcmp(actual.getReadPointer(channel), + expected.getReadPointer(channel), + static_cast(actual.getNumSamples()) * sizeof(float)) == 0; + expect(equal, message); +} +} + +int main() +{ + PrismBridgeProcessor processor; + processor.prepareToPlay(48000.0, 1024); + processor.setSelectedForTesting(true); + juce::MidiBuffer midi; + + juce::AudioBuffer stereo(2, 1025); + fillTestSignal(stereo); + juce::AudioBuffer stereoCopy; + stereoCopy.makeCopyOf(stereo); + processor.processBlock(stereo, midi); + expectBitIdentical(stereo, stereoCopy, "stereo processing must be bit-identical"); + expect(processor.getQueuedPacketCountForTesting() == 3, "1025 stereo frames must produce three packets"); + + processor.clearQueuedPacketsForTesting(); + juce::AudioBuffer mono(1, PrismBridgeProcessor::packetFrames); + fillTestSignal(mono); + juce::AudioBuffer monoCopy; + monoCopy.makeCopyOf(mono); + processor.processBlock(mono, midi); + expectBitIdentical(mono, monoCopy, "mono processing must be bit-identical"); + expect(processor.getQueuedPacketCountForTesting() == 1, "one mono packet must be queued"); + + processor.clearQueuedPacketsForTesting(); + processor.setNonRealtime(true); + processor.processBlock(mono, midi); + expect(processor.getQueuedPacketCountForTesting() == 0, "offline rendering must not enqueue audio"); + processor.setNonRealtime(false); + + for (int index = 0; index < PrismBridgeProcessor::packetCapacity + 2; ++index) + processor.processBlock(mono, midi); + expect(processor.getQueuedPacketCountForTesting() == PrismBridgeProcessor::packetCapacity, + "the audio queue must remain bounded"); + expect(processor.getDroppedFrameCount() >= 2 * PrismBridgeProcessor::packetFrames, + "overflow must report dropped frames"); + + PrismBridgeProcessor::TransportData transport; + transport.timeInSamples = 24000; + transport.timeInSeconds = 0.5; + transport.ppqPosition = 4.0; + transport.bpm = 120.0; + transport.hasTimeInSamples = true; + transport.hasTimeInSeconds = true; + transport.hasPpq = true; + transport.hasBpm = true; + const auto interpolated = processor.offsetTransportForTesting(transport, 24000); + expect(interpolated.timeInSamples == 48000, "sample transport must interpolate by packet offset"); + expect(std::abs(interpolated.timeInSeconds - 1.0) < 1.0e-9, + "seconds transport must interpolate by packet offset"); + expect(std::abs(interpolated.ppqPosition - 5.0) < 1.0e-9, + "PPQ transport must interpolate using tempo"); + + PrismBridgeProcessor::AudioPacket encodedPacket; + encodedPacket.sequence = 0x01020304; + encodedPacket.frameCount = 2; + encodedPacket.channelCount = 2; + encodedPacket.sampleRate = 48000.0; + encodedPacket.transport = transport; + encodedPacket.transport.isPlaying = true; + encodedPacket.transport.hasTimeSignature = true; + encodedPacket.transport.numerator = 7; + encodedPacket.transport.denominator = 8; + encodedPacket.left[0] = 0.25f; + encodedPacket.left[1] = -0.5f; + encodedPacket.right[0] = 0.75f; + encodedPacket.right[1] = -1.0f; + std::array encoded {}; + const auto encodedBytes = processor.encodeAudioPayloadForTesting( + encodedPacket, encoded.data(), encoded.size()); + expect(encodedBytes == 92, "protocol payload must contain the fixed header and planar samples"); + expect(encoded[0] == 0x04 && encoded[1] == 0x03 && encoded[2] == 0x02 && encoded[3] == 0x01, + "protocol sequence must be little-endian"); + expect(encoded[4] == 2 && encoded[5] == 0 && encoded[6] == 2, + "protocol frame and channel counts must be encoded"); + expect(encoded[72] == 7 && encoded[74] == 8, + "protocol time signature must be little-endian"); + float decodedLeft = 0.0f; + std::memcpy(&decodedLeft, encoded.data() + 76, sizeof(decodedLeft)); + decodedLeft = juce::ByteOrder::swapIfBigEndian(decodedLeft); + expect(decodedLeft == 0.25f, "protocol audio must be planar Float32"); + + processor.setCustomName("Drum Bus"); + juce::MemoryBlock state; + processor.getStateInformation(state); + PrismBridgeProcessor restored; + restored.setStateInformation(state.getData(), static_cast(state.getSize())); + expect(restored.getCustomName() == "Drum Bus", "custom source names must restore from DAW state"); + + processor.releaseResources(); + if (failures == 0) + std::cout << "Prism Bridge processor tests passed\n"; + return failures == 0 ? 0 : 1; +} diff --git a/resources/installer/linux/install-vst3-from-package.sh b/resources/installer/linux/install-vst3-from-package.sh index 2d70165..5efbc2b 100755 --- a/resources/installer/linux/install-vst3-from-package.sh +++ b/resources/installer/linux/install-vst3-from-package.sh @@ -97,6 +97,7 @@ install_plugin "Prism Loudness Meter.vst3" install_plugin "Prism Vectorscope.vst3" install_plugin "Prism Spectrogram.vst3" install_plugin "Prism Waveform.vst3" +install_plugin "Prism Bridge.vst3" chmod -R a+rX "$DEST_DIR"/Prism*.vst3 2>/dev/null || true echo "Prism VST3 plugins installed to $DEST_DIR" diff --git a/resources/installer/linux/install-vst3.sh b/resources/installer/linux/install-vst3.sh index 4509ccd..55935c4 100755 --- a/resources/installer/linux/install-vst3.sh +++ b/resources/installer/linux/install-vst3.sh @@ -94,6 +94,7 @@ install_plugin "Prism Loudness Meter.vst3" install_plugin "Prism Vectorscope.vst3" install_plugin "Prism Spectrogram.vst3" install_plugin "Prism Waveform.vst3" +install_plugin "Prism Bridge.vst3" chmod -R u+rwX,go+rX "$DEST_DIR"/Prism*.vst3 2>/dev/null || true diff --git a/resources/installer/linux/remove-vst3-from-system.sh b/resources/installer/linux/remove-vst3-from-system.sh index c751394..10559b5 100755 --- a/resources/installer/linux/remove-vst3-from-system.sh +++ b/resources/installer/linux/remove-vst3-from-system.sh @@ -52,6 +52,7 @@ remove_plugin "Prism Loudness Meter.vst3" remove_plugin "Prism Vectorscope.vst3" remove_plugin "Prism Spectrogram.vst3" remove_plugin "Prism Waveform.vst3" +remove_plugin "Prism Bridge.vst3" remove_tui_link echo "Prism VST3 plugins removed from $DEST_DIR" diff --git a/resources/installer/windows-vst3.nsh b/resources/installer/windows-vst3.nsh index fb5b587..c66f47f 100644 --- a/resources/installer/windows-vst3.nsh +++ b/resources/installer/windows-vst3.nsh @@ -60,7 +60,7 @@ Pop $PRISM_VST_CHECKBOX ${NSD_Check} $PRISM_VST_CHECKBOX - ${NSD_CreateLabel} 0 22u 100% 80u "Installs the seven Prism scope plugins (Spectrum, Oscilloscope, Vectorscope, Spectrogram, VU Meter, Loudness Meter, Waveform) to:$\r$\n$\r$\n $COMMONFILES64\VST3$\r$\n$\r$\nDAWs (FL Studio, Ableton, Logic, Reaper, etc.) will find them on the next plugin rescan.$\r$\n$\r$\nUncheck to install only the Prism desktop app. You can re-run this installer at any time to add the plugins later." + ${NSD_CreateLabel} 0 22u 100% 80u "Installs seven Prism analyzer plugins plus Prism Bridge to:$\r$\n$\r$\n $COMMONFILES64\VST3$\r$\n$\r$\nDAWs (FL Studio, Ableton, Logic, Reaper, etc.) will find them on the next plugin rescan.$\r$\n$\r$\nPrism Bridge sends one inserted track or bus to the standalone Prism app. Uncheck to install only the desktop app." Pop $0 nsDialogs::Show @@ -159,4 +159,5 @@ RMDir /r "$COMMONFILES64\VST3\Prism Vectorscope.vst3" RMDir /r "$COMMONFILES64\VST3\Prism Spectrogram.vst3" RMDir /r "$COMMONFILES64\VST3\Prism Waveform.vst3" + RMDir /r "$COMMONFILES64\VST3\Prism Bridge.vst3" !macroend diff --git a/scripts/run-daw-bridge-tests.mjs b/scripts/run-daw-bridge-tests.mjs new file mode 100644 index 0000000..07366d3 --- /dev/null +++ b/scripts/run-daw-bridge-tests.mjs @@ -0,0 +1,31 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawn } from 'node:child_process' +import { build } from 'esbuild' + +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))) +const tempDir = await mkdtemp(join(tmpdir(), 'prism-daw-bridge-tests-')) +const bundledTestPath = join(tempDir, 'daw-bridge-service.test.mjs') + +let exitCode = 1 +try { + await build({ + entryPoints: [join(rootDir, 'test', 'daw-bridge-service.test.ts')], + outfile: bundledTestPath, + bundle: true, + platform: 'node', + format: 'esm', + target: 'node23', + sourcemap: 'inline', + }) + exitCode = await new Promise((resolve) => { + const child = spawn(process.execPath, ['--test', bundledTestPath], { stdio: 'inherit', cwd: rootDir }) + child.on('exit', (code) => resolve(code ?? 1)) + child.on('error', () => resolve(1)) + }) +} finally { + await rm(tempDir, { recursive: true, force: true }) +} +process.exit(exitCode) diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 1c77f16..a3f9569 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -6,6 +6,7 @@ const testScripts = [ 'test:audio-store', 'test:astra', 'test:capture-support', + 'test:daw-bridge', 'test:spotify-provider', 'test:now-playing', 'test:profiles', diff --git a/src/main/index.ts b/src/main/index.ts index 6293727..9e77429 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -82,6 +82,8 @@ import { } from './services/trayMenu' import { TrayRendererCommandQueue } from './services/trayRendererCommandQueue' import { resolveTrayAssetPath } from './services/trayAssets' +import { DawBridgeService } from './services/dawBridgeService' +import type { DawBridgeAudioBatch, DawBridgeSnapshot } from '../types/dawBridge' let mainWindow: BrowserWindow | null = null let moveInterval: ReturnType | null = null @@ -130,6 +132,7 @@ let nativeWindowsMediaApi: NativeWindowsMediaAPI | null | undefined let nativeWindowChromeApi: NativeWindowChromeAPI | null | undefined let loginItemService: LoginItemService | null = null let audioClipLibrary: AudioClipLibrary | null = null +let dawBridgeService: DawBridgeService | null = null let desktopIntegrationPreferences: DesktopIntegrationPreferences = { ...DEFAULT_DESKTOP_INTEGRATION_PREFERENCES, } @@ -686,6 +689,19 @@ function createNativeTrayMenu(model: ReturnType): Ele deviceId: source.id || null, }), })), + { type: 'separator' }, + { label: 'DAW Bridges', enabled: false }, + ...model.rendererState.dawSources.map((source): MenuItemConstructorOptions => ({ + label: source.label, + type: 'radio', + checked: model.rendererState.captureMode === 'daw' + && source.id === model.rendererState.selectedDawSourceId, + enabled: model.rendererReady, + click: () => sendTrayRendererCommand({ + type: 'select-daw-source', + sourceId: source.id, + }), + })), ] if (model.rendererState.systemSources.length === 0) { audioSourceItems.splice(1, 0, { label: 'No outputs available', enabled: false }) @@ -693,6 +709,9 @@ function createNativeTrayMenu(model: ReturnType): Ele if (model.rendererState.inputSources.length === 0) { audioSourceItems.push({ label: 'No inputs available', enabled: false }) } + if (model.rendererState.dawSources.length === 0) { + audioSourceItems.push({ label: 'No DAW bridges connected', enabled: false }) + } const loginStatus = loginItemStatusLabel(model.desktopIntegration) const rollingCaptureItems: MenuItemConstructorOptions[] = [ @@ -2346,6 +2365,24 @@ function setupIPC(): void { return getAppBuildInfo() }) + ipcMain.handle('daw-bridge:get-snapshot', () => { + return dawBridgeService?.getSnapshot() ?? { + available: false, + reason: 'The DAW bridge listener is not initialized.', + selectedSourceId: null, + sources: [], + } satisfies DawBridgeSnapshot + }) + + ipcMain.handle('daw-bridge:select-source', (_event, sourceId: unknown) => { + return dawBridgeService?.selectSource(typeof sourceId === 'string' ? sourceId : null) ?? { + available: false, + reason: 'The DAW bridge listener is not initialized.', + selectedSourceId: null, + sources: [], + } satisfies DawBridgeSnapshot + }) + ipcMain.on('audio-clips:start-drag', (event, rawPayload: unknown) => { const targetWindow = getWindowFromSender(event.sender) if (!targetWindow || !isMainRendererWindow(targetWindow)) return @@ -2778,6 +2815,19 @@ if (!hasSingleInstanceLock) { setupPermissions() void getNowPlayingManager().initialize() setupIPC() + dawBridgeService = new DawBridgeService({ + onSnapshot: (snapshot: DawBridgeSnapshot) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('daw-bridge:snapshot', snapshot) + } + }, + onAudioBatch: (batch: DawBridgeAudioBatch) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('daw-bridge:audio-batch', batch) + } + }, + }) + await dawBridgeService.start() await getWindowStateStore().initialize() desktopIntegrationPreferences = await loadDesktopIntegrationPreferences( getDesktopIntegrationPreferencesPath(), @@ -2837,6 +2887,8 @@ app.on('window-all-closed', () => { app.on('before-quit', () => { isAppQuitting = true + dawBridgeService?.stop() + dawBridgeService = null destroyAppTray() pendingTrayRendererCommands.clear() }) diff --git a/src/main/profileLibrary.ts b/src/main/profileLibrary.ts index 24b2e72..c759cb4 100644 --- a/src/main/profileLibrary.ts +++ b/src/main/profileLibrary.ts @@ -356,6 +356,7 @@ export class FileBackedProfileLibrary { && candidate.version !== 2 && candidate.version !== 3 && candidate.version !== 4 + && candidate.version !== 5 && candidate.version !== PROFILE_FILE_VERSION ) { throw new Error(`Unsupported profile version in ${basename(filePath)}.`) diff --git a/src/main/services/dawBridgeService.ts b/src/main/services/dawBridgeService.ts new file mode 100644 index 0000000..33838f6 --- /dev/null +++ b/src/main/services/dawBridgeService.ts @@ -0,0 +1,458 @@ +import { createServer, type Server, type Socket } from 'net' +import { + DAW_BRIDGE_AUDIO_HEADER_BYTES, + DAW_BRIDGE_FRAME_HEADER_BYTES, + DAW_BRIDGE_FRAME_MAGIC, + DAW_BRIDGE_HOST, + DAW_BRIDGE_MAX_PAYLOAD_BYTES, + DAW_BRIDGE_MESSAGE, + DAW_BRIDGE_PORT, + DAW_BRIDGE_PROTOCOL_VERSION, + type DawBridgeAudioBatch, + type DawBridgeAudioPacket, + type DawBridgeHelloPayload, + type DawBridgeSnapshot, + type DawBridgeSourceDescriptor, + type DawBridgeSubscribePayload, + type DawTransportSnapshot, +} from '../../types/dawBridge' + +const HEARTBEAT_INTERVAL_MS = 1000 +const HEARTBEAT_TIMEOUT_MS = 3000 +const AUDIO_BATCH_INTERVAL_MS = 10 +const MAX_CONNECTIONS = 64 +const MAX_PENDING_PACKETS = 128 +const SAFE_ID_PATTERN = /^[a-zA-Z0-9._:-]{1,128}$/ + +interface ClientState { + socket: Socket + receiveBuffer: Buffer + source: DawBridgeSourceDescriptor | null + lastHeartbeatAt: number +} + +interface DawBridgeServiceOptions { + host?: string + port?: number + onSnapshot?: (snapshot: DawBridgeSnapshot) => void + onAudioBatch?: (batch: DawBridgeAudioBatch) => void + heartbeatIntervalMs?: number + heartbeatTimeoutMs?: number + audioBatchIntervalMs?: number +} + +function finiteNumber(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback +} + +function optionalString(value: unknown): string | null { + if (typeof value !== 'string') return null + const trimmed = value.trim() + return trimmed ? trimmed.slice(0, 160) : null +} + +function isHelloPayload(value: unknown): value is DawBridgeHelloPayload { + if (typeof value !== 'object' || value === null) return false + const candidate = value as Partial + return typeof candidate.sourceId === 'string' + && SAFE_ID_PATTERN.test(candidate.sourceId) + && typeof candidate.instanceId === 'string' + && SAFE_ID_PATTERN.test(candidate.instanceId) +} + +function sourceLabel(payload: DawBridgeHelloPayload): string { + return optionalString(payload.customName) + ?? optionalString(payload.trackName) + ?? `Prism Bridge ${payload.sourceId.slice(0, 8)}` +} + +function isLoopbackAddress(address: string | undefined): boolean { + return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1' +} + +export class DawBridgeService { + private readonly host: string + private readonly port: number + private readonly onSnapshot?: (snapshot: DawBridgeSnapshot) => void + private readonly onAudioBatch?: (batch: DawBridgeAudioBatch) => void + private readonly heartbeatIntervalMs: number + private readonly heartbeatTimeoutMs: number + private readonly audioBatchIntervalMs: number + private server: Server | null = null + private clients = new Set() + private sources = new Map() + private selectedSourceId: string | null = null + private available = false + private unavailableReason: string | null = 'The DAW bridge listener has not started.' + private heartbeatTimer: ReturnType | null = null + private audioBatchTimer: ReturnType | null = null + private pendingAudioPackets: DawBridgeAudioPacket[] = [] + + constructor(options: DawBridgeServiceOptions = {}) { + this.host = options.host ?? DAW_BRIDGE_HOST + this.port = options.port ?? DAW_BRIDGE_PORT + this.onSnapshot = options.onSnapshot + this.onAudioBatch = options.onAudioBatch + this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS + this.heartbeatTimeoutMs = options.heartbeatTimeoutMs ?? HEARTBEAT_TIMEOUT_MS + this.audioBatchIntervalMs = options.audioBatchIntervalMs ?? AUDIO_BATCH_INTERVAL_MS + } + + async start(): Promise { + if (this.server) return this.getSnapshot() + + const server = createServer((socket) => this.acceptClient(socket)) + this.server = server + server.maxConnections = MAX_CONNECTIONS + + await new Promise((resolve) => { + const handleError = (error: Error): void => { + server.removeListener('listening', handleListening) + this.available = false + this.unavailableReason = `Could not listen on ${this.host}:${this.port}: ${error.message}` + this.server = null + try { + server.close() + } catch { + // The server may never have entered the listening state. + } + resolve() + } + const handleListening = (): void => { + server.removeListener('error', handleError) + this.available = true + this.unavailableReason = null + this.heartbeatTimer = setInterval(() => this.expireSilentClients(), this.heartbeatIntervalMs) + resolve() + } + server.once('error', handleError) + server.once('listening', handleListening) + server.listen(this.port, this.host) + }) + + this.emitSnapshot() + return this.getSnapshot() + } + + stop(): void { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer) + this.heartbeatTimer = null + } + if (this.audioBatchTimer) { + clearTimeout(this.audioBatchTimer) + this.audioBatchTimer = null + } + this.pendingAudioPackets = [] + for (const client of this.clients) { + client.socket.destroy() + } + this.clients.clear() + this.sources.clear() + this.selectedSourceId = null + this.server?.close() + this.server = null + this.available = false + this.unavailableReason = 'The DAW bridge listener is stopped.' + } + + getSnapshot(): DawBridgeSnapshot { + return { + available: this.available, + reason: this.unavailableReason, + selectedSourceId: this.selectedSourceId, + sources: [...this.sources.values()] + .flatMap((client) => client.source ? [client.source] : []) + .sort((left, right) => left.label.localeCompare(right.label)), + } + } + + getListeningPort(): number | null { + const address = this.server?.address() + return typeof address === 'object' && address ? address.port : null + } + + selectSource(sourceId: string | null): DawBridgeSnapshot { + this.selectedSourceId = typeof sourceId === 'string' && this.sources.has(sourceId) ? sourceId : null + for (const client of this.clients) { + this.sendSubscription(client) + if (client.source) { + client.source = { + ...client.source, + connectionState: client.source.id === this.selectedSourceId ? 'selected' : 'connected', + } + } + } + this.emitSnapshot() + return this.getSnapshot() + } + + private acceptClient(socket: Socket): void { + if (!isLoopbackAddress(socket.remoteAddress) || this.clients.size >= MAX_CONNECTIONS) { + socket.destroy() + return + } + + socket.setNoDelay(true) + socket.setKeepAlive(true, this.heartbeatIntervalMs) + const client: ClientState = { + socket, + receiveBuffer: Buffer.alloc(0), + source: null, + lastHeartbeatAt: Date.now(), + } + this.clients.add(client) + + socket.on('data', (chunk) => this.handleData(client, chunk)) + socket.on('error', () => socket.destroy()) + socket.on('close', () => this.removeClient(client)) + } + + private handleData(client: ClientState, chunk: Buffer): void { + client.lastHeartbeatAt = Date.now() + client.receiveBuffer = client.receiveBuffer.length === 0 + ? chunk + : Buffer.concat([client.receiveBuffer, chunk]) + + while (client.receiveBuffer.length >= DAW_BRIDGE_FRAME_HEADER_BYTES) { + const magic = client.receiveBuffer.readUInt32LE(0) + const version = client.receiveBuffer.readUInt16LE(4) + const messageType = client.receiveBuffer.readUInt16LE(6) + const payloadLength = client.receiveBuffer.readUInt32LE(8) + if ( + magic !== DAW_BRIDGE_FRAME_MAGIC + || version !== DAW_BRIDGE_PROTOCOL_VERSION + || payloadLength > DAW_BRIDGE_MAX_PAYLOAD_BYTES + ) { + client.socket.destroy() + return + } + + const frameLength = DAW_BRIDGE_FRAME_HEADER_BYTES + payloadLength + if (client.receiveBuffer.length < frameLength) return + const payload = client.receiveBuffer.subarray(DAW_BRIDGE_FRAME_HEADER_BYTES, frameLength) + client.receiveBuffer = client.receiveBuffer.subarray(frameLength) + if (!this.handleFrame(client, messageType, payload)) { + client.socket.destroy() + return + } + } + } + + private handleFrame(client: ClientState, messageType: number, payload: Buffer): boolean { + switch (messageType) { + case DAW_BRIDGE_MESSAGE.hello: + case DAW_BRIDGE_MESSAGE.sourceUpdate: + return this.handleHello(client, payload) + case DAW_BRIDGE_MESSAGE.heartbeat: + return true + case DAW_BRIDGE_MESSAGE.audio: + return this.handleAudio(client, payload) + default: + return false + } + } + + private handleHello(client: ClientState, payloadBuffer: Buffer): boolean { + let payload: unknown + try { + payload = JSON.parse(payloadBuffer.toString('utf8')) + } catch { + return false + } + if (!isHelloPayload(payload)) return false + + if (client.source) { + this.sources.delete(client.source.id) + } + + let liveId = payload.sourceId + const conflictingClient = [...this.sources.values()].find((candidate) => ( + candidate !== client && candidate.source?.persistentId === payload.sourceId + )) + if (conflictingClient?.source) { + const existingSource = conflictingClient.source + if (existingSource.id === payload.sourceId) { + this.sources.delete(existingSource.id) + const distinctExistingId = this.allocateLiveId( + payload.sourceId, + existingSource.instanceId, + conflictingClient, + ) + conflictingClient.source = { ...existingSource, id: distinctExistingId, connectionState: 'connected' } + this.sources.set(distinctExistingId, conflictingClient) + this.pendingAudioPackets = this.pendingAudioPackets.filter((packet) => packet.sourceId !== payload.sourceId) + } + if (this.selectedSourceId === payload.sourceId) { + this.selectedSourceId = null + } + this.sendSubscription(conflictingClient) + liveId = this.allocateLiveId(payload.sourceId, payload.instanceId, client) + } + + const descriptor: DawBridgeSourceDescriptor = { + id: liveId, + persistentId: payload.sourceId, + instanceId: payload.instanceId, + label: sourceLabel(payload), + customName: optionalString(payload.customName), + trackName: optionalString(payload.trackName), + hostName: optionalString(payload.hostName), + sampleRate: Math.max(1, finiteNumber(payload.sampleRate, 48000)), + channelCount: Math.max(1, Math.min(2, Math.floor(finiteNumber(payload.channelCount, 2)))), + droppedFrames: Math.max(0, Math.floor(finiteNumber(payload.droppedFrames, 0))), + connectionState: liveId === this.selectedSourceId ? 'selected' : 'connected', + } + client.source = descriptor + this.sources.set(descriptor.id, client) + this.sendSubscription(client) + this.emitSnapshot() + return true + } + + private handleAudio(client: ClientState, payload: Buffer): boolean { + const source = client.source + if (!source || source.id !== this.selectedSourceId || payload.length < DAW_BRIDGE_AUDIO_HEADER_BYTES) { + return Boolean(source) + } + + const sequence = payload.readUInt32LE(0) + const frameCount = payload.readUInt16LE(4) + const channelCount = payload.readUInt8(6) + const flags = payload.readUInt8(7) + const sampleRate = payload.readDoubleLE(8) + const expectedBytes = DAW_BRIDGE_AUDIO_HEADER_BYTES + frameCount * channelCount * Float32Array.BYTES_PER_ELEMENT + if ( + frameCount === 0 + || (channelCount !== 1 && channelCount !== 2) + || !Number.isFinite(sampleRate) + || sampleRate <= 0 + || payload.length !== expectedBytes + ) { + return false + } + + const transport: DawTransportSnapshot = { + sequence, + isPlaying: Boolean(flags & (1 << 0)), + isRecording: Boolean(flags & (1 << 1)), + isLooping: Boolean(flags & (1 << 2)), + } + if (flags & (1 << 3)) transport.timeInSamples = Number(payload.readBigInt64LE(16)) + if (flags & (1 << 4)) transport.timeInSeconds = payload.readDoubleLE(24) + if (flags & (1 << 5)) { + transport.ppqPosition = payload.readDoubleLE(32) + const lastBar = payload.readDoubleLE(48) + if (Number.isFinite(lastBar)) transport.ppqPositionOfLastBarStart = lastBar + } + if (flags & (1 << 6)) transport.bpm = payload.readDoubleLE(40) + if (flags & (1 << 7)) { + transport.timeSignature = { + numerator: payload.readInt16LE(72), + denominator: payload.readInt16LE(74), + } + } + const loopStart = payload.readDoubleLE(56) + const loopEnd = payload.readDoubleLE(64) + if (transport.isLooping && Number.isFinite(loopStart) && Number.isFinite(loopEnd)) { + transport.loopPoints = { ppqStart: loopStart, ppqEnd: loopEnd } + } + + const audioOffset = DAW_BRIDGE_AUDIO_HEADER_BYTES + const left = new Float32Array(frameCount) + const right = new Float32Array(frameCount) + for (let index = 0; index < frameCount; index += 1) { + left[index] = payload.readFloatLE(audioOffset + index * 4) + } + if (channelCount === 2) { + const rightOffset = audioOffset + frameCount * 4 + for (let index = 0; index < frameCount; index += 1) { + right[index] = payload.readFloatLE(rightOffset + index * 4) + } + } else { + right.set(left) + } + + this.pendingAudioPackets.push({ + sourceId: source.id, + sequence, + sampleRate, + channelCount, + left, + right, + transport, + }) + if (this.pendingAudioPackets.length > MAX_PENDING_PACKETS) { + this.pendingAudioPackets.splice(0, this.pendingAudioPackets.length - MAX_PENDING_PACKETS) + } + this.scheduleAudioBatch() + return true + } + + private scheduleAudioBatch(): void { + if (this.audioBatchTimer) return + this.audioBatchTimer = setTimeout(() => { + this.audioBatchTimer = null + const sourceId = this.selectedSourceId + if (!sourceId) { + this.pendingAudioPackets = [] + return + } + const packets = this.pendingAudioPackets.filter((packet) => packet.sourceId === sourceId) + this.pendingAudioPackets = [] + if (packets.length > 0) { + this.onAudioBatch?.({ sourceId, packets }) + } + }, this.audioBatchIntervalMs) + } + + private sendSubscription(client: ClientState): void { + if (!client.source || client.socket.destroyed) return + const payload: DawBridgeSubscribePayload = { + selected: client.source.id === this.selectedSourceId, + } + this.sendFrame(client.socket, DAW_BRIDGE_MESSAGE.subscribe, Buffer.from(JSON.stringify(payload), 'utf8')) + } + + private allocateLiveId(sourceId: string, instanceId: string, owner: ClientState): string { + const base = `${sourceId}:${instanceId.slice(0, 16)}` + let candidate = base + let suffix = 2 + while (this.sources.has(candidate) && this.sources.get(candidate) !== owner) { + candidate = `${base}:${suffix}` + suffix += 1 + } + return candidate + } + + private sendFrame(socket: Socket, messageType: number, payload: Buffer): void { + const header = Buffer.allocUnsafe(DAW_BRIDGE_FRAME_HEADER_BYTES) + header.writeUInt32LE(DAW_BRIDGE_FRAME_MAGIC, 0) + header.writeUInt16LE(DAW_BRIDGE_PROTOCOL_VERSION, 4) + header.writeUInt16LE(messageType, 6) + header.writeUInt32LE(payload.length, 8) + socket.write(Buffer.concat([header, payload])) + } + + private expireSilentClients(): void { + const cutoff = Date.now() - this.heartbeatTimeoutMs + for (const client of this.clients) { + if (client.lastHeartbeatAt < cutoff) { + client.socket.destroy() + } + } + } + + private removeClient(client: ClientState): void { + this.clients.delete(client) + if (client.source && this.sources.get(client.source.id) === client) { + this.sources.delete(client.source.id) + this.pendingAudioPackets = this.pendingAudioPackets.filter((packet) => packet.sourceId !== client.source!.id) + this.emitSnapshot() + } + } + + private emitSnapshot(): void { + this.onSnapshot?.(this.getSnapshot()) + } +} diff --git a/src/main/services/trayMenu.ts b/src/main/services/trayMenu.ts index 37445a5..1bc3388 100644 --- a/src/main/services/trayMenu.ts +++ b/src/main/services/trayMenu.ts @@ -68,6 +68,7 @@ export function normalizeTrayRendererState(value: unknown): TrayRendererState { const candidate = value as Partial const captureStatus = candidate.captureStatus === 'connecting' + || candidate.captureStatus === 'waiting' || candidate.captureStatus === 'capturing' || candidate.captureStatus === 'error' ? candidate.captureStatus @@ -81,18 +82,24 @@ export function normalizeTrayRendererState(value: unknown): TrayRendererState { activeSourceLabel: typeof candidate.activeSourceLabel === 'string' ? normalizeText(candidate.activeSourceLabel, 'Audio Source') : null, - captureMode: candidate.captureMode === 'device' ? 'device' : 'system', + captureMode: candidate.captureMode === 'device' || candidate.captureMode === 'daw' + ? candidate.captureMode + : 'system', selectedSystemSourceId: typeof candidate.selectedSystemSourceId === 'string' ? candidate.selectedSystemSourceId : null, selectedDeviceId: typeof candidate.selectedDeviceId === 'string' ? candidate.selectedDeviceId : null, + selectedDawSourceId: typeof candidate.selectedDawSourceId === 'string' + ? candidate.selectedDawSourceId + : null, rollingCaptureSeconds: isRollingCaptureDuration(candidate.rollingCaptureSeconds) ? candidate.rollingCaptureSeconds : null, systemSources: normalizeSources(candidate.systemSources), inputSources: normalizeSources(candidate.inputSources), + dawSources: normalizeSources(candidate.dawSources), } } @@ -102,6 +109,8 @@ export function buildTrayMenuModel(state: TrayMenuState): TrayMenuModel { ? sourceLabel ? `Capturing · ${sourceLabel}` : 'Capturing' : state.rendererState.captureStatus === 'connecting' ? 'Connecting to audio…' + : state.rendererState.captureStatus === 'waiting' + ? 'Waiting for DAW bridge…' : state.rendererState.captureStatus === 'error' ? 'Audio capture error' : 'Capture stopped' @@ -115,6 +124,7 @@ export function buildTrayMenuModel(state: TrayMenuState): TrayMenuModel { mainWindowActionLabel: state.mainWindowVisible ? 'Hide Prism' : 'Show Prism', captureActionLabel: state.rendererState.captureStatus === 'capturing' || state.rendererState.captureStatus === 'connecting' + || state.rendererState.captureStatus === 'waiting' ? 'Stop Capture' : 'Start Capture', captureActionEnabled: state.rendererReady, diff --git a/src/plugin-ui/PluginWebViewDataSource.ts b/src/plugin-ui/PluginWebViewDataSource.ts index 11f3eae..0d26995 100644 --- a/src/plugin-ui/PluginWebViewDataSource.ts +++ b/src/plugin-ui/PluginWebViewDataSource.ts @@ -20,6 +20,7 @@ export class PluginWebViewDataSource implements SpectrumAnalyzerDataSource { sampleRate: 48000, channelCount: 2, capturing: false, + suspended: false, backendKind: null, } @@ -88,6 +89,10 @@ export class PluginWebViewDataSource implements SpectrumAnalyzerDataSource { return this.sessionState.sampleRate } + getBackendKind(): null { + return null + } + isPlaying(): boolean { return this.sessionState.capturing } diff --git a/src/preload/captureSupport.ts b/src/preload/captureSupport.ts index 7c6d7e1..79fce13 100644 --- a/src/preload/captureSupport.ts +++ b/src/preload/captureSupport.ts @@ -66,5 +66,10 @@ export function getCaptureBackendSupport( available: true, reason: null, }, + dawBridge: { + kind: 'daw-bridge', + available: false, + reason: 'The DAW bridge listener is still starting.', + }, } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 23a89c6..1a062d7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -44,6 +44,10 @@ import type { import type { VisualizerDSP } from '../renderer/audio/native/visualizer-dsp' import { resolveWindowCapabilities } from '../shared/windowCapabilities' import { getCaptureBackendSupport } from './captureSupport' +import type { + DawBridgeAudioBatch, + DawBridgeSnapshot, +} from '../types/dawBridge' type NativeAddonModule = VisualizerDSP & NativeCaptureAPI const windowCapabilities: WindowCapabilities = resolveWindowCapabilities({ @@ -104,7 +108,35 @@ contextBridge.exposeInMainWorld('electronAPI', { getWindowBackground: () => ipcRenderer.invoke('window:get-background') as Promise, setWindowBackground: (state: WindowBackgroundState) => ipcRenderer.invoke('window:set-background', state) as Promise, isCursorInsideWindow: () => ipcRenderer.invoke('window:is-cursor-inside') as Promise, - getCaptureBackendSupport: async () => getCaptureBackendSupport(process.platform, nativeCaptureAPI) as CaptureBackendSupport, + getCaptureBackendSupport: async () => { + const nativeSupport = getCaptureBackendSupport(process.platform, nativeCaptureAPI) + const bridgeSnapshot = await ipcRenderer.invoke('daw-bridge:get-snapshot') as DawBridgeSnapshot + return { + ...nativeSupport, + dawBridge: { + kind: 'daw-bridge', + available: bridgeSnapshot.available, + reason: bridgeSnapshot.reason, + }, + } as CaptureBackendSupport + }, + dawBridge: { + getSnapshot: () => ipcRenderer.invoke('daw-bridge:get-snapshot') as Promise, + selectSource: (sourceId: string | null) => ipcRenderer.invoke( + 'daw-bridge:select-source', + sourceId, + ) as Promise, + onSnapshot: (callback: (snapshot: DawBridgeSnapshot) => void) => { + const handler = (_event: Electron.IpcRendererEvent, snapshot: DawBridgeSnapshot): void => callback(snapshot) + ipcRenderer.on('daw-bridge:snapshot', handler) + return () => ipcRenderer.removeListener('daw-bridge:snapshot', handler) + }, + onAudioBatch: (callback: (batch: DawBridgeAudioBatch) => void) => { + const handler = (_event: Electron.IpcRendererEvent, batch: DawBridgeAudioBatch): void => callback(batch) + ipcRenderer.on('daw-bridge:audio-batch', handler) + return () => ipcRenderer.removeListener('daw-bridge:audio-batch', handler) + }, + }, audioClips: { startDrag: (payload: AudioClipDragPayload) => ipcRenderer.send('audio-clips:start-drag', payload), revealFolder: () => ipcRenderer.invoke('audio-clips:reveal-folder') as Promise, diff --git a/src/renderer/audio/AudioCapture.ts b/src/renderer/audio/AudioCapture.ts index 1ad5267..506b055 100644 --- a/src/renderer/audio/AudioCapture.ts +++ b/src/renderer/audio/AudioCapture.ts @@ -26,6 +26,11 @@ import type { NativeCaptureStartResult, NativeSystemCaptureAPI, } from '../../types/nativeCapture' +import type { + DawBridgeAudioBatch, + DawBridgeSnapshot, + DawTransportSnapshot, +} from '../../types/dawBridge' export type { CaptureMode } from '../../types/capture' @@ -35,6 +40,7 @@ interface CaptureChunk { channelCount: number capturedAt: number sequence: number + transport?: DawTransportSnapshot } interface CaptureBackendStatus { @@ -46,10 +52,12 @@ interface CaptureBackendStatus { channelCount: number activeSourceId: string | null activeSourceLabel: string | null + waiting?: boolean } interface CaptureBackendStartRequest { deviceId?: string + persistentSourceId?: string forceRestart?: boolean } @@ -140,6 +148,11 @@ export function createDefaultBackendSupport(platform = resolvePlatform()): Captu available: true, reason: null, }, + dawBridge: { + kind: 'daw-bridge', + available: false, + reason: 'The DAW bridge listener is still starting.', + }, } } @@ -152,6 +165,7 @@ export interface CaptureManagerStatus { isCapturing: boolean activeSourceId: string | null activeSourceLabel: string | null + waiting: boolean } type StatusListener = (status: CaptureManagerStatus) => void @@ -627,6 +641,158 @@ class NativeUnavailableCaptureBackend implements CaptureBackend { } } +class DawBridgeCaptureBackend implements CaptureBackend { + readonly kind = 'daw-bridge' as const + + private snapshot: DawBridgeSnapshot = { + available: false, + reason: 'The DAW bridge listener is not initialized.', + selectedSourceId: null, + sources: [], + } + private active = false + private selectedSourceId: string | null = null + private preferredPersistentId: string | null = null + private readonly chunkListeners = new Set<(chunk: CaptureChunk) => void>() + private readonly statusListeners = new Set<() => void>() + private readonly sourceListeners = new Set<() => void>() + + constructor() { + if (typeof window === 'undefined' || !window.electronAPI?.dawBridge) return + window.electronAPI.dawBridge.onSnapshot((snapshot) => { + this.snapshot = snapshot + void this.reconcileSelection() + for (const listener of this.sourceListeners) listener() + }) + window.electronAPI.dawBridge.onAudioBatch((batch) => this.handleAudioBatch(batch)) + void window.electronAPI.dawBridge.getSnapshot().then((snapshot) => { + this.snapshot = snapshot + this.emitStatus() + for (const listener of this.sourceListeners) listener() + }) + } + + async start(request?: CaptureBackendStartRequest): Promise { + this.active = true + this.preferredPersistentId = request?.persistentSourceId ?? request?.deviceId ?? this.preferredPersistentId + this.selectedSourceId = this.resolveRequestedSource(request?.deviceId ?? this.selectedSourceId) + if (typeof window === 'undefined' || !window.electronAPI?.dawBridge) { + throw new Error('The DAW bridge API is unavailable in this build.') + } + this.snapshot = await window.electronAPI.dawBridge.selectSource(this.selectedSourceId) + if (!this.snapshot.available) { + this.active = false + throw new Error(this.snapshot.reason ?? 'The DAW bridge listener is unavailable.') + } + this.emitStatus() + } + + async stop(): Promise { + this.active = false + if (typeof window !== 'undefined' && window.electronAPI?.dawBridge) { + this.snapshot = await window.electronAPI.dawBridge.selectSource(null) + } + this.emitStatus() + } + + async listSources(): Promise { + if (typeof window !== 'undefined' && window.electronAPI?.dawBridge) { + this.snapshot = await window.electronAPI.dawBridge.getSnapshot() + } + return this.snapshot.sources.map((source) => ({ + id: source.id, + persistentId: source.persistentId, + label: source.hostName ? `${source.label} — ${source.hostName}` : source.label, + kind: 'daw', + sampleRate: source.sampleRate, + channelCount: source.channelCount, + })) + } + + subscribe(listener: (chunk: CaptureChunk) => void): () => void { + this.chunkListeners.add(listener) + return () => this.chunkListeners.delete(listener) + } + + subscribeStatus(listener: () => void): () => void { + this.statusListeners.add(listener) + return () => this.statusListeners.delete(listener) + } + + subscribeSources(listener: () => void): () => void { + this.sourceListeners.add(listener) + return () => this.sourceListeners.delete(listener) + } + + setSelectedSourceId(persistentSourceId: string | null, liveSourceId: string | null = null): void { + this.preferredPersistentId = persistentSourceId + this.selectedSourceId = liveSourceId ?? this.resolveRequestedSource(persistentSourceId) + } + + getSelectedSourceId(): string | null { + return this.selectedSourceId + } + + getStatus(): CaptureBackendStatus { + const source = this.snapshot.sources.find((candidate) => candidate.id === this.selectedSourceId) ?? null + return { + kind: this.kind, + active: this.active, + available: this.snapshot.available, + reason: this.snapshot.reason, + sampleRate: source?.sampleRate ?? 48000, + channelCount: source?.channelCount ?? 2, + activeSourceId: this.active ? this.selectedSourceId : null, + activeSourceLabel: this.active ? source?.label ?? null : null, + waiting: this.active && !source, + } + } + + private handleAudioBatch(batch: DawBridgeAudioBatch): void { + if (!this.active || batch.sourceId !== this.selectedSourceId) return + for (const packet of batch.packets) { + const chunk: CaptureChunk = { + left: packet.left, + right: packet.right, + channelCount: packet.channelCount, + capturedAt: performance.now(), + sequence: packet.sequence, + transport: packet.transport, + } + for (const listener of this.chunkListeners) listener(chunk) + } + } + + private emitStatus(): void { + for (const listener of this.statusListeners) listener() + } + + private resolveRequestedSource(sourceId: string | null | undefined): string | null { + if (!sourceId) return null + const exact = this.snapshot.sources.find((source) => source.id === sourceId) + if (exact) return exact.id + const stableMatches = this.snapshot.sources.filter((source) => source.persistentId === sourceId) + return stableMatches.length === 1 ? stableMatches[0]!.id : null + } + + private async reconcileSelection(): Promise { + if (!this.active) { + this.emitStatus() + return + } + if (this.selectedSourceId && this.snapshot.sources.some((source) => source.id === this.selectedSourceId)) { + this.emitStatus() + return + } + + this.selectedSourceId = this.resolveRequestedSource(this.preferredPersistentId) + if (typeof window !== 'undefined' && window.electronAPI?.dawBridge) { + this.snapshot = await window.electronAPI.dawBridge.selectSource(this.selectedSourceId) + } + this.emitStatus() + } +} + interface CaptureStartOptions { forceDeviceRestart?: boolean } @@ -634,6 +800,7 @@ interface CaptureStartOptions { class AudioCapture { private readonly deviceInputRuntime = new DeviceInputCaptureRuntime() private readonly deviceInputBackend: CaptureBackend + private readonly dawBridgeBackend = new DawBridgeCaptureBackend() private backendSupport: CaptureBackendSupport | null = null private backendSupportPromise: Promise | null = null @@ -643,6 +810,8 @@ class AudioCapture { private selectedDeviceId: string | null = null private selectedSystemSourceId: string | null = DEFAULT_SYSTEM_SOURCE_ID + private selectedDawSourceId: string | null = null + private selectedDawConnectionId: string | null = null private captureMode: CaptureMode = 'system' private sessionId: number | null = null private inputGainDb = 0 @@ -655,6 +824,8 @@ class AudioCapture { constructor() { this.deviceInputBackend = new DeviceInputCaptureBackend(this.deviceInputRuntime) this.deviceInputBackend.subscribe((chunk) => this.handleChunk(this.deviceInputBackend.kind, chunk)) + this.dawBridgeBackend.subscribe((chunk) => this.handleChunk(this.dawBridgeBackend.kind, chunk)) + this.dawBridgeBackend.subscribeStatus(() => this.handleDawBridgeStatus()) this.nativeBackend = this.createNativeBackend(createDefaultBackendSupport().nativeBackend) this.bindNativeBackend(this.nativeBackend) @@ -701,6 +872,11 @@ class AudioCapture { await this.start(undefined, options) } + async startDawBridge(): Promise { + this.captureMode = 'daw' + await this.start() + } + async start(deviceId?: string, options: CaptureStartOptions = {}): Promise { if (deviceId) { this.selectedDeviceId = deviceId @@ -712,13 +888,18 @@ class AudioCapture { const requestedBackend = this.captureMode === 'device' ? this.deviceInputBackend - : this.nativeBackend + : this.captureMode === 'daw' + ? this.dawBridgeBackend + : this.nativeBackend const requestedDeviceId = this.captureMode === 'device' ? this.selectedDeviceId ?? undefined - : this.selectedSystemSourceId ?? DEFAULT_SYSTEM_SOURCE_ID + : this.captureMode === 'daw' + ? this.selectedDawConnectionId ?? this.selectedDawSourceId ?? undefined + : this.selectedSystemSourceId ?? DEFAULT_SYSTEM_SOURCE_ID await requestedBackend.start({ deviceId: requestedDeviceId, + persistentSourceId: this.captureMode === 'daw' ? this.selectedDawSourceId ?? undefined : undefined, forceRestart: this.captureMode === 'device' && options.forceDeviceRestart === true, }) this.activeBackend = requestedBackend @@ -734,6 +915,9 @@ class AudioCapture { backendStatus.channelCount, ) nativeVisualizerTransport.reset(audioRouter.getSessionState()) + if (requestedBackend.kind === 'daw-bridge') { + this.handleDawBridgeStatus() + } this.emitStatus() } @@ -747,6 +931,9 @@ class AudioCapture { if (mode === 'device') { return this.deviceInputBackend.listSources() } + if (mode === 'daw') { + return this.dawBridgeBackend.listSources() + } const sources = await this.nativeBackend.listSources() if (!sources.length) { @@ -780,6 +967,21 @@ class AudioCapture { this.emitStatus() } + getSelectedDawSourceId(): string | null { + return this.selectedDawSourceId + } + + setSelectedDawSourceId(id: string | null, liveConnectionId: string | null = null): void { + this.selectedDawSourceId = id + this.selectedDawConnectionId = liveConnectionId + this.dawBridgeBackend.setSelectedSourceId(id, liveConnectionId) + this.emitStatus() + } + + subscribeDawSources(listener: () => void): () => void { + return this.dawBridgeBackend.subscribeSources(listener) + } + getCaptureMode(): CaptureMode { return this.captureMode } @@ -805,6 +1007,7 @@ class AudioCapture { isCapturing, activeSourceId: isCapturing ? backendStatus?.activeSourceId ?? null : null, activeSourceLabel: isCapturing ? backendStatus?.activeSourceLabel ?? null : null, + waiting: Boolean(backendStatus?.waiting), } } @@ -918,15 +1121,30 @@ class AudioCapture { return } - if (originKind.startsWith('native-') && this.inputGainLinear !== 1) { + if ((originKind.startsWith('native-') || originKind === 'daw-bridge') && this.inputGainLinear !== 1) { applyInputGainToStereoSamples(chunk.left, chunk.right, this.inputGainLinear) } + if ( + originKind === 'daw-bridge' + && this.activeBackend + && ( + this.activeBackend.getStatus().sampleRate !== audioRouter.getSampleRate() + || chunk.channelCount !== audioRouter.getChannelCount() + ) + ) { + const status = this.activeBackend.getStatus() + this.sessionId = audioRouter.beginSession(status.sampleRate, chunk.channelCount, originKind) + this.beginRollingCaptureSession(status.sampleRate, chunk.channelCount) + nativeVisualizerTransport.reset(audioRouter.getSessionState()) + } + audioRouter.ingestChunk(chunk.left, chunk.right, { sessionId: this.sessionId, channelCount: chunk.channelCount, capturedAt: chunk.capturedAt, sequence: chunk.sequence, + transport: chunk.transport, }) let rollingAudioBuffer = this.rollingAudioBuffer @@ -955,6 +1173,32 @@ class AudioCapture { } } + private handleDawBridgeStatus(): void { + if (this.activeBackend?.kind !== 'daw-bridge' || this.sessionId === null) { + this.emitStatus() + return + } + + const status = this.activeBackend.getStatus() + if (status.waiting) { + audioRouter.suspendSession() + } else if ( + status.sampleRate !== audioRouter.getSampleRate() + || status.channelCount !== audioRouter.getChannelCount() + ) { + this.sessionId = audioRouter.beginSession( + status.sampleRate, + status.channelCount, + 'daw-bridge', + ) + this.beginRollingCaptureSession(status.sampleRate, status.channelCount) + nativeVisualizerTransport.reset(audioRouter.getSessionState()) + } else { + audioRouter.resumeSession() + } + this.emitStatus() + } + setInputGain(db: number): void { this.inputGainDb = db this.inputGainLinear = inputGainDbToLinear(this.inputGainDb) diff --git a/src/renderer/audio/AudioRouter.ts b/src/renderer/audio/AudioRouter.ts index 3ae0928..542ad10 100644 --- a/src/renderer/audio/AudioRouter.ts +++ b/src/renderer/audio/AudioRouter.ts @@ -6,6 +6,7 @@ import { AUDIO_SCOPE_KINDS, type AudioScopeKind } from '../../types/scope' import type { CaptureBackendKind } from '../../types/capture' +import type { DawTransportSnapshot } from '../../types/dawBridge' const MAX_PENDING_CHUNKS = 20 const MAX_PENDING_SPECTRUM_CHUNKS = 96 @@ -27,6 +28,7 @@ export interface AudioSessionState { sampleRate: number channelCount: number capturing: boolean + suspended: boolean backendKind: CaptureBackendKind | null } @@ -67,12 +69,14 @@ interface AudioChunkMeta { channelCount?: number capturedAt?: number sequence?: number + transport?: DawTransportSnapshot } interface MonoChunkRecord { samples: Float32Array capturedAt: number sequence: number + transport?: DawTransportSnapshot } interface StereoChunkRecord { @@ -80,6 +84,7 @@ interface StereoChunkRecord { right: Float32Array capturedAt: number sequence: number + transport?: DawTransportSnapshot } interface ScopeLatencyTracker { @@ -235,6 +240,7 @@ export class AudioRouter { private _sampleRate = 48000 private _capturing = false + private _suspended = false private _channelCount = 2 private _sessionId = 0 private _backendKind: CaptureBackendKind | null = null @@ -271,6 +277,7 @@ export class AudioRouter { sampleRate: this._sampleRate, channelCount: this._channelCount, capturing: this._capturing, + suspended: this._suspended, backendKind: this._backendKind, } } @@ -280,6 +287,7 @@ export class AudioRouter { this._sampleRate = sampleRate this._channelCount = Math.max(1, Math.floor(channelCount) || 1) this._capturing = true + this._suspended = false this._backendKind = backendKind this.reset() this.emitSessionState() @@ -289,11 +297,24 @@ export class AudioRouter { endSession(): void { this._sessionId += 1 this._capturing = false + this._suspended = false this._backendKind = null this.reset() this.emitSessionState() } + suspendSession(): void { + if (!this._capturing || this._suspended) return + this._suspended = true + this.emitSessionState() + } + + resumeSession(): void { + if (!this._capturing || !this._suspended) return + this._suspended = false + this.emitSessionState() + } + subscribeToSessionChanges(listener: (state: AudioSessionState) => void): () => void { this.sessionListeners.add(listener) listener(this.getSessionState()) @@ -344,7 +365,7 @@ export class AudioRouter { } ingestChunk(left: Float32Array, right: Float32Array, meta: AudioChunkMeta = {}): void { - if (!this._capturing) { + if (!this._capturing || this._suspended) { this.notCapturingDrops += 1 return } @@ -377,31 +398,31 @@ export class AudioRouter { const rightSamples = resolvedRight.length === len ? resolvedRight : resolvedRight.subarray(0, len) if (activeDemand.oscilloscope) { - this.rings.oscilloscope.push({ samples: leftSamples, capturedAt, sequence }) + this.rings.oscilloscope.push({ samples: leftSamples, capturedAt, sequence, transport: meta.transport }) } if (activeDemand.spectrum) { - this.rings.spectrum.push({ left: leftSamples, right: rightSamples, capturedAt, sequence }) + this.rings.spectrum.push({ left: leftSamples, right: rightSamples, capturedAt, sequence, transport: meta.transport }) } if (activeDemand.spectrogram) { - this.rings.spectrogram.push({ left: leftSamples, right: rightSamples, capturedAt, sequence }) + this.rings.spectrogram.push({ left: leftSamples, right: rightSamples, capturedAt, sequence, transport: meta.transport }) } if (activeDemand.vectorscope) { - this.rings.vectorscope.push({ left: leftSamples, right: rightSamples, capturedAt, sequence }) + this.rings.vectorscope.push({ left: leftSamples, right: rightSamples, capturedAt, sequence, transport: meta.transport }) } if (activeDemand.vumeter) { - this.rings.vumeter.push({ left: leftSamples, right: rightSamples, capturedAt, sequence }) + this.rings.vumeter.push({ left: leftSamples, right: rightSamples, capturedAt, sequence, transport: meta.transport }) } if (activeDemand.lufsmeter) { - this.rings.lufsmeter.push({ left: leftSamples, right: rightSamples, capturedAt, sequence }) + this.rings.lufsmeter.push({ left: leftSamples, right: rightSamples, capturedAt, sequence, transport: meta.transport }) } if (activeDemand.waveform) { - this.rings.waveform.push({ left: leftSamples, right: rightSamples, capturedAt, sequence }) + this.rings.waveform.push({ left: leftSamples, right: rightSamples, capturedAt, sequence, transport: meta.transport }) } } @@ -443,10 +464,10 @@ export class AudioRouter { }) } - flushPendingSpectrogramStereoSamples(): { left: Float32Array; right: Float32Array }[] { + flushPendingSpectrogramStereoSamples(): { left: Float32Array; right: Float32Array; transport?: DawTransportSnapshot }[] { const records = this.rings.spectrogram.drain() this.recordScopeDrain('spectrogram', records) - return records.map((record) => ({ left: record.left, right: record.right })) + return records.map((record) => ({ left: record.left, right: record.right, transport: record.transport })) } flushPendingVectorscopeSamples(): { left: Float32Array; right: Float32Array }[] { @@ -473,10 +494,16 @@ export class AudioRouter { return records.map((record) => record.left) } - flushPendingWaveformStereoSamples(): { left: Float32Array; right: Float32Array }[] { + flushPendingWaveformStereoSamples(): { left: Float32Array; right: Float32Array; transport?: DawTransportSnapshot }[] { const records = this.rings.waveform.drain() this.recordScopeDrain('waveform', records) - return records.map((record) => ({ left: record.left, right: record.right })) + return records.map((record) => ({ left: record.left, right: record.right, transport: record.transport })) + } + + flushPendingWaveformAnnotatedSamples(): { left: Float32Array; right: Float32Array; transport?: DawTransportSnapshot }[] { + const records = this.rings.waveform.drain() + this.recordScopeDrain('waveform', records) + return records.map((record) => ({ left: record.left, right: record.right, transport: record.transport })) } getDiagnosticsSnapshot(): AudioRouterDiagnostics { diff --git a/src/renderer/components/BottomBar.tsx b/src/renderer/components/BottomBar.tsx index 17e983a..b4ff414 100644 --- a/src/renderer/components/BottomBar.tsx +++ b/src/renderer/components/BottomBar.tsx @@ -173,11 +173,14 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): const { systemSources, devices, + dawSources, selectedSystemSourceId, selectedDeviceId, + selectedDawSourceId, captureMode, isCapturing, captureStatus, + activeSourceId, captureError, captureNotice, inputGainDb, @@ -186,6 +189,7 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): clearCaptureNotice, selectSystemSource, selectDevice, + selectDawSource, startCapture, setInputGain, setRollingCaptureSeconds, @@ -238,6 +242,13 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): const deviceId = value.slice('device:'.length) await selectDevice(deviceId === DEFAULT_INPUT_DEVICE_ID ? null : deviceId) await startCapture() + return + } + + if (value.startsWith('daw:')) { + const sourceId = value.slice('daw:'.length) + await selectDawSource(sourceId) + await startCapture() } } @@ -245,18 +256,32 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): ? systemSources : [{ id: '__default_system_output__', label: 'Default Output', kind: 'system', isDefault: true }] const defaultSystemSourceId = visibleSystemSources[0]?.id ?? '__default_system_output__' + const matchingDawSources = dawSources.filter((source) => ( + source.id === selectedDawSourceId || source.persistentId === selectedDawSourceId + )) + const selectedDawLiveSourceId = captureMode === 'daw' + && activeSourceId + && dawSources.some((source) => source.id === activeSourceId) + ? activeSourceId + : matchingDawSources.length === 1 + ? matchingDawSources[0]!.id + : null const selectedSourceValue = captureMode === 'system' ? `system:${selectedSystemSourceId ?? defaultSystemSourceId}` - : `device:${selectedDeviceId ?? DEFAULT_INPUT_DEVICE_ID}` + : captureMode === 'device' + ? `device:${selectedDeviceId ?? DEFAULT_INPUT_DEVICE_ID}` + : `daw:${selectedDawLiveSourceId ?? selectedDawSourceId ?? ''}` - const indicatorLabel = isCapturing - ? 'Capturing' + const indicatorLabel = captureStatus === 'waiting' + ? 'Waiting' : captureStatus === 'connecting' ? 'Connecting' : captureStatus === 'error' ? 'Capture Failed' - : 'Idle' + : isCapturing + ? 'Capturing' + : 'Idle' const trimPercent = Math.min(100, Math.max(0, ((inputGainDb + 12) / 24) * 100)) const roundedDockedRenderFps = Math.max(0, Math.round(dockedRenderFps)) @@ -421,7 +446,7 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): : nowPlayingSummary const canUseDefaultSource = captureMode === 'system' ? selectedSystemSourceId !== defaultSystemSourceId - : selectedDeviceId !== null + : captureMode === 'device' && selectedDeviceId !== null const loginItemStatusMessage = desktopIntegration.loginItemStatus === 'requires-approval' ? 'Approval required in system login settings' : desktopIntegration.loginItemStatus === 'blocked' @@ -718,6 +743,23 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): ))} + + {captureMode === 'daw' + && selectedDawSourceId + && !selectedDawLiveSourceId ? ( + + ) : null} + {dawSources.length === 0 && captureMode !== 'daw' ? ( + + ) : null} + {dawSources.map((source) => ( + + ))} +
diff --git a/src/renderer/components/ScopeModule.tsx b/src/renderer/components/ScopeModule.tsx index ab88e7c..4b48b91 100644 --- a/src/renderer/components/ScopeModule.tsx +++ b/src/renderer/components/ScopeModule.tsx @@ -264,6 +264,7 @@ export function scopeSettingsToOptions( showGrid: s.showGrid, orientation: 'horizontal', colorScheme: s.colorScheme, + timelineUnit: s.timelineUnit, } } case 'vumeter': { @@ -308,6 +309,7 @@ export function scopeSettingsToOptions( lineColor: t.line, gridMajorColor: t.guides, gridMinorColor: t.guidesSecondary, + labelColor: t.line, bandColors: { low: t.bandLow, mid: t.bandMid, @@ -316,6 +318,7 @@ export function scopeSettingsToOptions( mode: s.mode, scrollSpeed: s.scrollSpeed, multiband: s.multiband, + timelineUnit: s.timelineUnit, } } case 'nowPlaying': diff --git a/src/renderer/components/ScopePopoutBridge.tsx b/src/renderer/components/ScopePopoutBridge.tsx index 4773814..81f0f2e 100644 --- a/src/renderer/components/ScopePopoutBridge.tsx +++ b/src/renderer/components/ScopePopoutBridge.tsx @@ -45,9 +45,7 @@ function flushScopeAudioBatch(kind: AudioScopeKind, scopeSettings: ScopeSettings case 'lufsmeter': return audioRouter.flushPendingLUFSMeterSamples() case 'waveform': - return scopeSettings.waveform.mode === 'stereo' - ? audioRouter.flushPendingWaveformStereoSamples() - : audioRouter.flushPendingWaveformSamples() + return audioRouter.flushPendingWaveformAnnotatedSamples() } } @@ -57,6 +55,7 @@ function toPopoutSessionState(state: ScopePopoutSessionState): ScopePopoutSessio sampleRate: state.sampleRate, channelCount: state.channelCount, capturing: state.capturing, + suspended: state.suspended, backendKind: state.backendKind, } } diff --git a/src/renderer/components/ScopeSettingsSection.tsx b/src/renderer/components/ScopeSettingsSection.tsx index 52780ae..3129571 100644 --- a/src/renderer/components/ScopeSettingsSection.tsx +++ b/src/renderer/components/ScopeSettingsSection.tsx @@ -608,6 +608,16 @@ export default function ScopeSettingsSection({ + onUpdate('spectrogram', { timelineUnit: value as ScopeSettings['spectrogram']['timelineUnit'] })} + > + + + + + + onUpdate('waveform', { timelineUnit: value as ScopeSettings['waveform']['timelineUnit'] })} + > + + + + + { await useAudioStore.getState().startCapture() return } + if (command.type === 'select-daw-source') { + await audio.selectDawSource(command.sourceId) + await useAudioStore.getState().startCapture() + return + } if (command.type === 'set-rolling-capture') { audio.setRollingCaptureSeconds(command.durationSeconds) return @@ -66,12 +71,15 @@ export default function TrayControlBridge({ ready }: TrayControlBridgeProps): JS const hasUnsavedProfileChanges = useSettingsStore((state) => state.hasUnsavedProfileChanges) const captureStatus = useAudioStore((state) => state.captureStatus) const activeSourceLabel = useAudioStore((state) => state.activeSourceLabel) + const activeSourceId = useAudioStore((state) => state.activeSourceId) const captureMode = useAudioStore((state) => state.captureMode) const selectedSystemSourceId = useAudioStore((state) => state.selectedSystemSourceId) const selectedDeviceId = useAudioStore((state) => state.selectedDeviceId) + const selectedDawSourceId = useAudioStore((state) => state.selectedDawSourceId) const rollingCaptureSeconds = useAudioStore((state) => state.rollingCaptureSeconds) const systemSources = useAudioStore((state) => state.systemSources) const devices = useAudioStore((state) => state.devices) + const dawSources = useAudioStore((state) => state.dawSources) useEffect(() => { const unsubscribe = window.electronAPI.trayControls.onCommand((command) => { @@ -99,6 +107,16 @@ export default function TrayControlBridge({ ready }: TrayControlBridgeProps): JS const visibleSystemSources = systemSources.length > 0 ? systemSources : [{ id: DEFAULT_SYSTEM_SOURCE_ID, label: 'Default Output', isDefault: true }] + const matchingDawSources = dawSources.filter((source) => ( + source.id === selectedDawSourceId || source.persistentId === selectedDawSourceId + )) + const selectedDawLiveSourceId = captureMode === 'daw' + && activeSourceId + && dawSources.some((source) => source.id === activeSourceId) + ? activeSourceId + : matchingDawSources.length === 1 + ? matchingDawSources[0]!.id + : null const state: TrayRendererState = { profiles: Object.entries(profiles).map(([id, profile]) => ({ id, name: profile.name })), activeProfileId, @@ -108,6 +126,7 @@ export default function TrayControlBridge({ ready }: TrayControlBridgeProps): JS captureMode, selectedSystemSourceId, selectedDeviceId, + selectedDawSourceId: selectedDawLiveSourceId, rollingCaptureSeconds, systemSources: visibleSystemSources.map((source) => ({ id: source.id, @@ -123,10 +142,15 @@ export default function TrayControlBridge({ ready }: TrayControlBridgeProps): JS label: device.label || `Input ${device.deviceId.slice(0, 8)}`, })), ], + dawSources: dawSources.map((source) => ({ + id: source.id, + label: source.label, + })), } window.electronAPI.trayControls.publishState(state) }, [ activeProfileId, + activeSourceId, activeSourceLabel, captureMode, captureStatus, @@ -135,8 +159,10 @@ export default function TrayControlBridge({ ready }: TrayControlBridgeProps): JS profiles, rollingCaptureSeconds, selectedDeviceId, + selectedDawSourceId, selectedSystemSourceId, systemSources, + dawSources, ]) return null diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index e0376ad..780e714 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -44,6 +44,7 @@ import type { TrayRendererCommand, TrayRendererState, } from '../types/desktopIntegration' +import type { DawBridgeRendererAPI } from '../types/dawBridge' declare global { interface Window { @@ -81,6 +82,7 @@ declare global { setWindowBackground: (state: WindowBackgroundState) => Promise isCursorInsideWindow: () => Promise getCaptureBackendSupport: () => Promise + dawBridge: DawBridgeRendererAPI audioClips: { startDrag: (payload: AudioClipDragPayload) => void revealFolder: () => Promise diff --git a/src/renderer/popouts/ScopePopoutDataSource.ts b/src/renderer/popouts/ScopePopoutDataSource.ts index 89a506f..3598157 100644 --- a/src/renderer/popouts/ScopePopoutDataSource.ts +++ b/src/renderer/popouts/ScopePopoutDataSource.ts @@ -27,6 +27,7 @@ const INITIAL_SESSION_STATE: ScopePopoutSessionState = { sampleRate: 48000, channelCount: 2, capturing: false, + suspended: false, backendKind: null, } @@ -80,7 +81,7 @@ export class ScopePopoutDataSource implements AnyScopeDataSource { setSessionState(nextState: ScopePopoutSessionState): void { this.sessionState = nextState this.nativeVisualizerTransport.reset(nextState) - if (!nextState.capturing) { + if (!nextState.capturing || nextState.suspended) { this.monoQueue = [] this.stereoQueue = [] } @@ -95,7 +96,11 @@ export class ScopePopoutDataSource implements AnyScopeDataSource { } isPlaying(): boolean { - return this.sessionState.capturing + return this.sessionState.capturing && !this.sessionState.suspended + } + + getBackendKind(): ScopePopoutSessionState['backendKind'] { + return this.sessionState.backendKind } subscribeToSessionChanges(listener: (state: ScopePopoutSessionState) => void): () => void { @@ -149,6 +154,10 @@ export class ScopePopoutDataSource implements AnyScopeDataSource { return this.scopeKind === 'waveform' ? batch : [] } + getPendingWaveformAnnotatedSamples(): ScopePopoutStereoBatch { + return this.getPendingWaveformStereoSamples() + } + getPendingVectorscopeSamples(): ScopePopoutStereoBatch { const batch = this.stereoQueue this.stereoQueue = [] diff --git a/src/renderer/stores/audioStore.ts b/src/renderer/stores/audioStore.ts index a8ff56e..2540d43 100644 --- a/src/renderer/stores/audioStore.ts +++ b/src/renderer/stores/audioStore.ts @@ -10,6 +10,7 @@ import type { CaptureBackendSupport, CaptureMode, CaptureSourceDescriptor, + CaptureStatus, } from '../../types/capture' import { useUiStore } from './uiStore' @@ -25,6 +26,7 @@ export interface PersistedAudioState { captureMode: CaptureMode selectedSystemSourceId: string selectedDeviceId: string | null + selectedDawSourceId: string | null rollingCaptureSeconds: RollingCaptureDurationSeconds | null } @@ -40,13 +42,15 @@ interface StartCaptureOptions { interface AudioState { systemSources: CaptureSourceDescriptor[] devices: MediaDeviceInfo[] + dawSources: CaptureSourceDescriptor[] selectedSystemSourceId: string | null selectedDeviceId: string | null + selectedDawSourceId: string | null captureMode: CaptureMode activeBackendKind: CaptureBackendKind | null backendSupport: CaptureBackendSupport | null isCapturing: boolean - captureStatus: 'idle' | 'connecting' | 'capturing' | 'error' + captureStatus: CaptureStatus captureError: string | null captureNotice: string | null sampleRate: number @@ -63,9 +67,11 @@ interface AudioState { clearCaptureNotice: () => void refreshSystemSources: (options?: RefreshSourceOptions) => Promise refreshDevices: (options?: RefreshSourceOptions) => Promise + refreshDawSources: () => Promise refreshBackendSupport: (options?: RefreshSourceOptions) => Promise selectSystemSource: (sourceId: string | null) => Promise selectDevice: (deviceId: string | null) => Promise + selectDawSource: (sourceId: string) => Promise setCaptureMode: (mode: CaptureMode) => void startCapture: (options?: StartCaptureOptions) => Promise stopCapture: () => void @@ -129,6 +135,7 @@ function areCaptureSourcesEqual( return left.every((source, index) => { const candidate = right[index] return source.id === candidate.id + && source.persistentId === candidate.persistentId && source.label === candidate.label && source.kind === candidate.kind && source.isDefault === candidate.isDefault @@ -189,7 +196,7 @@ export function normalizeInputGainDb(raw: unknown): number { } function normalizeCaptureMode(raw: unknown): CaptureMode { - return raw === 'device' ? 'device' : 'system' + return raw === 'device' || raw === 'daw' ? raw : 'system' } function normalizeSystemSourceId(raw: unknown): string { @@ -220,6 +227,7 @@ export function normalizeAudioPreferences(raw: unknown): PersistedAudioState { captureMode: normalizeCaptureMode(parsed.captureMode), selectedSystemSourceId: normalizeSystemSourceId(parsed.selectedSystemSourceId), selectedDeviceId: normalizeDeviceId(parsed.selectedDeviceId), + selectedDawSourceId: normalizeDeviceId(parsed.selectedDawSourceId), rollingCaptureSeconds: normalizeRollingCaptureSeconds(parsed.rollingCaptureSeconds), } } @@ -230,12 +238,14 @@ function buildAudioPreferences( selectedSystemSourceId: string | null, selectedDeviceId: string | null, rollingCaptureSeconds: RollingCaptureDurationSeconds | null, + selectedDawSourceId: string | null, ): PersistedAudioState { return normalizeAudioPreferences({ inputGainDb, captureMode, selectedSystemSourceId, selectedDeviceId, + selectedDawSourceId, rollingCaptureSeconds, }) } @@ -271,14 +281,17 @@ const storedPreferences = loadAudioPreferences() audioCapture.setInputGain(storedPreferences.inputGainDb) audioCapture.setSelectedSystemSourceId(storedPreferences.selectedSystemSourceId) audioCapture.setSelectedDeviceId(storedPreferences.selectedDeviceId) +audioCapture.setSelectedDawSourceId(storedPreferences.selectedDawSourceId) audioCapture.setCaptureMode(storedPreferences.captureMode) audioCapture.setRollingCaptureSeconds(storedPreferences.rollingCaptureSeconds) export const useAudioStore = create((set, get) => ({ systemSources: [], devices: [], + dawSources: [], selectedSystemSourceId: audioCapture.getSelectedSystemSourceId(), selectedDeviceId: audioCapture.getSelectedDeviceId(), + selectedDawSourceId: audioCapture.getSelectedDawSourceId(), captureMode: audioCapture.getCaptureMode(), activeBackendKind: null, backendSupport: null, @@ -307,6 +320,7 @@ export const useAudioStore = create((set, get) => ({ currentState.selectedSystemSourceId, currentState.selectedDeviceId, currentState.rollingCaptureSeconds, + currentState.selectedDawSourceId, )) audioCapture.setInputGain(nextInputGainDb) set({ inputGainDb: nextInputGainDb }) @@ -323,6 +337,7 @@ export const useAudioStore = create((set, get) => ({ currentState.selectedSystemSourceId, currentState.selectedDeviceId, nextDuration, + currentState.selectedDawSourceId, )) audioCapture.setRollingCaptureSeconds(nextDuration) set({ @@ -389,6 +404,7 @@ export const useAudioStore = create((set, get) => ({ nextSelectedSystemSourceId, currentState.selectedDeviceId, currentState.rollingCaptureSeconds, + currentState.selectedDawSourceId, )) } @@ -458,6 +474,7 @@ export const useAudioStore = create((set, get) => ({ currentState.selectedSystemSourceId, nextSelectedDeviceId, currentState.rollingCaptureSeconds, + currentState.selectedDawSourceId, )) } @@ -503,6 +520,12 @@ export const useAudioStore = create((set, get) => ({ const backendSupport = await audioCapture.refreshBackendSupport() set({ backendSupport }) await get().refreshSystemSources(options) + await get().refreshDawSources() + }, + + refreshDawSources: async () => { + const dawSources = await audioCapture.listSources('daw') + set({ dawSources }) }, selectSystemSource: async (sourceId: string | null) => { @@ -516,6 +539,7 @@ export const useAudioStore = create((set, get) => ({ nextSelectedSystemSourceId, currentState.selectedDeviceId, currentState.rollingCaptureSeconds, + currentState.selectedDawSourceId, )) set({ selectedSystemSourceId: nextSelectedSystemSourceId, @@ -535,6 +559,7 @@ export const useAudioStore = create((set, get) => ({ currentState.selectedSystemSourceId, deviceId, currentState.rollingCaptureSeconds, + currentState.selectedDawSourceId, )) set({ selectedDeviceId: deviceId, @@ -544,6 +569,28 @@ export const useAudioStore = create((set, get) => ({ }) }, + selectDawSource: async (sourceId: string) => { + const currentState = get() + const source = currentState.dawSources.find((candidate) => candidate.id === sourceId) + const persistentSourceId = source?.persistentId ?? sourceId + audioCapture.setSelectedDawSourceId(persistentSourceId, sourceId) + audioCapture.setCaptureMode('daw') + persistAudioPreferences(buildAudioPreferences( + currentState.inputGainDb, + 'daw', + currentState.selectedSystemSourceId, + currentState.selectedDeviceId, + currentState.rollingCaptureSeconds, + persistentSourceId, + )) + set({ + selectedDawSourceId: persistentSourceId, + captureMode: 'daw', + captureError: null, + captureNotice: null, + }) + }, + setCaptureMode: (mode: CaptureMode) => { audioCapture.setCaptureMode(mode) const currentState = get() @@ -553,6 +600,7 @@ export const useAudioStore = create((set, get) => ({ currentState.selectedSystemSourceId, currentState.selectedDeviceId, currentState.rollingCaptureSeconds, + currentState.selectedDawSourceId, )) set({ captureMode: mode }) }, @@ -565,9 +613,10 @@ export const useAudioStore = create((set, get) => ({ if (options.skipSourceRefresh !== true) { await get().refreshBackendSupport({ rebindActiveCapture: false }) await get().refreshDevices({ rebindActiveCapture: false }) + await get().refreshDawSources() } - const { selectedDeviceId, selectedSystemSourceId, backendSupport } = get() + const { selectedDeviceId, selectedSystemSourceId, selectedDawSourceId, backendSupport } = get() const startDefaultInputFallback = async (reason: string | null): Promise => { const message = buildSystemCaptureFallbackMessage(reason) @@ -580,6 +629,7 @@ export const useAudioStore = create((set, get) => ({ currentState.selectedSystemSourceId, null, currentState.rollingCaptureSeconds, + currentState.selectedDawSourceId, )) set({ selectedDeviceId: null, @@ -601,17 +651,22 @@ export const useAudioStore = create((set, get) => ({ await startDefaultInputFallback(reason) } } - } else { + } else if (captureMode === 'device') { await audioCapture.startDevice(selectedDeviceId ?? undefined, { forceDeviceRestart: options.forceDeviceRestart === true, }) + } else { + if (!selectedDawSourceId) { + throw new Error('Select a connected Prism Bridge source first.') + } + await audioCapture.startDawBridge() } const status = audioCapture.getStatus() set((state) => ({ ...state, ...applyCaptureStatus(status), - captureStatus: 'capturing', + captureStatus: status.waiting ? 'waiting' : 'capturing', captureError: null, })) } catch (err) { @@ -748,9 +803,22 @@ audioCapture.subscribeStatus((status) => { useAudioStore.setState((state) => ({ ...state, ...applyCaptureStatus(status), + captureStatus: status.waiting + ? 'waiting' + : status.isCapturing + ? 'capturing' + : state.captureStatus === 'error' + ? 'error' + : 'idle', })) }) +audioCapture.subscribeDawSources(() => { + void useAudioStore.getState().refreshDawSources().catch((error) => { + console.error('Failed to refresh DAW bridge sources:', error) + }) +}) + audioCapture.subscribeRollingCaptureStatus((rollingCaptureStatus) => { useAudioStore.setState({ rollingCaptureStatus }) }) diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index edf5174..b3ed21f 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -1881,6 +1881,15 @@ button.toolbar__version:hover { border-color: var(--accent); } +.settings-status-pill.is-waiting .settings-status-pill__dot { + background: #f59e0b; + box-shadow: 0 0 8px rgba(245, 158, 11, 0.72); +} + +.settings-status-pill.is-waiting { + color: #fbbf24; +} + .settings-status-pill.is-capturing .settings-status-pill__dot { background: var(--success); box-shadow: 0 0 8px var(--success); diff --git a/src/renderer/visualizers/Spectrogram.ts b/src/renderer/visualizers/Spectrogram.ts index 19c8bac..72e0932 100644 --- a/src/renderer/visualizers/Spectrogram.ts +++ b/src/renderer/visualizers/Spectrogram.ts @@ -42,10 +42,12 @@ import { buildFrequencyGuides, clampFrequencyRangeToNyquist, } from '../../types/frequencyScale' +import type { DawTransportSnapshot, TimelineUnit } from '../../types/dawBridge' +import { ScrollingTimeline } from './scrollingTimeline' export interface SpectrogramDataSource extends VisualizerSessionSource { getPendingSpectrogramSamples: () => Float32Array[] - getPendingSpectrogramStereoSamples?: () => Array<{ left: Float32Array; right: Float32Array }> + getPendingSpectrogramStereoSamples?: () => Array<{ left: Float32Array; right: Float32Array; transport?: DawTransportSnapshot }> } export interface SpectrogramOptions { @@ -67,6 +69,7 @@ export interface SpectrogramOptions { showGrid?: boolean gridColor?: string labelColor?: string + timelineUnit?: TimelineUnit dataSource?: SpectrogramDataSource frameScheduler?: FrameScheduler nativeAnalyzer?: SpectrogramNativeAnalyzer | null @@ -93,6 +96,7 @@ const defaultOptions: ResolvedSpectrogramOptions = { showGrid: true, gridColor: 'rgba(255, 255, 255, 0.12)', labelColor: 'rgba(255, 255, 255, 0.4)', + timelineUnit: 'bars-beats', } const defaultSpectrogramDataSource: SpectrogramDataSource = { @@ -139,6 +143,7 @@ function resolveOptions(base: ResolvedSpectrogramOptions, overrides: Partial void) | null = null + private readonly timeline = new ScrollingTimeline() constructor(canvas: HTMLCanvasElement, options: SpectrogramOptions = {}) { this.canvas = canvas @@ -303,6 +309,7 @@ export class Spectrogram { this.nativeAnalyzer?.reset() this.lastNativeConfigKey = null this.waterfallCtx.clearRect(0, 0, this.waterfallCanvas.width, this.waterfallCanvas.height) + this.timeline.reset() this.invalidate() } @@ -503,7 +510,7 @@ export class Spectrogram { private tryDrawNativeColumns( pendingSamples: Float32Array[], - pendingStereoSamples: Array<{ left: Float32Array; right: Float32Array }>, + pendingStereoSamples: Array<{ left: Float32Array; right: Float32Array; transport?: DawTransportSnapshot }>, width: number, height: number, ): boolean { @@ -518,7 +525,11 @@ export class Spectrogram { this.ensureColumnBuffers(config.rowCount) - const results: SpectrogramNativeResult[] = [] + const results: Array<{ + result: SpectrogramNativeResult + frameCount: number + transport?: DawTransportSnapshot + }> = [] try { if (!this.configureNativeAnalyzer(config)) { return false @@ -529,9 +540,11 @@ export class Spectrogram { if (!this.isValidNativeResult(result, config.rowCount)) { return false } - if (result.columnCount > 0) { - results.push(result) - } + results.push({ + result, + frameCount: Math.min(chunk.left.length, chunk.right.length), + transport: chunk.transport, + }) } for (const chunk of pendingSamples) { @@ -539,9 +552,7 @@ export class Spectrogram { if (!this.isValidNativeResult(result, config.rowCount)) { return false } - if (result.columnCount > 0) { - results.push(result) - } + results.push({ result, frameCount: chunk.length }) } } catch (error) { console.warn('Spectrogram: native analyzer failed', error) @@ -550,8 +561,14 @@ export class Spectrogram { return false } - for (const result of results) { + for (const entry of results) { + const { result } = entry this.shiftAndPaintColumns(result.display, result.heat, result.columnCount, result.rowCount) + this.timeline.append({ + frameCount: entry.frameCount, + sampleRate: Math.max(1, this.dataSource.getSampleRate()), + transport: entry.transport, + }, result.columnCount, this.options.orientation === 'vertical' ? height : width) } return true @@ -593,6 +610,16 @@ export class Spectrogram { } this.ctx.drawImage(this.waterfallCanvas, 0, 0) this.drawFrequencyGrid(width, height) + this.timeline.draw( + this.ctx, + width, + height, + this.options.timelineUnit, + this.dataSource.getBackendKind?.() ?? null, + this.options.gridColor, + this.options.labelColor, + this.options.orientation, + ) } private drawFrequencyGrid(width: number, height: number): void { @@ -667,6 +694,7 @@ export class Spectrogram { this.waterfallCanvas.width = width this.waterfallCanvas.height = height this.waterfallCtx.imageSmoothingEnabled = false + this.timeline.resize(this.options.orientation === 'vertical' ? height : width) if (previousCtx && previousCanvas.width > 0 && previousCanvas.height > 0) { if (this.options.orientation === 'vertical') { diff --git a/src/renderer/visualizers/Waveform.ts b/src/renderer/visualizers/Waveform.ts index 3b38f3f..0254283 100644 --- a/src/renderer/visualizers/Waveform.ts +++ b/src/renderer/visualizers/Waveform.ts @@ -20,15 +20,19 @@ import { } from '../scopeMeasurement' import type { LinkedAnalysisProbe, LinkedAnalysisProjection } from '../../types/analysis' import type { NormalizedScopePoint } from '../scopeCanvasTransform' +import type { DawTransportSnapshot, TimelineUnit } from '../../types/dawBridge' +import { ScrollingTimeline } from './scrollingTimeline' export interface WaveformStereoChunk { left: Float32Array right: Float32Array + transport?: DawTransportSnapshot } export interface WaveformDataSource extends VisualizerSessionSource { getPendingWaveformSamples: () => Float32Array[] getPendingWaveformStereoSamples: () => WaveformStereoChunk[] + getPendingWaveformAnnotatedSamples?: () => WaveformStereoChunk[] } export interface WaveformOptions { @@ -36,6 +40,7 @@ export interface WaveformOptions { lineColor?: string gridMajorColor?: string gridMinorColor?: string + labelColor?: string bandColors?: { low: string mid: string @@ -44,6 +49,7 @@ export interface WaveformOptions { mode?: WaveformMode scrollSpeed?: number multiband?: boolean + timelineUnit?: TimelineUnit dataSource?: WaveformDataSource frameScheduler?: FrameScheduler nativeAnalyzer?: WaveformNativeAnalyzer | null @@ -56,6 +62,7 @@ const defaultOptions: ResolvedWaveformOptions = { lineColor: '#38bdf8', gridMajorColor: 'rgba(255, 255, 255, 0.08)', gridMinorColor: 'rgba(255, 255, 255, 0.04)', + labelColor: 'rgba(255, 255, 255, 0.4)', bandColors: { low: '#ff4444', mid: '#44dd44', @@ -64,6 +71,7 @@ const defaultOptions: ResolvedWaveformOptions = { mode: DEFAULT_WAVEFORM_MODE, scrollSpeed: DEFAULT_WAVEFORM_SCROLL_SPEED, multiband: false, + timelineUnit: 'bars-beats', } const MULTIBAND_WEIGHT_EMPHASIS = 2.6 @@ -74,6 +82,7 @@ const MULTIBAND_EDGE_ALPHA = 1.0 const defaultWaveformDataSource: WaveformDataSource = { getPendingWaveformSamples: () => audioRouter.flushPendingWaveformSamples(), getPendingWaveformStereoSamples: () => audioRouter.flushPendingWaveformStereoSamples(), + getPendingWaveformAnnotatedSamples: () => audioRouter.flushPendingWaveformAnnotatedSamples(), ...defaultVisualizerSessionSource, } @@ -106,6 +115,8 @@ export class Waveform { private rightBandHighAcc: Float32Array = new Float32Array(0) private multibandScratch: MultibandChunk = createMultibandChunk(0) private unsubscribeSessionChange: (() => void) | null = null + private readonly timeline = new ScrollingTimeline() + private timelineColumnRemainder = 0 constructor(canvas: HTMLCanvasElement, options: WaveformOptions = {}) { this.canvas = canvas @@ -159,6 +170,8 @@ export class Waveform { private resetDisplay(): void { this.waterfallCtx.clearRect(0, 0, this.waterfallCanvas.width, this.waterfallCanvas.height) this.columnAccumulatorPos = 0 + this.timelineColumnRemainder = 0 + this.timeline.reset() this.splitter.reset() this.nativeAnalyzer?.reset() this.configureNativeAnalyzer() @@ -202,6 +215,7 @@ export class Waveform { lineColor: optionUpdates.lineColor ?? this.options.lineColor, scrollSpeed: clampWaveformScrollSpeed(optionUpdates.scrollSpeed ?? this.options.scrollSpeed), multiband: optionUpdates.multiband ?? this.options.multiband, + timelineUnit: optionUpdates.timelineUnit ?? this.options.timelineUnit, } const speedChanged = nextOptions.scrollSpeed !== this.options.scrollSpeed const multibandChanged = nextOptions.multiband !== this.options.multiband @@ -509,6 +523,10 @@ export class Waveform { } private drainPendingSamples(): void { + if (this.dataSource.getPendingWaveformAnnotatedSamples) { + this.dataSource.getPendingWaveformAnnotatedSamples() + return + } if (this.options.mode === 'stereo') { this.dataSource.getPendingWaveformStereoSamples() return @@ -517,6 +535,30 @@ export class Waveform { this.dataSource.getPendingWaveformSamples() } + private recordTimeline(chunk: WaveformStereoChunk, width: number): void { + const frameCount = Math.min(chunk.left.length, chunk.right.length) + const total = this.timelineColumnRemainder + frameCount + const columnCount = Math.floor(total / Math.max(1, this.samplesPerColumn)) + this.timelineColumnRemainder = total % Math.max(1, this.samplesPerColumn) + this.timeline.append({ + frameCount, + sampleRate: Math.max(1, this.dataSource.getSampleRate()), + transport: chunk.transport, + }, columnCount, width) + } + + private drawTimeline(width: number, height: number): void { + this.timeline.draw( + this.ctx, + width, + height, + this.options.timelineUnit, + this.dataSource.getBackendKind?.() ?? null, + this.options.gridMajorColor, + this.options.labelColor, + ) + } + private processMonoChunk(chunk: Float32Array, width: number, height: number): void { if (this.useNativeAnalyzer()) { if (this.processNativeMonoChunk(chunk, width, height)) { @@ -712,6 +754,7 @@ export class Waveform { this.recomputeSamplesPerColumn() this.staticLayerKey = '' + this.timeline.resize(width) } const sampleRate = this.dataSource.getSampleRate() @@ -723,12 +766,24 @@ export class Waveform { this.drainPendingSamples() this.renderStaticLayer(width, height) this.ctx.drawImage(this.waterfallCanvas, 0, 0) + this.drawTimeline(width, height) return } - if (this.options.mode === 'stereo') { + const annotated = this.dataSource.getPendingWaveformAnnotatedSamples?.() + if (annotated) { + for (const chunk of annotated) { + this.recordTimeline(chunk, width) + if (this.options.mode === 'stereo') { + this.processStereoChunk(chunk, width, height) + } else { + this.processMonoChunk(chunk.left, width, height) + } + } + } else if (this.options.mode === 'stereo') { const pending = this.dataSource.getPendingWaveformStereoSamples() for (const chunk of pending) { + this.recordTimeline(chunk, width) this.processStereoChunk(chunk, width, height) } } else { @@ -740,6 +795,7 @@ export class Waveform { this.renderStaticLayer(width, height) this.ctx.drawImage(this.waterfallCanvas, 0, 0) + this.drawTimeline(width, height) } dispose(): void { diff --git a/src/renderer/visualizers/dataSource.ts b/src/renderer/visualizers/dataSource.ts index 8dfb308..b7fbcc5 100644 --- a/src/renderer/visualizers/dataSource.ts +++ b/src/renderer/visualizers/dataSource.ts @@ -1,14 +1,17 @@ import { audioRouter } from '../audio/AudioRouter' import type { ScopePopoutSessionState } from '../../types/popout' +import type { CaptureBackendKind } from '../../types/capture' export interface VisualizerSessionSource { getSampleRate: () => number isPlaying: () => boolean + getBackendKind?: () => CaptureBackendKind | null subscribeToSessionChanges: (listener: (state: ScopePopoutSessionState) => void) => () => void } export const defaultVisualizerSessionSource: VisualizerSessionSource = { getSampleRate: () => audioRouter.getSampleRate(), isPlaying: () => audioRouter.isCapturing(), + getBackendKind: () => audioRouter.getSessionState().backendKind, subscribeToSessionChanges: (listener) => audioRouter.subscribeToSessionChanges((state) => listener(state)), } diff --git a/src/renderer/visualizers/scrollingTimeline.ts b/src/renderer/visualizers/scrollingTimeline.ts new file mode 100644 index 0000000..a6deb0e --- /dev/null +++ b/src/renderer/visualizers/scrollingTimeline.ts @@ -0,0 +1,298 @@ +import type { CaptureBackendKind } from '../../types/capture' +import type { DawTransportSnapshot, TimelineUnit } from '../../types/dawBridge' + +export type TimelineSeamKind = 'Loop' | 'Jump' | 'Gap' + +interface TimelinePoint { + seconds?: number + ppq?: number + bpm?: number + numerator: number + denominator: number + lastBarPpq?: number + seam?: TimelineSeamKind +} + +export interface TimelineChunkAnchor { + frameCount: number + sampleRate: number + transport?: DawTransportSnapshot +} + +export interface PreviousTimelineAnchor { + sequence: number + frameCount: number + timeInSamples?: number + isPlaying: boolean + isLooping: boolean +} + +const SECOND_STEPS = [0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300] + +function formatSeconds(value: number): string { + const safe = Math.max(0, value) + const minutes = Math.floor(safe / 60) + const seconds = safe - minutes * 60 + if (minutes > 0) return `${minutes}:${seconds.toFixed(seconds < 10 ? 1 : 0).padStart(4, '0')}` + return `${seconds.toFixed(safe < 10 ? 1 : 0)}s` +} + +export function resolveTimelineSeam( + previous: PreviousTimelineAnchor | null, + current: DawTransportSnapshot, +): TimelineSeamKind | undefined { + if (!previous) return undefined + if (current.sequence !== previous.sequence + 1) return 'Gap' + if (!previous.isPlaying && current.isPlaying) return 'Jump' + if (previous.timeInSamples === undefined || current.timeInSamples === undefined) return undefined + + const expected = previous.timeInSamples + previous.frameCount + if (Math.abs(current.timeInSamples - expected) <= Math.max(2, previous.frameCount * 0.1)) return undefined + if (current.isLooping && current.timeInSamples < previous.timeInSamples) return 'Loop' + return 'Jump' +} + +function niceSecondStep(points: TimelinePoint[], width: number, dpr: number): number { + const timed = points.filter((point) => point.seconds !== undefined) + if (timed.length < 2) return 1 + const first = timed[0].seconds! + const last = timed[timed.length - 1].seconds! + const span = Math.max(0.001, Math.abs(last - first)) + const targetTicks = Math.max(1, width / Math.max(1, 80 * dpr)) + const targetStep = span / targetTicks + return SECOND_STEPS.find((step) => step >= targetStep) ?? SECOND_STEPS[SECOND_STEPS.length - 1] +} + +export class ScrollingTimeline { + private points: TimelinePoint[] = [] + private previousAnchor: PreviousTimelineAnchor | null = null + private pendingSeam: TimelineSeamKind | undefined + private stoppedPosition: TimelinePoint | null = null + + reset(): void { + this.points = [] + this.previousAnchor = null + this.pendingSeam = undefined + this.stoppedPosition = null + } + + resize(capacity: number): void { + if (capacity <= 0) { + this.points = [] + return + } + if (this.points.length > capacity) { + this.points = this.points.slice(this.points.length - capacity) + } + } + + append(anchor: TimelineChunkAnchor, columnCount: number, capacity: number): void { + const transport = anchor.transport + if (!transport) { + this.appendUnmapped(columnCount, capacity) + return + } + + const seam = resolveTimelineSeam(this.previousAnchor, transport) + if (seam) this.pendingSeam = seam + this.previousAnchor = { + sequence: transport.sequence, + frameCount: anchor.frameCount, + timeInSamples: transport.timeInSamples, + isPlaying: transport.isPlaying, + isLooping: transport.isLooping, + } + + const sampleRate = Math.max(1, anchor.sampleRate) + const numerator = Math.max(1, transport.timeSignature?.numerator ?? 4) + const denominator = Math.max(1, transport.timeSignature?.denominator ?? 4) + if (!transport.isPlaying) { + this.stoppedPosition = { + seconds: transport.timeInSeconds + ?? (transport.timeInSamples !== undefined ? transport.timeInSamples / sampleRate : undefined), + ppq: transport.ppqPosition, + bpm: transport.bpm, + numerator, + denominator, + lastBarPpq: transport.ppqPositionOfLastBarStart, + } + this.appendUnmapped(columnCount, capacity) + return + } + + this.stoppedPosition = null + for (let column = 0; column < columnCount; column += 1) { + const frameOffset = ((column + 1) / Math.max(1, columnCount)) * anchor.frameCount + const secondsOffset = frameOffset / sampleRate + const seconds = transport.timeInSeconds !== undefined + ? transport.timeInSeconds + secondsOffset + : transport.timeInSamples !== undefined + ? (transport.timeInSamples + frameOffset) / sampleRate + : undefined + const ppq = transport.ppqPosition !== undefined && transport.bpm !== undefined + ? transport.ppqPosition + secondsOffset * transport.bpm / 60 + : transport.ppqPosition + this.points.push({ + seconds, + ppq, + bpm: transport.bpm, + numerator, + denominator, + lastBarPpq: transport.ppqPositionOfLastBarStart, + ...(column === 0 && this.pendingSeam ? { seam: this.pendingSeam } : {}), + }) + if (column === 0) this.pendingSeam = undefined + } + this.resize(capacity) + } + + draw( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + unit: TimelineUnit, + backendKind: CaptureBackendKind | null, + gridColor: string, + labelColor: string, + orientation: 'horizontal' | 'vertical' = 'horizontal', + ): void { + if (unit === 'off' || backendKind !== 'daw-bridge' || this.points.length === 0) return + + const dpr = window.devicePixelRatio || 1 + const axisLength = orientation === 'horizontal' ? width : height + const start = axisLength - this.points.length + const hasPpq = this.points.some((point) => point.ppq !== undefined) + const effectiveUnit: TimelineUnit = unit === 'bars-beats' && !hasPpq ? 'seconds' : unit + const secondStep = niceSecondStep(this.points, axisLength, dpr) + let lastLabelPosition = -Infinity + + ctx.save() + ctx.font = `${10 * dpr}px monospace` + ctx.textBaseline = 'top' + ctx.lineWidth = Math.max(1, dpr) + + for (let index = 1; index < this.points.length; index += 1) { + const previous = this.points[index - 1] + const point = this.points[index] + const position = start + index + + if (point.seam) { + ctx.save() + ctx.strokeStyle = labelColor + ctx.setLineDash([4 * dpr, 4 * dpr]) + ctx.beginPath() + if (orientation === 'horizontal') { + ctx.moveTo(position, 0) + ctx.lineTo(position, height) + } else { + ctx.moveTo(0, position) + ctx.lineTo(width, position) + } + ctx.stroke() + ctx.setLineDash([]) + ctx.fillStyle = labelColor + ctx.fillText( + point.seam, + orientation === 'horizontal' ? position + 4 * dpr : 4 * dpr, + orientation === 'horizontal' ? 3 * dpr : position + 3 * dpr, + ) + ctx.restore() + continue + } + + let major = false + let label: string | null = null + let musicalLabel = false + if (effectiveUnit === 'bars-beats' && previous.ppq !== undefined && point.ppq !== undefined) { + const beatLength = 4 / point.denominator + const previousBeatIndex = Math.floor((previous.ppq + beatLength * 1.0e-7) / beatLength) + const beatIndex = Math.floor((point.ppq + beatLength * 1.0e-7) / beatLength) + if (previousBeatIndex === beatIndex) continue + const quartersPerBar = point.numerator * beatLength + const tickPpq = beatIndex * beatLength + const barAnchor = point.lastBarPpq + const beatsFromBarAnchor = barAnchor !== undefined + ? Math.round((tickPpq - barAnchor) / beatLength) + : beatIndex + const beatNumber = ((beatsFromBarAnchor % point.numerator) + point.numerator) % point.numerator + 1 + const barNumber = Math.floor((tickPpq + quartersPerBar * 1.0e-7) / quartersPerBar) + 1 + const ppqPerPixel = Math.abs(point.ppq - previous.ppq) + const estimatedBeatPixelSpacing = ppqPerPixel > 0 ? beatLength / ppqPerPixel : 0 + const hasRoomForBeatLabel = estimatedBeatPixelSpacing >= 34 * dpr + major = beatNumber === 1 + musicalLabel = true + // At roomy zoom levels the ruler names every beat. Dense histories keep + // every bar number instead of dropping apparently random bars. + label = major + ? hasRoomForBeatLabel ? `${barNumber}|1` : `${barNumber}` + : hasRoomForBeatLabel ? `${barNumber}|${beatNumber}` : null + } else if (previous.seconds !== undefined && point.seconds !== undefined) { + if (Math.floor(previous.seconds / secondStep) === Math.floor(point.seconds / secondStep)) continue + major = true + label = formatSeconds(Math.floor(point.seconds / secondStep) * secondStep) + } else { + continue + } + + ctx.strokeStyle = gridColor + ctx.globalAlpha = major ? 0.8 : 0.3 + ctx.beginPath() + if (orientation === 'horizontal') { + ctx.moveTo(position, 0) + ctx.lineTo(position, height) + } else { + ctx.moveTo(0, position) + ctx.lineTo(width, position) + } + ctx.stroke() + ctx.globalAlpha = 1 + + const secondsLabelHasRoom = label + ? position - lastLabelPosition >= Math.max(20 * dpr, ctx.measureText(label).width + 6 * dpr) + : false + if (label && (musicalLabel || secondsLabelHasRoom)) { + ctx.fillStyle = labelColor + ctx.fillText( + label, + orientation === 'horizontal' ? position + 3 * dpr : 3 * dpr, + orientation === 'horizontal' ? height - 15 * dpr : position + 3 * dpr, + ) + if (!musicalLabel) lastLabelPosition = position + } + } + + if (unit === 'bars-beats' && effectiveUnit === 'seconds') { + ctx.fillStyle = labelColor + ctx.fillText('Seconds (beat data unavailable)', 5 * dpr, 4 * dpr) + } + + if (this.stoppedPosition) { + const stoppedBeatLength = 4 / this.stoppedPosition.denominator + const stoppedQuartersPerBar = this.stoppedPosition.numerator * stoppedBeatLength + const stoppedBarAnchor = this.stoppedPosition.lastBarPpq + ?? Math.floor((this.stoppedPosition.ppq ?? 0) / stoppedQuartersPerBar) * stoppedQuartersPerBar + const stoppedLabel = effectiveUnit === 'bars-beats' && this.stoppedPosition.ppq !== undefined + ? `Stopped · ${Math.floor(this.stoppedPosition.ppq / stoppedQuartersPerBar) + 1}|${Math.floor((this.stoppedPosition.ppq - stoppedBarAnchor) / stoppedBeatLength) + 1}` + : this.stoppedPosition.seconds !== undefined + ? `Stopped · ${formatSeconds(this.stoppedPosition.seconds)}` + : 'Stopped' + ctx.fillStyle = labelColor + if (orientation === 'horizontal') { + ctx.textAlign = 'right' + ctx.fillText(stoppedLabel, width - 5 * dpr, 4 * dpr) + } else { + ctx.textAlign = 'left' + ctx.fillText(stoppedLabel, 5 * dpr, Math.max(4 * dpr, height - 15 * dpr)) + } + } + + ctx.restore() + } + + private appendUnmapped(columnCount: number, capacity: number): void { + for (let column = 0; column < columnCount; column += 1) { + this.points.push({ numerator: 4, denominator: 4 }) + } + this.resize(capacity) + } +} diff --git a/src/shared/profileState.ts b/src/shared/profileState.ts index 707aa3d..0bd380f 100644 --- a/src/shared/profileState.ts +++ b/src/shared/profileState.ts @@ -9,7 +9,7 @@ import { type ProfileLocalMetadata, type PrismProfileFile, type PrismProfileFileScopePopoutMap, - type PrismProfileFileV5, + type PrismProfileFileV6, type PrismProfileLocalStateV1, } from '../types/profile' import { AUDIO_SCOPE_KINDS, SCOPE_KINDS, normalizeScopeKind, type ScopeKind } from '../types/scope' @@ -38,6 +38,7 @@ import { DEFAULT_ANALYSIS_SETTINGS, normalizeAnalysisSettings, } from '../types/analysis' +import { DEFAULT_TIMELINE_UNIT, isTimelineUnit } from '../types/dawBridge' export const DEFAULT_VISIBLE: ScopeKind[] = ['spectrum', 'oscilloscope', 'vectorscope', 'vumeter'] export const DEFAULT_SCOPE_ORDER: ScopeKind[] = [...AUDIO_SCOPE_KINDS] @@ -245,6 +246,9 @@ export function mergeScopeSettings( tiltDbPerOctave: clampSpectrogramTiltDbPerOctave( rawSpectrogram.tiltDbPerOctave ?? DEFAULT_SCOPE_SETTINGS.spectrogram.tiltDbPerOctave ), + timelineUnit: isTimelineUnit(rawSpectrogram.timelineUnit) + ? rawSpectrogram.timelineUnit + : DEFAULT_TIMELINE_UNIT, }, vumeter: { ...DEFAULT_SCOPE_SETTINGS.vumeter, @@ -271,6 +275,9 @@ export function mergeScopeSettings( multiband: typeof rawWaveform.multiband === 'boolean' ? rawWaveform.multiband : DEFAULT_SCOPE_SETTINGS.waveform.multiband, + timelineUnit: isTimelineUnit(rawWaveform.timelineUnit) + ? rawWaveform.timelineUnit + : DEFAULT_TIMELINE_UNIT, }, nowPlaying: { ...DEFAULT_SCOPE_SETTINGS.nowPlaying, ...rawNowPlaying }, } @@ -346,9 +353,9 @@ export function normalizeProfileFile( raw: unknown, fallbackId: string, fallbackName = DEFAULT_PROFILE_NAME, -) : PrismProfileFileV5 { +) : PrismProfileFileV6 { const parsed = typeof raw === 'object' && raw !== null - ? raw as Omit, 'version'> & { version?: unknown } + ? raw as Omit, 'version'> & { version?: unknown } : {} const id = typeof parsed.id === 'string' && parsed.id.trim() @@ -373,7 +380,7 @@ export function normalizeProfileFile( } } -export function profileToFileData(id: string, profile: Profile): PrismProfileFileV5 { +export function profileToFileData(id: string, profile: Profile): PrismProfileFileV6 { const normalized = normalizeProfile(profile, profile.name) return { diff --git a/src/types/capture.ts b/src/types/capture.ts index 751c233..6e432fe 100644 --- a/src/types/capture.ts +++ b/src/types/capture.ts @@ -1,4 +1,5 @@ -export type CaptureMode = 'system' | 'device' +export type CaptureMode = 'system' | 'device' | 'daw' +export type CaptureStatus = 'idle' | 'connecting' | 'waiting' | 'capturing' | 'error' export type NativeSystemCaptureBackendKind = | 'native-macos' @@ -7,6 +8,7 @@ export type NativeSystemCaptureBackendKind = export type CaptureBackendKind = | 'device-input' + | 'daw-bridge' | NativeSystemCaptureBackendKind export function resolveNativeBackendKind(platform: string): NativeSystemCaptureBackendKind { @@ -22,6 +24,7 @@ export function resolveNativeBackendKind(platform: string): NativeSystemCaptureB export interface CaptureSourceDescriptor { id: string + persistentId?: string label: string kind: CaptureMode isDefault?: boolean @@ -38,4 +41,5 @@ export interface CaptureBackendSupportEntry { export interface CaptureBackendSupport { nativeBackend: CaptureBackendSupportEntry deviceInput: CaptureBackendSupportEntry + dawBridge: CaptureBackendSupportEntry } diff --git a/src/types/dawBridge.ts b/src/types/dawBridge.ts new file mode 100644 index 0000000..1d62c0d --- /dev/null +++ b/src/types/dawBridge.ts @@ -0,0 +1,106 @@ +export const DAW_BRIDGE_HOST = '127.0.0.1' +export const DAW_BRIDGE_PORT = 51789 +export const DAW_BRIDGE_PROTOCOL_VERSION = 1 +export const DAW_BRIDGE_FRAME_MAGIC = 0x4d535250 +export const DAW_BRIDGE_FRAME_HEADER_BYTES = 12 +export const DAW_BRIDGE_AUDIO_HEADER_BYTES = 76 +export const DAW_BRIDGE_MAX_PAYLOAD_BYTES = 256 * 1024 + +export const DAW_BRIDGE_MESSAGE = { + hello: 1, + heartbeat: 2, + sourceUpdate: 3, + subscribe: 10, + audio: 20, +} as const + +export type DawBridgeConnectionState = 'connected' | 'selected' + +export interface DawBridgeSourceDescriptor { + id: string + persistentId: string + instanceId: string + label: string + customName: string | null + trackName: string | null + hostName: string | null + sampleRate: number + channelCount: number + droppedFrames: number + connectionState: DawBridgeConnectionState +} + +export interface DawBridgeSnapshot { + available: boolean + reason: string | null + selectedSourceId: string | null + sources: DawBridgeSourceDescriptor[] +} + +export interface DawTransportSnapshot { + sequence: number + timeInSamples?: number + timeInSeconds?: number + ppqPosition?: number + bpm?: number + ppqPositionOfLastBarStart?: number + timeSignature?: { + numerator: number + denominator: number + } + loopPoints?: { + ppqStart: number + ppqEnd: number + } + isPlaying: boolean + isRecording: boolean + isLooping: boolean +} + +export interface DawAnnotatedAudioChunk { + sequence: number + sampleRate: number + channelCount: number + left: Float32Array + right: Float32Array + transport: DawTransportSnapshot +} + +export interface DawBridgeAudioPacket extends DawAnnotatedAudioChunk { + sourceId: string +} + +export interface DawBridgeAudioBatch { + sourceId: string + packets: DawBridgeAudioPacket[] +} + +export interface DawBridgeHelloPayload { + sourceId: string + instanceId: string + customName?: string + trackName?: string + hostName?: string + sampleRate?: number + channelCount?: number + droppedFrames?: number +} + +export interface DawBridgeSubscribePayload { + selected: boolean +} + +export interface DawBridgeRendererAPI { + getSnapshot: () => Promise + selectSource: (sourceId: string | null) => Promise + onSnapshot: (callback: (snapshot: DawBridgeSnapshot) => void) => () => void + onAudioBatch: (callback: (batch: DawBridgeAudioBatch) => void) => () => void +} + +export type TimelineUnit = 'off' | 'bars-beats' | 'seconds' + +export function isTimelineUnit(value: unknown): value is TimelineUnit { + return value === 'off' || value === 'bars-beats' || value === 'seconds' +} + +export const DEFAULT_TIMELINE_UNIT: TimelineUnit = 'bars-beats' diff --git a/src/types/desktopIntegration.ts b/src/types/desktopIntegration.ts index 21abdef..5cf261b 100644 --- a/src/types/desktopIntegration.ts +++ b/src/types/desktopIntegration.ts @@ -36,20 +36,23 @@ export interface TrayRendererState { profiles: TrayProfileOption[] activeProfileId: string | null hasUnsavedProfileChanges: boolean - captureStatus: 'idle' | 'connecting' | 'capturing' | 'error' + captureStatus: 'idle' | 'connecting' | 'waiting' | 'capturing' | 'error' activeSourceLabel: string | null - captureMode: 'system' | 'device' + captureMode: 'system' | 'device' | 'daw' selectedSystemSourceId: string | null selectedDeviceId: string | null + selectedDawSourceId: string | null rollingCaptureSeconds: RollingCaptureDurationSeconds | null systemSources: TrayAudioSourceOption[] inputSources: TrayAudioSourceOption[] + dawSources: TrayAudioSourceOption[] } export type TrayRendererCommand = | { type: 'load-profile'; profileId: string } | { type: 'select-system-source'; sourceId: string } | { type: 'select-input-source'; deviceId: string | null } + | { type: 'select-daw-source'; sourceId: string } | { type: 'set-rolling-capture'; durationSeconds: RollingCaptureDurationSeconds | null } | { type: 'set-capture-running'; running: boolean } | { type: 'open-settings' } @@ -68,7 +71,9 @@ export const DEFAULT_TRAY_RENDERER_STATE: TrayRendererState = { captureMode: 'system', selectedSystemSourceId: null, selectedDeviceId: null, + selectedDawSourceId: null, rollingCaptureSeconds: null, systemSources: [], inputSources: [], + dawSources: [], } diff --git a/src/types/popout.ts b/src/types/popout.ts index 667f66b..05c9426 100644 --- a/src/types/popout.ts +++ b/src/types/popout.ts @@ -1,5 +1,6 @@ import type { CaptureBackendKind } from './capture' import type { ScopeKind } from './scope' +import type { DawTransportSnapshot } from './dawBridge' import type { ScopeSettings } from './settings' import type { AnalysisSettings } from './analysis' import type { @@ -40,12 +41,14 @@ export interface ScopePopoutSessionState { sampleRate: number channelCount: number capturing: boolean + suspended: boolean backendKind: CaptureBackendKind | null } export interface ScopePopoutStereoChunk { left: Float32Array right: Float32Array + transport?: DawTransportSnapshot } export type ScopePopoutMonoBatch = Float32Array[] diff --git a/src/types/profile.ts b/src/types/profile.ts index 9e13d9d..2b0c6bb 100644 --- a/src/types/profile.ts +++ b/src/types/profile.ts @@ -4,7 +4,7 @@ import type { ScopeSettings } from './settings' import type { AnalysisSettings } from './analysis' export const PROFILE_FILE_FORMAT = 'prism-profile' -export const PROFILE_FILE_VERSION = 5 +export const PROFILE_FILE_VERSION = 6 export const PROFILE_LOCAL_STATE_FORMAT = 'prism-profile-local' export const PROFILE_LOCAL_STATE_VERSION = 1 export const LEGACY_PROFILE_MIGRATION_VERSION = 1 @@ -81,7 +81,7 @@ export interface PrismProfileFileV4 { export interface PrismProfileFileV5 { format: typeof PROFILE_FILE_FORMAT - version: typeof PROFILE_FILE_VERSION + version: 5 id: string name: string themeId?: string | null @@ -93,7 +93,11 @@ export interface PrismProfileFileV5 { scopePopouts: PrismProfileFileScopePopoutMap } -export type PrismProfileFile = PrismProfileFileV1 | PrismProfileFileV2 | PrismProfileFileV3 | PrismProfileFileV4 | PrismProfileFileV5 +export interface PrismProfileFileV6 extends Omit { + version: typeof PROFILE_FILE_VERSION +} + +export type PrismProfileFile = PrismProfileFileV1 | PrismProfileFileV2 | PrismProfileFileV3 | PrismProfileFileV4 | PrismProfileFileV5 | PrismProfileFileV6 export interface ProfileLocalMetadata { windowBounds?: WindowBounds diff --git a/src/types/settings.ts b/src/types/settings.ts index 6144989..62d1894 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -21,6 +21,7 @@ import { type ScopeDisplayTransformSettings, } from './scopeTransform' import { DEFAULT_VECTORSCOPE_ZOOM_DB } from './vectorscope' +import { DEFAULT_TIMELINE_UNIT, type TimelineUnit } from './dawBridge' export interface ScopeSettings { spectrum: ScopeDisplayTransformSettings & { @@ -61,6 +62,7 @@ export interface ScopeSettings { frequencyRangeMode: FrequencyRangeMode showGrid: boolean colorScheme: 'heat' | 'mono' + timelineUnit: TimelineUnit } vumeter: { mode: VUMeterMode @@ -76,6 +78,7 @@ export interface ScopeSettings { mode: WaveformMode scrollSpeed: number multiband: boolean + timelineUnit: TimelineUnit } nowPlaying: { showCoverArt: boolean @@ -91,10 +94,10 @@ export const DEFAULT_SCOPE_SETTINGS: ScopeSettings = { spectrum: { rotation: DEFAULT_SCOPE_DISPLAY_ROTATION, mirrorHorizontal: DEFAULT_SCOPE_MIRROR_HORIZONTAL, scaleMode: DEFAULT_FREQUENCY_SCALE_MODE, frequencyRangeMode: DEFAULT_FREQUENCY_RANGE_MODE, fftSize: 2048, tiltDbPerOctave: 2.0, heatmap: false, heatmapTiltDbPerOctave: 2.0, heatmapSmoothing: 0.5, showGrid: true, smoothing: 0.9, fillGradient: true, showSideLine: false, peakInfoMode: DEFAULT_SPECTRUM_PEAK_INFO_MODE }, oscilloscope: { rotation: DEFAULT_SCOPE_DISPLAY_ROTATION, mirrorHorizontal: DEFAULT_SCOPE_MIRROR_HORIZONTAL, pitchLock: true, underfillEnabled: false, showGrid: true, lineWidth: 2 }, vectorscope: { mode: 'lissajous', zoomDb: DEFAULT_VECTORSCOPE_ZOOM_DB, multiband: false, showGrid: true, persistence: 0.10, lineWidth: 1.5 }, - spectrogram: { rotation: DEFAULT_SCOPE_DISPLAY_ROTATION, mirrorHorizontal: DEFAULT_SCOPE_MIRROR_HORIZONTAL, fftSize: 4096, tiltDbPerOctave: DEFAULT_SPECTROGRAM_TILT_DB_PER_OCTAVE, scrollSpeed: 2, contrast: DEFAULT_SPECTROGRAM_CONTRAST, clarityMode: 'sharper', scaleMode: 'log', frequencyRangeMode: DEFAULT_FREQUENCY_RANGE_MODE, showGrid: true, colorScheme: 'heat' }, + spectrogram: { rotation: DEFAULT_SCOPE_DISPLAY_ROTATION, mirrorHorizontal: DEFAULT_SCOPE_MIRROR_HORIZONTAL, fftSize: 4096, tiltDbPerOctave: DEFAULT_SPECTROGRAM_TILT_DB_PER_OCTAVE, scrollSpeed: 2, contrast: DEFAULT_SPECTROGRAM_CONTRAST, clarityMode: 'sharper', scaleMode: 'log', frequencyRangeMode: DEFAULT_FREQUENCY_RANGE_MODE, showGrid: true, colorScheme: 'heat', timelineUnit: DEFAULT_TIMELINE_UNIT }, vumeter: { mode: 'bar', orientation: 'horizontal', needleChannels: 'stereo', referenceDb: DEFAULT_VU_REFERENCE_DBFS }, lufsmeter: { mode: 'bar', readout: DEFAULT_LUFS_METER_READOUT }, - waveform: { rotation: DEFAULT_SCOPE_DISPLAY_ROTATION, mirrorHorizontal: DEFAULT_SCOPE_MIRROR_HORIZONTAL, mode: DEFAULT_WAVEFORM_MODE, scrollSpeed: DEFAULT_WAVEFORM_SCROLL_SPEED, multiband: false }, + waveform: { rotation: DEFAULT_SCOPE_DISPLAY_ROTATION, mirrorHorizontal: DEFAULT_SCOPE_MIRROR_HORIZONTAL, mode: DEFAULT_WAVEFORM_MODE, scrollSpeed: DEFAULT_WAVEFORM_SCROLL_SPEED, multiband: false, timelineUnit: DEFAULT_TIMELINE_UNIT }, nowPlaying: { showCoverArt: true, showTitle: true, diff --git a/test/audio-router.test.ts b/test/audio-router.test.ts index b36a2b0..cd9c8ee 100644 --- a/test/audio-router.test.ts +++ b/test/audio-router.test.ts @@ -183,6 +183,26 @@ test('drops stale-session chunks before they reach scope queues', () => { assert.equal(router.getDiagnosticsSnapshot().staleSessionDrops, 1) }) +test('suspend and resume preserve queued history without creating a new session', () => { + const router = new AudioRouter() + const sessionId = router.beginSession(48000, 2, 'daw-bridge') + router.setVisualizerConsumerDemand('timeline', { waveform: true }) + router.ingestChunk(createChunk(1), createChunk(1), { sessionId, sequence: 1 }) + + router.suspendSession() + assert.equal(router.getSessionState().suspended, true) + assert.equal(router.getSessionState().sessionId, sessionId) + router.ingestChunk(createChunk(2), createChunk(2), { sessionId, sequence: 2 }) + + router.resumeSession() + assert.equal(router.getSessionState().suspended, false) + assert.equal(router.getSessionState().sessionId, sessionId) + router.ingestChunk(createChunk(3), createChunk(3), { sessionId, sequence: 3 }) + + const chunks = router.flushPendingWaveformStereoSamples() + assert.deepEqual(chunks.map((chunk) => chunk.left[0]), [1, 3]) +}) + test('publishes aggregated visualizer demand changes for downstream transports', () => { const router = new AudioRouter() const snapshots: Array> = [] diff --git a/test/audio-store.test.ts b/test/audio-store.test.ts index 079ce8a..8280349 100644 --- a/test/audio-store.test.ts +++ b/test/audio-store.test.ts @@ -30,6 +30,7 @@ function audioPreferences(overrides: Partial = {}): Persist captureMode: 'system', selectedSystemSourceId: DEFAULT_SYSTEM_SOURCE_ID, selectedDeviceId: null, + selectedDawSourceId: null, rollingCaptureSeconds: null, ...overrides, } @@ -107,6 +108,11 @@ function createBackendSupport(available: boolean, reason: string | null): Captur available: true, reason: null, }, + dawBridge: { + kind: 'daw-bridge', + available: true, + reason: null, + }, } } @@ -279,6 +285,7 @@ function installFakeDeviceWatcherEnvironment(): { function resetStores(): void { audioCapture.setSelectedSystemSourceId(DEFAULT_SYSTEM_SOURCE_ID) audioCapture.setSelectedDeviceId(null) + audioCapture.setSelectedDawSourceId(null) audioCapture.setCaptureMode('system') audioCapture.setInputGain(0) audioCapture.setRollingCaptureSeconds(null) @@ -288,6 +295,8 @@ function resetStores(): void { devices: [], selectedSystemSourceId: '__default_system_output__', selectedDeviceId: null, + selectedDawSourceId: null, + dawSources: [], captureMode: 'system', activeBackendKind: null, backendSupport: null, @@ -414,6 +423,7 @@ function installAudioCaptureHarness(options: { isCapturing, activeSourceId: isCapturing ? activeSourceId : null, activeSourceLabel: isCapturing ? activeSourceLabel : null, + waiting: false, }) audioCapture.setCaptureMode = (mode) => { captureMode = mode @@ -492,6 +502,16 @@ test('normalizeAudioPreferences preserves valid persisted selector values', () = })) }) +test('normalizeAudioPreferences preserves DAW mode and its stable bridge UUID', () => { + assert.deepEqual(normalizeAudioPreferences({ + captureMode: 'daw', + selectedDawSourceId: 'bridge-stable-uuid', + }), audioPreferences({ + captureMode: 'daw', + selectedDawSourceId: 'bridge-stable-uuid', + })) +}) + test('normalizeRollingCaptureSeconds accepts only supported durations', () => { assert.equal(normalizeRollingCaptureSeconds(5), 5) assert.equal(normalizeRollingCaptureSeconds(60), 60) @@ -633,6 +653,40 @@ test('audio store persists input source selections', async () => { } }) +test('audio store persists a Bridge stable UUID while selecting its live connection key', async () => { + resetStores() + const fakeStorage = installFakeLocalStorage() + const originalSetSelectedDawSourceId = audioCapture.setSelectedDawSourceId + const selections: Array<[string | null, string | null]> = [] + audioCapture.setSelectedDawSourceId = (stableId, liveId = null) => { + selections.push([stableId, liveId]) + } + + try { + useAudioStore.setState({ + dawSources: [{ + id: 'bridge-stable-uuid:live-key', + persistentId: 'bridge-stable-uuid', + label: 'Drums', + kind: 'daw', + }], + }) + await useAudioStore.getState().selectDawSource('bridge-stable-uuid:live-key') + + assert.equal(useAudioStore.getState().captureMode, 'daw') + assert.equal(useAudioStore.getState().selectedDawSourceId, 'bridge-stable-uuid') + assert.deepEqual(selections, [['bridge-stable-uuid', 'bridge-stable-uuid:live-key']]) + assert.equal(fakeStorage.getItem('prism:audio'), storedAudioPreferences({ + captureMode: 'daw', + selectedDawSourceId: 'bridge-stable-uuid', + })) + } finally { + audioCapture.setSelectedDawSourceId = originalSetSelectedDawSourceId + fakeStorage.restore() + resetStores() + } +}) + test('audio store auto-switches to device input when native system capture is unavailable on startup', async () => { resetStores() const fakeStorage = installFakeLocalStorage() diff --git a/test/capture-support.test.ts b/test/capture-support.test.ts index 5fecf54..e0e3d7a 100644 --- a/test/capture-support.test.ts +++ b/test/capture-support.test.ts @@ -46,6 +46,11 @@ test('getCaptureBackendSupport resolves Linux native support from the addon surf available: true, reason: null, }, + dawBridge: { + kind: 'daw-bridge', + available: false, + reason: 'The DAW bridge listener is still starting.', + }, }) }) diff --git a/test/daw-bridge-service.test.ts b/test/daw-bridge-service.test.ts new file mode 100644 index 0000000..d0eda56 --- /dev/null +++ b/test/daw-bridge-service.test.ts @@ -0,0 +1,178 @@ +import assert from 'node:assert/strict' +import { connect, type Socket } from 'node:net' +import test from 'node:test' +import { DawBridgeService } from '../src/main/services/dawBridgeService' +import { + DAW_BRIDGE_AUDIO_HEADER_BYTES, + DAW_BRIDGE_FRAME_HEADER_BYTES, + DAW_BRIDGE_FRAME_MAGIC, + DAW_BRIDGE_MESSAGE, + DAW_BRIDGE_PROTOCOL_VERSION, + type DawBridgeAudioBatch, +} from '../src/types/dawBridge' + +function frame(messageType: number, payload = Buffer.alloc(0), version = DAW_BRIDGE_PROTOCOL_VERSION): Buffer { + const header = Buffer.alloc(DAW_BRIDGE_FRAME_HEADER_BYTES) + header.writeUInt32LE(DAW_BRIDGE_FRAME_MAGIC, 0) + header.writeUInt16LE(version, 4) + header.writeUInt16LE(messageType, 6) + header.writeUInt32LE(payload.length, 8) + return Buffer.concat([header, payload]) +} + +function hello(sourceId: string, instanceId: string, trackName = 'Drums'): Buffer { + return frame(DAW_BRIDGE_MESSAGE.hello, Buffer.from(JSON.stringify({ + sourceId, + instanceId, + trackName, + hostName: 'Test DAW', + sampleRate: 48000, + channelCount: 2, + }))) +} + +function audioPacket(sequence = 7): Buffer { + const left = [0.25, -0.5] + const right = [0.75, -1] + const payload = Buffer.alloc(DAW_BRIDGE_AUDIO_HEADER_BYTES + 16) + payload.writeUInt32LE(sequence, 0) + payload.writeUInt16LE(2, 4) + payload.writeUInt8(2, 6) + payload.writeUInt8(0xff, 7) + payload.writeDoubleLE(48000, 8) + payload.writeBigInt64LE(96000n, 16) + payload.writeDoubleLE(2, 24) + payload.writeDoubleLE(8, 32) + payload.writeDoubleLE(120, 40) + payload.writeDoubleLE(8, 48) + payload.writeDoubleLE(4, 56) + payload.writeDoubleLE(12, 64) + payload.writeInt16LE(4, 72) + payload.writeInt16LE(4, 74) + left.forEach((value, index) => payload.writeFloatLE(value, DAW_BRIDGE_AUDIO_HEADER_BYTES + index * 4)) + right.forEach((value, index) => payload.writeFloatLE(value, DAW_BRIDGE_AUDIO_HEADER_BYTES + 8 + index * 4)) + return frame(DAW_BRIDGE_MESSAGE.audio, payload) +} + +async function openClient(port: number): Promise { + return new Promise((resolve, reject) => { + const socket = connect({ host: '127.0.0.1', port }, () => resolve(socket)) + socket.once('error', reject) + }) +} + +async function waitFor(predicate: () => boolean, timeoutMs = 1000, label = 'bridge state'): Promise { + const startedAt = Date.now() + while (!predicate()) { + if (Date.now() - startedAt >= timeoutMs) throw new Error(`Timed out waiting for ${label}.`) + await new Promise((resolve) => setTimeout(resolve, 5)) + } +} + +test('bridge service parses fragmented control frames and coalesced planar audio', async () => { + let deliveredBatch: DawBridgeAudioBatch | null = null + const service = new DawBridgeService({ + port: 0, + audioBatchIntervalMs: 1, + onAudioBatch: (batch) => { deliveredBatch = batch }, + }) + await service.start() + const port = service.getListeningPort() + assert.ok(port) + const socket = await openClient(port) + + try { + const helloFrame = hello('source-one', 'instance-one') + socket.write(helloFrame.subarray(0, 5)) + socket.write(helloFrame.subarray(5)) + await waitFor(() => service.getSnapshot().sources.length === 1, 1000, 'fragmented hello') + + service.selectSource('source-one') + socket.write(Buffer.concat([frame(DAW_BRIDGE_MESSAGE.heartbeat), audioPacket()])) + await waitFor(() => deliveredBatch !== null) + + const packet = deliveredBatch!.packets[0]! + assert.equal(deliveredBatch!.sourceId, 'source-one') + assert.deepEqual([...packet.left], [0.25, -0.5]) + assert.deepEqual([...packet.right], [0.75, -1]) + assert.equal(packet.transport.timeInSamples, 96000) + assert.equal(packet.transport.ppqPosition, 8) + assert.deepEqual(packet.transport.timeSignature, { numerator: 4, denominator: 4 }) + assert.deepEqual(packet.transport.loopPoints, { ppqStart: 4, ppqEnd: 12 }) + } finally { + socket.destroy() + service.stop() + } +}) + +test('duplicate persisted UUIDs get distinct live keys and clear implicit selection', async () => { + const service = new DawBridgeService({ port: 0 }) + await service.start() + const port = service.getListeningPort() + assert.ok(port) + const first = await openClient(port) + const second = await openClient(port) + + try { + first.write(hello('duplicate-source', 'instance-alpha')) + await waitFor(() => service.getSnapshot().sources.length === 1) + service.selectSource('duplicate-source') + second.write(hello('duplicate-source', 'instance-beta')) + await new Promise((resolve) => setTimeout(resolve, 30)) + assert.equal( + service.getSnapshot().sources.length, + 2, + JSON.stringify(service.getSnapshot()), + ) + + const snapshot = service.getSnapshot() + assert.equal(snapshot.selectedSourceId, null) + assert.equal(new Set(snapshot.sources.map((source) => source.id)).size, 2) + assert.ok(snapshot.sources.every((source) => source.id !== source.persistentId)) + } finally { + first.destroy() + second.destroy() + service.stop() + } +}) + +test('malformed protocol clients are rejected and silent sources expire', async () => { + const service = new DawBridgeService({ + port: 0, + heartbeatIntervalMs: 10, + heartbeatTimeoutMs: 40, + }) + await service.start() + const port = service.getListeningPort() + assert.ok(port) + const malformed = await openClient(port) + malformed.write(frame(DAW_BRIDGE_MESSAGE.hello, Buffer.from('{}'), 999)) + await new Promise((resolve) => malformed.once('close', resolve)) + assert.equal(service.getSnapshot().sources.length, 0) + + const silent = await openClient(port) + try { + silent.write(hello('silent-source', 'silent-instance')) + await waitFor(() => service.getSnapshot().sources.length === 1) + await waitFor(() => service.getSnapshot().sources.length === 0, 500) + } finally { + silent.destroy() + service.stop() + } +}) + +test('port conflicts surface an unavailable reason without changing ports', async () => { + const owner = new DawBridgeService({ port: 0 }) + await owner.start() + const port = owner.getListeningPort() + assert.ok(port) + const blocked = new DawBridgeService({ port }) + try { + const snapshot = await blocked.start() + assert.equal(snapshot.available, false) + assert.match(snapshot.reason ?? '', /Could not listen|address already in use/i) + } finally { + blocked.stop() + owner.stop() + } +}) diff --git a/test/profile-library.test.ts b/test/profile-library.test.ts index d56831b..38895a6 100644 --- a/test/profile-library.test.ts +++ b/test/profile-library.test.ts @@ -70,6 +70,7 @@ test('profile file serialization excludes geometry and round-trips with local me assert.equal(file.format, PROFILE_FILE_FORMAT) assert.equal(file.version, PROFILE_FILE_VERSION) + assert.equal(file.version, 6) assert.equal('themeId' in file, false) assert.equal(JSON.stringify(file).includes('windowBounds'), false) assert.equal(JSON.stringify(file).includes('frameTarget'), false) @@ -85,6 +86,8 @@ test('profile file serialization excludes geometry and round-trips with local me assert.equal(file.scopeSettings.spectrogram.showGrid, true) assert.equal(file.scopeSettings.spectrogram.rotation, 90) assert.equal(file.scopeSettings.spectrogram.mirrorHorizontal, true) + assert.equal(file.scopeSettings.spectrogram.timelineUnit, 'bars-beats') + assert.equal(file.scopeSettings.waveform.timelineUnit, 'bars-beats') assert.equal('orientation' in file.scopeSettings.spectrogram, false) assert.equal(file.scopeOrder.includes('nowPlaying'), false) assert.equal(file.hiddenScopes.includes('nowPlaying'), true) @@ -402,10 +405,12 @@ test('partial files normalize, unsupported versions fail, and import does not ch assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.spectrum.showSideLine, false) assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.spectrum.heatmapSmoothing, 0.5) assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.spectrogram.colorScheme, 'heat') + assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.spectrogram.timelineUnit, 'bars-beats') assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.lufsmeter.readout, 'shortTerm') assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.waveform.mode, 'stereo') assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.waveform.scrollSpeed, 1) assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.waveform.multiband, true) + assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.waveform.timelineUnit, 'bars-beats') assert.equal(Object.hasOwn(partialSnapshot.profiles.profile_partial.scopeSettings.waveform, 'gainDb'), false) assert.equal(partialSnapshot.profiles.profile_partial.scopePopouts.spectrogram.poppedOut, true) assert.equal(partialSnapshot.profiles.profile_partial.widthWeights.spectrum, 1) diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index 664ca70..6c0afef 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -101,6 +101,7 @@ import { } from '../src/renderer/visualizers/vuMeterBallistics' import { FrameScheduler } from '../src/renderer/visualizers/frameScheduler' import { VisualizerFrameLoop } from '../src/renderer/visualizers/visualizerFrameLoop' +import { resolveTimelineSeam, ScrollingTimeline } from '../src/renderer/visualizers/scrollingTimeline' import { NativeVisualizerTransport, type NativeVisualizerTransportBridge, @@ -7262,3 +7263,101 @@ test('NativePolledCaptureBackend trims stale backlog to the newest live slice wh timers.restore() } }) + +test('DAW timeline seams distinguish gaps, loops, seeks, and stopped resumption', () => { + const previous = { + sequence: 10, + frameCount: 512, + timeInSamples: 48000, + isPlaying: true, + isLooping: false, + } + const transport = { + sequence: 11, + timeInSamples: 48512, + isPlaying: true, + isRecording: false, + isLooping: false, + } + + assert.equal(resolveTimelineSeam(previous, transport), undefined) + assert.equal(resolveTimelineSeam(previous, { ...transport, sequence: 12 }), 'Gap') + assert.equal(resolveTimelineSeam(previous, { ...transport, timeInSamples: 96000 }), 'Jump') + assert.equal(resolveTimelineSeam(previous, { + ...transport, + timeInSamples: 12000, + isLooping: true, + }), 'Loop') + assert.equal(resolveTimelineSeam({ ...previous, isPlaying: false }, transport), 'Jump') +}) + +test('DAW musical ruler labels every roomy beat and every bar in dense history', () => { + const dom = installFakeCanvasDom() + try { + const roomyRecorder = createFakeCanvasRecorder() + const roomy = new ScrollingTimeline() + roomy.append({ + frameCount: 192000, + sampleRate: 48000, + transport: { + sequence: 1, + timeInSamples: 0, + timeInSeconds: 0, + ppqPosition: 0, + ppqPositionOfLastBarStart: 0, + bpm: 120, + timeSignature: { numerator: 4, denominator: 4 }, + isPlaying: true, + isRecording: false, + isLooping: false, + }, + }, 800, 800) + roomy.draw( + createFakeCanvasContext(roomyRecorder), + 800, + 180, + 'bars-beats', + 'daw-bridge', + '#333', + '#fff', + ) + assert.deepEqual( + roomyRecorder.fillTexts.slice(0, 7).map(({ text }) => text), + ['1|2', '1|3', '1|4', '2|1', '2|2', '2|3', '2|4'], + ) + + const denseRecorder = createFakeCanvasRecorder() + const dense = new ScrollingTimeline() + dense.append({ + frameCount: 768000, + sampleRate: 48000, + transport: { + sequence: 1, + timeInSamples: 0, + timeInSeconds: 0, + ppqPosition: 0, + ppqPositionOfLastBarStart: 0, + bpm: 120, + timeSignature: { numerator: 4, denominator: 4 }, + isPlaying: true, + isRecording: false, + isLooping: false, + }, + }, 160, 160) + dense.draw( + createFakeCanvasContext(denseRecorder), + 160, + 180, + 'bars-beats', + 'daw-bridge', + '#333', + '#fff', + ) + assert.deepEqual( + denseRecorder.fillTexts.map(({ text }) => text), + ['2', '3', '4', '5', '6', '7', '8', '9'], + ) + } finally { + dom.restore() + } +}) From 0def796ee8a08fd691811b903f6634aeccf051c1 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:12:12 -0400 Subject: [PATCH 03/27] multichannel matrix --- native/binding.gyp | 10 +- native/src/capture_channel_selection.cpp | 109 ++++ native/src/capture_channel_selection.h | 41 ++ native/src/capture_channel_selection_napi.cpp | 87 +++ native/src/capture_channel_selection_napi.h | 5 + native/src/device_input_capture.h | 26 + native/src/device_input_capture_napi.cpp | 179 ++++++ native/src/device_input_capture_napi.h | 5 + native/src/device_input_capture_stub.cpp | 31 + native/src/macos_capture.mm | 248 ++++---- native/src/macos_input_capture.mm | 542 ++++++++++++++++++ native/src/main.cpp | 4 + native/src/system_audio_capture.h | 15 + native/src/system_audio_capture_napi.cpp | 61 ++ package.json | 5 + scripts/run-tests.mjs | 1 + src/main/index.ts | 10 +- src/preload/captureSupport.ts | 3 + src/preload/index.ts | 2 + src/renderer/audio/AudioCapture.ts | 190 +++++- src/renderer/components/BottomBar.tsx | 79 ++- .../components/ChannelRoutingMatrix.tsx | 62 ++ src/renderer/components/TrayControlBridge.tsx | 6 +- src/renderer/env.d.ts | 1 + src/renderer/stores/audioStore.ts | 163 +++++- src/renderer/styles/globals.css | 78 +++ src/types/capture.ts | 55 ++ src/types/nativeCapture.ts | 36 +- test/audio-store.test.ts | 149 ++++- .../capture-channel-selection-native.test.mjs | 76 +++ test/capture-support.test.ts | 57 +- test/renderer-helpers.test.ts | 21 + test/spectrum-native.test.mjs | 15 + 33 files changed, 2172 insertions(+), 200 deletions(-) create mode 100644 native/src/capture_channel_selection.cpp create mode 100644 native/src/capture_channel_selection.h create mode 100644 native/src/capture_channel_selection_napi.cpp create mode 100644 native/src/capture_channel_selection_napi.h create mode 100644 native/src/device_input_capture.h create mode 100644 native/src/device_input_capture_napi.cpp create mode 100644 native/src/device_input_capture_napi.h create mode 100644 native/src/device_input_capture_stub.cpp create mode 100644 native/src/macos_input_capture.mm create mode 100644 src/renderer/components/ChannelRoutingMatrix.tsx create mode 100644 test/capture-channel-selection-native.test.mjs diff --git a/native/binding.gyp b/native/binding.gyp index 123c3a7..d0b1c95 100644 --- a/native/binding.gyp +++ b/native/binding.gyp @@ -8,6 +8,9 @@ "sources": [ "src/main.cpp", "src/system_audio_capture_napi.cpp", + "src/device_input_capture_napi.cpp", + "src/capture_channel_selection.cpp", + "src/capture_channel_selection_napi.cpp", "src/oscilloscope.cpp", "src/spectrum.cpp", "src/spectrogram.cpp", @@ -28,6 +31,7 @@ ["OS=='mac'", { "sources": [ "src/macos_capture.mm", + "src/macos_input_capture.mm", "src/windows_capture_stub.cpp" ], "xcode_settings": { @@ -44,7 +48,8 @@ }], ["OS=='win'", { "sources": [ - "src/windows_capture.cpp" + "src/windows_capture.cpp", + "src/device_input_capture_stub.cpp" ], "defines": [ "WIN32_LEAN_AND_MEAN", @@ -67,7 +72,8 @@ ["OS=='linux'", { "sources": [ "src/windows_capture_stub.cpp", - "src/linux_capture.cpp" + "src/linux_capture.cpp", + "src/device_input_capture_stub.cpp" ], "cflags_cc": ["-std=c++17", "-O3", "-ffast-math", "-fPIC"], "libraries": [ diff --git a/native/src/capture_channel_selection.cpp b/native/src/capture_channel_selection.cpp new file mode 100644 index 0000000..ad3cacc --- /dev/null +++ b/native/src/capture_channel_selection.cpp @@ -0,0 +1,109 @@ +#include "capture_channel_selection.h" + +#include +#include + +namespace Prism::Capture { +namespace { + +uint64_t readUnsignedSample(const uint8_t* data, size_t byteCount, bool bigEndian) { + uint64_t value = 0; + if (bigEndian) { + for (size_t index = 0; index < byteCount; ++index) { + value = (value << 8) | data[index]; + } + } else { + for (size_t index = 0; index < byteCount; ++index) { + value |= static_cast(data[index]) << (index * 8); + } + } + return value; +} + +float decodeSample(const uint8_t* data, const PCMFormat& format) { + const size_t byteCount = format.bitsPerChannel / 8; + if (data == nullptr || byteCount == 0 || byteCount > sizeof(uint64_t)) return 0.0f; + + if (format.encoding == SampleEncoding::Float && format.bitsPerChannel == 32) { + uint32_t bits = static_cast(readUnsignedSample(data, byteCount, format.bigEndian)); + float value = 0.0f; + std::memcpy(&value, &bits, sizeof(value)); + return value; + } + if (format.encoding == SampleEncoding::Float && format.bitsPerChannel == 64) { + const uint64_t bits = readUnsignedSample(data, byteCount, format.bigEndian); + double value = 0.0; + std::memcpy(&value, &bits, sizeof(value)); + return static_cast(value); + } + if (format.encoding != SampleEncoding::SignedInteger || format.bitsPerChannel > 32) { + return 0.0f; + } + + const uint64_t raw = readUnsignedSample(data, byteCount, format.bigEndian); + const uint64_t signBit = uint64_t{1} << (format.bitsPerChannel - 1); + const int64_t signedValue = (raw & signBit) != 0 + ? static_cast(raw) - static_cast(uint64_t{1} << format.bitsPerChannel) + : static_cast(raw); + const double maximum = static_cast(signBit - 1); + return maximum > 0.0 ? static_cast(static_cast(signedValue) / maximum) : 0.0f; +} + +float readChannelFrame(const PCMBufferView* buffers, + size_t bufferCount, + const PCMFormat& format, + uint32_t channel, + size_t frame) { + const size_t bytesPerSample = format.bitsPerChannel / 8; + uint32_t channelBase = 0; + for (size_t bufferIndex = 0; bufferIndex < bufferCount; ++bufferIndex) { + const PCMBufferView& buffer = buffers[bufferIndex]; + const uint32_t channelsInBuffer = std::max(1, buffer.channelCount); + if (channel >= channelBase + channelsInBuffer) { + channelBase += channelsInBuffer; + continue; + } + + const size_t localChannel = channel - channelBase; + const size_t sampleIndex = frame * channelsInBuffer + localChannel; + const size_t byteOffset = sampleIndex * bytesPerSample; + if (buffer.data == nullptr || byteOffset + bytesPerSample > buffer.byteLength) return 0.0f; + return decodeSample(buffer.data + byteOffset, format); + } + return 0.0f; +} + +} // namespace + +bool selectStereoChannels(const PCMBufferView* buffers, + size_t bufferCount, + const PCMFormat& format, + size_t frameCount, + uint32_t sourceChannelCount, + uint32_t leftChannel, + uint32_t rightChannel, + float* leftOutput, + float* rightOutput) { + if (leftOutput == nullptr || rightOutput == nullptr) return false; + const bool valid = buffers != nullptr + && bufferCount > 0 + && format.bitsPerChannel >= 8 + && format.bitsPerChannel % 8 == 0 + && format.encoding != SampleEncoding::Unsupported + && sourceChannelCount > 0 + && leftChannel < sourceChannelCount + && rightChannel < sourceChannelCount; + if (!valid) { + std::fill_n(leftOutput, frameCount, 0.0f); + std::fill_n(rightOutput, frameCount, 0.0f); + return false; + } + + for (size_t frame = 0; frame < frameCount; ++frame) { + leftOutput[frame] = readChannelFrame(buffers, bufferCount, format, leftChannel, frame); + rightOutput[frame] = readChannelFrame(buffers, bufferCount, format, rightChannel, frame); + } + return true; +} + +} // namespace Prism::Capture diff --git a/native/src/capture_channel_selection.h b/native/src/capture_channel_selection.h new file mode 100644 index 0000000..ffeab44 --- /dev/null +++ b/native/src/capture_channel_selection.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include + +namespace Prism::Capture { + +enum class SampleEncoding { + Float, + SignedInteger, + Unsupported, +}; + +struct PCMFormat { + SampleEncoding encoding = SampleEncoding::Unsupported; + uint32_t bitsPerChannel = 0; + bool bigEndian = false; +}; + +struct PCMBufferView { + const uint8_t* data = nullptr; + size_t byteLength = 0; + uint32_t channelCount = 0; +}; + +/** + * Selects a stereo pair from one or more interleaved or planar PCM buffers. + * The output arrays must each have room for frameCount samples. Invalid routes + * are rendered as silence and reported by the return value. + */ +bool selectStereoChannels(const PCMBufferView* buffers, + size_t bufferCount, + const PCMFormat& format, + size_t frameCount, + uint32_t sourceChannelCount, + uint32_t leftChannel, + uint32_t rightChannel, + float* leftOutput, + float* rightOutput); + +} // namespace Prism::Capture diff --git a/native/src/capture_channel_selection_napi.cpp b/native/src/capture_channel_selection_napi.cpp new file mode 100644 index 0000000..a593165 --- /dev/null +++ b/native/src/capture_channel_selection_napi.cpp @@ -0,0 +1,87 @@ +#include "capture_channel_selection_napi.h" + +#include "capture_channel_selection.h" + +#include +#include + +namespace { + +Napi::Value SelectFloat32(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 5 || !info[0].IsArray() || !info[1].IsNumber() + || !info[2].IsNumber() || !info[3].IsNumber() || !info[4].IsNumber()) { + Napi::TypeError::New( + env, + "Expected buffers, frame count, source channel count, left channel, and right channel.") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + + const Napi::Array sourceBuffers = info[0].As(); + std::vector retainedArrays; + std::vector buffers; + retainedArrays.reserve(sourceBuffers.Length()); + buffers.reserve(sourceBuffers.Length()); + for (uint32_t index = 0; index < sourceBuffers.Length(); ++index) { + const Napi::Value value = sourceBuffers.Get(index); + if (value.IsNull() || value.IsUndefined()) { + buffers.push_back({nullptr, 0, 1}); + continue; + } + if (!value.IsObject()) { + Napi::TypeError::New(env, "Each buffer must be an object or null.") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + const Napi::Object entry = value.As(); + const Napi::Value dataValue = entry.Get("data"); + const Napi::Value channelCountValue = entry.Get("channelCount"); + if (!dataValue.IsTypedArray() || !channelCountValue.IsNumber()) { + Napi::TypeError::New(env, "Each buffer requires Float32Array data and channelCount.") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + const Napi::TypedArray typedArray = dataValue.As(); + if (typedArray.TypedArrayType() != napi_float32_array) { + Napi::TypeError::New(env, "Buffer data must be a Float32Array.") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + retainedArrays.push_back(dataValue.As()); + const auto& retained = retainedArrays.back(); + buffers.push_back({ + reinterpret_cast(retained.Data()), + retained.ElementLength() * sizeof(float), + channelCountValue.As().Uint32Value(), + }); + } + + const size_t frameCount = info[1].As().Uint32Value(); + Napi::Float32Array left = Napi::Float32Array::New(env, frameCount); + Napi::Float32Array right = Napi::Float32Array::New(env, frameCount); + const bool valid = Prism::Capture::selectStereoChannels( + buffers.data(), + buffers.size(), + {Prism::Capture::SampleEncoding::Float, 32, false}, + frameCount, + info[2].As().Uint32Value(), + info[3].As().Uint32Value(), + info[4].As().Uint32Value(), + left.Data(), + right.Data()); + + Napi::Object result = Napi::Object::New(env); + result.Set("valid", Napi::Boolean::New(env, valid)); + result.Set("left", left); + result.Set("right", right); + return result; +} + +} // namespace + +void RegisterCaptureChannelSelection(Napi::Env env, Napi::Object exports) { + Napi::Object helper = Napi::Object::New(env); + helper.Set("selectFloat32", Napi::Function::New(env, SelectFloat32)); + exports.Set("captureChannelSelection", helper); +} diff --git a/native/src/capture_channel_selection_napi.h b/native/src/capture_channel_selection_napi.h new file mode 100644 index 0000000..83f3a95 --- /dev/null +++ b/native/src/capture_channel_selection_napi.h @@ -0,0 +1,5 @@ +#pragma once + +#include + +void RegisterCaptureChannelSelection(Napi::Env env, Napi::Object exports); diff --git a/native/src/device_input_capture.h b/native/src/device_input_capture.h new file mode 100644 index 0000000..95605ff --- /dev/null +++ b/native/src/device_input_capture.h @@ -0,0 +1,26 @@ +#pragma once + +#include "system_audio_capture.h" + +#include + +namespace Prism::Capture { + +class DeviceInputCapture { +public: + virtual ~DeviceInputCapture() = default; + + virtual Support getSupport() const = 0; + virtual std::vector listInputDevices() = 0; + virtual bool start(const std::string& requestedDeviceId, + StartResult* result, + std::string* errorMessage) = 0; + virtual ChannelRouting setChannelRouting(uint32_t left, uint32_t right) = 0; + virtual void stop() = 0; + virtual DrainResult drain(size_t maxChunks = 64) = 0; + virtual double nowMilliseconds() const = 0; +}; + +std::unique_ptr createDeviceInputCapture(); + +} // namespace Prism::Capture diff --git a/native/src/device_input_capture_napi.cpp b/native/src/device_input_capture_napi.cpp new file mode 100644 index 0000000..b943504 --- /dev/null +++ b/native/src/device_input_capture_napi.cpp @@ -0,0 +1,179 @@ +#include "device_input_capture_napi.h" + +#include "device_input_capture.h" + +#include +#include +#include +#include + +namespace { + +constexpr size_t kDefaultDrainChunkLimit = 64; +constexpr size_t kMaxDrainChunkLimit = 256; + +std::unique_ptr& activeInputCapture() { + static std::unique_ptr capture = + Prism::Capture::createDeviceInputCapture(); + return capture; +} + +Napi::Object routingToNapi(Napi::Env env, const Prism::Capture::ChannelRouting& routing) { + Napi::Object result = Napi::Object::New(env); + result.Set("left", Napi::Number::New(env, routing.left)); + result.Set("right", Napi::Number::New(env, routing.right)); + return result; +} + +Napi::Value GetSupport(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + const auto support = activeInputCapture()->getSupport(); + Napi::Object result = Napi::Object::New(env); + result.Set("available", Napi::Boolean::New(env, support.available)); + result.Set("reason", support.reason.empty() + ? static_cast(env.Null()) + : static_cast(Napi::String::New(env, support.reason))); + return result; +} + +Napi::Value ListInputDevices(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + const auto devices = activeInputCapture()->listInputDevices(); + Napi::Array result = Napi::Array::New(env, devices.size()); + for (size_t index = 0; index < devices.size(); ++index) { + const auto& device = devices[index]; + Napi::Object entry = Napi::Object::New(env); + entry.Set("id", Napi::String::New(env, device.id)); + entry.Set("label", Napi::String::New(env, device.label)); + entry.Set("kind", Napi::String::New(env, "device")); + entry.Set("isDefault", Napi::Boolean::New(env, device.isDefault)); + entry.Set("sampleRate", Napi::Number::New(env, device.sampleRate)); + entry.Set("channelCount", Napi::Number::New(env, device.channelCount)); + entry.Set("channelRoutingAvailable", Napi::Boolean::New(env, true)); + + Napi::Array channels = Napi::Array::New(env, device.channelCount); + for (uint32_t channelIndex = 0; channelIndex < device.channelCount; ++channelIndex) { + Napi::Object channel = Napi::Object::New(env); + const std::string label = channelIndex < device.channels.size() + && !device.channels[channelIndex].label.empty() + ? device.channels[channelIndex].label + : "Channel " + std::to_string(channelIndex + 1); + channel.Set("index", Napi::Number::New(env, channelIndex)); + channel.Set("label", Napi::String::New(env, label)); + channels.Set(channelIndex, channel); + } + entry.Set("channels", channels); + result.Set(static_cast(index), entry); + } + return result; +} + +Napi::Value Start(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + std::string requestedDeviceId; + if (info.Length() >= 1 && info[0].IsString()) { + requestedDeviceId = info[0].As().Utf8Value(); + } + if (info.Length() >= 2 && info[1].IsObject()) { + const Napi::Object routing = info[1].As(); + if (routing.Has("left") && routing.Has("right") + && routing.Get("left").IsNumber() && routing.Get("right").IsNumber()) { + activeInputCapture()->setChannelRouting( + routing.Get("left").As().Uint32Value(), + routing.Get("right").As().Uint32Value()); + } + } + + Prism::Capture::StartResult started; + std::string errorMessage; + if (!activeInputCapture()->start(requestedDeviceId, &started, &errorMessage)) { + Napi::Error::New( + env, + errorMessage.empty() ? "Device input capture failed to start." : errorMessage) + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + + Napi::Object result = Napi::Object::New(env); + result.Set("sampleRate", Napi::Number::New(env, started.sampleRate)); + result.Set("channelCount", Napi::Number::New(env, started.channelCount)); + result.Set("sourceChannelCount", Napi::Number::New(env, started.sourceChannelCount)); + result.Set("deviceId", Napi::String::New(env, started.deviceId)); + result.Set("deviceLabel", Napi::String::New(env, started.deviceLabel)); + return result; +} + +Napi::Value SetChannelRouting(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsNumber()) { + Napi::TypeError::New(env, "Expected left and right channel indices.") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + return routingToNapi( + env, + activeInputCapture()->setChannelRouting( + info[0].As().Uint32Value(), + info[1].As().Uint32Value())); +} + +Napi::Value Stop(const Napi::CallbackInfo& info) { + activeInputCapture()->stop(); + return info.Env().Undefined(); +} + +Napi::Value Drain(const Napi::CallbackInfo& info) { + size_t maxChunks = kDefaultDrainChunkLimit; + if (info.Length() >= 1 && info[0].IsNumber()) { + const int64_t requested = info[0].As().Int64Value(); + if (requested > 0) { + maxChunks = std::min(static_cast(requested), kMaxDrainChunkLimit); + } + } + + Napi::Env env = info.Env(); + auto drained = activeInputCapture()->drain(maxChunks); + Napi::Array chunks = Napi::Array::New(env, drained.chunks.size()); + for (size_t index = 0; index < drained.chunks.size(); ++index) { + auto& chunk = drained.chunks[index]; + Napi::Object entry = Napi::Object::New(env); + Napi::Float32Array left = Napi::Float32Array::New(env, chunk.left.size()); + Napi::Float32Array right = Napi::Float32Array::New(env, chunk.right.size()); + if (!chunk.left.empty()) { + std::memcpy(left.Data(), chunk.left.data(), chunk.left.size() * sizeof(float)); + } + if (!chunk.right.empty()) { + std::memcpy(right.Data(), chunk.right.data(), chunk.right.size() * sizeof(float)); + } + entry.Set("left", left); + entry.Set("right", right); + entry.Set("channelCount", Napi::Number::New(env, chunk.channelCount)); + entry.Set("capturedAtMilliseconds", Napi::Number::New(env, chunk.capturedAtMilliseconds)); + entry.Set("sequence", Napi::Number::New(env, static_cast(chunk.sequence))); + chunks.Set(static_cast(index), entry); + } + + Napi::Object result = Napi::Object::New(env); + result.Set("chunks", chunks); + result.Set("overwriteCount", Napi::Number::New(env, static_cast(drained.overwriteCount))); + result.Set("queueDepth", Napi::Number::New(env, drained.queueDepth)); + return result; +} + +Napi::Value NowMilliseconds(const Napi::CallbackInfo& info) { + return Napi::Number::New(info.Env(), activeInputCapture()->nowMilliseconds()); +} + +} // namespace + +void RegisterDeviceInputCapture(Napi::Env env, Napi::Object exports) { + Napi::Object capture = Napi::Object::New(env); + capture.Set("getSupport", Napi::Function::New(env, GetSupport)); + capture.Set("listInputDevices", Napi::Function::New(env, ListInputDevices)); + capture.Set("start", Napi::Function::New(env, Start)); + capture.Set("setChannelRouting", Napi::Function::New(env, SetChannelRouting)); + capture.Set("stop", Napi::Function::New(env, Stop)); + capture.Set("drain", Napi::Function::New(env, Drain)); + capture.Set("nowMilliseconds", Napi::Function::New(env, NowMilliseconds)); + exports.Set("deviceInputCapture", capture); +} diff --git a/native/src/device_input_capture_napi.h b/native/src/device_input_capture_napi.h new file mode 100644 index 0000000..eda1801 --- /dev/null +++ b/native/src/device_input_capture_napi.h @@ -0,0 +1,5 @@ +#pragma once + +#include + +void RegisterDeviceInputCapture(Napi::Env env, Napi::Object exports); diff --git a/native/src/device_input_capture_stub.cpp b/native/src/device_input_capture_stub.cpp new file mode 100644 index 0000000..a4755d2 --- /dev/null +++ b/native/src/device_input_capture_stub.cpp @@ -0,0 +1,31 @@ +#include "device_input_capture.h" + +#if !defined(__APPLE__) + +namespace Prism::Capture { + +class UnavailableDeviceInputCapture final : public DeviceInputCapture { +public: + Support getSupport() const override { + return {false, "Native multichannel device-input capture is currently available on macOS only."}; + } + std::vector listInputDevices() override { return {}; } + bool start(const std::string&, StartResult*, std::string* errorMessage) override { + if (errorMessage != nullptr) *errorMessage = getSupport().reason; + return false; + } + ChannelRouting setChannelRouting(uint32_t left, uint32_t right) override { + return {left, right}; + } + void stop() override {} + DrainResult drain(size_t) override { return {}; } + double nowMilliseconds() const override { return 0.0; } +}; + +std::unique_ptr createDeviceInputCapture() { + return std::make_unique(); +} + +} // namespace Prism::Capture + +#endif diff --git a/native/src/macos_capture.mm b/native/src/macos_capture.mm index f5c6403..8c19dc1 100644 --- a/native/src/macos_capture.mm +++ b/native/src/macos_capture.mm @@ -1,4 +1,5 @@ #include "system_audio_capture.h" +#include "capture_channel_selection.h" #if defined(__APPLE__) @@ -8,9 +9,10 @@ #import #include +#include +#include #include #include -#include #include #include #include @@ -29,8 +31,37 @@ double sampleRate; UInt32 channelCount; bool isDefault; + std::vector channels; }; +std::string cfStringToStdString(CFStringRef value); + +std::vector getChannelDescriptors( + AudioDeviceID deviceId, + AudioObjectPropertyScope scope, + UInt32 channelCount) { + std::vector channels; + channels.reserve(channelCount); + for (UInt32 index = 0; index < channelCount; ++index) { + std::string label = "Channel " + std::to_string(index + 1); + AudioObjectPropertyAddress address{ + kAudioObjectPropertyElementName, + scope, + index + 1, + }; + CFStringRef value = nullptr; + UInt32 size = sizeof(value); + if (AudioObjectGetPropertyData(deviceId, &address, 0, nullptr, &size, &value) == noErr + && value != nullptr) { + const std::string resolved = cfStringToStdString(value); + CFRelease(value); + if (!resolved.empty()) label = resolved; + } + channels.push_back({static_cast(index), label}); + } + return channels; +} + struct CapturedChunk { std::vector left; std::vector right; @@ -225,6 +256,10 @@ UInt32 getOutputChannelCount(AudioDeviceID deviceId) { sampleRate, channelCount, deviceId == defaultDeviceId, + getChannelDescriptors( + deviceId, + kAudioDevicePropertyScopeOutput, + channelCount), }); } @@ -246,75 +281,6 @@ bool getTapFormat(AudioObjectID tapId, AudioStreamBasicDescription* outFormat) { return AudioObjectGetPropertyData(tapId, &address, 0, nullptr, &size, outFormat) == noErr; } -float decodeSignedIntegerSample(const uint8_t* data, UInt32 bytesPerSample, bool isBigEndian) { - if (data == nullptr || bytesPerSample == 0 || bytesPerSample > 4) { - return 0.0f; - } - - int32_t rawValue = 0; - if (isBigEndian) { - for (UInt32 byteIndex = 0; byteIndex < bytesPerSample; ++byteIndex) { - rawValue = (rawValue << 8) | data[byteIndex]; - } - } else { - for (UInt32 byteIndex = 0; byteIndex < bytesPerSample; ++byteIndex) { - rawValue |= static_cast(data[byteIndex]) << (byteIndex * 8); - } - } - - const UInt32 totalBits = bytesPerSample * 8; - const int32_t signMask = 1 << (totalBits - 1); - if ((rawValue & signMask) != 0) { - rawValue |= ~((1 << totalBits) - 1); - } - - const double maxMagnitude = static_cast((1u << (totalBits - 1)) - 1u); - if (maxMagnitude <= 0.0) { - return 0.0f; - } - - return static_cast(static_cast(rawValue) / maxMagnitude); -} - -float readSampleFromFormat(const uint8_t* data, - const AudioStreamBasicDescription& format, - UInt32 sampleIndex) { - if (data == nullptr) { - return 0.0f; - } - - const UInt32 bytesPerChannel = format.mBitsPerChannel / 8; - if (bytesPerChannel == 0) { - return 0.0f; - } - - const uint8_t* samplePtr = data + static_cast(sampleIndex) * bytesPerChannel; - const bool isFloat = (format.mFormatFlags & kAudioFormatFlagIsFloat) != 0; - const bool isBigEndian = (format.mFormatFlags & kAudioFormatFlagIsBigEndian) != 0; - - if (isFloat && format.mBitsPerChannel == 32) { - Float32 value = 0.0f; - std::memcpy(&value, samplePtr, sizeof(Float32)); - return value; - } - - if (isFloat && format.mBitsPerChannel == 64) { - Float64 value = 0.0; - std::memcpy(&value, samplePtr, sizeof(Float64)); - return static_cast(value); - } - - if ((format.mFormatFlags & kAudioFormatFlagIsSignedInteger) != 0) { - return decodeSignedIntegerSample(samplePtr, bytesPerChannel, isBigEndian); - } - - return 0.0f; -} - -bool isFormatInterleaved(const AudioStreamBasicDescription& format) { - return (format.mFormatFlags & kAudioFormatFlagIsNonInterleaved) == 0; -} - std::string formatStatusMessage(const char* operation, OSStatus status) { return std::string(operation) + " failed (" + std::to_string(static_cast(status)) + ")"; } @@ -346,6 +312,7 @@ bool isFormatInterleaved(const AudioStreamBasicDescription& format) { device.sampleRate, static_cast(device.channelCount), device.isDefault, + device.channels, }); } return result; @@ -367,13 +334,28 @@ bool start(const std::string& requestedDeviceId, if (result != nullptr) { std::lock_guard lock(stateMutex_); result->sampleRate = sampleRate_; - result->channelCount = static_cast(channelCount_); + const UInt32 sourceChannelCount = channelCount_.load(std::memory_order_relaxed); + result->channelCount = sourceChannelCount > 1 ? 2u : 1u; + result->sourceChannelCount = static_cast(sourceChannelCount); result->deviceId = activeDeviceUid_; result->deviceLabel = activeDeviceLabel_; } return true; } + Prism::Capture::ChannelRouting setChannelRouting( + uint32_t left, + uint32_t right) override { + const UInt32 count = channelCount_.load(std::memory_order_relaxed); + if (active_.load(std::memory_order_acquire) && count > 0) { + left = std::min(left, count - 1); + right = std::min(right, count - 1); + } + routeLeft_.store(left, std::memory_order_release); + routeRight_.store(right, std::memory_order_release); + return {left, right}; + } + void stop() override { std::lock_guard lock(stateMutex_); stopLocked(); @@ -439,68 +421,69 @@ OSStatus handleIO(AudioObjectID, const AudioTimeStamp*, AudioBufferList*, const AudioTimeStamp*) { - AudioStreamBasicDescription format{}; - UInt32 channelCount = 0; - - { - std::lock_guard stateLock(stateMutex_); - if (!active_ || inputData == nullptr) { - return noErr; - } - format = tapFormat_; - channelCount = channelCount_; - } + if (!active_.load(std::memory_order_acquire) || inputData == nullptr) return noErr; + // tapFormat_ is configured before active_ is published and is not changed until + // AudioDeviceStop has joined the callback, so the realtime path needs no state lock. + const AudioStreamBasicDescription format = tapFormat_; + const UInt32 channelCount = channelCount_.load(std::memory_order_relaxed); if (inputData->mNumberBuffers == 0 || format.mBytesPerFrame == 0) { return noErr; } - const bool interleaved = isFormatInterleaved(format); const AudioBuffer& firstBuffer = inputData->mBuffers[0]; - const UInt32 frames = - format.mBytesPerFrame == 0 ? 0 : firstBuffer.mDataByteSize / format.mBytesPerFrame; + const UInt32 bytesPerChannel = std::max(1, format.mBitsPerChannel / 8); + const UInt32 firstBufferChannels = std::max(1, firstBuffer.mNumberChannels); + const UInt32 frames = firstBuffer.mDataByteSize / + std::max(1, bytesPerChannel * firstBufferChannels); if (frames == 0) { return noErr; } CapturedChunk chunk; - chunk.channelCount = channelCount; + chunk.channelCount = channelCount > 1 ? 2 : 1; chunk.capturedAtMilliseconds = monotonicMilliseconds(); - { - std::lock_guard stateLock(stateMutex_); - chunk.sequence = ++sequence_; - } + chunk.sequence = sequence_.fetch_add(1, std::memory_order_relaxed) + 1; chunk.left.resize(frames); chunk.right.resize(frames); - if (interleaved) { - const uint8_t* rawData = static_cast(firstBuffer.mData); - for (UInt32 frameIndex = 0; frameIndex < frames; ++frameIndex) { - const UInt32 sampleBaseIndex = frameIndex * std::max(1, channelCount); - const float leftSample = - readSampleFromFormat(rawData, format, sampleBaseIndex); - const float rightSample = channelCount > 1 - ? readSampleFromFormat(rawData, format, sampleBaseIndex + 1) - : leftSample; - chunk.left[frameIndex] = leftSample; - chunk.right[frameIndex] = rightSample; - } - } else { - const uint8_t* leftData = static_cast(inputData->mBuffers[0].mData); - const uint8_t* rightData = static_cast( - inputData->mNumberBuffers > 1 ? inputData->mBuffers[1].mData - : inputData->mBuffers[0].mData); - - for (UInt32 frameIndex = 0; frameIndex < frames; ++frameIndex) { - chunk.left[frameIndex] = - readSampleFromFormat(leftData, format, frameIndex); - chunk.right[frameIndex] = inputData->mNumberBuffers > 1 - ? readSampleFromFormat(rightData, format, frameIndex) - : chunk.left[frameIndex]; - } - } + const UInt32 leftChannel = std::min( + routeLeft_.load(std::memory_order_acquire), channelCount - 1); + const UInt32 rightChannel = std::min( + routeRight_.load(std::memory_order_acquire), channelCount - 1); + constexpr size_t kMaximumAudioBuffers = 128; + if (inputData->mNumberBuffers > kMaximumAudioBuffers) return noErr; + std::array buffers{}; + for (UInt32 bufferIndex = 0; bufferIndex < inputData->mNumberBuffers; ++bufferIndex) { + const AudioBuffer& buffer = inputData->mBuffers[bufferIndex]; + buffers[bufferIndex] = { + static_cast(buffer.mData), + buffer.mDataByteSize, + buffer.mNumberChannels, + }; + } + const Prism::Capture::SampleEncoding encoding = + (format.mFormatFlags & kAudioFormatFlagIsFloat) != 0 + ? Prism::Capture::SampleEncoding::Float + : (format.mFormatFlags & kAudioFormatFlagIsSignedInteger) != 0 + ? Prism::Capture::SampleEncoding::SignedInteger + : Prism::Capture::SampleEncoding::Unsupported; + Prism::Capture::selectStereoChannels( + buffers.data(), + inputData->mNumberBuffers, + { + encoding, + format.mBitsPerChannel, + (format.mFormatFlags & kAudioFormatFlagIsBigEndian) != 0, + }, + frames, + channelCount, + leftChannel, + rightChannel, + chunk.left.data(), + chunk.right.data()); { std::lock_guard queueLock(chunkMutex_); @@ -675,19 +658,28 @@ bool startInternal(const std::string& requestedDeviceUid, std::string* outErrorM overwriteCount_ = 0; } - active_ = true; + channelCount_.store( + std::max(1, tapFormat_.mChannelsPerFrame > 0 + ? tapFormat_.mChannelsPerFrame + : selected->channelCount), + std::memory_order_relaxed); + const UInt32 resolvedChannelCount = channelCount_.load(std::memory_order_relaxed); + routeLeft_.store( + std::min(routeLeft_.load(std::memory_order_relaxed), resolvedChannelCount - 1), + std::memory_order_relaxed); + routeRight_.store( + std::min(routeRight_.load(std::memory_order_relaxed), resolvedChannelCount - 1), + std::memory_order_relaxed); + active_.store(true, std::memory_order_release); activeDeviceUid_ = selected->uid; activeDeviceLabel_ = selected->label; sampleRate_ = tapFormat_.mSampleRate > 0 ? tapFormat_.mSampleRate : selected->sampleRate; - channelCount_ = - std::max(1, tapFormat_.mChannelsPerFrame > 0 ? tapFormat_.mChannelsPerFrame - : selected->channelCount); - sequence_ = 0; + sequence_.store(0, std::memory_order_relaxed); return true; } void stopLocked() { - active_ = false; + active_.store(false, std::memory_order_release); if (aggregateDeviceId_ != kUnknownObject && ioProcId_ != nullptr) { AudioDeviceStop(aggregateDeviceId_, ioProcId_); @@ -711,8 +703,8 @@ void stopLocked() { activeDeviceUid_.clear(); activeDeviceLabel_.clear(); sampleRate_ = 48000.0; - channelCount_ = 2; - sequence_ = 0; + channelCount_.store(2, std::memory_order_relaxed); + sequence_.store(0, std::memory_order_relaxed); std::lock_guard queueLock(chunkMutex_); chunkQueue_.clear(); @@ -730,18 +722,20 @@ bool isSupported() const { std::mutex chunkMutex_; std::deque chunkQueue_; uint64_t overwriteCount_ = 0; - uint64_t sequence_ = 0; + std::atomic sequence_{0}; AudioObjectID tapId_ = kUnknownObject; AudioObjectID aggregateDeviceId_ = kUnknownObject; AudioDeviceIOProcID ioProcId_ = nullptr; AudioStreamBasicDescription tapFormat_{}; - bool active_ = false; + std::atomic active_{false}; std::string activeDeviceUid_; std::string activeDeviceLabel_; double sampleRate_ = 48000.0; - UInt32 channelCount_ = 2; + std::atomic channelCount_{2}; + std::atomic routeLeft_{0}; + std::atomic routeRight_{1}; }; } // namespace diff --git a/native/src/macos_input_capture.mm b/native/src/macos_input_capture.mm new file mode 100644 index 0000000..76e312c --- /dev/null +++ b/native/src/macos_input_capture.mm @@ -0,0 +1,542 @@ +#include "device_input_capture.h" + +#if defined(__APPLE__) + +#import +#import +#import + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr size_t kMaxQueuedChunks = 256; +constexpr size_t kDefaultDrainChunkLimit = 64; +constexpr UInt32 kFallbackMaximumFramesPerSlice = 4096; +constexpr AudioObjectID kUnknownObject = kAudioObjectUnknown; + +double monotonicMilliseconds() { + const auto now = std::chrono::steady_clock::now().time_since_epoch(); + return std::chrono::duration(now).count(); +} + +std::string cfStringToStdString(CFStringRef value) { + if (value == nullptr) return {}; + const CFIndex length = CFStringGetLength(value); + const CFIndex maxBytes = + CFStringGetMaximumSizeForEncoding(length, kCFStringEncodingUTF8) + 1; + std::vector buffer(static_cast(std::max(1, maxBytes)), '\0'); + if (!CFStringGetCString(value, buffer.data(), maxBytes, kCFStringEncodingUTF8)) return {}; + return std::string(buffer.data()); +} + +template +bool getPropertyData(AudioObjectID objectId, + AudioObjectPropertySelector selector, + AudioObjectPropertyScope scope, + AudioObjectPropertyElement element, + T* outValue) { + if (outValue == nullptr) return false; + AudioObjectPropertyAddress address{selector, scope, element}; + UInt32 size = sizeof(T); + return AudioObjectGetPropertyData(objectId, &address, 0, nullptr, &size, outValue) == noErr; +} + +bool getDeviceStringProperty(AudioDeviceID deviceId, + AudioObjectPropertySelector selector, + std::string* outValue) { + if (outValue == nullptr) return false; + AudioObjectPropertyAddress address{ + selector, + kAudioObjectPropertyScopeGlobal, + kAudioObjectPropertyElementMain, + }; + CFStringRef value = nullptr; + UInt32 size = sizeof(value); + if (AudioObjectGetPropertyData(deviceId, &address, 0, nullptr, &size, &value) != noErr + || value == nullptr) { + return false; + } + *outValue = cfStringToStdString(value); + CFRelease(value); + return !outValue->empty(); +} + +AudioDeviceID getDefaultInputDeviceId() { + AudioDeviceID deviceId = kUnknownObject; + getPropertyData( + kAudioObjectSystemObject, + kAudioHardwarePropertyDefaultInputDevice, + kAudioObjectPropertyScopeGlobal, + kAudioObjectPropertyElementMain, + &deviceId); + return deviceId; +} + +UInt32 getInputChannelCount(AudioDeviceID deviceId) { + AudioObjectPropertyAddress address{ + kAudioDevicePropertyStreamConfiguration, + kAudioDevicePropertyScopeInput, + kAudioObjectPropertyElementMain, + }; + UInt32 size = 0; + if (AudioObjectGetPropertyDataSize(deviceId, &address, 0, nullptr, &size) != noErr + || size == 0) { + return 0; + } + std::vector storage(size); + auto* bufferList = reinterpret_cast(storage.data()); + if (AudioObjectGetPropertyData(deviceId, &address, 0, nullptr, &size, bufferList) != noErr) { + return 0; + } + UInt32 count = 0; + for (UInt32 index = 0; index < bufferList->mNumberBuffers; ++index) { + count += bufferList->mBuffers[index].mNumberChannels; + } + return count; +} + +double getDeviceSampleRate(AudioDeviceID deviceId) { + Float64 sampleRate = 48000.0; + if (!getPropertyData( + deviceId, + kAudioDevicePropertyNominalSampleRate, + kAudioObjectPropertyScopeGlobal, + kAudioObjectPropertyElementMain, + &sampleRate) + || sampleRate <= 0.0) { + return 48000.0; + } + return static_cast(sampleRate); +} + +std::vector getInputChannelDescriptors( + AudioDeviceID deviceId, + UInt32 channelCount) { + std::vector channels; + channels.reserve(channelCount); + for (UInt32 index = 0; index < channelCount; ++index) { + std::string label = "Channel " + std::to_string(index + 1); + AudioObjectPropertyAddress address{ + kAudioObjectPropertyElementName, + kAudioDevicePropertyScopeInput, + index + 1, + }; + CFStringRef value = nullptr; + UInt32 size = sizeof(value); + if (AudioObjectGetPropertyData(deviceId, &address, 0, nullptr, &size, &value) == noErr + && value != nullptr) { + const std::string resolved = cfStringToStdString(value); + CFRelease(value); + if (!resolved.empty()) label = resolved; + } + channels.push_back({static_cast(index), label}); + } + return channels; +} + +struct InputDeviceInfo { + AudioDeviceID objectId = kUnknownObject; + Prism::Capture::OutputDevice descriptor; +}; + +std::vector enumerateInputDevices() { + AudioObjectPropertyAddress address{ + kAudioHardwarePropertyDevices, + kAudioObjectPropertyScopeGlobal, + kAudioObjectPropertyElementMain, + }; + UInt32 size = 0; + if (AudioObjectGetPropertyDataSize(kAudioObjectSystemObject, &address, 0, nullptr, &size) != noErr + || size == 0) { + return {}; + } + std::vector ids(size / sizeof(AudioDeviceID), kUnknownObject); + if (AudioObjectGetPropertyData( + kAudioObjectSystemObject, &address, 0, nullptr, &size, ids.data()) != noErr) { + return {}; + } + + const AudioDeviceID defaultId = getDefaultInputDeviceId(); + std::vector devices; + for (const AudioDeviceID id : ids) { + const UInt32 channelCount = getInputChannelCount(id); + if (channelCount == 0) continue; + + std::string uid; + if (!getDeviceStringProperty(id, kAudioDevicePropertyDeviceUID, &uid)) continue; + std::string label; + if (!getDeviceStringProperty(id, kAudioObjectPropertyName, &label)) label = uid; + + devices.push_back({ + id, + { + uid, + label, + getDeviceSampleRate(id), + static_cast(channelCount), + id == defaultId, + getInputChannelDescriptors(id, channelCount), + }, + }); + } + return devices; +} + +std::string formatStatusMessage(const char* operation, OSStatus status) { + return std::string(operation) + " failed (" + std::to_string(static_cast(status)) + ")"; +} + +class MacOSDeviceInputCapture final : public Prism::Capture::DeviceInputCapture { +public: + ~MacOSDeviceInputCapture() override { stop(); } + + Prism::Capture::Support getSupport() const override { return {true, {}}; } + + std::vector listInputDevices() override { + const auto devices = enumerateInputDevices(); + std::vector result; + result.reserve(devices.size()); + for (const auto& device : devices) result.push_back(device.descriptor); + return result; + } + + bool start(const std::string& requestedDeviceId, + Prism::Capture::StartResult* result, + std::string* errorMessage) override { + std::lock_guard lock(stateMutex_); + stopLocked(); + + const auto devices = enumerateInputDevices(); + const InputDeviceInfo* selected = nullptr; + if (!requestedDeviceId.empty()) { + for (const auto& device : devices) { + if (device.descriptor.id == requestedDeviceId) { + selected = &device; + break; + } + } + if (selected == nullptr) { + if (errorMessage != nullptr) { + *errorMessage = "The selected macOS input device is no longer available."; + } + return false; + } + } else { + for (const auto& device : devices) { + if (device.descriptor.isDefault) { + selected = &device; + break; + } + } + if (selected == nullptr && !devices.empty()) selected = &devices.front(); + } + if (selected == nullptr) { + if (errorMessage != nullptr) *errorMessage = "No macOS input devices are available."; + return false; + } + + AudioComponentDescription description{}; + description.componentType = kAudioUnitType_Output; + description.componentSubType = kAudioUnitSubType_HALOutput; + description.componentManufacturer = kAudioUnitManufacturer_Apple; + const AudioComponent component = AudioComponentFindNext(nullptr, &description); + if (component == nullptr + || AudioComponentInstanceNew(component, &audioUnit_) != noErr + || audioUnit_ == nullptr) { + if (errorMessage != nullptr) *errorMessage = "Could not create the Core Audio input unit."; + stopLocked(); + return false; + } + + UInt32 enabled = 1; + OSStatus status = AudioUnitSetProperty( + audioUnit_, + kAudioOutputUnitProperty_EnableIO, + kAudioUnitScope_Input, + 1, + &enabled, + sizeof(enabled)); + if (status == noErr) { + enabled = 0; + status = AudioUnitSetProperty( + audioUnit_, + kAudioOutputUnitProperty_EnableIO, + kAudioUnitScope_Output, + 0, + &enabled, + sizeof(enabled)); + } + if (status == noErr) { + AudioDeviceID deviceId = selected->objectId; + status = AudioUnitSetProperty( + audioUnit_, + kAudioOutputUnitProperty_CurrentDevice, + kAudioUnitScope_Global, + 0, + &deviceId, + sizeof(deviceId)); + } + + sourceChannelCount_ = std::max(1, selected->descriptor.channelCount); + sampleRate_ = selected->descriptor.sampleRate; + clientFormat_ = AudioStreamBasicDescription{}; + clientFormat_.mSampleRate = sampleRate_; + clientFormat_.mFormatID = kAudioFormatLinearPCM; + clientFormat_.mFormatFlags = kAudioFormatFlagIsFloat + | kAudioFormatFlagIsPacked + | kAudioFormatFlagIsNonInterleaved + | kAudioFormatFlagsNativeEndian; + clientFormat_.mBytesPerPacket = sizeof(Float32); + clientFormat_.mFramesPerPacket = 1; + clientFormat_.mBytesPerFrame = sizeof(Float32); + clientFormat_.mChannelsPerFrame = sourceChannelCount_; + clientFormat_.mBitsPerChannel = 8 * sizeof(Float32); + + if (status == noErr) { + status = AudioUnitSetProperty( + audioUnit_, + kAudioUnitProperty_StreamFormat, + kAudioUnitScope_Output, + 1, + &clientFormat_, + sizeof(clientFormat_)); + } + + AURenderCallbackStruct callback{}; + callback.inputProc = StaticInputCallback; + callback.inputProcRefCon = this; + if (status == noErr) { + status = AudioUnitSetProperty( + audioUnit_, + kAudioOutputUnitProperty_SetInputCallback, + kAudioUnitScope_Global, + 0, + &callback, + sizeof(callback)); + } + if (status == noErr) status = AudioUnitInitialize(audioUnit_); + if (status != noErr) { + if (errorMessage != nullptr) { + *errorMessage = formatStatusMessage("Configuring Core Audio input", status); + } + stopLocked(); + return false; + } + + maximumFramesPerSlice_ = kFallbackMaximumFramesPerSlice; + UInt32 propertySize = sizeof(maximumFramesPerSlice_); + AudioUnitGetProperty( + audioUnit_, + kAudioUnitProperty_MaximumFramesPerSlice, + kAudioUnitScope_Global, + 0, + &maximumFramesPerSlice_, + &propertySize); + maximumFramesPerSlice_ = std::max(1, maximumFramesPerSlice_); + prepareRenderBuffers(); + + activeDeviceId_ = selected->descriptor.id; + activeDeviceLabel_ = selected->descriptor.label; + routeLeft_.store( + std::min(routeLeft_.load(std::memory_order_relaxed), sourceChannelCount_ - 1), + std::memory_order_relaxed); + routeRight_.store( + std::min(routeRight_.load(std::memory_order_relaxed), sourceChannelCount_ - 1), + std::memory_order_relaxed); + sequence_.store(0, std::memory_order_relaxed); + { + std::lock_guard queueLock(queueMutex_); + queue_.clear(); + overwriteCount_ = 0; + } + + active_.store(true, std::memory_order_release); + status = AudioOutputUnitStart(audioUnit_); + if (status != noErr) { + active_.store(false, std::memory_order_release); + if (errorMessage != nullptr) { + *errorMessage = formatStatusMessage("AudioOutputUnitStart", status); + } + stopLocked(); + return false; + } + + if (result != nullptr) { + result->sampleRate = sampleRate_; + result->channelCount = sourceChannelCount_ > 1 ? 2u : 1u; + result->sourceChannelCount = sourceChannelCount_; + result->deviceId = activeDeviceId_; + result->deviceLabel = activeDeviceLabel_; + } + return true; + } + + Prism::Capture::ChannelRouting setChannelRouting( + uint32_t left, + uint32_t right) override { + if (active_.load(std::memory_order_acquire) && sourceChannelCount_ > 0) { + left = std::min(left, sourceChannelCount_ - 1); + right = std::min(right, sourceChannelCount_ - 1); + } + routeLeft_.store(left, std::memory_order_release); + routeRight_.store(right, std::memory_order_release); + return {left, right}; + } + + void stop() override { + std::lock_guard lock(stateMutex_); + stopLocked(); + } + + Prism::Capture::DrainResult drain(size_t maxChunks) override { + const size_t limit = maxChunks == 0 + ? kDefaultDrainChunkLimit + : std::min(maxChunks, kMaxQueuedChunks); + Prism::Capture::DrainResult result; + std::lock_guard lock(queueMutex_); + result.overwriteCount = overwriteCount_; + const size_t count = std::min(limit, queue_.size()); + result.chunks.reserve(count); + for (size_t index = 0; index < count; ++index) { + result.chunks.push_back(std::move(queue_.front())); + queue_.pop_front(); + } + result.queueDepth = queue_.size(); + return result; + } + + double nowMilliseconds() const override { return monotonicMilliseconds(); } + +private: + static OSStatus StaticInputCallback(void* refCon, + AudioUnitRenderActionFlags* actionFlags, + const AudioTimeStamp* timestamp, + UInt32, + UInt32 frameCount, + AudioBufferList*) { + auto* self = static_cast(refCon); + return self == nullptr + ? noErr + : self->captureInput(actionFlags, timestamp, frameCount); + } + + OSStatus captureInput(AudioUnitRenderActionFlags* actionFlags, + const AudioTimeStamp* timestamp, + UInt32 frameCount) { + if (!active_.load(std::memory_order_acquire) || audioUnit_ == nullptr) return noErr; + if (frameCount == 0 || frameCount > maximumFramesPerSlice_ || renderBufferList_ == nullptr) { + return noErr; + } + + for (UInt32 index = 0; index < sourceChannelCount_; ++index) { + renderBufferList_->mBuffers[index].mDataByteSize = frameCount * sizeof(Float32); + } + const OSStatus status = AudioUnitRender( + audioUnit_, actionFlags, timestamp, 1, frameCount, renderBufferList_); + if (status != noErr) return status; + + const uint32_t leftChannel = std::min( + routeLeft_.load(std::memory_order_acquire), sourceChannelCount_ - 1); + const uint32_t rightChannel = std::min( + routeRight_.load(std::memory_order_acquire), sourceChannelCount_ - 1); + const auto* left = static_cast(renderBufferList_->mBuffers[leftChannel].mData); + const auto* right = static_cast(renderBufferList_->mBuffers[rightChannel].mData); + + Prism::Capture::AudioChunk chunk; + chunk.left.assign(left, left + frameCount); + chunk.right.assign(right, right + frameCount); + chunk.channelCount = sourceChannelCount_ > 1 ? 2u : 1u; + chunk.capturedAtMilliseconds = monotonicMilliseconds(); + chunk.sequence = sequence_.fetch_add(1, std::memory_order_relaxed) + 1; + + std::lock_guard lock(queueMutex_); + if (queue_.size() >= kMaxQueuedChunks) { + queue_.pop_front(); + ++overwriteCount_; + } + queue_.push_back(std::move(chunk)); + return noErr; + } + + void prepareRenderBuffers() { + const size_t listBytes = offsetof(AudioBufferList, mBuffers) + + sizeof(AudioBuffer) * sourceChannelCount_; + renderBufferListStorage_.assign(listBytes, 0); + renderBufferList_ = reinterpret_cast(renderBufferListStorage_.data()); + renderBufferList_->mNumberBuffers = sourceChannelCount_; + renderSampleStorage_.assign( + static_cast(sourceChannelCount_) * maximumFramesPerSlice_, + 0.0f); + for (UInt32 index = 0; index < sourceChannelCount_; ++index) { + auto& buffer = renderBufferList_->mBuffers[index]; + buffer.mNumberChannels = 1; + buffer.mDataByteSize = maximumFramesPerSlice_ * sizeof(Float32); + buffer.mData = renderSampleStorage_.data() + + static_cast(index) * maximumFramesPerSlice_; + } + } + + void stopLocked() { + active_.store(false, std::memory_order_release); + if (audioUnit_ != nullptr) { + AudioOutputUnitStop(audioUnit_); + AudioUnitUninitialize(audioUnit_); + AudioComponentInstanceDispose(audioUnit_); + audioUnit_ = nullptr; + } + renderBufferList_ = nullptr; + renderBufferListStorage_.clear(); + renderSampleStorage_.clear(); + activeDeviceId_.clear(); + activeDeviceLabel_.clear(); + sampleRate_ = 48000.0; + sourceChannelCount_ = 2; + maximumFramesPerSlice_ = kFallbackMaximumFramesPerSlice; + sequence_.store(0, std::memory_order_relaxed); + std::lock_guard queueLock(queueMutex_); + queue_.clear(); + overwriteCount_ = 0; + } + + mutable std::mutex stateMutex_; + std::mutex queueMutex_; + std::deque queue_; + uint64_t overwriteCount_ = 0; + std::atomic sequence_{0}; + std::atomic active_{false}; + std::atomic routeLeft_{0}; + std::atomic routeRight_{1}; + + AudioUnit audioUnit_ = nullptr; + AudioStreamBasicDescription clientFormat_{}; + std::vector renderBufferListStorage_; + std::vector renderSampleStorage_; + AudioBufferList* renderBufferList_ = nullptr; + UInt32 maximumFramesPerSlice_ = kFallbackMaximumFramesPerSlice; + uint32_t sourceChannelCount_ = 2; + double sampleRate_ = 48000.0; + std::string activeDeviceId_; + std::string activeDeviceLabel_; +}; + +} // namespace + +namespace Prism::Capture { + +std::unique_ptr createDeviceInputCapture() { + return std::make_unique(); +} + +} // namespace Prism::Capture + +#endif diff --git a/native/src/main.cpp b/native/src/main.cpp index ad21f93..e5ce56b 100644 --- a/native/src/main.cpp +++ b/native/src/main.cpp @@ -3,6 +3,8 @@ #include #include #include "system_audio_capture_napi.h" +#include "device_input_capture_napi.h" +#include "capture_channel_selection_napi.h" #include "windows_capture.h" #include "oscilloscope.h" #include "spectrum.h" @@ -787,6 +789,8 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) { exports.Set("lufsmeter", lufsExports); RegisterSystemAudioCapture(env, exports); + RegisterDeviceInputCapture(env, exports); + RegisterCaptureChannelSelection(env, exports); RegisterWindowsMedia(env, exports); RegisterWindowChrome(env, exports); diff --git a/native/src/system_audio_capture.h b/native/src/system_audio_capture.h index a1bd75c..5dcc5fc 100644 --- a/native/src/system_audio_capture.h +++ b/native/src/system_audio_capture.h @@ -13,17 +13,29 @@ struct Support { std::string reason; }; +struct ChannelDescriptor { + uint32_t index = 0; + std::string label; +}; + +struct ChannelRouting { + uint32_t left = 0; + uint32_t right = 1; +}; + struct OutputDevice { std::string id; std::string label; double sampleRate = 48000.0; uint32_t channelCount = 2; bool isDefault = false; + std::vector channels; }; struct StartResult { double sampleRate = 48000.0; uint32_t channelCount = 2; + uint32_t sourceChannelCount = 2; std::string deviceId; std::string deviceLabel; }; @@ -51,6 +63,9 @@ class SystemAudioCapture { virtual bool start(const std::string& requestedDeviceId, StartResult* result, std::string* errorMessage) = 0; + virtual ChannelRouting setChannelRouting(uint32_t left, uint32_t right) { + return {left, right}; + } virtual void stop() = 0; virtual DrainResult drain(size_t maxChunks = 64) = 0; virtual double nowMilliseconds() const = 0; diff --git a/native/src/system_audio_capture_napi.cpp b/native/src/system_audio_capture_napi.cpp index c3bcb95..744770a 100644 --- a/native/src/system_audio_capture_napi.cpp +++ b/native/src/system_audio_capture_napi.cpp @@ -39,6 +39,33 @@ Napi::Object supportToNapi(Napi::Env env, const Prism::Capture::Support& support return result; } +Napi::Array channelsToNapi( + Napi::Env env, + const std::vector& channels, + uint32_t fallbackCount) { + const uint32_t count = channels.empty() + ? fallbackCount + : static_cast(channels.size()); + Napi::Array result = Napi::Array::New(env, count); + for (uint32_t index = 0; index < count; ++index) { + Napi::Object channel = Napi::Object::New(env); + const std::string label = index < channels.size() && !channels[index].label.empty() + ? channels[index].label + : "Channel " + std::to_string(index + 1); + channel.Set("index", Napi::Number::New(env, index)); + channel.Set("label", Napi::String::New(env, label)); + result.Set(index, channel); + } + return result; +} + +Napi::Object routingToNapi(Napi::Env env, const Prism::Capture::ChannelRouting& routing) { + Napi::Object result = Napi::Object::New(env); + result.Set("left", Napi::Number::New(env, routing.left)); + result.Set("right", Napi::Number::New(env, routing.right)); + return result; +} + Napi::Object drainToNapi(Napi::Env env, Prism::Capture::DrainResult&& drained) { Napi::Array chunks = Napi::Array::New(env, drained.chunks.size()); for (size_t index = 0; index < drained.chunks.size(); ++index) { @@ -84,6 +111,12 @@ Napi::Value ListOutputDevices(const Napi::CallbackInfo& info) { entry.Set("isDefault", Napi::Boolean::New(env, device.isDefault)); entry.Set("sampleRate", Napi::Number::New(env, device.sampleRate)); entry.Set("channelCount", Napi::Number::New(env, device.channelCount)); + entry.Set("channels", channelsToNapi(env, device.channels, device.channelCount)); +#if defined(__APPLE__) + entry.Set("channelRoutingAvailable", Napi::Boolean::New(env, true)); +#else + entry.Set("channelRoutingAvailable", Napi::Boolean::New(env, false)); +#endif result.Set(static_cast(index), entry); } return result; @@ -96,6 +129,16 @@ Napi::Value Start(const Napi::CallbackInfo& info) { requestedDeviceId = info[0].As().Utf8Value(); } + if (info.Length() >= 2 && info[1].IsObject()) { + const Napi::Object routing = info[1].As(); + if (routing.Has("left") && routing.Has("right") + && routing.Get("left").IsNumber() && routing.Get("right").IsNumber()) { + activeCapture()->setChannelRouting( + routing.Get("left").As().Uint32Value(), + routing.Get("right").As().Uint32Value()); + } + } + Prism::Capture::StartResult started; std::string errorMessage; if (!activeCapture()->start(requestedDeviceId, &started, &errorMessage)) { @@ -107,11 +150,26 @@ Napi::Value Start(const Napi::CallbackInfo& info) { Napi::Object result = Napi::Object::New(env); result.Set("sampleRate", Napi::Number::New(env, started.sampleRate)); result.Set("channelCount", Napi::Number::New(env, started.channelCount)); + result.Set("sourceChannelCount", Napi::Number::New(env, started.sourceChannelCount)); result.Set("deviceId", Napi::String::New(env, started.deviceId)); result.Set("deviceLabel", Napi::String::New(env, started.deviceLabel)); return result; } +Napi::Value SetChannelRouting(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsNumber()) { + Napi::TypeError::New(env, "Expected left and right channel indices.") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + return routingToNapi( + env, + activeCapture()->setChannelRouting( + info[0].As().Uint32Value(), + info[1].As().Uint32Value())); +} + Napi::Value Stop(const Napi::CallbackInfo& info) { activeCapture()->stop(); return info.Env().Undefined(); @@ -140,6 +198,9 @@ void RegisterCaptureObject(Napi::Env env, Napi::Object exports, const char* expo capture.Set("stop", Napi::Function::New(env, Stop)); capture.Set("drain", Napi::Function::New(env, Drain)); capture.Set("nowMilliseconds", Napi::Function::New(env, NowMilliseconds)); +#if defined(__APPLE__) + capture.Set("setChannelRouting", Napi::Function::New(env, SetChannelRouting)); +#endif exports.Set(exportName, capture); } diff --git a/package.json b/package.json index 2a4ca27..9f2a28c 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "test:audio-store": "node scripts/run-audio-store-tests.mjs", "test:astra": "node scripts/run-astra-integration-tests.mjs", "test:capture-support": "node scripts/run-capture-support-tests.mjs", + "test:capture-channel-selection-native": "node --test test/capture-channel-selection-native.test.mjs", "test:daw-bridge": "node scripts/run-daw-bridge-tests.mjs", "test:spotify-provider": "node scripts/run-mac-spotify-provider-tests.mjs", "test:now-playing": "node scripts/run-now-playing-manager-tests.mjs", @@ -134,6 +135,10 @@ "target": ["pkg", "zip"], "icon": "resources/icon.icns", "category": "public.app-category.music", + "extendInfo": { + "NSMicrophoneUsageDescription": "Prism uses audio inputs so you can meter microphones, interfaces, and line inputs.", + "NSAudioCaptureUsageDescription": "Prism captures system audio so you can meter output devices." + }, "hardenedRuntime": true, "entitlements": "resources/entitlements.mac.plist", "entitlementsInherit": "resources/entitlements.mac.inherit.plist" diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index a3f9569..3ce49bf 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -6,6 +6,7 @@ const testScripts = [ 'test:audio-store', 'test:astra', 'test:capture-support', + 'test:capture-channel-selection-native', 'test:daw-bridge', 'test:spotify-provider', 'test:now-playing', diff --git a/src/main/index.ts b/src/main/index.ts index 9e77429..60e99d6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, nativeTheme, safeStorage, screen, session, shell, Tray } from 'electron' +import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, nativeTheme, safeStorage, screen, session, shell, systemPreferences, Tray } from 'electron' import type { BrowserWindowConstructorOptions, MenuItemConstructorOptions, OpenDialogOptions, WebContents } from 'electron' import { execFileSync } from 'child_process' import { existsSync, readFileSync } from 'fs' @@ -2365,6 +2365,14 @@ function setupIPC(): void { return getAppBuildInfo() }) + ipcMain.handle('audio:request-microphone-access', async () => { + if (process.platform !== 'darwin') return true + const status = systemPreferences.getMediaAccessStatus('microphone') + if (status === 'granted') return true + if (status === 'denied' || status === 'restricted') return false + return systemPreferences.askForMediaAccess('microphone') + }) + ipcMain.handle('daw-bridge:get-snapshot', () => { return dawBridgeService?.getSnapshot() ?? { available: false, diff --git a/src/preload/captureSupport.ts b/src/preload/captureSupport.ts index 79fce13..d54a47f 100644 --- a/src/preload/captureSupport.ts +++ b/src/preload/captureSupport.ts @@ -25,6 +25,7 @@ export function resolveNativeCaptureSupport( kind: 'native-macos', available: support.available, reason: support.reason, + channelRoutingAvailable: typeof macosCapture.setChannelRouting === 'function', } } @@ -59,12 +60,14 @@ export function getCaptureBackendSupport( platform: string, nativeCaptureAPI: NativeCaptureAPI | null, ): CaptureBackendSupport { + const nativeDeviceInputSupport = nativeCaptureAPI?.deviceInputCapture?.getSupport() return { nativeBackend: resolveNativeCaptureSupport(platform, nativeCaptureAPI), deviceInput: { kind: 'device-input', available: true, reason: null, + channelRoutingAvailable: nativeDeviceInputSupport?.available === true, }, dawBridge: { kind: 'daw-bridge', diff --git a/src/preload/index.ts b/src/preload/index.ts index 1a062d7..0f74ead 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -62,6 +62,7 @@ contextBridge.exposeInMainWorld('electronAPI', { platform: process.platform, windowCapabilities, getAppBuildInfo: () => ipcRenderer.invoke('app:get-build-info') as Promise, + requestMicrophoneAccess: () => ipcRenderer.invoke('audio:request-microphone-access') as Promise, minimize: () => ipcRenderer.send('window:minimize'), close: () => ipcRenderer.send('window:close'), desktopIntegration: { @@ -354,6 +355,7 @@ const nativeCaptureAPI = nativeAddonModule macosCapture: nativeAddonModule.macosCapture, windowsCapture: nativeAddonModule.windowsCapture, linuxCapture: nativeAddonModule.linuxCapture, + deviceInputCapture: nativeAddonModule.deviceInputCapture, } : null diff --git a/src/renderer/audio/AudioCapture.ts b/src/renderer/audio/AudioCapture.ts index 506b055..53e8a95 100644 --- a/src/renderer/audio/AudioCapture.ts +++ b/src/renderer/audio/AudioCapture.ts @@ -1,7 +1,8 @@ /** * AudioCapture — backend manager for Prism's live capture pipeline. * System output capture flows through native platform backends while - * input-device capture continues to use browser media devices. + * input-device capture uses native multichannel capture where available and + * falls back to browser media devices elsewhere. */ import { audioRouter } from './AudioRouter' @@ -17,13 +18,20 @@ import type { CaptureBackendKind, CaptureBackendSupport, CaptureBackendSupportEntry, + CaptureChannelRouting, CaptureMode, CaptureSourceDescriptor, } from '../../types/capture' -import { resolveNativeBackendKind } from '../../types/capture' +import { + createDefaultCaptureChannelRouting, + normalizeCaptureChannelRouting, + resolveNativeBackendKind, +} from '../../types/capture' import type { NativeCaptureDrainResult, + NativeCaptureSource, NativeCaptureStartResult, + NativeDeviceInputCaptureAPI, NativeSystemCaptureAPI, } from '../../types/nativeCapture' import type { @@ -50,6 +58,9 @@ interface CaptureBackendStatus { reason: string | null sampleRate: number channelCount: number + sourceChannelCount: number + channelRoutingAvailable: boolean + channelRouting: CaptureChannelRouting activeSourceId: string | null activeSourceLabel: string | null waiting?: boolean @@ -59,6 +70,7 @@ interface CaptureBackendStartRequest { deviceId?: string persistentSourceId?: string forceRestart?: boolean + channelRouting?: CaptureChannelRouting } interface CaptureBackend { @@ -68,6 +80,7 @@ interface CaptureBackend { listSources(): Promise subscribe(listener: (chunk: CaptureChunk) => void): () => void getStatus(): CaptureBackendStatus + setChannelRouting(routing: CaptureChannelRouting): CaptureChannelRouting } const NATIVE_BACKLOG_CATCH_UP_CHUNK_THRESHOLD = 4 @@ -142,11 +155,13 @@ export function createDefaultBackendSupport(platform = resolvePlatform()): Captu kind: resolveNativeBackendKind(platform), available: false, reason: 'Native system audio capture is not available in this build.', + channelRoutingAvailable: false, }, deviceInput: { kind: 'device-input', available: true, reason: null, + channelRoutingAvailable: false, }, dawBridge: { kind: 'daw-bridge', @@ -162,6 +177,9 @@ export interface CaptureManagerStatus { backendSupport: CaptureBackendSupport | null sampleRate: number channelCount: number + sourceChannelCount: number + channelRoutingAvailable: boolean + channelRouting: CaptureChannelRouting isCapturing: boolean activeSourceId: string | null activeSourceLabel: string | null @@ -178,6 +196,8 @@ function toDeviceSourceDescriptor(device: MediaDeviceInfo): CaptureSourceDescrip id: device.deviceId, label: device.label || `Input ${device.deviceId.slice(0, 8)}`, kind: 'device', + isDefault: device.deviceId === 'default', + channelRoutingAvailable: false, } } @@ -251,6 +271,7 @@ class DeviceInputCaptureRuntime { } getStatus(kind: CaptureBackendKind): CaptureBackendStatus { + const routing = createDefaultCaptureChannelRouting(this.channelCount) return { kind, active: this.active, @@ -258,6 +279,9 @@ class DeviceInputCaptureRuntime { reason: null, sampleRate: this.sampleRate, channelCount: this.channelCount, + sourceChannelCount: this.channelCount, + channelRoutingAvailable: false, + channelRouting: routing, activeSourceId: this.active ? this.activeSourceId : null, activeSourceLabel: this.active ? this.activeSourceLabel : null, } @@ -406,6 +430,19 @@ class DeviceInputCaptureBackend implements CaptureBackend { getStatus(): CaptureBackendStatus { return this.runtime.getStatus(this.kind) } + + setChannelRouting(): CaptureChannelRouting { + return createDefaultCaptureChannelRouting(this.runtime.getStatus(this.kind).channelCount) + } +} + +interface NativePolledCaptureModule { + getSupport: () => { available: boolean; reason: string | null } + start: (deviceId?: string, routing?: CaptureChannelRouting) => NativeCaptureStartResult + setChannelRouting?: (left: number, right: number) => CaptureChannelRouting + stop: () => void + drain: (maxChunks?: number) => NativeCaptureDrainResult + nowMilliseconds: () => number } export abstract class NativePolledCaptureBackend implements CaptureBackend { @@ -416,6 +453,8 @@ export abstract class NativePolledCaptureBackend implements CaptureBackend { private active = false private sampleRate = 48000 private channelCount = 2 + private sourceChannelCount = 2 + private channelRouting: CaptureChannelRouting = createDefaultCaptureChannelRouting(2) private supportReason: string | null private performanceOffsetMilliseconds = 0 private activeSourceId: string | null = null @@ -443,10 +482,25 @@ export abstract class NativePolledCaptureBackend implements CaptureBackend { request?.deviceId && request.deviceId !== DEFAULT_SYSTEM_SOURCE_ID ? request.deviceId : undefined, + request?.channelRouting, ) as NativeCaptureStartResult this.sampleRate = Math.max(1, Math.floor(startResult.sampleRate) || 48000) - this.channelCount = Math.max(1, Math.floor(startResult.channelCount) || 2) + this.channelCount = Math.max(1, Math.min(2, Math.floor(startResult.channelCount) || 2)) + this.sourceChannelCount = Math.max( + 1, + Math.floor(startResult.sourceChannelCount ?? startResult.channelCount) || 1, + ) + this.channelRouting = normalizeCaptureChannelRouting( + request?.channelRouting, + this.sourceChannelCount, + ) + if (nativeCapture.setChannelRouting) { + this.channelRouting = nativeCapture.setChannelRouting( + this.channelRouting.left, + this.channelRouting.right, + ) + } this.activeSourceId = startResult.deviceId || null this.activeSourceLabel = startResult.deviceLabel || null this.supportReason = null @@ -473,13 +527,16 @@ export abstract class NativePolledCaptureBackend implements CaptureBackend { return [] } - return nativeCapture.listOutputDevices().map((source) => ({ + return this.listNativeSources(nativeCapture).map((source) => ({ id: source.id, label: source.label, - kind: 'system', + kind: source.kind, isDefault: source.isDefault, sampleRate: source.sampleRate, channelCount: source.channelCount, + channels: source.channels, + channelRoutingAvailable: source.channelRoutingAvailable + && typeof nativeCapture.setChannelRouting === 'function', })) } @@ -498,11 +555,23 @@ export abstract class NativePolledCaptureBackend implements CaptureBackend { reason: this.supportReason, sampleRate: this.sampleRate, channelCount: this.channelCount, + sourceChannelCount: this.sourceChannelCount, + channelRoutingAvailable: typeof this.getNativeCaptureModule()?.setChannelRouting === 'function', + channelRouting: this.channelRouting, activeSourceId: this.active ? this.activeSourceId : null, activeSourceLabel: this.active ? this.activeSourceLabel : null, } } + setChannelRouting(routing: CaptureChannelRouting): CaptureChannelRouting { + const normalized = normalizeCaptureChannelRouting(routing, this.sourceChannelCount) + const nativeCapture = this.getNativeCaptureModule() + this.channelRouting = nativeCapture?.setChannelRouting + ? nativeCapture.setChannelRouting(normalized.left, normalized.right) + : normalized + return this.channelRouting + } + protected shouldTrimBacklogForLiveCapture(): boolean { return false } @@ -558,7 +627,8 @@ export abstract class NativePolledCaptureBackend implements CaptureBackend { } } - protected abstract getNativeCaptureModule(): NativeSystemCaptureAPI | null + protected abstract getNativeCaptureModule(): NativePolledCaptureModule | null + protected abstract listNativeSources(module: NativePolledCaptureModule): NativeCaptureSource[] protected abstract getBackendLabel(): string } @@ -569,6 +639,10 @@ class NativeMacOSCaptureBackend extends NativePolledCaptureBackend { return typeof window !== 'undefined' ? window.nativeCaptureAPI?.macosCapture ?? null : null } + protected listNativeSources(module: NativePolledCaptureModule): NativeCaptureSource[] { + return (module as NativeSystemCaptureAPI).listOutputDevices() + } + protected getBackendLabel(): string { return 'Native macOS' } @@ -581,6 +655,10 @@ class NativeWindowsCaptureBackend extends NativePolledCaptureBackend { return typeof window !== 'undefined' ? window.nativeCaptureAPI?.windowsCapture ?? null : null } + protected listNativeSources(module: NativePolledCaptureModule): NativeCaptureSource[] { + return (module as NativeSystemCaptureAPI).listOutputDevices() + } + protected getBackendLabel(): string { return 'Native Windows' } @@ -593,6 +671,10 @@ class NativeLinuxCaptureBackend extends NativePolledCaptureBackend { return typeof window !== 'undefined' ? window.nativeCaptureAPI?.linuxCapture ?? null : null } + protected listNativeSources(module: NativePolledCaptureModule): NativeCaptureSource[] { + return (module as NativeSystemCaptureAPI).listOutputDevices() + } + protected getBackendLabel(): string { return 'Native Linux' } @@ -602,6 +684,36 @@ class NativeLinuxCaptureBackend extends NativePolledCaptureBackend { } } +class NativeDeviceInputCaptureBackend extends NativePolledCaptureBackend { + readonly kind = 'device-input' as const + + async start(request?: CaptureBackendStartRequest): Promise { + if (typeof window !== 'undefined' && window.electronAPI?.requestMicrophoneAccess) { + const granted = await window.electronAPI.requestMicrophoneAccess() + if (!granted) { + throw new Error( + 'Microphone access is required for input-device capture. Enable Prism in System Settings → Privacy & Security → Microphone, then restart Prism.', + ) + } + } + await super.start(request) + } + + protected getNativeCaptureModule(): NativeDeviceInputCaptureAPI | null { + return typeof window !== 'undefined' + ? window.nativeCaptureAPI?.deviceInputCapture ?? null + : null + } + + protected listNativeSources(module: NativePolledCaptureModule): NativeCaptureSource[] { + return (module as NativeDeviceInputCaptureAPI).listInputDevices() + } + + protected getBackendLabel(): string { + return 'Native device input' + } +} + class NativeUnavailableCaptureBackend implements CaptureBackend { readonly kind: CaptureBackendKind private readonly reason: string | null @@ -627,6 +739,10 @@ class NativeUnavailableCaptureBackend implements CaptureBackend { return () => {} } + setChannelRouting(): CaptureChannelRouting { + return createDefaultCaptureChannelRouting(2) + } + getStatus(): CaptureBackendStatus { return { kind: this.kind, @@ -635,6 +751,9 @@ class NativeUnavailableCaptureBackend implements CaptureBackend { reason: this.reason, sampleRate: 48000, channelCount: 2, + sourceChannelCount: 2, + channelRoutingAvailable: false, + channelRouting: createDefaultCaptureChannelRouting(2), activeSourceId: null, activeSourceLabel: null, } @@ -742,12 +861,19 @@ class DawBridgeCaptureBackend implements CaptureBackend { reason: this.snapshot.reason, sampleRate: source?.sampleRate ?? 48000, channelCount: source?.channelCount ?? 2, + sourceChannelCount: source?.channelCount ?? 2, + channelRoutingAvailable: false, + channelRouting: createDefaultCaptureChannelRouting(source?.channelCount ?? 2), activeSourceId: this.active ? this.selectedSourceId : null, activeSourceLabel: this.active ? source?.label ?? null : null, waiting: this.active && !source, } } + setChannelRouting(): CaptureChannelRouting { + return createDefaultCaptureChannelRouting(this.getStatus().channelCount) + } + private handleAudioBatch(batch: DawBridgeAudioBatch): void { if (!this.active || batch.sourceId !== this.selectedSourceId) return for (const packet of batch.packets) { @@ -795,6 +921,7 @@ class DawBridgeCaptureBackend implements CaptureBackend { interface CaptureStartOptions { forceDeviceRestart?: boolean + channelRouting?: CaptureChannelRouting } class AudioCapture { @@ -813,6 +940,7 @@ class AudioCapture { private selectedDawSourceId: string | null = null private selectedDawConnectionId: string | null = null private captureMode: CaptureMode = 'system' + private selectedChannelRouting: CaptureChannelRouting = createDefaultCaptureChannelRouting(2) private sessionId: number | null = null private inputGainDb = 0 private inputGainLinear = 1 @@ -822,7 +950,18 @@ class AudioCapture { private rollingCaptureStatusListeners = new Set() constructor() { - this.deviceInputBackend = new DeviceInputCaptureBackend(this.deviceInputRuntime) + const nativeInputModule = typeof window !== 'undefined' + ? window.nativeCaptureAPI?.deviceInputCapture ?? null + : null + const nativeInputSupport = nativeInputModule?.getSupport() + this.deviceInputBackend = nativeInputSupport?.available + ? new NativeDeviceInputCaptureBackend({ + kind: 'device-input', + available: true, + reason: null, + channelRoutingAvailable: true, + }) + : new DeviceInputCaptureBackend(this.deviceInputRuntime) this.deviceInputBackend.subscribe((chunk) => this.handleChunk(this.deviceInputBackend.kind, chunk)) this.dawBridgeBackend.subscribe((chunk) => this.handleChunk(this.dawBridgeBackend.kind, chunk)) this.dawBridgeBackend.subscribeStatus(() => this.handleDawBridgeStatus()) @@ -858,12 +997,12 @@ class AudioCapture { return this.ensureBackendSupport() } - async startSystemAudio(sourceId?: string): Promise { + async startSystemAudio(sourceId?: string, options: CaptureStartOptions = {}): Promise { this.captureMode = 'system' if (sourceId) { this.selectedSystemSourceId = sourceId } - await this.start() + await this.start(undefined, options) } async startDevice(deviceId?: string, options: CaptureStartOptions = {}): Promise { @@ -901,10 +1040,12 @@ class AudioCapture { deviceId: requestedDeviceId, persistentSourceId: this.captureMode === 'daw' ? this.selectedDawSourceId ?? undefined : undefined, forceRestart: this.captureMode === 'device' && options.forceDeviceRestart === true, + channelRouting: options.channelRouting ?? this.selectedChannelRouting, }) this.activeBackend = requestedBackend const backendStatus = requestedBackend.getStatus() + this.selectedChannelRouting = backendStatus.channelRouting this.sessionId = audioRouter.beginSession( backendStatus.sampleRate, backendStatus.channelCount, @@ -944,9 +1085,8 @@ class AudioCapture { return [getDefaultSystemSourceDescriptor(), ...dedupedSources] } - async listDevices(): Promise { - const devices = await navigator.mediaDevices.enumerateDevices() - return devices.filter((device) => device.kind === 'audioinput') + async listDevices(): Promise { + return this.listSources('device') } getSelectedDeviceId(): string | null { @@ -991,6 +1131,22 @@ class AudioCapture { this.emitStatus() } + setChannelRouting(routing: CaptureChannelRouting): CaptureChannelRouting { + const sourceChannelCount = this.activeBackend?.getStatus().sourceChannelCount ?? 2 + this.selectedChannelRouting = normalizeCaptureChannelRouting(routing, sourceChannelCount) + if ( + this.activeBackend + && this.activeBackend.kind !== 'daw-bridge' + && this.activeBackend.getStatus().channelRoutingAvailable + ) { + this.selectedChannelRouting = this.activeBackend.setChannelRouting( + this.selectedChannelRouting, + ) + } + this.emitStatus() + return this.selectedChannelRouting + } + getSampleRate(): number { return this.activeBackend?.getStatus().sampleRate ?? 48000 } @@ -1004,6 +1160,9 @@ class AudioCapture { backendSupport: this.backendSupport, sampleRate: backendStatus?.sampleRate ?? 48000, channelCount: backendStatus?.channelCount ?? 2, + sourceChannelCount: backendStatus?.sourceChannelCount ?? 2, + channelRoutingAvailable: backendStatus?.channelRoutingAvailable ?? false, + channelRouting: backendStatus?.channelRouting ?? this.selectedChannelRouting, isCapturing, activeSourceId: isCapturing ? backendStatus?.activeSourceId ?? null : null, activeSourceLabel: isCapturing ? backendStatus?.activeSourceLabel ?? null : null, @@ -1121,7 +1280,12 @@ class AudioCapture { return } - if ((originKind.startsWith('native-') || originKind === 'daw-bridge') && this.inputGainLinear !== 1) { + const nativeDeviceInput = originKind === 'device-input' + && this.activeBackend instanceof NativeDeviceInputCaptureBackend + if ( + (originKind.startsWith('native-') || originKind === 'daw-bridge' || nativeDeviceInput) + && this.inputGainLinear !== 1 + ) { applyInputGainToStereoSamples(chunk.left, chunk.right, this.inputGainLinear) } diff --git a/src/renderer/components/BottomBar.tsx b/src/renderer/components/BottomBar.tsx index b4ff414..546b50f 100644 --- a/src/renderer/components/BottomBar.tsx +++ b/src/renderer/components/BottomBar.tsx @@ -13,8 +13,14 @@ import type { ScopeKind } from '../../types/scope' import { VISUALIZER_FRAME_TARGETS, type VisualizerFrameTarget } from '../../types/performance' import { ROLLING_CAPTURE_DURATIONS } from '../../types/audioClip' import { SCOPE_KINDS } from '../../types/scope' +import { + getCaptureRoutingStorageKey, + normalizeCaptureChannelRouting, + type CaptureSourceDescriptor, +} from '../../types/capture' import type { WindowBackgroundMode, WindowBackgroundState } from '../../types/windowState' import ThemedSelect from './ThemedSelect' +import ChannelRoutingMatrix from './ChannelRoutingMatrix' const SCOPE_LABELS: Record = { spectrum: 'Spectrum', @@ -42,6 +48,22 @@ const FRAME_TARGET_LABELS: Record = { } const DEFAULT_INPUT_DEVICE_ID = '__default_input__' +const DEFAULT_SYSTEM_SOURCE_ID = '__default_system_output__' + +function resolveRoutingSource( + captureMode: 'system' | 'device' | 'daw', + sources: CaptureSourceDescriptor[], + selectedId: string | null, +): CaptureSourceDescriptor | null { + if (captureMode === 'daw') return null + const defaultSentinel = captureMode === 'system' ? DEFAULT_SYSTEM_SOURCE_ID : null + if (selectedId && selectedId !== defaultSentinel) { + return sources.find((source) => source.id === selectedId) ?? null + } + return sources.find((source) => source.id !== defaultSentinel && source.isDefault) + ?? sources.find((source) => source.id !== defaultSentinel) + ?? null +} const WINDOW_BACKGROUND_MODES: readonly WindowBackgroundMode[] = ['solid', 'blurred', 'clear'] @@ -186,12 +208,14 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): inputGainDb, rollingCaptureSeconds, rollingCaptureStatus, + channelRoutingBySource, clearCaptureNotice, selectSystemSource, selectDevice, selectDawSource, startCapture, setInputGain, + setChannelRouting, setRollingCaptureSeconds, revealRollingCaptureFolder, } = useAudioStore() @@ -252,10 +276,10 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): } } - const visibleSystemSources = systemSources.length + const visibleSystemSources: CaptureSourceDescriptor[] = systemSources.length ? systemSources - : [{ id: '__default_system_output__', label: 'Default Output', kind: 'system', isDefault: true }] - const defaultSystemSourceId = visibleSystemSources[0]?.id ?? '__default_system_output__' + : [{ id: DEFAULT_SYSTEM_SOURCE_ID, label: 'Default Output', kind: 'system', isDefault: true }] + const defaultSystemSourceId = visibleSystemSources[0]?.id ?? DEFAULT_SYSTEM_SOURCE_ID const matchingDawSources = dawSources.filter((source) => ( source.id === selectedDawSourceId || source.persistentId === selectedDawSourceId )) @@ -273,6 +297,29 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): ? `device:${selectedDeviceId ?? DEFAULT_INPUT_DEVICE_ID}` : `daw:${selectedDawLiveSourceId ?? selectedDawSourceId ?? ''}` + const routingSource = captureMode === 'system' + ? resolveRoutingSource(captureMode, visibleSystemSources, selectedSystemSourceId) + : resolveRoutingSource(captureMode, devices, selectedDeviceId) + const routingChannels = routingSource?.channelRoutingAvailable + ? routingSource.channels ?? Array.from( + { length: Math.max(1, routingSource.channelCount ?? 1) }, + (_, index) => ({ index, label: `Channel ${index + 1}` }), + ) + : [] + const routingKey = routingSource + ? getCaptureRoutingStorageKey( + routingSource.kind === 'system' ? 'system' : 'device', + routingSource.id, + ) + : null + const selectedChannelRouting = normalizeCaptureChannelRouting( + routingKey ? channelRoutingBySource[routingKey] : undefined, + routingChannels.length, + ) + const routingSectionStyle = { + width: Math.max(128, Math.min(620, 48 + routingChannels.length * 31)), + } as CSSProperties + const indicatorLabel = captureStatus === 'waiting' ? 'Waiting' : captureStatus === 'connecting' @@ -737,9 +784,9 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): - {devices.map((device) => ( - ))} @@ -809,6 +856,26 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
+ {routingChannels.length > 0 ? ( + <> +
+ +
+
Channel Routing
+
+ +
+
+ + ) : null} +
diff --git a/src/renderer/components/ChannelRoutingMatrix.tsx b/src/renderer/components/ChannelRoutingMatrix.tsx new file mode 100644 index 0000000..63292eb --- /dev/null +++ b/src/renderer/components/ChannelRoutingMatrix.tsx @@ -0,0 +1,62 @@ +import type { CSSProperties, JSX } from 'react' +import type { + CaptureChannelDescriptor, + CaptureChannelRouting, +} from '../../types/capture' + +interface ChannelRoutingMatrixProps { + channels: CaptureChannelDescriptor[] + routing: CaptureChannelRouting + onChange: (routing: CaptureChannelRouting) => void +} + +export default function ChannelRoutingMatrix({ + channels, + routing, + onChange, +}: ChannelRoutingMatrixProps): JSX.Element | null { + if (channels.length === 0) return null + + const renderRow = ( + side: 'left' | 'right', + rowLabel: 'L' | 'R', + ): JSX.Element => { + const selectedIndex = routing[side] + return ( +
+ + {channels.map((channel) => { + const selected = channel.index === selectedIndex + return ( + + ) + })} +
+ ) + } + + return ( +
+
+
+ {renderRow('left', 'L')} + {renderRow('right', 'R')} +
+
+
+ ) +} diff --git a/src/renderer/components/TrayControlBridge.tsx b/src/renderer/components/TrayControlBridge.tsx index b340a72..4f5b2d3 100644 --- a/src/renderer/components/TrayControlBridge.tsx +++ b/src/renderer/components/TrayControlBridge.tsx @@ -136,10 +136,10 @@ export default function TrayControlBridge({ ready }: TrayControlBridgeProps): JS inputSources: [ { id: '', label: 'Default Input', isDefault: true }, ...devices - .filter((device) => device.deviceId !== 'default') + .filter((device) => device.id !== 'default') .map((device) => ({ - id: device.deviceId, - label: device.label || `Input ${device.deviceId.slice(0, 8)}`, + id: device.id, + label: device.label || `Input ${device.id.slice(0, 8)}`, })), ], dawSources: dawSources.map((source) => ({ diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index 780e714..361f000 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -54,6 +54,7 @@ declare global { platform: string windowCapabilities: WindowCapabilities getAppBuildInfo: () => Promise + requestMicrophoneAccess: () => Promise minimize: () => void close: () => void desktopIntegration: { diff --git a/src/renderer/stores/audioStore.ts b/src/renderer/stores/audioStore.ts index 2540d43..38587bb 100644 --- a/src/renderer/stores/audioStore.ts +++ b/src/renderer/stores/audioStore.ts @@ -8,10 +8,15 @@ import { import type { CaptureBackendKind, CaptureBackendSupport, + CaptureChannelRouting, CaptureMode, CaptureSourceDescriptor, CaptureStatus, } from '../../types/capture' +import { + getCaptureRoutingStorageKey, + normalizeCaptureChannelRouting, +} from '../../types/capture' import { useUiStore } from './uiStore' const STORAGE_KEY = 'prism:audio' @@ -28,6 +33,7 @@ export interface PersistedAudioState { selectedDeviceId: string | null selectedDawSourceId: string | null rollingCaptureSeconds: RollingCaptureDurationSeconds | null + channelRoutingBySource: Record } interface RefreshSourceOptions { @@ -41,7 +47,7 @@ interface StartCaptureOptions { interface AudioState { systemSources: CaptureSourceDescriptor[] - devices: MediaDeviceInfo[] + devices: CaptureSourceDescriptor[] dawSources: CaptureSourceDescriptor[] selectedSystemSourceId: string | null selectedDeviceId: string | null @@ -55,12 +61,17 @@ interface AudioState { captureNotice: string | null sampleRate: number channelCount: number + sourceChannelCount: number + channelRoutingAvailable: boolean + activeChannelRouting: CaptureChannelRouting + channelRoutingBySource: Record activeSourceId: string | null activeSourceLabel: string | null inputGainDb: number rollingCaptureSeconds: RollingCaptureDurationSeconds | null rollingCaptureStatus: RollingCaptureStatus setInputGain: (db: number) => void + setChannelRouting: (routing: CaptureChannelRouting) => void setRollingCaptureSeconds: (duration: RollingCaptureDurationSeconds | null) => void startRollingClipDrag: () => boolean revealRollingCaptureFolder: () => Promise @@ -89,6 +100,9 @@ function applyCaptureStatus(status: CaptureManagerStatus): Partial { backendSupport: status.backendSupport, sampleRate: status.sampleRate, channelCount: status.channelCount, + sourceChannelCount: status.sourceChannelCount, + channelRoutingAvailable: status.channelRoutingAvailable, + activeChannelRouting: status.channelRouting, isCapturing: status.isCapturing, activeSourceId: status.activeSourceId, activeSourceLabel: status.activeSourceLabel, @@ -111,8 +125,8 @@ function showSystemCaptureFallbackBanner(message: string): void { }) } -function describeInputDevice(deviceId: string, devices: MediaDeviceInfo[]): string { - const matchingDevice = devices.find((device) => device.deviceId === deviceId) +function describeInputDevice(deviceId: string, devices: CaptureSourceDescriptor[]): string { + const matchingDevice = devices.find((device) => device.id === deviceId) if (matchingDevice?.label) { return matchingDevice.label } @@ -141,20 +155,8 @@ function areCaptureSourcesEqual( && source.isDefault === candidate.isDefault && source.sampleRate === candidate.sampleRate && source.channelCount === candidate.channelCount - }) -} - -function areMediaDevicesEqual(left: MediaDeviceInfo[], right: MediaDeviceInfo[]): boolean { - if (left.length !== right.length) { - return false - } - - return left.every((device, index) => { - const candidate = right[index] - return device.deviceId === candidate.deviceId - && device.kind === candidate.kind - && device.label === candidate.label - && device.groupId === candidate.groupId + && source.channelRoutingAvailable === candidate.channelRoutingAvailable + && JSON.stringify(source.channels ?? []) === JSON.stringify(candidate.channels ?? []) }) } @@ -166,20 +168,75 @@ function getResolvedDefaultSystemSourceId(sources: CaptureSourceDescriptor[]): s ))?.id ?? null } -function getDefaultInputSignature(devices: MediaDeviceInfo[]): string | null { - const defaultDevice = devices.find((device) => device.deviceId === 'default') ?? devices[0] ?? null +function getDefaultInputSignature(devices: CaptureSourceDescriptor[]): string | null { + const defaultDevice = devices.find((device) => device.isDefault) ?? devices[0] ?? null if (!defaultDevice) { return null } return [ - defaultDevice.deviceId, + defaultDevice.id, defaultDevice.label, - defaultDevice.groupId, defaultDevice.kind, + defaultDevice.channelCount ?? '', ].join('\0') } +function normalizeChannelRoutingMap(raw: unknown): Record { + if (typeof raw !== 'object' || raw === null) return {} + const result: Record = {} + for (const [key, value] of Object.entries(raw)) { + if (!key || typeof value !== 'object' || value === null) continue + const candidate = value as Partial + if ( + typeof candidate.left !== 'number' + || !Number.isInteger(candidate.left) + || candidate.left < 0 + || typeof candidate.right !== 'number' + || !Number.isInteger(candidate.right) + || candidate.right < 0 + ) continue + result[key] = { left: candidate.left, right: candidate.right } + } + return result +} + +function resolveRoutingSource(state: Pick< + AudioState, + 'captureMode' | 'systemSources' | 'devices' | 'selectedSystemSourceId' | 'selectedDeviceId' +>): CaptureSourceDescriptor | null { + if (state.captureMode === 'system') { + if (state.selectedSystemSourceId && state.selectedSystemSourceId !== DEFAULT_SYSTEM_SOURCE_ID) { + return state.systemSources.find((source) => source.id === state.selectedSystemSourceId) ?? null + } + return state.systemSources.find((source) => ( + source.id !== DEFAULT_SYSTEM_SOURCE_ID && source.isDefault + )) ?? null + } + if (state.captureMode === 'device') { + if (state.selectedDeviceId) { + return state.devices.find((source) => source.id === state.selectedDeviceId) ?? null + } + return state.devices.find((source) => source.isDefault) ?? state.devices[0] ?? null + } + return null +} + +function resolveRoutingForState(state: Pick< + AudioState, + | 'captureMode' + | 'systemSources' + | 'devices' + | 'selectedSystemSourceId' + | 'selectedDeviceId' + | 'channelRoutingBySource' +>): CaptureChannelRouting | undefined { + const source = resolveRoutingSource(state) + if (!source?.channelRoutingAvailable || !source.channelCount) return undefined + const key = getCaptureRoutingStorageKey(source.kind === 'system' ? 'system' : 'device', source.id) + return normalizeCaptureChannelRouting(state.channelRoutingBySource[key], source.channelCount) +} + function getStorage(): StorageLike | null { if (typeof localStorage === 'undefined') { return null @@ -229,6 +286,7 @@ export function normalizeAudioPreferences(raw: unknown): PersistedAudioState { selectedDeviceId: normalizeDeviceId(parsed.selectedDeviceId), selectedDawSourceId: normalizeDeviceId(parsed.selectedDawSourceId), rollingCaptureSeconds: normalizeRollingCaptureSeconds(parsed.rollingCaptureSeconds), + channelRoutingBySource: normalizeChannelRoutingMap(parsed.channelRoutingBySource), } } @@ -239,6 +297,7 @@ function buildAudioPreferences( selectedDeviceId: string | null, rollingCaptureSeconds: RollingCaptureDurationSeconds | null, selectedDawSourceId: string | null, + channelRoutingBySource: Record, ): PersistedAudioState { return normalizeAudioPreferences({ inputGainDb, @@ -247,6 +306,7 @@ function buildAudioPreferences( selectedDeviceId, selectedDawSourceId, rollingCaptureSeconds, + channelRoutingBySource, }) } @@ -301,6 +361,10 @@ export const useAudioStore = create((set, get) => ({ captureNotice: null, sampleRate: 48000, channelCount: 2, + sourceChannelCount: 2, + channelRoutingAvailable: false, + activeChannelRouting: { left: 0, right: 1 }, + channelRoutingBySource: storedPreferences.channelRoutingBySource, activeSourceId: null, activeSourceLabel: null, inputGainDb: storedPreferences.inputGainDb, @@ -321,11 +385,46 @@ export const useAudioStore = create((set, get) => ({ currentState.selectedDeviceId, currentState.rollingCaptureSeconds, currentState.selectedDawSourceId, + currentState.channelRoutingBySource, )) audioCapture.setInputGain(nextInputGainDb) set({ inputGainDb: nextInputGainDb }) }, + setChannelRouting: (routing) => { + const currentState = get() + const source = resolveRoutingSource(currentState) + if (!source?.channelRoutingAvailable || !source.channelCount) return + + const normalized = normalizeCaptureChannelRouting(routing, source.channelCount) + const sourceKind = source.kind === 'system' ? 'system' : 'device' + const key = getCaptureRoutingStorageKey(sourceKind, source.id) + const nextRoutingBySource = { + ...currentState.channelRoutingBySource, + [key]: normalized, + } + persistAudioPreferences(buildAudioPreferences( + currentState.inputGainDb, + currentState.captureMode, + currentState.selectedSystemSourceId, + currentState.selectedDeviceId, + currentState.rollingCaptureSeconds, + currentState.selectedDawSourceId, + nextRoutingBySource, + )) + + const routingActiveSource = currentState.isCapturing + && currentState.captureMode === sourceKind + && currentState.activeSourceId === source.id + const activeChannelRouting = routingActiveSource + ? audioCapture.setChannelRouting(normalized) + : currentState.activeChannelRouting + set({ + channelRoutingBySource: nextRoutingBySource, + activeChannelRouting, + }) + }, + setRollingCaptureSeconds: (duration) => { const nextDuration = normalizeRollingCaptureSeconds(duration) const currentState = get() @@ -338,6 +437,7 @@ export const useAudioStore = create((set, get) => ({ currentState.selectedDeviceId, nextDuration, currentState.selectedDawSourceId, + currentState.channelRoutingBySource, )) audioCapture.setRollingCaptureSeconds(nextDuration) set({ @@ -405,6 +505,7 @@ export const useAudioStore = create((set, get) => ({ currentState.selectedDeviceId, currentState.rollingCaptureSeconds, currentState.selectedDawSourceId, + currentState.channelRoutingBySource, )) } @@ -452,7 +553,7 @@ export const useAudioStore = create((set, get) => ({ const previousSelectedDeviceId = currentState.selectedDeviceId const previousDevices = currentState.devices const nextSelectedDeviceId = previousSelectedDeviceId - && devices.some((device) => device.deviceId === previousSelectedDeviceId) + && devices.some((device) => device.id === previousSelectedDeviceId) ? previousSelectedDeviceId : null const shouldShowFallbackNotice = Boolean( @@ -460,7 +561,7 @@ export const useAudioStore = create((set, get) => ({ && previousSelectedDeviceId !== nextSelectedDeviceId && currentState.captureMode === 'device', ) - const deviceListChanged = !areMediaDevicesEqual(previousDevices, devices) + const deviceListChanged = !areCaptureSourcesEqual(previousDevices, devices) const selectedDeviceChanged = previousSelectedDeviceId !== nextSelectedDeviceId const nextCaptureNotice = shouldShowFallbackNotice ? `${describeInputDevice(previousSelectedDeviceId!, previousDevices)} is unavailable. Prism switched to Default Input.` @@ -475,6 +576,7 @@ export const useAudioStore = create((set, get) => ({ nextSelectedDeviceId, currentState.rollingCaptureSeconds, currentState.selectedDawSourceId, + currentState.channelRoutingBySource, )) } @@ -540,6 +642,7 @@ export const useAudioStore = create((set, get) => ({ currentState.selectedDeviceId, currentState.rollingCaptureSeconds, currentState.selectedDawSourceId, + currentState.channelRoutingBySource, )) set({ selectedSystemSourceId: nextSelectedSystemSourceId, @@ -560,6 +663,7 @@ export const useAudioStore = create((set, get) => ({ deviceId, currentState.rollingCaptureSeconds, currentState.selectedDawSourceId, + currentState.channelRoutingBySource, )) set({ selectedDeviceId: deviceId, @@ -582,6 +686,7 @@ export const useAudioStore = create((set, get) => ({ currentState.selectedDeviceId, currentState.rollingCaptureSeconds, persistentSourceId, + currentState.channelRoutingBySource, )) set({ selectedDawSourceId: persistentSourceId, @@ -601,6 +706,7 @@ export const useAudioStore = create((set, get) => ({ currentState.selectedDeviceId, currentState.rollingCaptureSeconds, currentState.selectedDawSourceId, + currentState.channelRoutingBySource, )) set({ captureMode: mode }) }, @@ -630,6 +736,7 @@ export const useAudioStore = create((set, get) => ({ null, currentState.rollingCaptureSeconds, currentState.selectedDawSourceId, + currentState.channelRoutingBySource, )) set({ selectedDeviceId: null, @@ -637,7 +744,10 @@ export const useAudioStore = create((set, get) => ({ captureNotice: message, }) showSystemCaptureFallbackBanner(message) - await audioCapture.startDevice(undefined, { forceDeviceRestart: true }) + await audioCapture.startDevice(undefined, { + forceDeviceRestart: true, + channelRouting: resolveRoutingForState(get()), + }) } if (captureMode === 'system') { @@ -645,7 +755,9 @@ export const useAudioStore = create((set, get) => ({ await startDefaultInputFallback(backendSupport?.nativeBackend.reason ?? null) } else { try { - await audioCapture.startSystemAudio(selectedSystemSourceId ?? undefined) + await audioCapture.startSystemAudio(selectedSystemSourceId ?? undefined, { + channelRouting: resolveRoutingForState(get()), + }) } catch (error) { const reason = error instanceof Error ? error.message : 'Native system capture failed.' await startDefaultInputFallback(reason) @@ -654,6 +766,7 @@ export const useAudioStore = create((set, get) => ({ } else if (captureMode === 'device') { await audioCapture.startDevice(selectedDeviceId ?? undefined, { forceDeviceRestart: options.forceDeviceRestart === true, + channelRouting: resolveRoutingForState(get()), }) } else { if (!selectedDawSourceId) { diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index b3ed21f..4d3efae 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -2063,6 +2063,11 @@ button.toolbar__version:hover { min-width: 360px; } +.bottom-bar__section--routing { + min-width: 0; + gap: 1px; +} + .bottom-bar__section--rolling-capture { min-width: 500px; } @@ -2257,6 +2262,79 @@ button.toolbar__version:hover { max-width: 280px; } +.channel-routing { + max-width: 620px; + min-width: 0; +} + +.channel-routing__scroll { + width: 100%; + height: 43px; + max-width: 100%; + overflow-x: auto; + overflow-y: hidden; + padding: 2px 5px; + border: 1px solid var(--control-border); + border-radius: 10px; + background: linear-gradient(180deg, var(--panel-surface), var(--control-bg)); + box-shadow: inset 0 1px 0 var(--glass-highlight); +} + +.channel-routing__grid { + width: max-content; + display: flex; + flex-direction: column; + gap: 1px; +} + +.channel-routing__row { + display: grid; + grid-template-columns: 20px repeat(var(--channel-count, 1), 28px); + gap: 3px; + align-items: center; +} + +.channel-routing__row-label { + display: inline-flex; + min-width: 20px; + height: 19px; + align-items: center; + justify-content: center; + color: var(--text-secondary); + font-family: 'JetBrains Mono', monospace; + font-size: 9px; +} + +.channel-routing__cell { + width: 28px; + height: 19px; + padding: 0; + border: 1px solid var(--input-border); + border-radius: 4px; + background: var(--input-bg); + color: var(--text-primary); + font-family: 'JetBrains Mono', monospace; + font-size: 9px; + line-height: 1; + cursor: pointer; +} + +.channel-routing__cell:hover { + border-color: var(--input-border-focus); + background: var(--input-bg-focus); +} + +.channel-routing__cell.is-selected { + border-color: color-mix(in srgb, var(--accent) 62%, var(--input-border)); + background: color-mix(in srgb, var(--accent) 22%, var(--input-bg)); + color: var(--text-primary); +} + +.channel-routing__cell:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + .bottom-bar__text-input { height: 32px; padding: 0 10px; diff --git a/src/types/capture.ts b/src/types/capture.ts index 6e432fe..6ad7e93 100644 --- a/src/types/capture.ts +++ b/src/types/capture.ts @@ -1,6 +1,58 @@ export type CaptureMode = 'system' | 'device' | 'daw' export type CaptureStatus = 'idle' | 'connecting' | 'waiting' | 'capturing' | 'error' +export interface CaptureChannelDescriptor { + /** Zero-based channel index used by capture backends. */ + index: number + /** Human-readable driver label, falling back to the one-based channel number. */ + label: string +} + +export interface CaptureChannelRouting { + /** Zero-based source channel routed to Prism's left channel. */ + left: number + /** Zero-based source channel routed to Prism's right channel. */ + right: number +} + +export type CaptureRoutingSourceKind = 'system' | 'device' + +export function createDefaultCaptureChannelRouting(channelCount: number): CaptureChannelRouting { + const normalizedCount = Math.max(1, Math.floor(channelCount) || 1) + return { + left: 0, + right: normalizedCount > 1 ? 1 : 0, + } +} + +export function normalizeCaptureChannelRouting( + raw: unknown, + channelCount: number, +): CaptureChannelRouting { + const normalizedCount = Math.max(1, Math.floor(channelCount) || 1) + const fallback = createDefaultCaptureChannelRouting(normalizedCount) + if (typeof raw !== 'object' || raw === null) return fallback + + const candidate = raw as Partial + const normalizeIndex = (value: unknown): number | null => { + if (typeof value !== 'number' || !Number.isFinite(value)) return null + const index = Math.floor(value) + return index >= 0 && index < normalizedCount ? index : null + } + const left = normalizeIndex(candidate.left) + const right = normalizeIndex(candidate.right) + if (left === null || right === null) return fallback + + return { left, right } +} + +export function getCaptureRoutingStorageKey( + kind: CaptureRoutingSourceKind, + sourceId: string, +): string { + return `${kind}:${sourceId}` +} + export type NativeSystemCaptureBackendKind = | 'native-macos' | 'native-windows' @@ -30,12 +82,15 @@ export interface CaptureSourceDescriptor { isDefault?: boolean sampleRate?: number channelCount?: number + channels?: CaptureChannelDescriptor[] + channelRoutingAvailable?: boolean } export interface CaptureBackendSupportEntry { kind: CaptureBackendKind available: boolean reason: string | null + channelRoutingAvailable?: boolean } export interface CaptureBackendSupport { diff --git a/src/types/nativeCapture.ts b/src/types/nativeCapture.ts index b071d96..b2c80e9 100644 --- a/src/types/nativeCapture.ts +++ b/src/types/nativeCapture.ts @@ -3,18 +3,33 @@ export interface NativeCaptureSupport { reason: string | null } +export interface NativeCaptureChannel { + index: number + label: string +} + +export interface NativeCaptureChannelRouting { + left: number + right: number +} + export interface NativeCaptureSource { id: string label: string - kind: 'system' + kind: 'system' | 'device' isDefault: boolean sampleRate: number channelCount: number + channels: NativeCaptureChannel[] + channelRoutingAvailable: boolean } export interface NativeCaptureStartResult { sampleRate: number + /** Effective mono/stereo count emitted to the renderer. */ channelCount: number + /** Physical channel count exposed by the selected source. */ + sourceChannelCount: number deviceId: string deviceLabel: string } @@ -36,7 +51,23 @@ export interface NativeCaptureDrainResult { export interface NativeSystemCaptureAPI { getSupport: () => NativeCaptureSupport listOutputDevices: () => NativeCaptureSource[] - start: (deviceId?: string) => NativeCaptureStartResult + start: ( + deviceId?: string, + routing?: NativeCaptureChannelRouting, + ) => NativeCaptureStartResult + setChannelRouting?: (left: number, right: number) => NativeCaptureChannelRouting + stop: () => void + drain: (maxChunks?: number) => NativeCaptureDrainResult + nowMilliseconds: () => number +} +export interface NativeDeviceInputCaptureAPI { + getSupport: () => NativeCaptureSupport + listInputDevices: () => NativeCaptureSource[] + start: ( + deviceId?: string, + routing?: NativeCaptureChannelRouting, + ) => NativeCaptureStartResult + setChannelRouting: (left: number, right: number) => NativeCaptureChannelRouting stop: () => void drain: (maxChunks?: number) => NativeCaptureDrainResult nowMilliseconds: () => number @@ -67,4 +98,5 @@ export interface NativeCaptureAPI { macosCapture: NativeMacOSCaptureAPI windowsCapture: NativeWindowsCaptureAPI linuxCapture: NativeLinuxCaptureAPI + deviceInputCapture: NativeDeviceInputCaptureAPI } diff --git a/test/audio-store.test.ts b/test/audio-store.test.ts index 8280349..5610d4b 100644 --- a/test/audio-store.test.ts +++ b/test/audio-store.test.ts @@ -11,6 +11,11 @@ import { } from '../src/renderer/stores/audioStore' import { useUiStore } from '../src/renderer/stores/uiStore' import type { CaptureBackendSupport, CaptureSourceDescriptor } from '../src/types/capture' +import { + createDefaultCaptureChannelRouting, + getCaptureRoutingStorageKey, + normalizeCaptureChannelRouting, +} from '../src/types/capture' type GlobalWithStorage = typeof globalThis & { localStorage?: Storage @@ -18,6 +23,7 @@ type GlobalWithStorage = typeof globalThis & { type StartDeviceOptions = { forceDeviceRestart?: boolean + channelRouting?: { left: number; right: number } } type HarnessSourceProvider = T[] | (() => T[] | Promise) @@ -32,6 +38,7 @@ function audioPreferences(overrides: Partial = {}): Persist selectedDeviceId: null, selectedDawSourceId: null, rollingCaptureSeconds: null, + channelRoutingBySource: {}, ...overrides, } } @@ -140,24 +147,31 @@ function systemSource( } } -function mediaDevice(deviceId: string, label: string, groupId = ''): MediaDeviceInfo { +function mediaDevice(deviceId: string, label: string, isDefault = false): CaptureSourceDescriptor { return { - deviceId, - groupId, - kind: 'audioinput', + id: deviceId, + kind: 'device', label, - toJSON() { - return {} - }, - } as MediaDeviceInfo + isDefault, + sampleRate: 48000, + channelCount: 2, + channels: [ + { index: 0, label: 'Channel 1' }, + { index: 1, label: 'Channel 2' }, + ], + channelRoutingAvailable: false, + } } function getDefaultSystemSourceId(sources: CaptureSourceDescriptor[]): string | null { return sources.find((source) => source.id !== DEFAULT_SYSTEM_SOURCE_ID && source.isDefault)?.id ?? null } -function getDefaultInputDeviceId(devices: MediaDeviceInfo[]): string | null { - return devices.find((device) => device.deviceId !== 'default')?.deviceId ?? devices[0]?.deviceId ?? null +function getDefaultInputDeviceId(devices: CaptureSourceDescriptor[]): string | null { + return devices.find((device) => device.id !== 'default' && device.isDefault)?.id + ?? devices.find((device) => device.id !== 'default')?.id + ?? devices[0]?.id + ?? null } async function resolveHarnessSources(provider: HarnessSourceProvider | undefined, fallback: T[]): Promise { @@ -306,6 +320,10 @@ function resetStores(): void { captureNotice: null, sampleRate: 48000, channelCount: 2, + sourceChannelCount: 2, + channelRoutingAvailable: false, + activeChannelRouting: { left: 0, right: 1 }, + channelRoutingBySource: {}, activeSourceId: null, activeSourceLabel: null, inputGainDb: 0, @@ -323,7 +341,7 @@ function resetStores(): void { function installAudioCaptureHarness(options: { support: CaptureBackendSupport systemSources?: HarnessSourceProvider - devices?: HarnessSourceProvider + devices?: HarnessSourceProvider startSystemAudio?: (deviceId?: string) => Promise startDevice?: (deviceId?: string, options?: StartDeviceOptions) => Promise }): { @@ -405,11 +423,11 @@ function installAudioCaptureHarness(options: { } else { const devices = await resolveHarnessSources(options.devices, []) const resolvedDeviceId = deviceId ?? getDefaultInputDeviceId(devices) - const resolvedDevice = devices.find((device) => device.deviceId === resolvedDeviceId) ?? null + const resolvedDevice = devices.find((device) => device.id === resolvedDeviceId) ?? null captureMode = 'device' selectedDeviceId = deviceId ?? null activeBackendKind = 'device-input' - activeSourceId = resolvedDevice?.deviceId ?? resolvedDeviceId + activeSourceId = resolvedDevice?.id ?? resolvedDeviceId activeSourceLabel = resolvedDevice?.label ?? null isCapturing = true } @@ -420,6 +438,9 @@ function installAudioCaptureHarness(options: { backendSupport: options.support, sampleRate: 48000, channelCount: 2, + sourceChannelCount: 2, + channelRoutingAvailable: false, + channelRouting: { left: 0, right: 1 }, isCapturing, activeSourceId: isCapturing ? activeSourceId : null, activeSourceLabel: isCapturing ? activeSourceLabel : null, @@ -512,6 +533,94 @@ test('normalizeAudioPreferences preserves DAW mode and its stable bridge UUID', })) }) +test('channel routes default safely and allow one source to feed both sides', () => { + assert.deepEqual(createDefaultCaptureChannelRouting(1), { left: 0, right: 0 }) + assert.deepEqual(createDefaultCaptureChannelRouting(16), { left: 0, right: 1 }) + assert.deepEqual(normalizeCaptureChannelRouting({ left: 7, right: 7 }, 16), { + left: 7, + right: 7, + }) + assert.deepEqual(normalizeCaptureChannelRouting({ left: 99, right: -1 }, 2), { + left: 0, + right: 1, + }) + assert.deepEqual(normalizeCaptureChannelRouting({ left: 1, right: 99 }, 2), { + left: 0, + right: 1, + }) +}) + +test('normalizeAudioPreferences keeps valid per-device routes and drops malformed entries', () => { + assert.deepEqual(normalizeAudioPreferences({ + channelRoutingBySource: { + 'system:output-uid': { left: 7, right: 9 }, + 'device:input-uid': { left: 3, right: 3 }, + broken: { left: -1, right: 2 }, + }, + }).channelRoutingBySource, { + 'system:output-uid': { left: 7, right: 9 }, + 'device:input-uid': { left: 3, right: 3 }, + }) +}) + +test('audio store persists routes by direction and physical device UID', () => { + resetStores() + const fakeStorage = installFakeLocalStorage() + const originalSetChannelRouting = audioCapture.setChannelRouting + const forwarded: Array<{ left: number; right: number }> = [] + audioCapture.setChannelRouting = (routing) => { + forwarded.push(routing) + return routing + } + + const output = { + ...systemSource('output-uid', '16ch Output', true), + channelCount: 16, + channels: Array.from({ length: 16 }, (_, index) => ({ index, label: `Output ${index + 1}` })), + channelRoutingAvailable: true, + } + const input = { + ...mediaDevice('input-uid', '8ch Input', true), + channelCount: 8, + channels: Array.from({ length: 8 }, (_, index) => ({ index, label: `Input ${index + 1}` })), + channelRoutingAvailable: true, + } + + try { + useAudioStore.setState({ + systemSources: [defaultSystemSource(), output], + devices: [input], + captureMode: 'system', + selectedSystemSourceId: DEFAULT_SYSTEM_SOURCE_ID, + isCapturing: true, + activeSourceId: output.id, + }) + useAudioStore.getState().setChannelRouting({ left: 7, right: 9 }) + + useAudioStore.setState({ + captureMode: 'device', + selectedDeviceId: null, + activeSourceId: input.id, + }) + useAudioStore.getState().setChannelRouting({ left: 3, right: 3 }) + + const expectedRoutes = { + [getCaptureRoutingStorageKey('system', output.id)]: { left: 7, right: 9 }, + [getCaptureRoutingStorageKey('device', input.id)]: { left: 3, right: 3 }, + } + assert.deepEqual(useAudioStore.getState().channelRoutingBySource, expectedRoutes) + assert.deepEqual(forwarded, [{ left: 7, right: 9 }, { left: 3, right: 3 }]) + assert.deepEqual( + JSON.parse(fakeStorage.getItem('prism:audio') ?? '{}').channelRoutingBySource, + expectedRoutes, + ) + } finally { + audioCapture.setChannelRouting = originalSetChannelRouting + fakeStorage.restore() + resetStores() + } +}) + test('normalizeRollingCaptureSeconds accepts only supported durations', () => { assert.equal(normalizeRollingCaptureSeconds(5), 5) assert.equal(normalizeRollingCaptureSeconds(60), 60) @@ -934,8 +1043,8 @@ test('audio store persists Default Input when an explicit input disappears', asy const fakeStorage = installFakeLocalStorage() const support = createBackendSupport(true, null) let devices = [ - mediaDevice('mic-1', 'Mic 1', 'group-1'), - mediaDevice('mic-2', 'Mic 2', 'group-2'), + mediaDevice('mic-1', 'Mic 1'), + mediaDevice('mic-2', 'Mic 2'), ] const harness = installAudioCaptureHarness({ support, @@ -956,7 +1065,7 @@ test('audio store persists Default Input when an explicit input disappears', asy }) devices = [ - mediaDevice('mic-2', 'Mic 2', 'group-2'), + mediaDevice('mic-2', 'Mic 2', true), ] await useAudioStore.getState().refreshDevices({ rebindActiveCapture: true }) @@ -985,8 +1094,8 @@ test('audio store forces default input reacquisition when the default input sign resetStores() const support = createBackendSupport(true, null) let devices = [ - mediaDevice('default', 'Default - Mic 1', 'default-group'), - mediaDevice('mic-1', 'Mic 1', 'group-1'), + mediaDevice('default', 'Default - Mic 1', true), + mediaDevice('mic-1', 'Mic 1'), ] const harness = installAudioCaptureHarness({ support, @@ -1007,8 +1116,8 @@ test('audio store forces default input reacquisition when the default input sign }) devices = [ - mediaDevice('default', 'Default - Mic 2', 'default-group'), - mediaDevice('mic-2', 'Mic 2', 'group-2'), + mediaDevice('default', 'Default - Mic 2', true), + mediaDevice('mic-2', 'Mic 2'), ] await useAudioStore.getState().refreshDevices({ rebindActiveCapture: true }) diff --git a/test/capture-channel-selection-native.test.mjs b/test/capture-channel-selection-native.test.mjs new file mode 100644 index 0000000..e166b5e --- /dev/null +++ b/test/capture-channel-selection-native.test.mjs @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import test from 'node:test' + +const require = createRequire(import.meta.url) +const { captureChannelSelection } = require('../native/build/Release/visualizer_dsp.node') + +function select(buffers, frames, sourceChannels, left, right) { + return captureChannelSelection.selectFloat32(buffers, frames, sourceChannels, left, right) +} + +function values(array) { + return [...array] +} + +test('selects non-adjacent channels from an interleaved buffer', () => { + const result = select([{ + data: new Float32Array([ + 1, 2, 3, 4, + 5, 6, 7, 8, + ]), + channelCount: 4, + }], 2, 4, 3, 1) + + assert.equal(result.valid, true) + assert.deepEqual(values(result.left), [4, 8]) + assert.deepEqual(values(result.right), [2, 6]) +}) + +test('selects channels from planar buffers and supports duplicate routing', () => { + const buffers = [ + { data: new Float32Array([1, 2]), channelCount: 1 }, + { data: new Float32Array([3, 4]), channelCount: 1 }, + { data: new Float32Array([5, 6]), channelCount: 1 }, + ] + const result = select(buffers, 2, 3, 2, 2) + + assert.equal(result.valid, true) + assert.deepEqual(values(result.left), [5, 6]) + assert.deepEqual(values(result.right), [5, 6]) +}) + +test('selects across multiple buffers that each contain interleaved channels', () => { + const result = select([ + { data: new Float32Array([1, 2, 5, 6]), channelCount: 2 }, + { data: new Float32Array([3, 4, 7, 8]), channelCount: 2 }, + ], 2, 4, 2, 0) + + assert.equal(result.valid, true) + assert.deepEqual(values(result.left), [3, 7]) + assert.deepEqual(values(result.right), [1, 5]) +}) + +test('renders silent or short native buffers safely', () => { + const silent = select([null, null], 3, 2, 0, 1) + assert.equal(silent.valid, true) + assert.deepEqual(values(silent.left), [0, 0, 0]) + assert.deepEqual(values(silent.right), [0, 0, 0]) + + const short = select([ + { data: new Float32Array([1]), channelCount: 1 }, + { data: new Float32Array([2]), channelCount: 1 }, + ], 2, 2, 0, 1) + assert.deepEqual(values(short.left), [1, 0]) + assert.deepEqual(values(short.right), [2, 0]) +}) + +test('rejects out-of-range routes without reading native memory', () => { + const result = select([ + { data: new Float32Array([1, 2, 3, 4]), channelCount: 2 }, + ], 2, 2, 0, 8) + + assert.equal(result.valid, false) + assert.deepEqual(values(result.left), [0, 0]) + assert.deepEqual(values(result.right), [0, 0]) +}) diff --git a/test/capture-support.test.ts b/test/capture-support.test.ts index e0e3d7a..7cf8552 100644 --- a/test/capture-support.test.ts +++ b/test/capture-support.test.ts @@ -3,7 +3,11 @@ import { readFile } from 'node:fs/promises' import { join } from 'node:path' import test from 'node:test' import { getCaptureBackendSupport, resolveNativeCaptureSupport } from '../src/preload/captureSupport' -import type { NativeCaptureSupport, NativeSystemCaptureAPI } from '../src/types/nativeCapture' +import type { + NativeCaptureSupport, + NativeDeviceInputCaptureAPI, + NativeSystemCaptureAPI, +} from '../src/types/nativeCapture' function createNativeSystemCaptureAPI(support: NativeCaptureSupport): NativeSystemCaptureAPI { return { @@ -12,6 +16,7 @@ function createNativeSystemCaptureAPI(support: NativeCaptureSupport): NativeSyst start: () => ({ sampleRate: 48000, channelCount: 2, + sourceChannelCount: 2, deviceId: 'default', deviceLabel: 'Default', }), @@ -25,6 +30,24 @@ function createNativeSystemCaptureAPI(support: NativeCaptureSupport): NativeSyst } } +function createNativeDeviceInputCaptureAPI(support: NativeCaptureSupport): NativeDeviceInputCaptureAPI { + return { + getSupport: () => support, + listInputDevices: () => [], + start: () => ({ + sampleRate: 48000, + channelCount: 2, + sourceChannelCount: 2, + deviceId: 'default-input', + deviceLabel: 'Default Input', + }), + setChannelRouting: (left, right) => ({ left, right }), + stop: () => {}, + drain: () => ({ chunks: [], overwriteCount: 0, queueDepth: 0 }), + nowMilliseconds: () => 0, + } +} + test('getCaptureBackendSupport resolves Linux native support from the addon surface', () => { const support = getCaptureBackendSupport('linux', { macosCapture: createNativeSystemCaptureAPI({ available: false, reason: 'macOS only' }), @@ -33,6 +56,7 @@ test('getCaptureBackendSupport resolves Linux native support from the addon surf available: false, reason: 'PulseAudio connection failed.', }), + deviceInputCapture: createNativeDeviceInputCaptureAPI({ available: false, reason: 'macOS only' }), }) assert.deepEqual(support, { @@ -45,6 +69,7 @@ test('getCaptureBackendSupport resolves Linux native support from the addon surf kind: 'device-input', available: true, reason: null, + channelRoutingAvailable: false, }, dawBridge: { kind: 'daw-bridge', @@ -54,6 +79,20 @@ test('getCaptureBackendSupport resolves Linux native support from the addon surf }) }) +test('getCaptureBackendSupport exposes macOS routing capability explicitly', () => { + const macosCapture = createNativeSystemCaptureAPI({ available: true, reason: null }) + macosCapture.setChannelRouting = (left, right) => ({ left, right }) + const support = getCaptureBackendSupport('darwin', { + macosCapture, + windowsCapture: createNativeSystemCaptureAPI({ available: false, reason: 'Windows only' }), + linuxCapture: createNativeSystemCaptureAPI({ available: false, reason: 'Linux only' }), + deviceInputCapture: createNativeDeviceInputCaptureAPI({ available: true, reason: null }), + }) + + assert.equal(support.nativeBackend.channelRoutingAvailable, true) + assert.equal(support.deviceInput.channelRoutingAvailable, true) +}) + test('resolveNativeCaptureSupport returns a module-unavailable reason when the addon is missing', () => { assert.deepEqual(resolveNativeCaptureSupport('linux', null), { kind: 'native-linux', @@ -70,3 +109,19 @@ test('preload no longer exposes desktop source capture APIs', async () => { assert.doesNotMatch(preloadSource, /audio:get-desktop-sources/) assert.match(preloadSource, /vumeter: nativeAddonModule\.vumeter/) }) + +test('packaged macOS builds declare audio capture privacy usage', async () => { + const packageJson = JSON.parse(await readFile(join(process.cwd(), 'package.json'), 'utf8')) as { + build?: { mac?: { extendInfo?: Record } } + } + const extendInfo = packageJson.build?.mac?.extendInfo + + assert.equal(typeof extendInfo?.NSMicrophoneUsageDescription, 'string') + assert.equal(typeof extendInfo?.NSAudioCaptureUsageDescription, 'string') + + const entitlements = await readFile( + join(process.cwd(), 'resources', 'entitlements.mac.plist'), + 'utf8', + ) + assert.match(entitlements, /com\.apple\.security\.device\.audio-input/) +}) diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index 6c0afef..4ad422e 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -4578,6 +4578,27 @@ test('BottomBar keeps Window controls on one row', async () => { assert.match(stylesSource, /\.bottom-bar__inline--window \{[\s\S]*gap: 8px;/) }) +test('BottomBar channel routing uses horizontal space without increasing the settings height', async () => { + const bottomBarSource = await readFile(join(process.cwd(), 'src', 'renderer', 'components', 'BottomBar.tsx'), 'utf8') + const matrixSource = await readFile(join(process.cwd(), 'src', 'renderer', 'components', 'ChannelRoutingMatrix.tsx'), 'utf8') + const stylesSource = await readFile(join(process.cwd(), 'src', 'renderer', 'styles', 'globals.css'), 'utf8') + + const sourceSection = bottomBarSource.match( + /
([\s\S]*?)<\/section>/, + )?.[1] + + assert.ok(sourceSection) + assert.doesNotMatch(sourceSection, /ChannelRoutingMatrix/) + assert.match(bottomBarSource, /bottom-bar__section--routing/) + assert.match(bottomBarSource, /
Channel Routing<\/div>/) + assert.doesNotMatch(matrixSource, /channel-routing__header/) + assert.match(matrixSource, /\{channel\.index \+ 1\}/) + assert.match(stylesSource, /\.bottom-bar__section--routing \{[\s\S]*gap: 1px;/) + assert.match(stylesSource, /\.channel-routing__scroll \{[\s\S]*height: 43px;/) + assert.match(stylesSource, /\.channel-routing__scroll \{[\s\S]*border: 1px solid var\(--control-border\);/) + assert.match(stylesSource, /\.channel-routing__cell \{[\s\S]*width: 28px;[\s\S]*height: 19px;/) +}) + test('BottomBar close button uses flat themed control backgrounds', async () => { const stylesSource = await readFile(join(process.cwd(), 'src', 'renderer', 'styles', 'globals.css'), 'utf8') const closeBlock = [...stylesSource.matchAll(/^\.settings-panel__close \{([\s\S]*?)\n\}/gm)] diff --git a/test/spectrum-native.test.mjs b/test/spectrum-native.test.mjs index ed27c6d..b71a99c 100644 --- a/test/spectrum-native.test.mjs +++ b/test/spectrum-native.test.mjs @@ -55,6 +55,21 @@ test('native capture exports preserve the renderer-facing API shape', () => { } } + const deviceInputCapture = nativeAddon.deviceInputCapture + assert.ok(deviceInputCapture, 'deviceInputCapture should be exported') + for (const method of [ + 'getSupport', + 'listInputDevices', + 'start', + 'setChannelRouting', + 'stop', + 'drain', + 'nowMilliseconds', + ]) { + assert.equal(typeof deviceInputCapture[method], 'function', `deviceInputCapture.${method} should be a function`) + } + assert.equal(deviceInputCapture.getSupport().available, process.platform === 'darwin') + const activeExport = process.platform === 'darwin' ? 'macosCapture' : process.platform === 'win32' From ae5a8617bf221ea59d84a6b3d36c7aca1f796994 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:18:32 -0400 Subject: [PATCH 04/27] new waterfall scope --- README.md | 18 +- native/binding.gyp | 2 + native/src/main.cpp | 2 + native/src/waterfall.cpp | 156 +++++++++++ native/src/waterfall.h | 52 ++++ native/src/waterfall_napi.cpp | 76 ++++++ native/src/waterfall_napi.h | 3 + package.json | 1 + scripts/run-tests.mjs | 1 + src/preload/index.ts | 1 + src/renderer/audio/AudioRouter.ts | 19 +- .../audio/NativeVisualizerTransport.ts | 5 +- src/renderer/audio/native/index.ts | 9 + src/renderer/audio/native/visualizer-dsp.d.ts | 2 + src/renderer/components/BottomBar.tsx | 1 + src/renderer/components/ScopeModule.tsx | 11 + src/renderer/components/ScopePopoutBridge.tsx | 2 + .../components/ScopeSettingsSection.tsx | 29 ++ src/renderer/components/Strip.tsx | 1 + src/renderer/popouts/ScopePopoutDataSource.ts | 10 +- src/renderer/stores/settingsStore.ts | 4 +- src/renderer/visualizers/Waterfall.ts | 257 ++++++++++++++++++ src/renderer/visualizers/waterfallPlot.ts | 47 ++++ src/shared/profileState.ts | 12 +- src/shared/themeState.ts | 21 ++ src/types/popout.ts | 3 + src/types/scope.ts | 4 + src/types/settings.ts | 3 + src/types/theme.ts | 6 + src/types/waterfall.ts | 60 ++++ test/audio-router.test.ts | 24 ++ test/profile-library.test.ts | 22 ++ test/renderer-helpers.test.ts | 153 +++++++++++ test/theme-library.test.ts | 16 ++ test/waterfall-native.test.mjs | 113 ++++++++ tui/CMakeLists.txt | 4 +- tui/src/analysis_pipeline.cpp | 20 +- tui/src/analysis_pipeline.h | 9 +- tui/src/cli.cpp | 2 +- tui/src/dashboard_layout.cpp | 5 + tui/src/dashboard_layout.h | 1 + tui/src/tui_runtime.cpp | 85 +++++- tui/src/tui_settings.cpp | 84 +++++- tui/src/tui_settings.h | 21 ++ tui/src/tui_theme.cpp | 11 + tui/src/tui_theme.h | 3 + tui/src/waterfall_plot_model.cpp | 155 +++++++++++ tui/src/waterfall_plot_model.h | 31 +++ tui/test/tui_tests.cpp | 254 ++++++++++++++++- 49 files changed, 1808 insertions(+), 23 deletions(-) create mode 100644 native/src/waterfall.cpp create mode 100644 native/src/waterfall.h create mode 100644 native/src/waterfall_napi.cpp create mode 100644 native/src/waterfall_napi.h create mode 100644 src/renderer/visualizers/Waterfall.ts create mode 100644 src/renderer/visualizers/waterfallPlot.ts create mode 100644 src/types/waterfall.ts create mode 100644 test/waterfall-native.test.mjs create mode 100644 tui/src/waterfall_plot_model.cpp create mode 100644 tui/src/waterfall_plot_model.h diff --git a/README.md b/README.md index c8f5b8d..93b1c69 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,12 @@ Monitor system audio or an input with a configurable set of real-time scopes and ## Scopes -Prism includes seven real-time scopes and meters: +Prism includes eight real-time scopes and meters: * **Spectrum Analyzer** — FFT spectrum with calibrated dBFS levels, configurable FFT size, spectral tilt, Log/Mel/Linear scales, heatmap and fill modes, and peak/pitch readouts * **Oscilloscope** — Time-domain waveform with fundamental-frequency pitch locking and sub-sample triggering for a stable display * **Vectorscope** — Full-band stereo phase analysis with XY, Polar, and M/S Linear views, calibrated references, adjustable zoom, and optional multiband RGB +* **Waterfall** — Layered spectrum history with 1–30 seconds of audio, adaptive ridge density, theme or heat colors, and frequency/time guides in the desktop app and TUI * **Spectrogram** — Scrolling frequency-over-time display with Log/Mel/Linear scales, stereo-energy analysis, and frequency reassignment in Sharp and Sharper modes * **VU Meter** — 300 ms metering with adjustable 0 VU reference, stereo correlation, and needle or bar displays * **Loudness Meter** — ITU-R BS.1770 momentary, short-term, and integrated LUFS metering with BS.1770 true-peak activity @@ -52,6 +53,19 @@ Most of Prism's analysis runs in native C++, with the same DSP implementations r * Peak analysis can report dBFS, frequency, musical note, octave, and cents offset * Interactive measurement overlay exposes frequency, level, and pitch directly from the graph +### Waterfall + +* Independent native FFT analyzer with the same calibrated channel-max stereo spectrum as Spectrum +* Stores 60 spectrum slices per second on the audio clock, independent of terminal or desktop refresh rate +* Bounded 1–30 second history; defaults to 5 seconds +* Sparse, Balanced, and Dense ridge settings adapt to panel size while retaining the selected history duration +* Desktop shows a lightly softened live spectrum followed by its history, retaining distinct peaks and a common frequency axis +* Frequency scale, FFT size, smoothing, range, and spectral tilt controls +* Theme-colored ridges fading with age, or level-based heat colors +* Foreground ridges hide covered sections of older lines +* Available through the desktop add-scope menu and TUI layout editor (shortcut **8**) +* Desktop popouts and saved profiles are supported; existing layouts stay unchanged + ### Spectrogram * Hann-windowed FFT with 4× zero-padding @@ -155,7 +169,7 @@ Most of Prism's analysis runs in native C++, with the same DSP implementations r ### Terminal UI -* Native C++ frontend using the same Spectrum, Oscilloscope, Vectorscope, VU, Loudness, Spectrogram, Waveform, and system-capture implementations +* Native C++ frontend using the same Spectrum, Waterfall, Oscilloscope, Vectorscope, VU, Loudness, Spectrogram, Waveform, and system-capture implementations * 60 FPS default rendering * Experimental 120 FPS mode * Compatibility mode uses 256-color output at up to 60 FPS diff --git a/native/binding.gyp b/native/binding.gyp index d0b1c95..49b0d5b 100644 --- a/native/binding.gyp +++ b/native/binding.gyp @@ -13,6 +13,8 @@ "src/capture_channel_selection_napi.cpp", "src/oscilloscope.cpp", "src/spectrum.cpp", + "src/waterfall.cpp", + "src/waterfall_napi.cpp", "src/spectrogram.cpp", "src/vectorscope.cpp", "src/multiband.cpp", diff --git a/native/src/main.cpp b/native/src/main.cpp index e5ce56b..3d7d094 100644 --- a/native/src/main.cpp +++ b/native/src/main.cpp @@ -8,6 +8,7 @@ #include "windows_capture.h" #include "oscilloscope.h" #include "spectrum.h" +#include "waterfall_napi.h" #include "spectrogram.h" #include "vectorscope.h" #include "waveform.h" @@ -788,6 +789,7 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) { lufsExports.Set("reset", Napi::Function::New(env, LUFSMeterReset)); exports.Set("lufsmeter", lufsExports); + RegisterWaterfall(env, exports); RegisterSystemAudioCapture(env, exports); RegisterDeviceInputCapture(env, exports); RegisterCaptureChannelSelection(env, exports); diff --git a/native/src/waterfall.cpp b/native/src/waterfall.cpp new file mode 100644 index 0000000..ac68d04 --- /dev/null +++ b/native/src/waterfall.cpp @@ -0,0 +1,156 @@ +#include "waterfall.h" +#include +#include +#include + +namespace Visualizer { +namespace { +float finiteOr(float value, float fallback) { + // The addon is built with fast-math on some platforms; inspect IEEE bits so + // NaN/Infinity sanitization cannot be optimized away under those flags. + uint32_t bits; + std::memcpy(&bits, &value, sizeof(bits)); + return (bits & 0x7f800000U) == 0x7f800000U ? fallback : value; +} +float mel(float hz) { + return hz < 1000 ? hz / (200.0f / 3) : 15 + std::log(hz / 1000) / (std::log(6.4f) / 27); +} +float inverseMel(float value) { + return value < 15 ? value * (200.0f / 3) : 1000 * std::exp((value - 15) * (std::log(6.4f) / 27)); +} +float frequencyAt(float x, const WaterfallConfig& config) { + const float high = std::min(config.maxFrequency, config.sampleRate * 0.5f); + const float low = std::min(config.minFrequency, high); + if (config.scaleMode == "linear") return low + x * (high - low); + if (config.scaleMode == "mel") return inverseMel(mel(low) + x * (mel(high) - mel(low))); + return low * std::pow(high / low, x); +} +} + +WaterfallAnalyzer::WaterfallAnalyzer() { configure(config_); } + +void WaterfallAnalyzer::resizeHistory(size_t capacity) { + const size_t bins = config_.fftSize / 2; + std::vector values(capacity * bins, -100); + std::vector times(capacity); + const size_t keep = std::min(count_, capacity); + for (size_t i = 0; i < keep; ++i) { + const size_t source = (write_ + capacity_ - keep + i) % capacity_; + std::copy_n(history_.data() + source * bins, bins, values.data() + i * bins); + times[i] = times_[source]; + } + history_.swap(values); + times_.swap(times); + capacity_ = capacity; + count_ = keep; + write_ = keep % capacity; +} + +void WaterfallAnalyzer::configure(const WaterfallConfig& value) { + WaterfallConfig next = value; + next.sampleRate = std::clamp(finiteOr(next.sampleRate, 48000), 8000.0f, 384000.0f); + if (next.fftSize < 1024 || next.fftSize > 16384 || (next.fftSize & (next.fftSize - 1))) next.fftSize = 2048; + next.historySeconds = std::clamp(finiteOr(next.historySeconds, 5), 1.0f, 30.0f); + next.smoothing = std::clamp(finiteOr(next.smoothing, 0.9f), 0.0f, 0.99f); + next.tiltDbPerOctave = std::clamp(finiteOr(next.tiltDbPerOctave, 2), -2.0f, 8.0f); + next.minFrequency = std::clamp(finiteOr(next.minFrequency, 10), 1.0f, next.sampleRate * 0.5f); + next.maxFrequency = std::clamp(finiteOr(next.maxFrequency, 24000), next.minFrequency, next.sampleRate * 0.5f); + if (next.scaleMode != "mel" && next.scaleMode != "linear") next.scaleMode = "log"; + const bool restart = next.sampleRate != config_.sampleRate || next.fftSize != config_.fftSize; + if (restart) reset(); + config_ = next; + spectrum_.setFFTSize(config_.fftSize); + spectrum_.setSampleRate(config_.sampleRate); + spectrum_.setSmoothing(config_.smoothing); + const size_t capacity = static_cast(std::ceil(config_.historySeconds * 60)) + 2; + if (restart || capacity != capacity_) resizeHistory(capacity); + pendingLeft_.reserve(static_cast(std::ceil(config_.sampleRate / 60))); + pendingRight_.reserve(pendingLeft_.capacity()); +} + +void WaterfallAnalyzer::append() { + const auto& levels = spectrum_.getChannelMaxMagnitudes(); + std::copy(levels.begin(), levels.end(), history_.begin() + write_ * levels.size()); + times_[write_] = static_cast(samples_) / config_.sampleRate; + write_ = (write_ + 1) % capacity_; + count_ = std::min(count_ + 1, capacity_); +} + +void WaterfallAnalyzer::processStereo(const float* left, const float* right, size_t length) { + if (!left || !right) return; + size_t offset = 0; + while (offset < length) { + const uint64_t boundary = static_cast(std::ceil(tick_ * static_cast(config_.sampleRate) / 60)); + const size_t take = std::min(length - offset, static_cast(boundary - samples_)); + for (size_t i = 0; i < take; ++i) { + pendingLeft_.push_back(finiteOr(left[offset + i], 0)); + pendingRight_.push_back(finiteOr(right[offset + i], 0)); + } + offset += take; + samples_ += take; + if (samples_ == boundary) { + spectrum_.pushStereoSamples(pendingLeft_.data(), pendingRight_.data(), pendingLeft_.size()); + pendingLeft_.clear(); + pendingRight_.clear(); + if (samples_ >= config_.fftSize) append(); + ++tick_; + } + } +} + +WaterfallFrame WaterfallAnalyzer::getFrame(size_t ridges, size_t columns) const { + WaterfallFrame frame; + frame.columns = std::clamp(columns, size_t{2}, size_t{512}); + frame.audioSeconds = static_cast(samples_) / config_.sampleRate; + ridges = std::clamp(ridges, size_t{2}, size_t{64}); + frame.frequencies.resize(frame.columns); + const size_t bins = config_.fftSize / 2; + std::vector positions(frame.columns), tilts(frame.columns); + std::vector starts(frame.columns), ends(frame.columns); + for (size_t x = 0; x < frame.columns; ++x) { + const float t = static_cast(x) / (frame.columns - 1); + const float half = 0.5f / (frame.columns - 1); + const float hz = frequencyAt(t, config_); + frame.frequencies[x] = hz; + positions[x] = std::clamp(hz * config_.fftSize / config_.sampleRate, 0.0f, static_cast(bins - 1)); + starts[x] = std::min(bins - 1, static_cast(std::ceil(frequencyAt(std::max(0.0f, t - half), config_) * config_.fftSize / config_.sampleRate))); + ends[x] = std::min(bins - 1, static_cast(std::floor(frequencyAt(std::min(1.0f, t + half), config_) * config_.fftSize / config_.sampleRate))); + tilts[x] = config_.tiltDbPerOctave * std::log2(hz / 1000); + } + // Anchor historical ridges to the audio timeline so they move without morphing. + const double spacing = config_.historySeconds / static_cast(ridges - 1); + const double anchor = std::floor(frame.audioSeconds / spacing) * spacing; + size_t cursor = 0; + for (size_t ridge = 0; ridge < ridges; ++ridge) { + const double target = ridge == 0 ? frame.audioSeconds : anchor - (ridge - 1) * spacing; + if (ridge == 1 && frame.audioSeconds - anchor < 1.0 / 60) continue; + while (cursor < count_) { + const size_t index = (write_ + capacity_ - 1 - cursor) % capacity_; + if (times_[index] <= target + 1e-8) break; + ++cursor; + } + if (cursor >= count_) break; + const size_t index = (write_ + capacity_ - 1 - cursor) % capacity_; + const float age = static_cast(frame.audioSeconds - times_[index]); + if (age > config_.historySeconds) break; + frame.ages.push_back(age); + const float* values = history_.data() + index * bins; + for (size_t x = 0; x < frame.columns; ++x) { + const size_t lo = static_cast(positions[x]); + const size_t hi = std::min(lo + 1, bins - 1); + float db = values[lo] + (values[hi] - values[lo]) * (positions[x] - lo); + for (size_t bin = starts[x]; bin <= ends[x]; ++bin) db = std::max(db, values[bin]); + frame.levels.push_back(db + tilts[x]); + } + } + return frame; +} + +void WaterfallAnalyzer::reset() { + spectrum_.reset(); + pendingLeft_.clear(); + pendingRight_.clear(); + count_ = write_ = samples_ = 0; + tick_ = 1; +} +} // namespace Visualizer diff --git a/native/src/waterfall.h b/native/src/waterfall.h new file mode 100644 index 0000000..258dae8 --- /dev/null +++ b/native/src/waterfall.h @@ -0,0 +1,52 @@ +#pragma once + +#include "spectrum.h" +#include +#include +#include + +namespace Visualizer { + +struct WaterfallConfig { + float sampleRate = 48000; + size_t fftSize = 2048; + float historySeconds = 5; + float smoothing = 0.9f; + float tiltDbPerOctave = 2; + float minFrequency = 10; + float maxFrequency = 24000; + std::string scaleMode = "log"; +}; + +// Newest ridge first. Levels retain dB units; projection and painting are separate. +struct WaterfallFrame { + std::vector levels; + std::vector ages; + std::vector frequencies; + size_t columns = 0; + double audioSeconds = 0; +}; + +class WaterfallAnalyzer { +public: + WaterfallAnalyzer(); + void configure(const WaterfallConfig& config); + void processStereo(const float* left, const float* right, size_t length); + WaterfallFrame getFrame(size_t ridges, size_t columns) const; + void reset(); + size_t storedSlices() const { return count_; } + size_t capacitySlices() const { return capacity_; } + +private: + WaterfallConfig config_; + Spectrum spectrum_{2048}; + std::vector history_; + std::vector times_; + std::vector pendingLeft_, pendingRight_; + size_t capacity_ = 0, count_ = 0, write_ = 0; + uint64_t samples_ = 0, tick_ = 1; + void resizeHistory(size_t capacity); + void append(); +}; + +} // namespace Visualizer diff --git a/native/src/waterfall_napi.cpp b/native/src/waterfall_napi.cpp new file mode 100644 index 0000000..30f6609 --- /dev/null +++ b/native/src/waterfall_napi.cpp @@ -0,0 +1,76 @@ +#include "waterfall_napi.h" +#include "waterfall.h" +#include +#include + +namespace { +Visualizer::WaterfallAnalyzer analyzer; +Napi::Value configure(const Napi::CallbackInfo& info) { + const auto env = info.Env(); + if (info.Length() < 1 || !info[0].IsObject()) { + Napi::TypeError::New(env, "Expected waterfall configuration").ThrowAsJavaScriptException(); + return env.Undefined(); + } + const auto options = info[0].As(); + Visualizer::WaterfallConfig config; + const auto number = [&](const char* name, float fallback) { + auto value = options.Get(name); + return value.IsNumber() ? value.As().FloatValue() : fallback; + }; + config.sampleRate = number("sampleRate", config.sampleRate); + auto fft = options.Get("fftSize"); + if (fft.IsNumber()) config.fftSize = fft.As().Uint32Value(); + config.historySeconds = number("historySeconds", config.historySeconds); + config.smoothing = number("smoothing", config.smoothing); + config.tiltDbPerOctave = number("tiltDbPerOctave", config.tiltDbPerOctave); + config.minFrequency = number("minFrequency", config.minFrequency); + config.maxFrequency = number("maxFrequency", config.maxFrequency); + auto scale = options.Get("scaleMode"); + if (scale.IsString()) config.scaleMode = scale.As().Utf8Value(); + analyzer.configure(config); + return env.Undefined(); +} +Napi::Value processStereo(const Napi::CallbackInfo& info) { + const auto env = info.Env(); + if (info.Length() < 2 || !info[0].IsTypedArray() || !info[1].IsTypedArray() + || info[0].As().TypedArrayType() != napi_float32_array + || info[1].As().TypedArrayType() != napi_float32_array) { + Napi::TypeError::New(env, "Expected left and right Float32Array audio").ThrowAsJavaScriptException(); + return env.Undefined(); + } + const auto left = info[0].As(); + const auto right = info[1].As(); + analyzer.processStereo(left.Data(), right.Data(), std::min(left.ElementLength(), right.ElementLength())); + return env.Undefined(); +} +Napi::Value getFrame(const Napi::CallbackInfo& info) { + const auto env = info.Env(); + const size_t ridges = info.Length() > 0 && info[0].IsNumber() ? info[0].As().Uint32Value() : 32; + const size_t columns = info.Length() > 1 && info[1].IsNumber() ? info[1].As().Uint32Value() : 256; + const auto frame = analyzer.getFrame(ridges, columns); + auto output = Napi::Object::New(env); + const auto floats = [&](const char* name, const std::vector& values) { + auto array = Napi::Float32Array::New(env, values.size()); + if (!values.empty()) std::memcpy(array.Data(), values.data(), values.size() * sizeof(float)); + output.Set(name, array); + }; + floats("levels", frame.levels); + floats("ages", frame.ages); + floats("frequencies", frame.frequencies); + output.Set("columns", Napi::Number::New(env, frame.columns)); + output.Set("audioSeconds", Napi::Number::New(env, frame.audioSeconds)); + return output; +} +} + +void RegisterWaterfall(Napi::Env env, Napi::Object exports) { + auto api = Napi::Object::New(env); + api.Set("configure", Napi::Function::New(env, configure)); + api.Set("processStereo", Napi::Function::New(env, processStereo)); + api.Set("getFrame", Napi::Function::New(env, getFrame)); + api.Set("reset", Napi::Function::New(env, [](const Napi::CallbackInfo& info) { + analyzer.reset(); + return info.Env().Undefined(); + })); + exports.Set("waterfall", api); +} diff --git a/native/src/waterfall_napi.h b/native/src/waterfall_napi.h new file mode 100644 index 0000000..f1dda9c --- /dev/null +++ b/native/src/waterfall_napi.h @@ -0,0 +1,3 @@ +#pragma once +#include +void RegisterWaterfall(Napi::Env env, Napi::Object exports); diff --git a/package.json b/package.json index 9f2a28c..28f00f6 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "test:window-state": "node scripts/run-window-state-tests.mjs", "test:desktop-integration": "node scripts/run-desktop-integration-tests.mjs", "test:renderer-helpers": "node scripts/run-renderer-helper-tests.mjs", + "test:waterfall-native": "node --test test/waterfall-native.test.mjs", "test:spectrum-native": "node --test test/spectrum-native.test.mjs", "test:spectrogram-native": "node --test test/spectrogram-native.test.mjs", "test:vectorscope-native": "node --test test/vectorscope-native.test.mjs", diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 3ce49bf..1076eec 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -18,6 +18,7 @@ const testScripts = [ 'test:renderer-helpers', 'test:build-metadata', 'test:updates', + 'test:waterfall-native', 'test:spectrum-native', 'test:spectrogram-native', 'test:vectorscope-native', diff --git a/src/preload/index.ts b/src/preload/index.ts index 0f74ead..8b14a32 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -341,6 +341,7 @@ try { const visualizerAPI = nativeAddonModule ? { oscilloscope: nativeAddonModule.oscilloscope, + waterfall: nativeAddonModule.waterfall, spectrum: nativeAddonModule.spectrum, spectrogram: nativeAddonModule.spectrogram, vectorscope: nativeAddonModule.vectorscope, diff --git a/src/renderer/audio/AudioRouter.ts b/src/renderer/audio/AudioRouter.ts index 542ad10..9850e4e 100644 --- a/src/renderer/audio/AudioRouter.ts +++ b/src/renderer/audio/AudioRouter.ts @@ -14,6 +14,7 @@ const MAX_PENDING_VECTORSCOPE_CHUNKS = 20 const LATENCY_SAMPLE_WINDOW = 240 const SCOPE_RING_CAPACITY: Record = { + waterfall: MAX_PENDING_SPECTRUM_CHUNKS, spectrum: MAX_PENDING_SPECTRUM_CHUNKS, oscilloscope: MAX_PENDING_CHUNKS, vectorscope: MAX_PENDING_VECTORSCOPE_CHUNKS, @@ -33,6 +34,7 @@ export interface AudioSessionState { } export interface VisualizerConsumerDemand { + waterfall?: boolean spectrum?: boolean oscilloscope?: boolean vectorscope?: boolean @@ -95,6 +97,7 @@ interface ScopeLatencyTracker { } type ScopeRingMap = { + waterfall: FixedChunkRing spectrum: FixedChunkRing oscilloscope: FixedChunkRing vectorscope: FixedChunkRing @@ -196,6 +199,7 @@ class RollingLatencyWindow { function createEmptyDemand(): NormalizedVisualizerConsumerDemand { return { + waterfall: false, spectrum: false, oscilloscope: false, vectorscope: false, @@ -217,6 +221,7 @@ function createScopeLatencyTracker(): ScopeLatencyTracker { export class AudioRouter { private readonly rings: ScopeRingMap = { + waterfall: new FixedChunkRing(SCOPE_RING_CAPACITY.waterfall), spectrum: new FixedChunkRing(SCOPE_RING_CAPACITY.spectrum), oscilloscope: new FixedChunkRing(SCOPE_RING_CAPACITY.oscilloscope), vectorscope: new FixedChunkRing(SCOPE_RING_CAPACITY.vectorscope), @@ -227,6 +232,7 @@ export class AudioRouter { } private readonly scopeLatency: Record = { + waterfall: createScopeLatencyTracker(), spectrum: createScopeLatencyTracker(), oscilloscope: createScopeLatencyTracker(), vectorscope: createScopeLatencyTracker(), @@ -340,6 +346,7 @@ export class AudioRouter { spectrum: Boolean(demand.spectrum), oscilloscope: Boolean(demand.oscilloscope), vectorscope: Boolean(demand.vectorscope), + waterfall: Boolean(demand.waterfall), spectrogram: Boolean(demand.spectrogram), vumeter: Boolean(demand.vumeter), lufsmeter: Boolean(demand.lufsmeter), @@ -384,7 +391,7 @@ export class AudioRouter { const activeDemand = this.getActiveDemand() const needsSpectrum = Boolean(activeDemand.spectrum) - const needsStereo = Boolean(activeDemand.spectrogram || activeDemand.vectorscope || activeDemand.vumeter || activeDemand.lufsmeter || activeDemand.waveform) + const needsStereo = Boolean(activeDemand.waterfall || activeDemand.spectrogram || activeDemand.vectorscope || activeDemand.vumeter || activeDemand.lufsmeter || activeDemand.waveform) const needsLeft = Boolean(activeDemand.oscilloscope) if (!needsSpectrum && !needsStereo && !needsLeft) { @@ -405,6 +412,10 @@ export class AudioRouter { this.rings.spectrum.push({ left: leftSamples, right: rightSamples, capturedAt, sequence, transport: meta.transport }) } + if (activeDemand.waterfall) { + this.rings.waterfall.push({ left: leftSamples, right: rightSamples, capturedAt, sequence }) + } + if (activeDemand.spectrogram) { this.rings.spectrogram.push({ left: leftSamples, right: rightSamples, capturedAt, sequence, transport: meta.transport }) } @@ -426,6 +437,12 @@ export class AudioRouter { } } + flushPendingWaterfallSamples(): { left: Float32Array; right: Float32Array; sequence: number }[] { + const records = this.rings.waterfall.drain() + this.recordScopeDrain('waterfall', records) + return records.map(({ left, right, sequence }) => ({ left, right, sequence })) + } + flushPendingOscilloscopeSamples(): Float32Array[] { const records = this.rings.oscilloscope.drain() this.recordScopeDrain('oscilloscope', records) diff --git a/src/renderer/audio/NativeVisualizerTransport.ts b/src/renderer/audio/NativeVisualizerTransport.ts index 09ed069..977e87a 100644 --- a/src/renderer/audio/NativeVisualizerTransport.ts +++ b/src/renderer/audio/NativeVisualizerTransport.ts @@ -29,6 +29,7 @@ export interface NativeVisualizerTransportBridge { } const EMPTY_DEMAND: Required = { + waterfall: false, spectrum: false, oscilloscope: false, vectorscope: false, @@ -60,6 +61,7 @@ const defaultBridge: NativeVisualizerTransportBridge = { function normalizeDemand(demand: VisualizerConsumerDemand): Required { return { + waterfall: Boolean(demand.waterfall), spectrum: Boolean(demand.spectrum), oscilloscope: Boolean(demand.oscilloscope), vectorscope: Boolean(demand.vectorscope), @@ -84,7 +86,8 @@ export class NativeVisualizerTransport { setDemand(demand: VisualizerConsumerDemand): void { const nextDemand = normalizeDemand(demand) if ( - nextDemand.spectrum === this.demand.spectrum + nextDemand.waterfall === this.demand.waterfall + && nextDemand.spectrum === this.demand.spectrum && nextDemand.oscilloscope === this.demand.oscilloscope && nextDemand.vectorscope === this.demand.vectorscope && nextDemand.spectrogram === this.demand.spectrogram diff --git a/src/renderer/audio/native/index.ts b/src/renderer/audio/native/index.ts index 55051ac..e7f6fe6 100644 --- a/src/renderer/audio/native/index.ts +++ b/src/renderer/audio/native/index.ts @@ -1,3 +1,4 @@ +import type { WaterfallNativeAnalyzer } from '../../../types/waterfall' // Native visualizer DSP module loader // This loads the native C++ addon for high-performance audio visualization @@ -462,3 +463,11 @@ export type { VectorscopeMultibandPointsResult, VUMeterNativeSnapshot, } + +export const waterfall: WaterfallNativeAnalyzer = { + isAvailable: () => Boolean(nativeModule?.waterfall), + configure: (options) => nativeModule?.waterfall?.configure(options), + processStereo: (left, right) => nativeModule?.waterfall?.processStereo(left, right), + getFrame: (ridges, columns) => nativeModule?.waterfall?.getFrame(ridges, columns) ?? null, + reset: () => nativeModule?.waterfall?.reset(), +} diff --git a/src/renderer/audio/native/visualizer-dsp.d.ts b/src/renderer/audio/native/visualizer-dsp.d.ts index f82707c..b9908ae 100644 --- a/src/renderer/audio/native/visualizer-dsp.d.ts +++ b/src/renderer/audio/native/visualizer-dsp.d.ts @@ -1,3 +1,4 @@ +import type { WaterfallNativeAnalyzer } from '../../../types/waterfall' // Type definitions for visualizer_dsp native addon export interface OscilloscopeResult { @@ -162,6 +163,7 @@ export interface VUMeterModule { export interface VisualizerDSP { oscilloscope: OscilloscopeModule; + waterfall: WaterfallNativeAnalyzer; spectrum: SpectrumModule; spectrogram: SpectrogramModule; vectorscope: VectorscopeModule; diff --git a/src/renderer/components/BottomBar.tsx b/src/renderer/components/BottomBar.tsx index 546b50f..45a7dcb 100644 --- a/src/renderer/components/BottomBar.tsx +++ b/src/renderer/components/BottomBar.tsx @@ -23,6 +23,7 @@ import ThemedSelect from './ThemedSelect' import ChannelRoutingMatrix from './ChannelRoutingMatrix' const SCOPE_LABELS: Record = { + waterfall: 'Waterfall', spectrum: 'Spectrum', oscilloscope: 'Oscilloscope', vectorscope: 'Vectorscope', diff --git a/src/renderer/components/ScopeModule.tsx b/src/renderer/components/ScopeModule.tsx index 4b48b91..01d5578 100644 --- a/src/renderer/components/ScopeModule.tsx +++ b/src/renderer/components/ScopeModule.tsx @@ -1,3 +1,4 @@ +import { Waterfall, type WaterfallDataSource } from '../visualizers/Waterfall' import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type JSX } from 'react' import { isTransformableScopeKind, type ScopeKind } from '../../types/scope' import { @@ -18,6 +19,7 @@ import type { ResolvedSpectrumTheme, ResolvedVectorscopeTheme, ResolvedVUMeterTheme, + ResolvedWaterfallTheme, ResolvedWaveformTheme, } from '../../types/theme' import type { SpectrumPeakInfo } from '../../types/spectrum' @@ -54,6 +56,7 @@ type ScopeModuleTheme = | ResolvedSpectrogramTheme | ResolvedVUMeterTheme | ResolvedLUFSMeterTheme + | ResolvedWaterfallTheme | ResolvedWaveformTheme | ResolvedAstraTheme @@ -67,6 +70,7 @@ interface ScopeModuleProps { linkedAnalysisProbe?: LinkedAnalysisProbe | null onLinkedAnalysisMessage?: (message: LinkedAnalysisMessage) => void dataSource?: + | WaterfallDataSource | SpectrumAnalyzerDataSource | OscilloscopeDataSource | VectorscopeDataSource @@ -178,6 +182,11 @@ export function scopeSettingsToOptions( theme: ScopeModuleTheme, ): Record { switch (kind) { + case 'waterfall': { + const s = settings as ScopeSettings['waterfall'] + const t = theme as ResolvedWaterfallTheme + return { ...s, lineColor: t.line, heatColors: t.heatColors, backgroundColor: t.background, gridColor: t.guides, labelColor: t.labels } + } case 'spectrum': { const s = settings as ScopeSettings['spectrum'] const t = theme as ResolvedSpectrumTheme @@ -368,6 +377,8 @@ function createVisualizer( frameScheduler, } switch (scopeKind) { + case 'waterfall': + return new Waterfall(canvas, { ...opts, ...(dataSource ? { dataSource: dataSource as WaterfallDataSource } : {}) }) case 'spectrum': return new SpectrumAnalyzer(canvas, { ...opts, diff --git a/src/renderer/components/ScopePopoutBridge.tsx b/src/renderer/components/ScopePopoutBridge.tsx index 81f0f2e..1c3b701 100644 --- a/src/renderer/components/ScopePopoutBridge.tsx +++ b/src/renderer/components/ScopePopoutBridge.tsx @@ -30,6 +30,8 @@ function buildConsumerDemand(kind: AudioScopeKind): Record
+ {kind === 'waterfall' && (() => { + const current = settings as ScopeSettings['waterfall'] + return <> + onUpdate('waterfall', { historySeconds: value })} /> + onUpdate('waterfall', { density: value as ScopeSettings['waterfall']['density'] })}> + + + onUpdate('waterfall', { colorMode: value as ScopeSettings['waterfall']['colorMode'] })}> + + + onUpdate('waterfall', { fftSize: Number(value) })}> + {[1024, 2048, 4096, 8192, 16384].map((size) => )} + + onUpdate('waterfall', { scaleMode: value as ScopeSettings['waterfall']['scaleMode'] })}> + + + onUpdate('waterfall', { frequencyRangeMode: value as ScopeSettings['waterfall']['frequencyRangeMode'] })}> + + + onUpdate('waterfall', { showGrid: !current.showGrid })} /> + onUpdate('waterfall', { tiltDbPerOctave: value })} /> + onUpdate('waterfall', { smoothing: value })} /> + + })()} + {kind === 'spectrum' && (() => { const current = settings as ScopeSettings['spectrum'] return ( diff --git a/src/renderer/components/Strip.tsx b/src/renderer/components/Strip.tsx index 0bc8bbc..cf9376f 100644 --- a/src/renderer/components/Strip.tsx +++ b/src/renderer/components/Strip.tsx @@ -87,6 +87,7 @@ export default function Strip({ useEffect(() => { const visibleAudioScopeSet = new Set(dockedScopes.filter(isAudioScopeKind)) audioRouter.setVisualizerConsumerDemand('docked-strip', { + waterfall: visibleAudioScopeSet.has('waterfall'), spectrum: visibleAudioScopeSet.has('spectrum'), oscilloscope: visibleAudioScopeSet.has('oscilloscope'), vectorscope: visibleAudioScopeSet.has('vectorscope'), diff --git a/src/renderer/popouts/ScopePopoutDataSource.ts b/src/renderer/popouts/ScopePopoutDataSource.ts index 3598157..01395e7 100644 --- a/src/renderer/popouts/ScopePopoutDataSource.ts +++ b/src/renderer/popouts/ScopePopoutDataSource.ts @@ -1,3 +1,4 @@ +import type { WaterfallDataSource } from '../visualizers/Waterfall' import type { ScopePopoutAudioBatch, ScopePopoutSessionState, @@ -14,6 +15,7 @@ import type { VUMeterDataSource } from '../visualizers/VUMeter' import type { WaveformDataSource } from '../visualizers/Waveform' type AnyScopeDataSource = + & WaterfallDataSource & SpectrumAnalyzerDataSource & OscilloscopeDataSource & VectorscopeDataSource @@ -36,7 +38,7 @@ function isStereoBatch(batch: ScopePopoutAudioBatch): batch is ScopePopoutStereo } function isStereoScope(kind: ScopeKind): boolean { - return kind === 'spectrogram' || kind === 'vectorscope' || kind === 'vumeter' || kind === 'lufsmeter' + return kind === 'waterfall' || kind === 'spectrogram' || kind === 'vectorscope' || kind === 'vumeter' || kind === 'lufsmeter' } export class ScopePopoutDataSource implements AnyScopeDataSource { @@ -112,6 +114,12 @@ export class ScopePopoutDataSource implements AnyScopeDataSource { } + getPendingWaterfallSamples(): ScopePopoutStereoBatch { + const result = this.stereoQueue + this.stereoQueue = [] + return result + } + getPendingSpectrumSamples(): Float32Array[] { const batch = this.monoQueue this.monoQueue = [] diff --git a/src/renderer/stores/settingsStore.ts b/src/renderer/stores/settingsStore.ts index 79e73de..bf9d632 100644 --- a/src/renderer/stores/settingsStore.ts +++ b/src/renderer/stores/settingsStore.ts @@ -160,7 +160,7 @@ function normalizePersistedProfileBaselineSignature(raw: string): string | null return JSON.stringify({ name: typeof candidate.name === 'string' ? candidate.name : DEFAULT_PROFILE_NAME, scopeOrder: normalizeScopeOrder(candidate.scopeOrder), - hiddenScopes: normalizeHiddenScopes(candidate.hiddenScopes), + hiddenScopes: normalizeHiddenScopes(candidate.hiddenScopes, candidate.scopeOrder ?? []), widthWeights: normalizeWidthWeights(candidate.widthWeights), scopeSettings: mergeScopeSettings(candidate.scopeSettings), analysisSettings: normalizeAnalysisSettings(candidate.analysisSettings), @@ -362,7 +362,7 @@ function createWorkingStateFromProfile(profile: Profile): WorkingSettingsState { function createWorkingStateFromPersistedState(state: Partial): WorkingSettingsState { return { scopeOrder: normalizeScopeOrder(state.scopeOrder), - hiddenScopes: new Set(normalizeHiddenScopes(state.hiddenScopes)), + hiddenScopes: new Set(normalizeHiddenScopes(state.hiddenScopes, state.scopeOrder ?? [])), widthWeights: normalizeWidthWeights(state.widthWeights), scopeSettings: mergeScopeSettings(state.scopeSettings), analysisSettings: state.analysisSettings === undefined diff --git a/src/renderer/visualizers/Waterfall.ts b/src/renderer/visualizers/Waterfall.ts new file mode 100644 index 0000000..210552b --- /dev/null +++ b/src/renderer/visualizers/Waterfall.ts @@ -0,0 +1,257 @@ +import { audioRouter } from '../audio/AudioRouter' +import { waterfall as nativeWaterfall } from '../audio/native' +import { frequencyBoundsForRange, buildFrequencyGuides } from '../../types/frequencyScale' +import { DEFAULT_WATERFALL_SETTINGS, normalizeWaterfallSettings, type WaterfallAudioChunk, type WaterfallFrame, type WaterfallNativeAnalyzer, type WaterfallSettings } from '../../types/waterfall' +import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource' +import type { FrameScheduler } from './frameScheduler' +import { VisualizerFrameLoop } from './visualizerFrameLoop' +import { parseColorToRgba } from '../utils/color' +import { normalizeHeatDb } from './heatScale' +import { softenWaterfallSpectra, waterfallRidgeHeight, waterfallPlotLayout } from './waterfallPlot' + +export interface WaterfallDataSource extends VisualizerSessionSource { + getPendingWaterfallSamples(): WaterfallAudioChunk[] +} +export interface WaterfallOptions extends Partial { + lineColor?: string + backgroundColor?: string + gridColor?: string + labelColor?: string + heatColors?: [string, string, string] + dataSource?: WaterfallDataSource + frameScheduler?: FrameScheduler + nativeAnalyzer?: WaterfallNativeAnalyzer +} +const defaultSource: WaterfallDataSource = { + ...defaultVisualizerSessionSource, + getPendingWaterfallSamples: () => audioRouter.flushPendingWaterfallSamples(), +} +const defaults = { + ...DEFAULT_WATERFALL_SETTINGS, + lineColor: '#00ffff', backgroundColor: 'transparent', gridColor: 'rgba(128,128,128,0.25)', labelColor: '#999999', + heatColors: ['#0f0721', '#a31a79', '#fff1d1'] as [string, string, string], +} +type DrawOptions = typeof defaults + +function heatPalette(colors: [string, string, string]): string[] { + const stops = colors.map((color) => parseColorToRgba(color) ?? { r: 0, g: 255, b: 255, a: 1 }) + return Array.from({ length: 256 }, (_, index) => { + const t = index / 255 * 2 + const a = stops[t < 1 ? 0 : 1], b = stops[t < 1 ? 1 : 2] + const mix = t < 1 ? t : t - 1 + return `rgb(${Math.round(a.r + (b.r - a.r) * mix)},${Math.round(a.g + (b.g - a.g) * mix)},${Math.round(a.b + (b.b - a.b) * mix)})` + }) +} + +export class Waterfall { + private readonly ctx: CanvasRenderingContext2D + private readonly traces: HTMLCanvasElement + private readonly traceContext: CanvasRenderingContext2D + private readonly loop: VisualizerFrameLoop + private readonly analyzer: WaterfallNativeAnalyzer + private source: WaterfallDataSource + private options: DrawOptions + private unsubscribe: (() => void) | null = null + private configKey = '' + private lastSequence: number | undefined + private sessionActive = false + private palette: string[] + + constructor(private readonly canvas: HTMLCanvasElement, options: WaterfallOptions = {}) { + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('Could not get waterfall 2D context') + this.ctx = ctx + this.traces = document.createElement('canvas') + this.traceContext = this.traces.getContext('2d')! + this.options = { ...defaults, ...options, ...normalizeWaterfallSettings(options) } + this.palette = heatPalette(this.options.heatColors) + this.source = options.dataSource ?? defaultSource + this.analyzer = options.nativeAnalyzer ?? nativeWaterfall + this.loop = new VisualizerFrameLoop({ frameScheduler: options.frameScheduler, shouldRun: () => this.sessionActive && this.source.isPlaying(), onFrame: this.draw }) + this.attachSession() + } + + private attachSession(): void { + this.unsubscribe?.() + let sessionId: number | undefined + let sampleRate: number | undefined + this.unsubscribe = this.source.subscribeToSessionChanges((state) => { + this.sessionActive = state.capturing && !state.suspended + if (sessionId !== state.sessionId || sampleRate !== state.sampleRate) { + this.analyzer.reset() + this.lastSequence = undefined + this.configKey = '' + } + sessionId = state.sessionId + sampleRate = state.sampleRate + this.loop.invalidate() + }) + } + start(): void { this.loop.start() } + stop(): void { this.loop.stop() } + resize(): void { this.loop.invalidate() } + dispose(): void { this.unsubscribe?.(); this.loop.dispose() } + setOptions(options: WaterfallOptions): void { + this.options = { ...this.options, ...options, ...normalizeWaterfallSettings({ ...this.options, ...options }) } + this.palette = heatPalette(this.options.heatColors) + if (options.dataSource && options.dataSource !== this.source) { + this.source = options.dataSource + this.attachSession() + } + this.loop.invalidate() + } + + private draw = (): void => { + const o = this.options + const range = frequencyBoundsForRange(o.frequencyRangeMode, this.source.getSampleRate()) + const config = { sampleRate: this.source.getSampleRate(), fftSize: o.fftSize, historySeconds: o.historySeconds, + smoothing: o.smoothing, tiltDbPerOctave: o.tiltDbPerOctave, scaleMode: o.scaleMode, ...range } + const key = JSON.stringify(config) + if (key !== this.configKey) { this.analyzer.configure(config); this.configKey = key } + if (this.sessionActive && this.source.isPlaying()) { + for (const chunk of this.source.getPendingWaterfallSamples()) { + // A cleared history visibly separates audio across an overrun or capture gap. + if (chunk.sequence && this.lastSequence && chunk.sequence !== this.lastSequence + 1) this.analyzer.reset() + if (chunk.sequence) this.lastSequence = chunk.sequence + this.analyzer.processStereo(chunk.left, chunk.right) + } + } + const dpr = window.devicePixelRatio || 1 + const width = this.canvas.width / dpr, height = this.canvas.height / dpr + if (width <= 0 || height <= 0) return + const layout = waterfallPlotLayout(height, o.density, o.showGrid) + const frame = this.analyzer.getFrame(layout.ridgeCount, Math.max(2, Math.min(512, Math.round(width)))) + const ctx = this.ctx + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, width, height) + ctx.fillStyle = o.backgroundColor + ctx.fillRect(0, 0, width, height) + if (!frame) { + ctx.fillStyle = o.labelColor + ctx.font = '12px sans-serif' + ctx.fillText('Waterfall requires the native audio module', 10, 22) + return + } + this.paintRidges(frame, layout, width, height, dpr) + if (!o.showGrid || width < 90 || height < 55) return + const left = width >= 240 ? 36 : 12, right = width - 12 + const rulerY = layout.bottom + 3 + const showHistory = width >= 240 && height >= 80 + const backY = layout.bottom - layout.historyHeight + ctx.save() + ctx.font = '10px monospace' + ctx.lineWidth = 1 + ctx.fillStyle = o.labelColor + ctx.strokeStyle = o.gridColor + // Keep the rulers at the edges: interior grid lines can be mistaken for + // spectrum history. The corner anchors the front edge to the present. + ctx.globalAlpha = 0.7 + ctx.beginPath() + if (showHistory) { ctx.moveTo(left - 1, backY); ctx.lineTo(left - 1, rulerY) } + else ctx.moveTo(left - 1, rulerY) + ctx.lineTo(right, rulerY) + ctx.stroke() + ctx.globalAlpha = 1 + + // Short ruler marks need the label contrast; the subdued grid color is + // intended for long lines and made intermediate ticks almost invisible. + ctx.strokeStyle = o.labelColor + ctx.lineWidth = 1.25 + ctx.textAlign = 'center' + const guides = buildFrequencyGuides(range.minFrequency, range.maxFrequency, o.scaleMode, right - left) + let labelRight = left - 4 + let lastTickX = left - 8 + for (const guide of guides) { + const x = left + guide.normalizedPosition * (right - left) + if (x - lastTickX < 4) continue + lastTickX = x + const major = guide.kind === 'major' + ctx.globalAlpha = major ? 1 : 0.85 + ctx.beginPath(); ctx.moveTo(x, rulerY); ctx.lineTo(x, rulerY + (major ? 6 : 4)); ctx.stroke() + if (!guide.label) continue + const halfWidth = ctx.measureText(guide.label).width / 2 + const labelX = Math.max(left + halfWidth, Math.min(right - halfWidth, x)) + if (labelX - halfWidth < labelRight + 12) continue + ctx.globalAlpha = 1 + ctx.fillText(guide.label, labelX, layout.bottom + 18) + labelRight = labelX + halfWidth + } + + ctx.globalAlpha = 1 + if (width >= 240) { + ctx.textAlign = 'right' + ctx.font = '9px monospace' + ctx.fillText('Hz', left - 8, layout.bottom + 18) + } + if (showHistory) { + ctx.textAlign = 'right' + ctx.textBaseline = 'middle' + const fractions = layout.historyHeight >= 100 ? [0, 0.25, 0.5, 0.75, 1] : [0, 0.5, 1] + for (const fraction of fractions) { + const y = layout.bottom - fraction * layout.historyHeight + const major = fraction === 0 || fraction === 1 || (fraction === 0.5 && layout.historyHeight >= 70) + ctx.globalAlpha = major ? 1 : 0.85 + ctx.beginPath(); ctx.moveTo(left - (major ? 7 : 5), y); ctx.lineTo(left - 1, y); ctx.stroke() + if (!major) continue + ctx.globalAlpha = 1 + ctx.font = fraction === 0 ? '600 9px monospace' : '10px monospace' + const label = fraction === 0 ? 'NOW' : `${Number((fraction * o.historySeconds).toFixed(1))}s` + ctx.fillText(label, left - 9, y) + } + } + ctx.restore() + } + + private paintRidges(frame: WaterfallFrame, layout: ReturnType, width: number, height: number, dpr: number): void { + if (this.traces.width !== this.canvas.width || this.traces.height !== this.canvas.height) { + this.traces.width = this.canvas.width; this.traces.height = this.canvas.height + } + const ctx = this.traceContext, o = this.options + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, width, height) + const left = o.showGrid && width >= 240 ? 36 : 12, right = width - 12 + const levels = softenWaterfallSpectra(frame) + const xs = Array.from({ length: frame.columns }, (_, i) => left + i / (frame.columns - 1) * (right - left)) + ctx.lineWidth = 1 + ctx.lineJoin = 'round' + for (let ridge = frame.ages.length - 1; ridge >= 0; --ridge) { + const depth = Math.min(1, frame.ages[ridge] / o.historySeconds) + const baseline = layout.bottom - depth * layout.historyHeight + const ys = xs.map((_, column) => baseline - waterfallRidgeHeight(levels[ridge * frame.columns + column], layout.amplitude)) + const path = new Path2D() + path.moveTo(xs[0], ys[0]) + for (let x = 1; x < xs.length; x++) path.lineTo(xs[x], ys[x]) + // Erase older traces beneath this ridge on a transparent layer; never paint an opaque backdrop. + const mask = new Path2D(path) + mask.lineTo(xs[xs.length - 1], height); mask.lineTo(xs[0], height); mask.closePath() + ctx.globalAlpha = 1 + ctx.globalCompositeOperation = 'destination-out' + ctx.fill(mask) + ctx.globalCompositeOperation = 'source-over' + ctx.globalAlpha = 0.16 + 0.84 * Math.pow(1 - depth, 1.4) + ctx.lineWidth = ridge === 0 ? 1.4 : 1 + if (o.colorMode === 'theme') { + ctx.strokeStyle = o.lineColor + ctx.stroke(path) + } else { + // Batch similarly colored segments to avoid a canvas stroke per FFT column. + const coloredPaths: Array = new Array(64) + for (let x = 1; x < xs.length; ++x) { + const db = (levels[ridge * frame.columns + x - 1] + levels[ridge * frame.columns + x]) / 2 + const colorIndex = Math.min(63, Math.floor(normalizeHeatDb(db) * 64)) + const coloredPath = coloredPaths[colorIndex] ??= new Path2D() + coloredPath.moveTo(xs[x - 1], ys[x - 1]) + coloredPath.lineTo(xs[x], ys[x]) + } + for (let colorIndex = 0; colorIndex < coloredPaths.length; ++colorIndex) { + const coloredPath = coloredPaths[colorIndex] + if (!coloredPath) continue + ctx.strokeStyle = this.palette[Math.min(255, colorIndex * 4 + 2)] + ctx.stroke(coloredPath) + } + } + } + ctx.globalAlpha = 1 + this.ctx.drawImage(this.traces, 0, 0, width, height) + } +} diff --git a/src/renderer/visualizers/waterfallPlot.ts b/src/renderer/visualizers/waterfallPlot.ts new file mode 100644 index 0000000..2747da3 --- /dev/null +++ b/src/renderer/visualizers/waterfallPlot.ts @@ -0,0 +1,47 @@ +import { WATERFALL_RIDGES, type WaterfallDensity, type WaterfallFrame } from '../../types/waterfall' + +// Keep the live spectrum's relief independent of history density. Reserve the +// remaining height for age, with enough headroom for the oldest spectrum's peaks. +const SPECTRUM_HEIGHT_RATIO = 0.32 +const MIN_RIDGE_SPACING = 5 + +export function waterfallPlotLayout(height: number, density: WaterfallDensity, showGuides: boolean) { + const bottom = height - (showGuides ? 22 : 10) + const plotHeight = Math.max(1, bottom - 8) + const amplitude = plotHeight * SPECTRUM_HEIGHT_RATIO + const historyHeight = plotHeight - amplitude + const ridgeCount = Math.max(2, Math.min(WATERFALL_RIDGES[density], Math.floor(historyHeight / MIN_RIDGE_SPACING) + 1)) + return { + bottom, + ridgeCount, + spacing: historyHeight / (ridgeCount - 1), + amplitude, + historyHeight, + } +} + +export function waterfallRidgeHeight(db: number, amplitude: number): number { + return Math.max(0, Math.min(1, (db + 90) / 80)) * amplitude +} + +// One light three-point energy filter softens fine bin jitter. At full plot +// resolution the center retains 60% of its energy, limiting an isolated peak's +// attenuation to 2.22 dB. Coarser projections need less additional softening. +// Each spectrum is treated identically and independently: moving into history +// must never reshape it through averaging with other moments in time. +export function softenWaterfallSpectra(frame: WaterfallFrame): Float32Array { + if (frame.columns < 2 || frame.ages.length === 0) return new Float32Array(frame.levels) + const source = Float32Array.from(frame.levels, (db) => 10 ** (db / 10)) + const result = new Float32Array(source.length) + const neighborWeight = 0.2 * Math.min(1, (frame.columns - 1) / 512) + const centerWeight = 1 - 2 * neighborWeight + for (let row = 0; row < source.length; row += frame.columns) { + for (let column = 0; column < frame.columns; ++column) { + const energy = source[row + column] * centerWeight + + source[row + Math.max(0, column - 1)] * neighborWeight + + source[row + Math.min(frame.columns - 1, column + 1)] * neighborWeight + result[row + column] = 10 * Math.log10(Math.max(1e-20, energy)) + } + } + return result +} diff --git a/src/shared/profileState.ts b/src/shared/profileState.ts index 0bd380f..ba9365c 100644 --- a/src/shared/profileState.ts +++ b/src/shared/profileState.ts @@ -1,3 +1,4 @@ +import { normalizeWaterfallSettings } from '../types/waterfall' import type { ScopePopoutStateMap, WindowBounds } from '../types/popout' import { DEFAULT_PROFILE_NAME, @@ -44,6 +45,7 @@ export const DEFAULT_VISIBLE: ScopeKind[] = ['spectrum', 'oscilloscope', 'vector export const DEFAULT_SCOPE_ORDER: ScopeKind[] = [...AUDIO_SCOPE_KINDS] export const DEFAULT_SCOPE_WIDTH_WEIGHTS: Record = { + waterfall: 1, spectrum: 1, oscilloscope: 1, vectorscope: 1, @@ -142,7 +144,7 @@ export function normalizeScopeOrder(raw: unknown): ScopeKind[] { return normalized } -export function normalizeHiddenScopes(raw: unknown): ScopeKind[] { +export function normalizeHiddenScopes(raw: unknown, originalOrder?: unknown): ScopeKind[] { if (!Array.isArray(raw)) { return SCOPE_KINDS.filter((kind) => !DEFAULT_VISIBLE.includes(kind)) } @@ -155,6 +157,7 @@ export function normalizeHiddenScopes(raw: unknown): ScopeKind[] { seen.add(kind) normalized.push(kind) } + if (Array.isArray(originalOrder) && !originalOrder.includes('waterfall') && !seen.has('waterfall')) normalized.push('waterfall') return normalized } @@ -212,6 +215,7 @@ export function mergeScopeSettings( : (typeof legacyParsed.astra === 'object' && legacyParsed.astra !== null ? legacyParsed.astra : {}) return { + waterfall: normalizeWaterfallSettings(parsed.waterfall), spectrum: { ...DEFAULT_SCOPE_SETTINGS.spectrum, ...rawSpectrum, @@ -327,7 +331,7 @@ export function normalizeProfile(raw: unknown, fallbackName = DEFAULT_PROFILE_NA return { name: normalizeProfileName(parsed.name, fallbackName), scopeOrder: normalizeScopeOrder(parsed.scopeOrder), - hiddenScopes: normalizeHiddenScopes(parsed.hiddenScopes), + hiddenScopes: normalizeHiddenScopes(parsed.hiddenScopes, parsed.scopeOrder ?? []), widthWeights: normalizeWidthWeights(parsed.widthWeights), scopeSettings: mergeScopeSettings(parsed.scopeSettings), analysisSettings: normalizeAnalysisSettings(parsed.analysisSettings), @@ -370,7 +374,7 @@ export function normalizeProfileFile( id, name, scopeOrder: normalizeScopeOrder(parsed.scopeOrder), - hiddenScopes: normalizeHiddenScopes(parsed.hiddenScopes), + hiddenScopes: normalizeHiddenScopes(parsed.hiddenScopes, parsed.scopeOrder ?? []), widthWeights: normalizeWidthWeights(parsed.widthWeights), scopeSettings: mergeScopeSettings(parsed.scopeSettings, { legacyProfileFileScale: isLegacyProfileFileVersion(parsed.version), @@ -485,7 +489,7 @@ export function profileFileToProfile( return { name: normalizeProfileName(file.name, DEFAULT_PROFILE_NAME), scopeOrder: normalizeScopeOrder(file.scopeOrder), - hiddenScopes: normalizeHiddenScopes(file.hiddenScopes), + hiddenScopes: normalizeHiddenScopes(file.hiddenScopes, file.scopeOrder ?? []), widthWeights: normalizeWidthWeights(file.widthWeights), scopeSettings: mergeScopeSettings(file.scopeSettings, { legacyProfileFileScale: isLegacyProfileFileVersion(file.version), diff --git a/src/shared/themeState.ts b/src/shared/themeState.ts index 849531a..481cbe1 100644 --- a/src/shared/themeState.ts +++ b/src/shared/themeState.ts @@ -181,10 +181,13 @@ const NOW_PLAYING_SCHEMA = { status_error: 'statusError', } as const satisfies SectionSchema +const WATERFALL_SCHEMA = { background: 'background', line: 'line', heat_low: 'heatLow', heat_mid: 'heatMid', heat_high: 'heatHigh', guides: 'guides', labels: 'labels' } as const + const SECTION_KEY_MAP: Record = { app: 'app', controls: 'controls', scopes: 'scopes', + waterfall: 'waterfall', spectrum: 'spectrum', oscilloscope: 'oscilloscope', vectorscope: 'vectorscope', @@ -200,6 +203,7 @@ const SECTION_LABEL_MAP: Record = { app: 'App', controls: 'Controls', scopes: 'Scopes', + waterfall: 'Waterfall', spectrum: 'Spectrum', oscilloscope: 'Oscilloscope', vectorscope: 'Vectorscope', @@ -214,6 +218,7 @@ const SECTION_SCHEMAS = { app: APP_SCHEMA, controls: CONTROLS_SCHEMA, scopes: SCOPES_SCHEMA, + waterfall: WATERFALL_SCHEMA, spectrum: SPECTRUM_SCHEMA, oscilloscope: OSCILLOSCOPE_SCHEMA, vectorscope: VECTORSCOPE_SCHEMA, @@ -434,6 +439,7 @@ function createEmptyTheme(): PrismTheme { app: {}, controls: {}, scopes: {}, + waterfall: {}, spectrum: {}, oscilloscope: {}, vectorscope: {}, @@ -1243,6 +1249,7 @@ export function normalizeTheme( normalized.app = normalizeSectionTokens(parsed.app, APP_SCHEMA) normalized.controls = normalizeSectionTokens(parsed.controls, CONTROLS_SCHEMA) normalized.scopes = normalizeSectionTokens(parsed.scopes, SCOPES_SCHEMA) + normalized.waterfall = normalizeSectionTokens(parsed.waterfall, WATERFALL_SCHEMA) normalized.spectrum = normalizeSectionTokens(parsed.spectrum, SPECTRUM_SCHEMA) normalized.oscilloscope = normalizeSectionTokens(parsed.oscilloscope, OSCILLOSCOPE_SCHEMA) normalized.vectorscope = normalizeSectionTokens(parsed.vectorscope, VECTORSCOPE_SCHEMA) @@ -1522,6 +1529,12 @@ export function createTemplateThemeFile(): string { ) scopesSection.splice(1, 0, '# Entire section optional. Uncomment tokens here only if you want to override Prism defaults.') + const waterfallSection = commentExampleTokens(serializeSection('Waterfall', { + ...base.waterfall, line: resolved.waterfall.line, background: resolved.waterfall.background, + guides: resolved.waterfall.guides, labels: resolved.waterfall.labels, + heatLow: resolved.waterfall.heatColors[0], heatMid: resolved.waterfall.heatColors[1], heatHigh: resolved.waterfall.heatColors[2], + }, WATERFALL_SCHEMA as SectionSchema>)) + const spectrumSection = commentExampleTokens(serializeSection('Spectrum', { ...base.spectrum, background: resolved.spectrum.background, @@ -1601,6 +1614,7 @@ ${[ scopesSection.join('\n'), '# Module sections below are optional overrides.', '# Uncomment the tokens you want to customize and leave the rest as examples.', + waterfallSection.join('\n'), spectrumSection.join('\n'), oscilloscopeSection.join('\n'), vectorscopeSection.join('\n'), @@ -1964,6 +1978,13 @@ export function resolveTheme(theme: PrismTheme): PrismResolvedTheme { website: normalized.website, description: normalized.description, interface: resolveInterfaceTheme(app, controls, scopes), + waterfall: (() => { + const fallback = resolveSpectrumTheme(normalized, app, scopes) + const w = normalized.waterfall + return { line: w.line ?? fallback.line, background: w.background ?? fallback.background, + guides: w.guides ?? fallback.guides, labels: w.labels ?? w.guides ?? normalized.spectrum.labels ?? app.textMuted, + heatColors: [w.heatLow ?? fallback.heatColors[0], w.heatMid ?? fallback.heatColors[1], w.heatHigh ?? fallback.heatColors[2]] as [string, string, string] } + })(), spectrum: resolveSpectrumTheme(normalized, app, scopes), oscilloscope: resolveOscilloscopeTheme(normalized, app, scopes), vectorscope: resolveVectorscopeTheme(normalized, app, scopes), diff --git a/src/types/popout.ts b/src/types/popout.ts index 05c9426..730e999 100644 --- a/src/types/popout.ts +++ b/src/types/popout.ts @@ -12,6 +12,7 @@ import type { ResolvedSpectrumTheme, ResolvedVectorscopeTheme, ResolvedVUMeterTheme, + ResolvedWaterfallTheme, ResolvedWaveformTheme, } from './theme' @@ -46,6 +47,7 @@ export interface ScopePopoutSessionState { } export interface ScopePopoutStereoChunk { + sequence?: number left: Float32Array right: Float32Array transport?: DawTransportSnapshot @@ -62,6 +64,7 @@ export type ScopePopoutResolvedScopeTheme = | ResolvedSpectrogramTheme | ResolvedVUMeterTheme | ResolvedLUFSMeterTheme + | ResolvedWaterfallTheme | ResolvedWaveformTheme | ResolvedAstraTheme diff --git a/src/types/scope.ts b/src/types/scope.ts index f79fd19..f7fc670 100644 --- a/src/types/scope.ts +++ b/src/types/scope.ts @@ -6,6 +6,7 @@ export type ScopeKind = | 'vumeter' | 'lufsmeter' | 'waveform' + | 'waterfall' | 'nowPlaying' export type AudioScopeKind = Exclude @@ -24,6 +25,7 @@ export const SCOPE_KINDS: ScopeKind[] = [ 'vumeter', 'lufsmeter', 'waveform', + 'waterfall', 'nowPlaying', ] @@ -35,6 +37,7 @@ export const AUDIO_SCOPE_KINDS: AudioScopeKind[] = [ 'vumeter', 'lufsmeter', 'waveform', + 'waterfall', ] export const TRANSFORMABLE_SCOPE_KINDS: TransformableScopeKind[] = [ @@ -63,6 +66,7 @@ export function normalizeScopeKind(value: unknown): ScopeKind | null { } export const SCOPE_LABELS: Record = { + waterfall: 'Waterfall', spectrum: 'Spectrum', oscilloscope: 'Oscilloscope', vectorscope: 'Vectorscope', diff --git a/src/types/settings.ts b/src/types/settings.ts index 62d1894..65ef058 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -1,3 +1,4 @@ +import { DEFAULT_WATERFALL_SETTINGS, type WaterfallSettings } from './waterfall' import type { VectorscopeMode } from '../renderer/visualizers/Vectorscope' import { DEFAULT_SPECTROGRAM_CONTRAST, @@ -24,6 +25,7 @@ import { DEFAULT_VECTORSCOPE_ZOOM_DB } from './vectorscope' import { DEFAULT_TIMELINE_UNIT, type TimelineUnit } from './dawBridge' export interface ScopeSettings { + waterfall: WaterfallSettings spectrum: ScopeDisplayTransformSettings & { scaleMode: FrequencyScaleMode frequencyRangeMode: FrequencyRangeMode @@ -91,6 +93,7 @@ export interface ScopeSettings { } export const DEFAULT_SCOPE_SETTINGS: ScopeSettings = { + waterfall: { ...DEFAULT_WATERFALL_SETTINGS }, spectrum: { rotation: DEFAULT_SCOPE_DISPLAY_ROTATION, mirrorHorizontal: DEFAULT_SCOPE_MIRROR_HORIZONTAL, scaleMode: DEFAULT_FREQUENCY_SCALE_MODE, frequencyRangeMode: DEFAULT_FREQUENCY_RANGE_MODE, fftSize: 2048, tiltDbPerOctave: 2.0, heatmap: false, heatmapTiltDbPerOctave: 2.0, heatmapSmoothing: 0.5, showGrid: true, smoothing: 0.9, fillGradient: true, showSideLine: false, peakInfoMode: DEFAULT_SPECTRUM_PEAK_INFO_MODE }, oscilloscope: { rotation: DEFAULT_SCOPE_DISPLAY_ROTATION, mirrorHorizontal: DEFAULT_SCOPE_MIRROR_HORIZONTAL, pitchLock: true, underfillEnabled: false, showGrid: true, lineWidth: 2 }, vectorscope: { mode: 'lissajous', zoomDb: DEFAULT_VECTORSCOPE_ZOOM_DB, multiband: false, showGrid: true, persistence: 0.10, lineWidth: 1.5 }, diff --git a/src/types/theme.ts b/src/types/theme.ts index 5f186a2..b724e1d 100644 --- a/src/types/theme.ts +++ b/src/types/theme.ts @@ -67,6 +67,8 @@ export interface ThemeSpectrumTokens { labels?: string } +export type ThemeWaterfallTokens = Pick + export interface ThemeOscilloscopeTokens { background?: string line?: string @@ -150,6 +152,7 @@ export interface PrismTheme { app: ThemeAppTokens controls: ThemeControlsTokens scopes: ThemeScopesTokens + waterfall: ThemeWaterfallTokens spectrum: ThemeSpectrumTokens oscilloscope: ThemeOscilloscopeTokens vectorscope: ThemeVectorscopeTokens @@ -259,6 +262,8 @@ export interface ResolvedSpectrumTheme { heatBase: string } +export type ResolvedWaterfallTheme = Pick + export interface ResolvedOscilloscopeTheme { line: string guides: string @@ -345,6 +350,7 @@ export interface PrismResolvedTheme { website?: string description?: string interface: ResolvedInterfaceTheme + waterfall: ResolvedWaterfallTheme spectrum: ResolvedSpectrumTheme oscilloscope: ResolvedOscilloscopeTheme vectorscope: ResolvedVectorscopeTheme diff --git a/src/types/waterfall.ts b/src/types/waterfall.ts new file mode 100644 index 0000000..cc6afa5 --- /dev/null +++ b/src/types/waterfall.ts @@ -0,0 +1,60 @@ +import { normalizeFrequencyRangeMode, normalizeFrequencyScaleMode, type FrequencyRangeMode, type FrequencyScaleMode } from './frequencyScale' +import { clampSpectrumTiltDbPerOctave } from './spectrum' + +export type WaterfallDensity = 'sparse' | 'balanced' | 'dense' +export interface WaterfallSettings { + historySeconds: number + density: WaterfallDensity + colorMode: 'theme' | 'heat' + showGrid: boolean + fftSize: number + scaleMode: FrequencyScaleMode + frequencyRangeMode: FrequencyRangeMode + smoothing: number + tiltDbPerOctave: number +} +export const WATERFALL_RIDGES: Record = { sparse: 16, balanced: 32, dense: 64 } +export const DEFAULT_WATERFALL_SETTINGS: WaterfallSettings = { + historySeconds: 5, density: 'balanced', colorMode: 'theme', showGrid: true, + fftSize: 2048, scaleMode: 'log', frequencyRangeMode: 'extended', smoothing: 0.9, tiltDbPerOctave: 2, +} +export function normalizeWaterfallSettings(raw: unknown): WaterfallSettings { + const value = raw && typeof raw === 'object' ? raw as Partial : {} + const numeric = (input: unknown, fallback: number): number => typeof input === 'number' && Number.isFinite(input) ? input : fallback + return { + historySeconds: Math.max(1, Math.min(30, Math.round(numeric(value.historySeconds, 5)))), + density: value.density === 'sparse' || value.density === 'dense' ? value.density : 'balanced', + colorMode: value.colorMode === 'heat' ? 'heat' : 'theme', + showGrid: typeof value.showGrid === 'boolean' ? value.showGrid : true, + fftSize: [1024, 2048, 4096, 8192, 16384].includes(value.fftSize as number) ? value.fftSize! : 2048, + scaleMode: normalizeFrequencyScaleMode(value.scaleMode), + frequencyRangeMode: normalizeFrequencyRangeMode(value.frequencyRangeMode), + smoothing: Math.max(0, Math.min(0.99, numeric(value.smoothing, 0.9))), + tiltDbPerOctave: clampSpectrumTiltDbPerOctave(value.tiltDbPerOctave ?? 2), + } +} + +export interface WaterfallNativeConfig extends Pick { + sampleRate: number + minFrequency: number + maxFrequency: number +} +export interface WaterfallFrame { + levels: Float32Array + ages: Float32Array + frequencies: Float32Array + columns: number + audioSeconds: number +} +export interface WaterfallNativeAnalyzer { + configure(options: WaterfallNativeConfig): void + processStereo(left: Float32Array, right: Float32Array): void + getFrame(ridges: number, columns: number): WaterfallFrame | null + reset(): void + isAvailable?: () => boolean +} +export interface WaterfallAudioChunk { + left: Float32Array + right: Float32Array + sequence?: number +} diff --git a/test/audio-router.test.ts b/test/audio-router.test.ts index cd9c8ee..1fde559 100644 --- a/test/audio-router.test.ts +++ b/test/audio-router.test.ts @@ -217,6 +217,7 @@ test('publishes aggregated visualizer demand changes for downstream transports', unsubscribe() assert.deepEqual(snapshots[0], { + waterfall: false, spectrum: false, oscilloscope: false, vectorscope: false, @@ -226,6 +227,7 @@ test('publishes aggregated visualizer demand changes for downstream transports', waveform: false, }) assert.deepEqual(snapshots[1], { + waterfall: false, spectrum: true, oscilloscope: true, vectorscope: false, @@ -235,6 +237,7 @@ test('publishes aggregated visualizer demand changes for downstream transports', waveform: false, }) assert.deepEqual(snapshots[2], { + waterfall: false, spectrum: true, oscilloscope: true, vectorscope: true, @@ -244,6 +247,7 @@ test('publishes aggregated visualizer demand changes for downstream transports', waveform: false, }) assert.deepEqual(snapshots[3], { + waterfall: false, spectrum: false, oscilloscope: false, vectorscope: true, @@ -259,3 +263,23 @@ test('audio diagnostics stay scoped to audio-only visualizers', () => { assert.equal('astra' in router.getDiagnosticsSnapshot().scopes, false) }) + + +test('Waterfall has independent stereo demand, a bounded queue, and preserves gap sequences', () => { + const router = new AudioRouter() + const sessionId = router.beginSession(48000, 2, 'native-macos') + router.setVisualizerConsumerDemand('waterfall-test', { waterfall: true, spectrum: true, spectrogram: true }) + for (let sequence = 1; sequence <= 100; sequence++) { + router.ingestChunk(createChunk(0.5), createChunk(-0.5), { sessionId, channelCount: 2, sequence }) + } + const chunks = router.flushPendingWaterfallSamples() + assert.equal(chunks.length, 96) + assert.equal(chunks[0].sequence, 5) + assert.deepEqual(Array.from(chunks[0].right), [-0.5, -0.5, -0.5, -0.5]) + assert.equal(router.flushPendingSpectrumStereoSamples().length, 96) + assert.equal(router.flushPendingSpectrogramStereoSamples().length, 96) + assert.equal(router.flushPendingWaterfallSamples().length, 0) + router.clearVisualizerConsumerDemand('waterfall-test') + router.ingestChunk(createChunk(1), createChunk(1), { sessionId, sequence: 101 }) + assert.equal(router.flushPendingWaterfallSamples().length, 0) +}) diff --git a/test/profile-library.test.ts b/test/profile-library.test.ts index 38895a6..81a1c20 100644 --- a/test/profile-library.test.ts +++ b/test/profile-library.test.ts @@ -567,3 +567,25 @@ test('legacy migration writes managed files, preserves active profile, and store await harness.cleanup() } }) + + +test('Waterfall defaults preserve older racks and new settings and popouts round-trip', () => { + const old = normalizeProfileFile({ scopeOrder: ['spectrum', 'spectrogram'], hiddenScopes: [], scopeSettings: {} }, 'old') + assert.ok(old.hiddenScopes.includes('waterfall')) + assert.ok(normalizeProfileFile({ hiddenScopes: [] }, 'partial').hiddenScopes.includes('waterfall')) + assert.equal(old.scopeSettings.waterfall.historySeconds, 5) + const profile = createDefaultProfile() + profile.hiddenScopes = profile.hiddenScopes.filter((kind) => kind !== 'waterfall') + profile.scopeSettings.waterfall = { ...profile.scopeSettings.waterfall, historySeconds: 30, density: 'dense', colorMode: 'heat', scaleMode: 'mel', fftSize: 8192, showGrid: false } + profile.scopePopouts.waterfall = { poppedOut: true } + const restored = profileFileToProfile(normalizeProfileFile(profile, 'waterfall')) + assert.deepEqual(restored.scopeSettings.waterfall, profile.scopeSettings.waterfall) + assert.equal(restored.hiddenScopes.includes('waterfall'), false) + assert.equal(restored.scopePopouts.waterfall.poppedOut, true) + const invalid = mergeScopeSettings({ waterfall: { historySeconds: 90, density: 'bad', smoothing: -1, fftSize: 5, scaleMode: 'bad' } }).waterfall + assert.equal(invalid.historySeconds, 30) + assert.equal(invalid.density, 'balanced') + assert.equal(invalid.smoothing, 0) + assert.equal(invalid.fftSize, 2048) + assert.equal(invalid.scaleMode, 'log') +}) diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index 4ad422e..070cdab 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -1,3 +1,6 @@ +import { Waterfall } from '../src/renderer/visualizers/Waterfall' +import { softenWaterfallSpectra, waterfallRidgeHeight, waterfallPlotLayout } from '../src/renderer/visualizers/waterfallPlot' +import type { WaterfallFrame } from '../src/types/waterfall' import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import { join } from 'node:path' @@ -1709,6 +1712,7 @@ test('moveDockedScopeOrder swaps a middle docked scope with its adjacent docked 'vumeter', 'lufsmeter', 'waveform', + 'waterfall', 'nowPlaying', ]) }) @@ -5845,6 +5849,7 @@ test('moveDockedScopeOrder preserves hidden scope positions in the full order', 'vumeter', 'lufsmeter', 'waveform', + 'waterfall', 'nowPlaying', ]) }) @@ -5866,6 +5871,7 @@ test('moveDockedScopeOrder preserves popped-out scope positions in the full orde 'vumeter', 'lufsmeter', 'waveform', + 'waterfall', 'nowPlaying', ]) }) @@ -7382,3 +7388,150 @@ test('DAW musical ruler labels every roomy beat and every bar in dense history', dom.restore() } }) + + +test('Waterfall popouts preserve stereo samples and discontinuity sequence numbers', () => { + const source = new ScopePopoutDataSource('waterfall') + const left = new Float32Array([0.2, 0.4]) + const right = new Float32Array([-0.2, -0.4]) + source.pushAudioBatch([{ left, right, sequence: 17 }]) + assert.deepEqual(source.getPendingWaterfallSamples(), [{ left, right, sequence: 17 }]) + assert.equal(source.getPendingWaterfallSamples().length, 0) +}) + + +test('Waterfall holds history and detaches rendering while capture is suspended', () => { + const raf = installFakeAnimationFrame() + const dom = installFakeCanvasDom() + const canvas = createFakeCanvas() + canvas.getContext('2d')!.setTransform = () => {} + let session = { sessionId: 1, sampleRate: 48000, channelCount: 2, capturing: true, suspended: false, backendKind: null } + let notify: (state: typeof session) => void = () => {} + let pulls = 0, processed = 0, resets = 0 + const waterfall = new Waterfall(canvas, { + frameScheduler: new FrameScheduler({ frameTarget: 'display-sync' }), + dataSource: { + getSampleRate: () => session.sampleRate, + isPlaying: () => true, + getPendingWaterfallSamples: () => [{ left: new Float32Array(10), right: new Float32Array(10), sequence: ++pulls }], + subscribeToSessionChanges: (callback) => { notify = callback; callback(session); return () => {} }, + }, + nativeAnalyzer: { configure: () => {}, processStereo: () => { processed++ }, getFrame: () => null, reset: () => { resets++ } }, + }) + try { + waterfall.start() + raf.runFrame(0) + assert.equal(processed, 1) + session = { ...session, suspended: true } + notify(session) + raf.runFrame(17) + assert.equal(processed, 1) + assert.equal(resets, 1, 'pausing must preserve native history') + assert.equal(raf.pendingCount(), 0, 'paused capture must release its frame subscription') + session = { ...session, suspended: false } + notify(session) + raf.runFrame(34) + assert.equal(processed, 2) + assert.equal(resets, 1) + session = { ...session, sessionId: 2, sampleRate: 96000 } + notify(session) + assert.equal(resets, 2, 'a new source session clears its history') + } finally { + waterfall.dispose() + dom.restore() + raf.restore() + } +}) + +test('Waterfall plot fits history and clear spectrum peaks inside large and small panels', () => { + for (const height of [55, 100, 140, 300, 600]) { + for (const density of ['sparse', 'balanced', 'dense'] as const) { + for (const guides of [false, true]) { + const layout = waterfallPlotLayout(height, density, guides) + assert.ok(layout.ridgeCount >= 2 && layout.ridgeCount <= 64) + assert.ok(layout.spacing >= 4) + assert.ok(layout.bottom - layout.historyHeight - layout.amplitude >= 8 - 1e-6, 'oldest peaks retain top clearance') + assert.equal(waterfallRidgeHeight(-100, layout.amplitude), 0) + assert.equal(waterfallRidgeHeight(0, layout.amplitude), layout.amplitude) + } + } + } + const normal = waterfallPlotLayout(300, 'balanced', true) + assert.ok(normal.spacing >= 4 && normal.spacing <= 7) + assert.ok(3 / (normal.ridgeCount - 1) >= 0.06 && 3 / (normal.ridgeCount - 1) <= 0.1) + assert.ok(waterfallPlotLayout(100, 'balanced', true).ridgeCount < normal.ridgeCount) + assert.ok(waterfallRidgeHeight(-40, normal.amplitude) > normal.spacing * 4, 'clear peaks must retain substantial relief') + assert.equal(waterfallPlotLayout(300, 'sparse', true).amplitude, waterfallPlotLayout(300, 'dense', true).amplitude, 'density must not flatten the spectrum') +}) + +function waterfallFrame(columns: number, ages: number[], levelAt: (column: number, ridge: number) => number): WaterfallFrame { + return { + columns, ages: new Float32Array(ages), frequencies: new Float32Array(columns), audioSeconds: 3, + levels: Float32Array.from({ length: columns * ages.length }, (_, i) => levelAt(i % columns, Math.floor(i / columns))), + } +} + +test('Waterfall plot smoothing preserves constant levels and does not modify native snapshots', () => { + for (const columns of [2, 128, 512]) { + for (const db of [-100, -60, -20]) { + const frame = waterfallFrame(columns, [0, 0.07, 0.14], () => db) + const before = structuredClone(frame) + const result = softenWaterfallSpectra(frame) + assert.ok(result.every((value) => Math.abs(value - db) < 1e-4)) + assert.deepEqual(frame, before) + assert.deepEqual(softenWaterfallSpectra(frame), result, 'redraws must not advance smoothing') + } + } + assert.equal(softenWaterfallSpectra(waterfallFrame(128, [], () => 0)).length, 0) +}) + +test('Waterfall plot softens fine bin jitter while retaining peak location and relief', () => { + const columns = 512 + const frame = waterfallFrame(columns, [0], (column) => { + const x = column / (columns - 1) + return -75 + 30 * Math.exp(-0.5 * ((x - 0.3) / 0.055) ** 2) + (column % 2 ? 6 : -6) + }) + const result = softenWaterfallSpectra(frame) + const roughness = (values: Float32Array): number => { + let total = 0 + for (let i = 1; i < values.length - 1; ++i) total += Math.abs(values[i - 1] - 2 * values[i] + values[i + 1]) + return total + } + assert.ok(roughness(result) < roughness(frame.levels) * 0.3) + const peak = Math.max(...result) + assert.ok(Math.abs(result.indexOf(peak) / (columns - 1) - 0.3) < 0.02) + assert.ok(peak - result[columns - 1] > 20, 'smoothing should retain a broad hill') +}) + +test('Waterfall plot preserves a spectrum as it moves from the live edge into history', () => { + const shape = (column: number) => column === 32 ? -20 : -60 + const frame = waterfallFrame(128, [0, 0.05, 0.1, 0.15, 1], (column, ridge) => ridge === 2 ? shape(column) : -60) + const result = softenWaterfallSpectra(frame) + const live = softenWaterfallSpectra(waterfallFrame(128, [0], shape)) + assert.deepEqual(result.subarray(128 * 2, 128 * 3), live, 'age and neighboring spectra must not reshape a captured trace') + for (const row of [0, 1, 3, 4]) { + assert.ok(result.subarray(row * 128, (row + 1) * 128).every((db) => Math.abs(db + 60) < 1e-4), 'a transient must not bleed into another moment') + } +}) + +test('Waterfall plot keeps narrow peaks distinct with only light attenuation', () => { + const columns = 512 + const frame = waterfallFrame(columns, [0, 0.064, 0.128, 0.192], (column) => column === 200 ? -6 : -100) + const result = softenWaterfallSpectra(frame) + const newest = result.subarray(0, columns) + const peak = Math.max(...newest) + assert.ok(peak >= -8.3, 'even a single-column peak should lose less than 2.3 dB') + assert.ok(Math.abs(newest.indexOf(peak) - 200) <= 1) + assert.ok(newest[199] > -14 && newest[201] > -14, 'soften the immediate shoulders') + assert.ok(newest[197] < -80 && newest[203] < -80, 'retain a narrow peak rather than broadening it into a hill') + for (let row = 1; row < frame.ages.length; ++row) { + assert.ok(newest.every((value, column) => Math.abs(value - result[row * columns + column]) < 1e-4)) + } +}) + +test('Waterfall plot smoothing retains its frequency shape across plot resolutions', () => { + const sample = (columns: number) => softenWaterfallSpectra(waterfallFrame(columns, [0], (column) => + -70 + 25 * Math.exp(-0.5 * ((column / (columns - 1) - 0.4) / 0.08) ** 2))) + const small = sample(129), large = sample(513) + for (let i = 0; i < small.length; ++i) assert.ok(Math.abs(small[i] - large[i * 4]) < 0.75) +}) diff --git a/test/theme-library.test.ts b/test/theme-library.test.ts index de4a84a..e069ca0 100644 --- a/test/theme-library.test.ts +++ b/test/theme-library.test.ts @@ -585,3 +585,19 @@ test('library falls back when legacy active theme ids target retired bundled the await harness.cleanup() } }) + + +test('Waterfall inherits Spectrum colors and its own optional tokens round-trip', () => { + const base = createDefaultTheme() + base.spectrum.line = '#123456' + base.spectrum.heatLow = '#112233' + const inherited = resolveTheme(base) + assert.equal(inherited.waterfall.line, inherited.spectrum.line) + assert.equal(inherited.waterfall.labels, inherited.interface.textMuted) + assert.deepEqual(inherited.waterfall.heatColors, inherited.spectrum.heatColors) + base.waterfall = { line: '#abcdef', heatHigh: '#fedcba', background: '#010203', guides: '#aabbcc' } + const text = serializeThemeFile(base) + assert.match(text, /\[Waterfall\]/) + const parsed = parseThemeFileContent(text, 'Waterfall') + assert.deepEqual(resolveTheme(parsed).waterfall, resolveTheme(base).waterfall) +}) diff --git a/test/waterfall-native.test.mjs b/test/waterfall-native.test.mjs new file mode 100644 index 0000000..dd2d337 --- /dev/null +++ b/test/waterfall-native.test.mjs @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import test from 'node:test' + +const require = createRequire(import.meta.url) +const { waterfall, spectrum } = require('../native/build/Release/visualizer_dsp.node') +const defaults = { sampleRate: 48000, fftSize: 2048, smoothing: 0, tiltDbPerOctave: 0, historySeconds: 5, minFrequency: 10, maxFrequency: 24000, scaleMode: 'log' } +function configure(options = {}) { waterfall.reset(); waterfall.configure({ ...defaults, ...options }) } +function tone(length, hz = 1007.8125, amplitude = 0.5, sampleRate = 48000) { + return Float32Array.from({ length }, (_, index) => amplitude * Math.sin(2 * Math.PI * hz * index / sampleRate)) +} +function push(samples, chunkSize = samples.length, antiPhase = false) { + const right = antiPhase ? samples.map((value) => -value) : samples + for (let offset = 0; offset < samples.length; offset += chunkSize) { + waterfall.processStereo(samples.subarray(offset, offset + chunkSize), right.subarray(offset, offset + chunkSize)) + } +} + +test('waterfall preserves calibrated stereo levels and frequency placement at every FFT size and scale', () => { + for (const sampleRate of [44100, 48000, 96000]) { + for (const fftSize of [1024, 2048, 4096, 8192, 16384]) { + const hz = Math.round(1000 * fftSize / sampleRate) * sampleRate / fftSize + for (const scaleMode of ['log', 'mel', 'linear']) { + configure({ sampleRate, fftSize, scaleMode }) + push(tone(sampleRate, hz, 0.5, sampleRate), 713, true) + const frame = waterfall.getFrame(32, 512) + const latest = frame.levels.subarray(0, frame.columns) + const peak = Math.max(...latest) + assert.ok(Math.abs(peak - 20 * Math.log10(0.5)) < 0.1, `${sampleRate}/${fftSize}/${scaleMode}: ${peak}`) + const index = latest.indexOf(peak) + assert.ok(Math.abs(frame.frequencies[index] - hz) < Math.max(100, sampleRate / fftSize)) + } + } + } +}) + +test('waterfall cadence and history are independent of chunks and 30/60/120 FPS reads', () => { + for (const sampleRate of [44100, 48000, 44101]) { + const audio = tone(sampleRate * 3, 731, 0.4, sampleRate) + let expected + for (const fps of [30, 60, 120]) { + configure({ sampleRate }) + let offset = 0 + for (let frame = 1; offset < audio.length; frame++) { + const end = Math.min(audio.length, Math.round(frame * sampleRate / fps)) + const chunk = audio.subarray(offset, end) + waterfall.processStereo(chunk, chunk) + const first = waterfall.getFrame(32, 128) + assert.deepEqual(waterfall.getFrame(32, 128), first, 'reads must not advance history') + offset = end + } + const result = waterfall.getFrame(32, 128) + assert.equal(result.audioSeconds, 3) + assert.ok(result.ages.length > 12) + if (expected) assert.deepEqual(result, expected) + expected = result + } + configure({ sampleRate }) + push(audio, 137) + assert.deepEqual(waterfall.getFrame(32, 128), expected) + } +}) + +test('history grows from available audio, retains recent rows on resize, and stays bounded', () => { + configure({ historySeconds: 1 }) + push(tone(48000 * 2)) + const recent = waterfall.getFrame(32, 128) + assert.ok(recent.ages.every((age) => age <= 1)) + waterfall.configure({ ...defaults, historySeconds: 30 }) + const expanded = waterfall.getFrame(64, 128) + assert.equal(expanded.audioSeconds, 2) + assert.deepEqual(expanded.levels.subarray(0, 128), recent.levels.subarray(0, 128)) + assert.ok(expanded.ages.every((age) => age <= 1.04), 'cannot invent missing history') + push(tone(48000 * 32)) + const full = waterfall.getFrame(10000, 10000) + assert.ok(full.ages.length <= 64 && full.levels.length <= 64 * 512) + assert.ok(full.ages.at(-1) > 29 && full.ages.at(-1) <= 30) + waterfall.configure({ ...defaults, historySeconds: 1 }) + assert.ok(waterfall.getFrame(64, 128).ages.every((age) => age <= 1)) +}) + +test('appearance projection preserves history while sample rate, FFT size, and reset clear it', () => { + configure() + push(tone(48000)) + waterfall.configure({ ...defaults, scaleMode: 'mel', tiltDbPerOctave: 4, smoothing: 0.8 }) + assert.equal(waterfall.getFrame(32, 128).audioSeconds, 1) + assert.ok(waterfall.getFrame(32, 128).ages.length > 0) + waterfall.configure({ ...defaults, fftSize: 4096 }) + assert.equal(waterfall.getFrame(32, 128).ages.length, 0) + push(tone(48000)) + waterfall.configure({ ...defaults, fftSize: 4096, sampleRate: 44100 }) + assert.equal(waterfall.getFrame(32, 128).ages.length, 0) + push(tone(44100)) + waterfall.reset() + assert.equal(waterfall.getFrame(32, 128).audioSeconds, 0) + assert.equal(waterfall.getFrame(32, 128).ages.length, 0) +}) + +test('silence and invalid samples stay finite and waterfall does not change Spectrum state', () => { + spectrum.setFFTSize(4096) + spectrum.setSmoothing(0) + const audio = tone(48000) + spectrum.pushStereoSamples(audio, audio) + const spectrumBefore = spectrum.getChannelMaxMagnitudes() + configure() + push(new Float32Array(48000)) + assert.ok(waterfall.getFrame(32, 128).levels.every((db) => db <= -99)) + push(Float32Array.from({ length: 48000 }, (_, i) => i % 2 ? NaN : Infinity)) + assert.ok(waterfall.getFrame(32, 128).levels.every(Number.isFinite)) + assert.equal(spectrum.getFFTSize(), 4096) + assert.deepEqual(spectrum.getChannelMaxMagnitudes(), spectrumBefore) + assert.throws(() => waterfall.processStereo(new Float64Array(10), new Float32Array(10)), /Float32Array/) +}) diff --git a/tui/CMakeLists.txt b/tui/CMakeLists.txt index 3f84555..c09237a 100644 --- a/tui/CMakeLists.txt +++ b/tui/CMakeLists.txt @@ -40,10 +40,12 @@ add_library(prism_tui_analysis STATIC src/profile_library.cpp src/scrolling_history.cpp src/scope_plot_model.cpp + src/waterfall_plot_model.cpp src/spectrum_peak_model.cpp src/tui_theme.cpp src/tui_settings.cpp ${PRISM_NATIVE_DIR}/spectrum.cpp + ${PRISM_NATIVE_DIR}/waterfall.cpp ${PRISM_NATIVE_DIR}/oscilloscope.cpp ${PRISM_NATIVE_DIR}/vectorscope.cpp ${PRISM_NATIVE_DIR}/multiband.cpp @@ -60,7 +62,7 @@ if(WIN32) target_link_libraries(prism_tui_analysis PUBLIC shell32 ole32) endif() -add_library(prism_system_capture STATIC) +add_library(prism_system_capture STATIC ${PRISM_NATIVE_DIR}/capture_channel_selection.cpp) target_include_directories(prism_system_capture PUBLIC ${PRISM_NATIVE_DIR}) target_compile_definitions(prism_system_capture PRIVATE PRISM_CAPTURE_CORE_ONLY=1) target_link_libraries(prism_system_capture PUBLIC Threads::Threads) diff --git a/tui/src/analysis_pipeline.cpp b/tui/src/analysis_pipeline.cpp index b036c6c..696f137 100644 --- a/tui/src/analysis_pipeline.cpp +++ b/tui/src/analysis_pipeline.cpp @@ -61,6 +61,11 @@ void AnalysisPipeline::process(const Prism::Capture::AudioChunk& chunk) { left = trimmedLeftScratch_.data(); right = trimmedRightScratch_.data(); } + if (waterfallEnabled_) { + if (chunk.sequence > 0 && waterfallLastSequence_ > 0 && chunk.sequence != waterfallLastSequence_ + 1) waterfall_.reset(); + waterfallLastSequence_ = chunk.sequence; + waterfall_.processStereo(left, right, count); + } spectrum_.pushStereoSamples(left, right, count); vu_.pushSamples(left, right, count); lufs_.pushSamples(left, right, count); @@ -96,8 +101,11 @@ void AnalysisPipeline::process(const Prism::Capture::AudioChunk& chunk) { } } -AnalysisFrame AnalysisPipeline::snapshot() { +AnalysisFrame AnalysisPipeline::snapshot(size_t waterfallRidges, size_t waterfallColumns) { AnalysisFrame frame; + if (waterfallEnabled_ && waterfallRidges > 0) { + frame.waterfall = waterfall_.getFrame(std::min(waterfallRidges_, waterfallRidges), waterfallColumns); + } frame.magnitudes = spectrum_.getChannelMaxMagnitudes(); frame.spectrumPeak = spectrumPeakTracker_.select( frame.magnitudes, sampleRate_, fftSize_, spectrumTiltDbPerOctave_); @@ -142,6 +150,8 @@ AnalysisFrame AnalysisPipeline::snapshot() { } void AnalysisPipeline::reset() { + waterfall_.reset(); + waterfallLastSequence_ = 0; spectrum_.reset(); vu_.reset(); lufs_.reset(); @@ -166,6 +176,14 @@ void AnalysisPipeline::setInputTrimDb(float db) { inputGainLinear_ = std::pow(10.0f, normalized / 20.0f); } +void AnalysisPipeline::setWaterfallSettings(Visualizer::WaterfallConfig config, size_t ridges, bool enabled) { + config.sampleRate = sampleRate_; + if (enabled != waterfallEnabled_) { waterfall_.reset(); waterfallLastSequence_ = 0; } + waterfallEnabled_ = enabled; + waterfallRidges_ = ridges; + waterfall_.configure(config); +} + void AnalysisPipeline::setSpectrumTilt(float dbPerOctave) { spectrumTiltDbPerOctave_ = std::clamp( std::isfinite(dbPerOctave) ? dbPerOctave : 2.0f, -2.0f, 8.0f); diff --git a/tui/src/analysis_pipeline.h b/tui/src/analysis_pipeline.h index 3a6f681..f352768 100644 --- a/tui/src/analysis_pipeline.h +++ b/tui/src/analysis_pipeline.h @@ -10,6 +10,7 @@ #include "vectorscope.h" #include "vumeter.h" #include "waveform.h" +#include "waterfall.h" #include #include @@ -45,6 +46,7 @@ struct WaveformFrame { }; struct AnalysisFrame { + Visualizer::WaterfallFrame waterfall; std::vector magnitudes; std::optional spectrumPeak; Visualizer::VUMeterSnapshot vu{}; @@ -60,10 +62,11 @@ class AnalysisPipeline { explicit AnalysisPipeline(float sampleRate, size_t fftSize = kDefaultFftSize); void process(const Prism::Capture::AudioChunk& chunk); - AnalysisFrame snapshot(); + AnalysisFrame snapshot(size_t waterfallRidges = 64, size_t waterfallColumns = 256); void reset(); void setInputTrimDb(float db); void setSpectrumTilt(float dbPerOctave); + void setWaterfallSettings(Visualizer::WaterfallConfig config, size_t ridges, bool enabled); void setOscilloscopePitchLock(bool enabled); void setSpectrogramSettings(float scrollSpeed, float contrast, @@ -74,6 +77,10 @@ class AnalysisPipeline { void setWaveformSettings(bool stereo, int scrollSpeed); private: + Visualizer::WaterfallAnalyzer waterfall_; + size_t waterfallRidges_ = 32; + bool waterfallEnabled_ = false; + uint64_t waterfallLastSequence_ = 0; Visualizer::Spectrum spectrum_; Visualizer::VUMeterAnalyzer vu_; Visualizer::LUFSMeterAnalyzer lufs_; diff --git a/tui/src/cli.cpp b/tui/src/cli.cpp index ea5d5b2..ad6af43 100644 --- a/tui/src/cli.cpp +++ b/tui/src/cli.cpp @@ -90,7 +90,7 @@ std::string usageText() { " a Add a removed scope by name while editing.\n" " ? Show layout editing help and fallback keys.\n" " 1 / 2 / 3 / 4 / 5 Focus Spectrum, Oscilloscope, Vectorscope, VU, or LUFS.\n" - " 6 / 7 Focus Spectrogram or Waveform (also layout shortcuts).\n" + " 6 / 7 / 8 Focus Spectrogram, Waveform, or Waterfall (also layout shortcuts).\n" " r Reset analyzers and integrated loudness.\n" " q / Esc / Ctrl-C Quit.\n"; } diff --git a/tui/src/dashboard_layout.cpp b/tui/src/dashboard_layout.cpp index f5a61ce..4fb5de2 100644 --- a/tui/src/dashboard_layout.cpp +++ b/tui/src/dashboard_layout.cpp @@ -30,6 +30,8 @@ MinimumSize panelMinimumSize(PanelId panel) { return {30, 8}; case PanelId::Waveform: return {30, 7}; + case PanelId::Waterfall: + return {30, 8}; } return {1, 1}; } @@ -175,6 +177,7 @@ const char* panelConfigName(PanelId panel) { case PanelId::LUFSMeter: return "lufs"; case PanelId::Spectrogram: return "spectrogram"; case PanelId::Waveform: return "waveform"; + case PanelId::Waterfall: return "waterfall"; } return "spectrum"; } @@ -187,6 +190,7 @@ std::optional parsePanelConfigName(const std::string& value) { if (value == "lufs") return PanelId::LUFSMeter; if (value == "spectrogram") return PanelId::Spectrogram; if (value == "waveform") return PanelId::Waveform; + if (value == "waterfall") return PanelId::Waterfall; return std::nullopt; } @@ -524,6 +528,7 @@ std::vector panelOrder() { PanelId::LUFSMeter, PanelId::Spectrogram, PanelId::Waveform, + PanelId::Waterfall, }; } diff --git a/tui/src/dashboard_layout.h b/tui/src/dashboard_layout.h index 92173d9..f97ebe1 100644 --- a/tui/src/dashboard_layout.h +++ b/tui/src/dashboard_layout.h @@ -15,6 +15,7 @@ enum class PanelId { LUFSMeter, Spectrogram, Waveform, + Waterfall, }; enum class SplitAxis { diff --git a/tui/src/tui_runtime.cpp b/tui/src/tui_runtime.cpp index bc8a01b..b6504a8 100644 --- a/tui/src/tui_runtime.cpp +++ b/tui/src/tui_runtime.cpp @@ -1,3 +1,4 @@ +#include "waterfall_plot_model.h" #include "tui_runtime.h" #include "analysis_pipeline.h" @@ -92,6 +93,20 @@ void applySpectrogramSettings(AnalysisPipeline& pipeline, spectrogramOrientationConfig(settings.spectrogramOrientation)); } +void applyWaterfallSettings(AnalysisPipeline& pipeline, const TuiSettings& settings) { + Visualizer::WaterfallConfig config; + config.historySeconds = static_cast(settings.waterfallHistorySeconds); + config.fftSize = static_cast(settings.waterfallFftSize); + config.smoothing = settings.waterfallSmoothing; + config.tiltDbPerOctave = settings.waterfallTiltDbPerOctave; + config.scaleMode = spectrogramScaleConfig(settings.waterfallScale); + config.minFrequency = settings.waterfallAudibleRange ? 20 : 10; + config.maxFrequency = settings.waterfallAudibleRange ? 20000 : 24000; + const auto panels = configuredPanelOrder(settings.rackLayout); + const bool enabled = std::find(panels.begin(), panels.end(), PanelId::Waterfall) != panels.end(); + pipeline.setWaterfallSettings(config, size_t{16} << settings.waterfallDensity, enabled); +} + void applyWaveformSettings(AnalysisPipeline& pipeline, const TuiSettings& settings) { pipeline.setWaveformSettings( @@ -123,6 +138,7 @@ class SignalHandlerGuard { }; struct DisplayFrame { + Visualizer::WaterfallFrame waterfall; std::vector magnitudes; std::optional spectrumPeak; Visualizer::VUMeterSnapshot vu{}; @@ -183,7 +199,7 @@ struct InterfaceState { bool settingsOpen = false; SettingsPage settingsPage = SettingsPage::Home; size_t settingsHomeSelection = 0; - std::array settingsSelections{}; + std::array(SettingsPage::Waterfall) + 1> settingsSelections{}; std::string settingsStatus; bool profilesOpen = false; ProfileOverlayMode profileMode = ProfileOverlayMode::Browse; @@ -418,6 +434,7 @@ std::string panelName(PanelId panel) { return "LUFS Meter"; case PanelId::Spectrogram: return "Spectrogram"; + case PanelId::Waterfall: return "Waterfall"; case PanelId::Waveform: return "Waveform"; } @@ -438,6 +455,7 @@ std::string panelNumber(PanelId panel) { return "5"; case PanelId::Spectrogram: return "6"; + case PanelId::Waterfall: return "8"; case PanelId::Waveform: return "7"; } @@ -539,6 +557,7 @@ ThemeColor panelBackground(PanelId panel) { case PanelId::VUMeter: return theme.vuBackground; case PanelId::LUFSMeter: return theme.lufsBackground; case PanelId::Spectrogram: return theme.spectrogramBackground; + case PanelId::Waterfall: return theme.waterfallBackground; case PanelId::Waveform: return theme.waveformBackground; } return theme.background; @@ -1651,6 +1670,54 @@ const PanelRect* findPanelRect(const DashboardLayout& layout, PanelId panel) { return found == layout.panels.end() ? nullptr : &*found; } +ftxui::Element renderWaterfallPanel(const DisplayFrame& frame, + int width, int height, bool focused, + const TuiSettings& settings) { + using namespace ftxui; + const int contentWidth = std::max(1, width - 2); + const int margin = settings.waterfallGuides && contentWidth >= 40 ? 10 : 0; + auto plot = canvas([history = frame.waterfall, settings, margin, theme = palette()](Canvas& surface) { + fillCanvasBackground(surface, terminalColor(theme.waterfallBackground)); + const int w = surface.width() - margin, h = surface.height(); + if (w < 2 || h < 2) return; + if (settings.waterfallGuides) { + surface.DrawPointLine(margin, h - 1, surface.width() - 1, h - 1, terminalColor(theme.waterfallGuides)); + if (margin > 0 && h >= 32) { + const auto geometry = waterfallPlotGeometry(h); + for (float fraction : {0.0f, 0.5f, 1.0f}) { + const int y = std::max(0, static_cast(std::lround(geometry.front - fraction * geometry.depth))); + std::ostringstream label; + if (fraction == 0) label << "NOW"; + else label << fraction * settings.waterfallHistorySeconds << 's'; + surface.DrawText(0, y, label.str(), terminalColor(theme.waterfallLabels)); + } + } + } + const auto points = buildWaterfallPlot(history, w, h, static_cast(settings.waterfallHistorySeconds)); + // All eight dots share one foreground color. Resolve the foremost + // visible ridge once per cell so older dots cannot dim newer ones. + for (const auto& cell : buildWaterfallCells(points, w, h)) { + const auto color = terminalColor(waterfallRidgeColor(cell.db, cell.age, + static_cast(settings.waterfallHistorySeconds), settings.waterfallHeat, theme)); + for (int dot = 0; dot < 8; ++dot) { + if (cell.dots & (1 << dot)) { + surface.DrawPoint(cell.x * 2 + dot % 2 + margin, cell.y * 4 + dot / 2, true, color); + } + } + } + }) | flex; + Elements content; + content.push_back(std::move(plot)); + if (settings.waterfallGuides && height >= 5) { + content.push_back(text(std::string(margin / 2, ' ') + buildWaterfallFrequencyAxis( + frame.waterfall, static_cast(std::max(1, contentWidth - margin / 2)))) | color(terminalColor(palette().waterfallLabels))); + } + auto panel = window(panelTitle(PanelId::Waterfall, focused, + std::to_string(settings.waterfallHistorySeconds) + "s " + (settings.waterfallHeat ? "Heat" : "Theme")), vbox(std::move(content))); + return stylePanel(std::move(panel), focused, PanelId::Waterfall) | + size(WIDTH, EQUAL, std::max(1, width)) | size(HEIGHT, EQUAL, std::max(1, height)); +} + ftxui::Element renderLayoutNode(const LayoutNode& node, const DashboardLayout& layout, const DisplayFrame& frame, @@ -1697,6 +1764,8 @@ ftxui::Element renderLayoutNode(const LayoutNode& node, rect->height, focused, state.settings); + case PanelId::Waterfall: + return renderWaterfallPanel(frame, rect->width, rect->height, focused, state.settings); case PanelId::Waveform: return renderWaveformPanel( frame, @@ -2380,6 +2449,7 @@ int runInteractive(std::unique_ptr capture, SnapshotStore frameStore; SnapshotStore settingsStore; + SnapshotStore waterfallPlotStore; SnapshotStore outputSwitchRequestStore; SnapshotStore outputSwitchNoticeStore; SnapshotStore outputListNoticeStore; @@ -2493,6 +2563,7 @@ int runInteractive(std::unique_ptr capture, next->setOscilloscopePitchLock( appliedSettings.oscilloscopePitchLock); applySpectrogramSettings(*next, appliedSettings); + applyWaterfallSettings(*next, appliedSettings); applyWaveformSettings(*next, appliedSettings); return next; }; @@ -2565,6 +2636,7 @@ int runInteractive(std::unique_ptr capture, effectiveRefreshRate( appliedSettings.refreshRate, appliedSettings.terminalCompatibility); + applyWaterfallSettings(*pipeline, requestedSettings); pipeline->setInputTrimDb(requestedSettings.inputTrimDb); pipeline->setSpectrumTilt( requestedSettings.spectrumTiltDbPerOctave); @@ -2595,7 +2667,8 @@ int runInteractive(std::unique_ptr capture, const auto now = std::chrono::steady_clock::now(); if (now >= nextFrameAt) { DisplayFrame next; - auto analyzed = pipeline->snapshot(); + const auto waterfallPlot = waterfallPlotStore.read(); + auto analyzed = pipeline->snapshot(waterfallPlot.ridges, waterfallPlot.columns); next.magnitudes = std::move(analyzed.magnitudes); next.spectrumPeak = std::move(analyzed.spectrumPeak); next.vu = analyzed.vu; @@ -2603,6 +2676,7 @@ int runInteractive(std::unique_ptr capture, next.oscilloscope = std::move(analyzed.oscilloscope); next.vectorscope = std::move(analyzed.vectorscope); next.spectrogram = std::move(analyzed.spectrogram); + next.waterfall = std::move(analyzed.waterfall); next.waveform = std::move(analyzed.waveform); next.sampleRate = activeStarted.sampleRate; next.backend = capture->backendName(); @@ -2639,6 +2713,12 @@ int runInteractive(std::unique_ptr capture, std::chrono::steady_clock::now().time_since_epoch()).count(); interfaceState.renderedFramesPerSecond = frameRateMeter.record(renderedAt); + const auto layout = buildDashboardLayout(screen.dimx(), screen.dimy(), + interfaceState.settings.rackLayout, interfaceState.expandedPanel); + const auto* waterfall = findPanelRect(layout, PanelId::Waterfall); + waterfallPlotStore.publish(waterfall ? waterfallPlotRequest(waterfall->width, waterfall->height, + interfaceState.settings.waterfallGuides, size_t{16} << interfaceState.settings.waterfallDensity) + : WaterfallPlotRequest{0, 2}); return renderFrame( frameStore.read(), screen.dimx(), screen.dimy(), interfaceState) | color(terminalColor(interfaceState.theme.text)) | @@ -2737,6 +2817,7 @@ int runInteractive(std::unique_ptr capture, if (event == Event::Character('5')) return PanelId::LUFSMeter; if (event == Event::Character('6')) return PanelId::Spectrogram; if (event == Event::Character('7')) return PanelId::Waveform; + if (event == Event::Character('8')) return PanelId::Waterfall; return std::nullopt; }; const auto addedScopeStatus = [&](PanelId panel) { diff --git a/tui/src/tui_settings.cpp b/tui/src/tui_settings.cpp index a072bb6..f47ca17 100644 --- a/tui/src/tui_settings.cpp +++ b/tui/src/tui_settings.cpp @@ -23,6 +23,18 @@ const std::vector kGeneralSettings = { {SettingId::RefreshRate, "Refresh rate", "120 FPS is experimental and depends heavily on the terminal."}, }; +const std::vector kWaterfallSettings = { + {SettingId::WaterfallHistory, "History", "Spectrum history in seconds."}, + {SettingId::WaterfallDensity, "Ridges", "Sparse, Balanced, or Dense, adapted to panel size."}, + {SettingId::WaterfallHeat, "Heat colors", "Color ridges by level using the theme heat palette."}, + {SettingId::WaterfallGuides, "Guides", "Show frequency and history-time guides."}, + {SettingId::WaterfallFft, "FFT size", "Frequency resolution of each spectrum slice."}, + {SettingId::WaterfallScale, "Frequency scale", "Logarithmic, mel, or linear frequency spacing."}, + {SettingId::WaterfallRange, "Audible range", "On: 20 Hz to 20 kHz. Off: 10 Hz to 24 kHz, clamped to Nyquist."}, + {SettingId::WaterfallSmoothing, "Smoothing", "Smooth the live spectrum before storing its history."}, + {SettingId::WaterfallTilt, "Display tilt", "Decibels per octave around 1 kHz."}, +}; + const std::vector kSpectrumSettings = { {SettingId::SpectrumPeakReadout, "Peak readout", "Shows the strongest stable spectral peak in the panel title."}, {SettingId::SpectrumTilt, "Display tilt", "Offsets the spectrum by decibels per octave around 1 kHz."}, @@ -363,6 +375,12 @@ TuiSettings normalizeSettings(TuiSettings settings) { snap(settings.spectrogramContrast, 0.1f), 0.5f, 2.0f); settings.spectrogramTiltDbPerOctave = std::clamp( snap(settings.spectrogramTiltDbPerOctave, 0.5f), -2.0f, 8.0f); + settings.waterfallHistorySeconds = std::clamp(settings.waterfallHistorySeconds, 1, 30); + settings.waterfallDensity = std::clamp(settings.waterfallDensity, 0, 2); + if (settings.waterfallFftSize < 1024 || settings.waterfallFftSize > 16384 || (settings.waterfallFftSize & (settings.waterfallFftSize - 1))) settings.waterfallFftSize = 2048; + settings.waterfallSmoothing = std::isfinite(settings.waterfallSmoothing) ? std::clamp(snap(settings.waterfallSmoothing, 0.01f), 0.0f, 0.99f) : 0.9f; + settings.waterfallTiltDbPerOctave = std::isfinite(settings.waterfallTiltDbPerOctave) ? std::clamp(snap(settings.waterfallTiltDbPerOctave, 0.1f), -2.0f, 8.0f) : 2.0f; + if (static_cast(settings.waterfallScale) < 0 || static_cast(settings.waterfallScale) > 2) settings.waterfallScale = SpectrogramScale::Logarithmic; settings.waveformScrollSpeed = std::clamp(settings.waveformScrollSpeed, 1, 8); if (!settings.oscilloscopePitchLock) { settings.oscilloscopeFrequencyReadout = false; @@ -371,7 +389,16 @@ TuiSettings normalizeSettings(TuiSettings settings) { } bool operator==(const TuiSettings& left, const TuiSettings& right) { - return left.themeId == right.themeId && + return left.waterfallHistorySeconds == right.waterfallHistorySeconds && + left.waterfallDensity == right.waterfallDensity && + left.waterfallHeat == right.waterfallHeat && + left.waterfallGuides == right.waterfallGuides && + left.waterfallFftSize == right.waterfallFftSize && + left.waterfallScale == right.waterfallScale && + left.waterfallAudibleRange == right.waterfallAudibleRange && + left.waterfallSmoothing == right.waterfallSmoothing && + left.waterfallTiltDbPerOctave == right.waterfallTiltDbPerOctave && + left.themeId == right.themeId && left.terminalCompatibility == right.terminalCompatibility && left.inputTrimDb == right.inputTrimDb && left.refreshRate == right.refreshRate && @@ -416,6 +443,7 @@ std::vector settingsPages() { SettingsPage::LUFSMeter, SettingsPage::Spectrogram, SettingsPage::Waveform, + SettingsPage::Waterfall, }; } @@ -430,6 +458,7 @@ const char* settingsPageName(SettingsPage page) { case SettingsPage::VUMeter: return "VU meter"; case SettingsPage::LUFSMeter: return "LUFS meter"; case SettingsPage::Spectrogram: return "Spectrogram"; + case SettingsPage::Waterfall: return "Waterfall"; case SettingsPage::Waveform: return "Waveform"; } return "Settings"; @@ -446,6 +475,7 @@ const char* settingsPageDescription(SettingsPage page) { case SettingsPage::VUMeter: return "Classic level, peak, and phase metering."; case SettingsPage::LUFSMeter: return "Loudness window and target presentation."; case SettingsPage::Spectrogram: return "Frequency history, color, and time direction."; + case SettingsPage::Waterfall: return "Layered spectrum history."; case SettingsPage::Waveform: return "Rolling amplitude envelope and band color."; } return {}; @@ -461,6 +491,7 @@ const std::vector& settingsForPage(SettingsPage page) { case SettingsPage::VUMeter: return kVUMeterSettings; case SettingsPage::LUFSMeter: return kLUFSMeterSettings; case SettingsPage::Spectrogram: return kSpectrogramSettings; + case SettingsPage::Waterfall: return kWaterfallSettings; case SettingsPage::Waveform: return kWaveformSettings; case SettingsPage::Home: break; } @@ -470,6 +501,16 @@ const std::vector& settingsForPage(SettingsPage page) { std::string settingValue(const TuiSettings& settings, SettingId setting) { switch (setting) { + case SettingId::WaterfallHistory: return std::to_string(settings.waterfallHistorySeconds) + "s"; + case SettingId::WaterfallDensity: return settings.waterfallDensity == 0 ? "Sparse" : settings.waterfallDensity == 2 ? "Dense" : "Balanced"; + case SettingId::WaterfallHeat: return boolValue(settings.waterfallHeat); + case SettingId::WaterfallGuides: return boolValue(settings.waterfallGuides); + case SettingId::WaterfallFft: return std::to_string(settings.waterfallFftSize); + case SettingId::WaterfallScale: return spectrogramScaleName(settings.waterfallScale); + case SettingId::WaterfallRange: return settings.waterfallAudibleRange ? "Audible" : "Extended"; + case SettingId::WaterfallSmoothing: return trimFloat(settings.waterfallSmoothing, 2); + case SettingId::WaterfallTilt: return trimFloat(settings.waterfallTiltDbPerOctave, 1) + " dB/oct"; + case SettingId::Theme: return settings.themeId; case SettingId::TerminalCompatibility: @@ -535,7 +576,8 @@ std::string settingValue(const TuiSettings& settings, SettingId setting) { } bool settingIsBoolean(SettingId setting) { - return setting == SettingId::SpectrumPeakReadout || + return setting == SettingId::WaterfallHeat || setting == SettingId::WaterfallGuides || setting == SettingId::WaterfallRange || + setting == SettingId::SpectrumPeakReadout || setting == SettingId::OscilloscopePitchLock || setting == SettingId::OscilloscopeFrequencyReadout || setting == SettingId::VectorscopeGuides || @@ -546,6 +588,16 @@ bool adjustSetting(TuiSettings& settings, SettingId setting, int direction) { if (direction == 0) return false; const TuiSettings before = settings; switch (setting) { + case SettingId::WaterfallHistory: settings.waterfallHistorySeconds += direction > 0 ? 1 : -1; break; + case SettingId::WaterfallDensity: settings.waterfallDensity = (settings.waterfallDensity + (direction > 0 ? 1 : 2)) % 3; break; + case SettingId::WaterfallHeat: settings.waterfallHeat = !settings.waterfallHeat; break; + case SettingId::WaterfallGuides: settings.waterfallGuides = !settings.waterfallGuides; break; + case SettingId::WaterfallFft: settings.waterfallFftSize = direction > 0 ? (settings.waterfallFftSize >= 16384 ? 1024 : settings.waterfallFftSize * 2) : (settings.waterfallFftSize <= 1024 ? 16384 : settings.waterfallFftSize / 2); break; + case SettingId::WaterfallScale: settings.waterfallScale = static_cast((static_cast(settings.waterfallScale) + (direction > 0 ? 1 : 2)) % 3); break; + case SettingId::WaterfallRange: settings.waterfallAudibleRange = !settings.waterfallAudibleRange; break; + case SettingId::WaterfallSmoothing: settings.waterfallSmoothing += direction > 0 ? 0.01f : -0.01f; break; + case SettingId::WaterfallTilt: settings.waterfallTiltDbPerOctave += direction > 0 ? 0.1f : -0.1f; break; + case SettingId::Theme: return false; case SettingId::TerminalCompatibility: { @@ -686,6 +738,16 @@ bool resetSetting(TuiSettings& settings, SettingId setting) { const TuiSettings defaults; const TuiSettings before = settings; switch (setting) { + case SettingId::WaterfallHistory: settings.waterfallHistorySeconds = defaults.waterfallHistorySeconds; break; + case SettingId::WaterfallDensity: settings.waterfallDensity = defaults.waterfallDensity; break; + case SettingId::WaterfallHeat: settings.waterfallHeat = defaults.waterfallHeat; break; + case SettingId::WaterfallGuides: settings.waterfallGuides = defaults.waterfallGuides; break; + case SettingId::WaterfallFft: settings.waterfallFftSize = defaults.waterfallFftSize; break; + case SettingId::WaterfallScale: settings.waterfallScale = defaults.waterfallScale; break; + case SettingId::WaterfallRange: settings.waterfallAudibleRange = defaults.waterfallAudibleRange; break; + case SettingId::WaterfallSmoothing: settings.waterfallSmoothing = defaults.waterfallSmoothing; break; + case SettingId::WaterfallTilt: settings.waterfallTiltDbPerOctave = defaults.waterfallTiltDbPerOctave; break; + case SettingId::Theme: settings.themeId = defaults.themeId; break; case SettingId::TerminalCompatibility: settings.terminalCompatibility = defaults.terminalCompatibility; break; case SettingId::InputTrim: settings.inputTrimDb = defaults.inputTrimDb; break; @@ -762,6 +824,15 @@ TuiSettings parseSettingsText(const std::string& text, else if (key == "input_trim_db") settings.inputTrimDb = parseFloat(value, settings.inputTrimDb); else if (key == "refresh_rate") settings.refreshRate = parseInt(value, settings.refreshRate); else if (key == "rack_layout") settings.rackLayout = parseRackLayout(value, settings.rackLayout); + else if (key == "waterfall_history") settings.waterfallHistorySeconds = parseInt(value, settings.waterfallHistorySeconds); + else if (key == "waterfall_density") settings.waterfallDensity = parseInt(value, settings.waterfallDensity); + else if (key == "waterfall_heat") settings.waterfallHeat = parseBool(value, settings.waterfallHeat); + else if (key == "waterfall_guides") settings.waterfallGuides = parseBool(value, settings.waterfallGuides); + else if (key == "waterfall_fft") settings.waterfallFftSize = parseInt(value, settings.waterfallFftSize); + else if (key == "waterfall_scale") settings.waterfallScale = parseSpectrogramScale(value, settings.waterfallScale); + else if (key == "waterfall_audible") settings.waterfallAudibleRange = parseBool(value, settings.waterfallAudibleRange); + else if (key == "waterfall_smoothing") settings.waterfallSmoothing = parseFloat(value, settings.waterfallSmoothing); + else if (key == "waterfall_tilt") settings.waterfallTiltDbPerOctave = parseFloat(value, settings.waterfallTiltDbPerOctave); else if (key == "spectrum_peak") settings.spectrumPeakReadout = parseBool(value, settings.spectrumPeakReadout); else if (key == "spectrum_tilt") settings.spectrumTiltDbPerOctave = parseFloat(value, settings.spectrumTiltDbPerOctave); else if (key == "osc_pitch_lock") settings.oscilloscopePitchLock = parseBool(value, settings.oscilloscopePitchLock); @@ -801,6 +872,15 @@ std::string serializeSettingsText(const TuiSettings& rawSettings, } output << "input_trim_db=" << settings.inputTrimDb << '\n' << "rack_layout=" << serializeRackLayout(settings.rackLayout) << '\n' + << "waterfall_history=" << settings.waterfallHistorySeconds << '\n' + << "waterfall_density=" << settings.waterfallDensity << '\n' + << "waterfall_heat=" << (settings.waterfallHeat ? "true" : "false") << '\n' + << "waterfall_guides=" << (settings.waterfallGuides ? "true" : "false") << '\n' + << "waterfall_fft=" << settings.waterfallFftSize << '\n' + << "waterfall_scale=" << serializeSpectrogramScale(settings.waterfallScale) << '\n' + << "waterfall_audible=" << (settings.waterfallAudibleRange ? "true" : "false") << '\n' + << "waterfall_smoothing=" << settings.waterfallSmoothing << '\n' + << "waterfall_tilt=" << settings.waterfallTiltDbPerOctave << '\n' << "spectrum_peak=" << (settings.spectrumPeakReadout ? "true" : "false") << '\n' << "spectrum_tilt=" << settings.spectrumTiltDbPerOctave << '\n' << "osc_pitch_lock=" << (settings.oscilloscopePitchLock ? "true" : "false") << '\n' diff --git a/tui/src/tui_settings.h b/tui/src/tui_settings.h index cfc920e..50c60d8 100644 --- a/tui/src/tui_settings.h +++ b/tui/src/tui_settings.h @@ -21,6 +21,7 @@ enum class SettingsPage { LUFSMeter, Spectrogram, Waveform, + Waterfall, }; enum class SettingId { @@ -51,6 +52,16 @@ enum class SettingId { WaveformMode, WaveformScrollSpeed, WaveformMultiband, + WaterfallHistory, + WaterfallDensity, + WaterfallHeat, + WaterfallGuides, + WaterfallFft, + WaterfallScale, + WaterfallRange, + WaterfallSmoothing, + WaterfallTilt, + }; enum class VectorscopeDetail { @@ -67,6 +78,16 @@ enum class WaveformMode { Mono, Stereo }; enum class TerminalCompatibilityMode { Modern, Compatible, Safe }; struct TuiSettings { + int waterfallHistorySeconds = 5; + int waterfallDensity = 1; + bool waterfallHeat = false; + bool waterfallGuides = true; + int waterfallFftSize = 2048; + SpectrogramScale waterfallScale = SpectrogramScale::Logarithmic; + bool waterfallAudibleRange = false; + float waterfallSmoothing = 0.9f; + float waterfallTiltDbPerOctave = 2.0f; + std::string themeId = "Default"; TerminalCompatibilityMode terminalCompatibility = TerminalCompatibilityMode::Modern; diff --git a/tui/src/tui_theme.cpp b/tui/src/tui_theme.cpp index 7ba38ca..7188b91 100644 --- a/tui/src/tui_theme.cpp +++ b/tui/src/tui_theme.cpp @@ -681,6 +681,17 @@ bool parseIroThemeText(const std::string& content, tokens, "spectrum", "labels", colorOr( tokens, "spectrum", "guides", guidesRaw)), theme.spectrumBackground); + const RgbaColor waterfallBackgroundRaw = colorOr(tokens, "waterfall", "background", colorOr(tokens, "spectrum", "background", scopeBackgroundRaw)); + theme.waterfallBackground = flatten(waterfallBackgroundRaw, background); + theme.waterfallLine = flatten(colorOr(tokens, "waterfall", "line", spectrumLineRaw), theme.waterfallBackground); + theme.waterfallGuides = flatten(colorOr(tokens, "waterfall", "guides", colorOr(tokens, "spectrum", "guides", guidesRaw)), theme.waterfallBackground); + theme.waterfallLabels = flatten(colorOr(tokens, "waterfall", "labels", colorOr(tokens, "waterfall", "guides", colorOr(tokens, "spectrum", "labels", mutedRaw))), theme.waterfallBackground); + theme.waterfallHeat = {{ + flatten(colorOr(tokens, "waterfall", "heat_low", colorOr(tokens, "spectrum", "heat_low", rgba(15, 7, 33))), theme.waterfallBackground), + flatten(colorOr(tokens, "waterfall", "heat_mid", colorOr(tokens, "spectrum", "heat_mid", rgba(163, 26, 121))), theme.waterfallBackground), + flatten(colorOr(tokens, "waterfall", "heat_high", colorOr(tokens, "spectrum", "heat_high", rgba(255, 241, 209))), theme.waterfallBackground), + }}; + theme.oscilloscopeBackground = resolveBackground("oscilloscope"); theme.oscilloscopeLine = flatten(colorOr( tokens, "oscilloscope", "line", accentRaw), theme.oscilloscopeBackground); diff --git a/tui/src/tui_theme.h b/tui/src/tui_theme.h index be6b5d2..2001971 100644 --- a/tui/src/tui_theme.h +++ b/tui/src/tui_theme.h @@ -36,6 +36,9 @@ struct TuiTheme { ThemeColor scopeGuides; ThemeColor scopeGuidesSecondary; + ThemeColor waterfallBackground, waterfallLine, waterfallGuides, waterfallLabels; + std::array waterfallHeat{}; + ThemeColor spectrumBackground; ThemeColor spectrumLine; ThemeColor spectrumLabels; diff --git a/tui/src/waterfall_plot_model.cpp b/tui/src/waterfall_plot_model.cpp new file mode 100644 index 0000000..8af18e9 --- /dev/null +++ b/tui/src/waterfall_plot_model.cpp @@ -0,0 +1,155 @@ +#include "waterfall_plot_model.h" +#include +#include + +namespace Prism::Tui { +WaterfallPlotGeometry waterfallPlotGeometry(int dotHeight) { + const float front = static_cast(std::max(0, dotHeight - 2)); + const float available = std::max(0.0f, front - 2); + return {front, available * 0.68f, available * 0.32f}; +} + +WaterfallPlotRequest waterfallPlotRequest(int panelWidth, int panelHeight, bool guides, size_t ridgeLimit) { + const int contentWidth = std::max(1, panelWidth - 2); + const int margin = guides && contentWidth >= 40 ? 10 : 0; + const int dotHeight = std::max(1, panelHeight - 2 - (guides && panelHeight >= 5 ? 1 : 0)) * 4; + // Leave at least a character row between two-dot-thick ridges. Ask the + // analyzer for this resolution instead of reselecting rows every paint. + const size_t ridges = static_cast(waterfallPlotGeometry(dotHeight).depth / 6) + 1; + return {std::clamp(ridges, size_t{2}, std::clamp(ridgeLimit, size_t{2}, size_t{64})), + static_cast(std::clamp(contentWidth * 2 - margin, 2, 512))}; +} + +std::vector buildWaterfallPlot(const Visualizer::WaterfallFrame& frame, + int width, int height, float historySeconds) { + std::vector points; + if (width < 2 || height < 2 || frame.columns < 2 || frame.ages.empty() + || frame.levels.size() < frame.columns * frame.ages.size()) return points; + const auto geometry = waterfallPlotGeometry(height); + std::vector horizon(width, height); + std::vector softened(frame.columns); + std::vector levels(width), curve(width); + // Every historical row is anchored to an audio timestamp by the analyzer. + // Decimating by ages.size() here used to switch ALL visible slices whenever + // the newest history row appeared or the oldest one expired. + for (size_t row = 0; row < frame.ages.size(); ++row) { + const float age = frame.ages[row]; + const float depth = std::clamp(age / std::max(1.0f, historySeconds), 0.0f, 1.0f); + // A single light energy-domain filter removes sub-dot jaggedness while + // retaining narrow peaks (an isolated peak loses at most 2.22 dB). + const auto power = [&](size_t x) { + const float db = frame.levels[row * frame.columns + x]; + return std::pow(10.0f, (std::isfinite(db) ? std::clamp(db, -160.0f, 40.0f) : -160.0f) / 10); + }; + for (size_t x = 0; x < frame.columns; ++x) { + softened[x] = 10 * std::log10(0.6f * power(x) + + 0.2f * power(x == 0 ? 0 : x - 1) + 0.2f * power(std::min(x + 1, frame.columns - 1))); + } + for (int x = 0; x < width; ++x) { + const float position = static_cast(x) * (frame.columns - 1) / (width - 1); + const size_t a = static_cast(position), b = std::min(a + 1, frame.columns - 1); + const float db = softened[a] + (position - a) * (softened[b] - softened[a]); + const float level = std::isfinite(db) ? std::clamp((db + 90) / 80, 0.0f, 1.0f) : 0; + levels[x] = db; + curve[x] = std::clamp(geometry.front - depth * geometry.depth - level * geometry.amplitude, + 0.0f, static_cast(height - 1)); + } + for (int x = 0; x < width; ++x) { + // Split each slope at the boundary between dot columns, before + // rounding. Putting the whole join in its right-hand column made + // diagonals lopsided and added vertical tails beside sharp peaks. + const float left = (curve[std::max(0, x - 1)] + curve[x]) * 0.5f; + const float right = (curve[x] + curve[std::min(width - 1, x + 1)]) * 0.5f; + const int start = static_cast(std::lround(std::min({left, curve[x], right}))); + const int end = std::min(height - 1, static_cast(std::lround(std::max({left, curve[x], right}))) + 1); + if (start < horizon[x]) { + for (int py = start; py <= end && py < horizon[x]; ++py) points.push_back({x, py, levels[x], age}); + // Leave one empty dot above a visible foreground ridge. A + // fully hidden ridge must not expand this clearance further. + horizon[x] = start - 1; + } + } + } + return points; +} + +std::vector buildWaterfallCells(const std::vector& points, int width, int height) { + if (width < 1 || height < 1) return {}; + const int columns = (width + 1) / 2, rows = (height + 3) / 4; + std::vector cells(static_cast(columns * rows)); + for (const auto& point : points) { + if (point.x < 0 || point.x >= width || point.y < 0 || point.y >= height) continue; + auto& cell = cells[(point.y / 4) * columns + point.x / 2]; + if (!cell.dots || point.age < cell.age) { + cell.db = point.db; + cell.age = point.age; + } else if (point.age == cell.age) { + cell.db = std::max(cell.db, point.db); + } + cell.x = point.x / 2; + cell.y = point.y / 4; + cell.dots |= static_cast(1 << ((point.y % 4) * 2 + point.x % 2)); + } + cells.erase(std::remove_if(cells.begin(), cells.end(), [](const auto& cell) { return !cell.dots; }), cells.end()); + return cells; +} + +ThemeColor waterfallRidgeColor(float db, float age, float historySeconds, bool heat, const TuiTheme& theme) { + const auto mix = [](ThemeColor a, ThemeColor b, float t) { + return ThemeColor{static_cast(std::lround(a.red + (b.red - a.red) * t)), + static_cast(std::lround(a.green + (b.green - a.green) * t)), + static_cast(std::lround(a.blue + (b.blue - a.blue) * t))}; + }; + ThemeColor line = theme.waterfallLine; + if (heat) { + const float level = std::clamp((db + 100) / 80, 0.0f, 1.0f) * 2; + const size_t low = level < 1 ? 0 : 1; + line = mix(theme.waterfallHeat[low], theme.waterfallHeat[low + 1], level - static_cast(low)); + } + const float depth = std::clamp(age / std::max(1.0f, historySeconds), 0.0f, 1.0f); + line = mix(theme.waterfallBackground, line, 1 - 0.45f * depth); + // Braille covers very little of a character. A heat palette designed for + // filled pixels can otherwise make whole ridges effectively invisible. + const auto luminance = [](ThemeColor color) { + const auto linear = [](uint8_t channel) { + const float s = channel / 255.0f; + return s <= 0.04045f ? s / 12.92f : std::pow((s + 0.055f) / 1.055f, 2.4f); + }; + return 0.2126f * linear(color.red) + 0.7152f * linear(color.green) + 0.0722f * linear(color.blue); + }; + const float background = luminance(theme.waterfallBackground); + const auto contrast = [&](ThemeColor color) { + const float value = luminance(color); + return (std::max(value, background) + 0.05f) / (std::min(value, background) + 0.05f); + }; + const float minimumContrast = 4.5f - 1.5f * depth; + if (contrast(line) >= minimumContrast) return line; + const ThemeColor ink = background < 0.18f ? ThemeColor{255, 255, 255} : ThemeColor{0, 0, 0}; + float low = 0, high = 1; + for (int i = 0; i < 8; ++i) { + const float middle = (low + high) * 0.5f; + if (contrast(mix(line, ink, middle)) < minimumContrast) low = middle; + else high = middle; + } + return mix(line, ink, high); +} + +std::string buildWaterfallFrequencyAxis(const Visualizer::WaterfallFrame& frame, size_t width) { + std::string result(width, ' '); + if (frame.frequencies.size() < 2 || width < 10) return result; + const std::pair guides[] = {{20, "20"}, {100, "100"}, {1000, "1k"}, {10000, "10k"}, {20000, "20k"}}; + size_t lastEnd = 0; + for (const auto& guide : guides) { + const auto it = std::lower_bound(frame.frequencies.begin(), frame.frequencies.end(), guide.first); + if (it == frame.frequencies.end() || guide.first < frame.frequencies.front()) continue; + const size_t index = it - frame.frequencies.begin(); + const size_t x = index * (width - 1) / (frame.frequencies.size() - 1); + const std::string label = guide.second; + const size_t start = std::min(width - label.size(), x > label.size() / 2 ? x - label.size() / 2 : 0); + if (start < lastEnd) continue; + result.replace(start, label.size(), label); + lastEnd = start + label.size() + 2; + } + return result; +} +} diff --git a/tui/src/waterfall_plot_model.h b/tui/src/waterfall_plot_model.h new file mode 100644 index 0000000..af69e43 --- /dev/null +++ b/tui/src/waterfall_plot_model.h @@ -0,0 +1,31 @@ +#pragma once +#include "waterfall.h" +#include "tui_theme.h" +#include +#include + +namespace Prism::Tui { +struct WaterfallPlotPoint { + int x, y; + float db, age; +}; +struct WaterfallPlotRequest { + size_t ridges = 2; + size_t columns = 2; +}; +struct WaterfallPlotGeometry { + float front, depth, amplitude; +}; +struct WaterfallPlotCell { + int x, y; + uint8_t dots = 0; // Row-major 2x4 dots, independent of Unicode encoding. + float db = -100, age = 0; +}; +WaterfallPlotGeometry waterfallPlotGeometry(int dotHeight); +WaterfallPlotRequest waterfallPlotRequest(int panelWidth, int panelHeight, bool guides, size_t ridgeLimit); +std::vector buildWaterfallPlot(const Visualizer::WaterfallFrame& frame, + int width, int height, float historySeconds); +std::vector buildWaterfallCells(const std::vector& points, int width, int height); +ThemeColor waterfallRidgeColor(float db, float age, float historySeconds, bool heat, const TuiTheme& theme); +std::string buildWaterfallFrequencyAxis(const Visualizer::WaterfallFrame& frame, size_t width); +} diff --git a/tui/test/tui_tests.cpp b/tui/test/tui_tests.cpp index 0cc8b7f..457141a 100644 --- a/tui/test/tui_tests.cpp +++ b/tui/test/tui_tests.cpp @@ -14,6 +14,7 @@ #include "system_audio_capture.h" #include "tui_settings.h" #include "tui_theme.h" +#include "waterfall_plot_model.h" #include #include @@ -24,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -93,9 +95,9 @@ class FakeCapture final : public Prism::Capture::SystemAudioCapture { if (result) { *result = selected == "alternate" ? Prism::Capture::StartResult{ - 44100.0, 2, "alternate", "Alternate Output"} + 44100.0, 2, 2, "alternate", "Alternate Output"} : Prism::Capture::StartResult{ - 48000.0, 2, "fake", "Fake Output"}; + 48000.0, 2, 2, "fake", "Fake Output"}; } return true; } @@ -358,7 +360,7 @@ void testProjectionAndLayout() { Prism::Tui::PanelId::Oscilloscope, "panel focus should cycle forward"); require(Prism::Tui::nextPanel(Prism::Tui::PanelId::Spectrum, true) == - Prism::Tui::PanelId::Waveform, + Prism::Tui::PanelId::Waterfall, "panel focus should cycle backward"); const auto compactPanels = Prism::Tui::visiblePanelOrder(oneRow); require(compactPanels.size() == 3 && @@ -579,7 +581,7 @@ void testSettingsModelAndPersistence() { "settings normalization should enforce public ranges"); const auto pages = Prism::Tui::settingsPages(); - require(pages.size() == 9 && + require(pages.size() == 10 && Prism::Tui::settingsForPage(Prism::Tui::SettingsPage::Appearance).size() == 2 && Prism::Tui::settingsForPage(Prism::Tui::SettingsPage::General).size() == 2, "settings should expose shallow category pages"); @@ -1176,9 +1178,253 @@ void testFramePacing() { "120 FPS should remain bounded by the available coarse wakeups"); } +void testWaterfall() { + using namespace Prism::Tui; + TuiSettings settings; + require(!rackPanelLocation(settings.rackLayout, PanelId::Waterfall).has_value(), "Waterfall should not alter the default rack"); + require(parseSettingsText("spectrum_peak=true\n").waterfallHistorySeconds == 5, "old TUI settings get Waterfall defaults"); + require(addRackPanel(settings.rackLayout, PanelId::Waterfall, PanelId::Spectrum), "Waterfall can be added to the rack"); + settings.waterfallHistorySeconds = 30; + settings.waterfallDensity = 2; + settings.waterfallHeat = true; + settings.waterfallFftSize = 8192; + settings.waterfallScale = SpectrogramScale::Mel; + settings.waterfallAudibleRange = true; + settings.waterfallSmoothing = 0.7f; + settings.waterfallTiltDbPerOctave = 4; + require(parseSettingsText(serializeSettingsText(settings)) == settings, "Waterfall settings and rack must round-trip"); + for (const auto& descriptor : settingsForPage(SettingsPage::Waterfall)) { + require(!settingValue(settings, descriptor.id).empty(), "Waterfall controls have readable values"); + resetSetting(settings, descriptor.id); + require(adjustSetting(settings, descriptor.id, 1), "Waterfall controls can be adjusted"); + } + const auto invalid = parseSettingsText("waterfall_history=999\nwaterfall_fft=3\nwaterfall_smoothing=nan\nwaterfall_density=-4\n"); + require(invalid.waterfallHistorySeconds == 30 && invalid.waterfallFftSize == 2048 && invalid.waterfallSmoothing == 0.9f && invalid.waterfallDensity == 0, "Waterfall settings enforce bounded defaults"); + TuiTheme themed; + require(parseIroThemeText("[Spectrum]\nline=18, 52, 86\n[Waterfall]\nheat_high=254, 220, 186\n", "waterfall", themed), "Waterfall theme section parses"); + require(themed.waterfallLine == themed.spectrumLine && themed.waterfallHeat[2] == ThemeColor{254, 220, 186}, "Waterfall themes inherit Spectrum and allow overrides"); + + Visualizer::WaterfallAnalyzer analyzer; + Visualizer::WaterfallConfig config; + config.fftSize = 16384; + config.historySeconds = 30; + analyzer.configure(config); + require(analyzer.capacitySlices() == 1802, "maximum native history allocation stays bounded"); + config.fftSize = 2048; + config.historySeconds = 5; + config.smoothing = 0; + config.tiltDbPerOctave = 0; + analyzer.configure(config); + auto audio = sineChunk(1007.8125f, 0.5f, 48000 * 6, 48000); + analyzer.processStereo(audio.left.data(), audio.right.data(), audio.left.size()); + const auto frame = analyzer.getFrame(64, 256); + require(analyzer.storedSlices() <= analyzer.capacitySlices() && frame.ages.back() > 4.8f, "Waterfall stores the selected history duration"); + for (const auto size : {std::pair{8, 8}, {100, 60}, {300, 180}}) { + const auto points = buildWaterfallPlot(frame, size.first, size.second, 5); + require(!points.empty(), "Waterfall renders in small and expanded panels"); + for (const auto& point : points) require(point.x >= 0 && point.x < size.first && point.y >= 0 && point.y < size.second && std::isfinite(point.db), "Waterfall points remain inside the panel"); + } + Visualizer::WaterfallFrame masked; + masked.columns = 2; + masked.ages = {0, 5}; + masked.levels = {-10, -10, -90, -90}; + // The rear baseline lies behind a sufficiently high front ridge in this custom history span. + const auto points = buildWaterfallPlot(masked, 40, 80, 30); + require(std::all_of(points.begin(), points.end(), [](const auto& p) { return p.age == 0; }), "foreground ridges mask older lines below them"); + require(buildWaterfallFrequencyAxis(frame, 60).find("1k") != std::string::npos, "frequency guides follow the projected spectrum"); + + AnalysisPipeline pipeline(48000); + pipeline.setWaterfallSettings(config, 32, true); + auto chunk = sineChunk(1007.8125f, 0.5f, 48000, 48000); + chunk.sequence = 1; + pipeline.process(chunk); + require(pipeline.snapshot().waterfall.audioSeconds == 1, "TUI publishes Waterfall frames"); + chunk.sequence = 3; + pipeline.process(chunk); + require(pipeline.snapshot().waterfall.audioSeconds == 1, "a missing capture sequence starts a distinct history"); + chunk.sequence = 4; + pipeline.process(chunk); + require(pipeline.snapshot().waterfall.audioSeconds == 2, "history continues normally after a gap"); + const auto compact = pipeline.snapshot(5, 40).waterfall; + require(compact.columns == 40 && compact.ages.size() <= 5 && compact.audioSeconds == 2, + "TUI snapshot requests only the visible plot resolution without resetting history"); + require(pipeline.snapshot(0, 2).waterfall.ages.empty() && pipeline.snapshot().waterfall.audioSeconds == 2, + "covered panels can skip projection while retaining their audio history"); + pipeline.setWaterfallSettings(config, 32, false); + require(pipeline.snapshot().waterfall.ages.empty(), "hidden Waterfall avoids snapshot work"); +} + +void testWaterfallRendering() { + using namespace Prism::Tui; + // A snapshot can have one fewer row when a new slice coincides with NOW. + // This must never cause the renderer to pick a different set of old rows. + Visualizer::WaterfallFrame frame; + frame.columns = 5; + for (int i = 0; i < 32; ++i) { + frame.ages.push_back(i * 3.0f / 32); + frame.levels.insert(frame.levels.end(), 5, -80.0f); + } + auto points = buildWaterfallPlot(frame, 80, 128, 3); + for (float age : frame.ages) { + require(std::any_of(points.begin(), points.end(), [age](const auto& point) { + return point.x == 0 && point.age == age; + }), "render every supplied historical slice without index-based resampling"); + } + frame.ages.erase(frame.ages.begin() + 1); + frame.levels.erase(frame.levels.begin() + 5, frame.levels.begin() + 10); + const auto fewer = buildWaterfallPlot(frame, 80, 128, 3); + for (const auto& point : points) { + if (point.age <= 3.0f / 32) continue; + require(std::any_of(fewer.begin(), fewer.end(), [&](const auto& next) { + return next.x == point.x && next.y == point.y && next.age == point.age && next.db == point.db; + }), "a changing snapshot row count must not move or replace older ridges"); + } + + // Eight Braille dots share a color. Keep all visible dots but use the + // nearest ridge's strongest value regardless of insertion order. + std::vector shared{{0, 0, -30, 0}, {1, 1, -20, 0}, {1, 3, -90, 3}}; + const auto cells = buildWaterfallCells(shared, 2, 4); + std::reverse(shared.begin(), shared.end()); + const auto reversed = buildWaterfallCells(shared, 2, 4); + require(cells.size() == 1 && cells[0].dots == 137 && cells[0].db == -20 && cells[0].age == 0, + "older dots cannot overwrite the foreground ridge color"); + require(reversed[0].dots == cells[0].dots && reversed[0].db == cells[0].db && reversed[0].age == cells[0].age, + "Braille cell color is independent of dot drawing order"); + + Visualizer::WaterfallFrame touching; + touching.columns = 2; + const float dotAge = 3.0f / waterfallPlotGeometry(80).depth; + touching.ages = {0, 2 * dotAge, 6 * dotAge}; + touching.levels.assign(6, -90); + const auto separated = buildWaterfallPlot(touching, 20, 80, 3); + require(std::none_of(separated.begin(), separated.end(), [](const auto& point) { return point.y == 77; }), + "leave an empty dot between touching foreground and historical strokes"); + for (int x = 0; x < 20; ++x) { + for (int y : {72, 73, 76, 78, 79}) { + require(std::any_of(separated.begin(), separated.end(), [=](const auto& point) { + return point.x == x && point.y == y; + }), "clearance preserves the front stroke and history beyond the gap"); + } + } + // These extra rows lie entirely inside the foreground's clearance. They + // must not eat another dot of history each just because they are present. + touching.ages.insert(touching.ages.begin() + 1, {dotAge, 1.1f * dotAge, 1.2f * dotAge}); + touching.levels.assign(touching.ages.size() * 2, -90); + const auto hidden = buildWaterfallPlot(touching, 20, 80, 3); + require(hidden.size() == separated.size(), "invisible ridges do not accumulate extra clearance"); + for (size_t i = 0; i < hidden.size(); ++i) { + require(hidden[i].x == separated[i].x && hidden[i].y == separated[i].y && hidden[i].age == separated[i].age, + "adding fully hidden slices cannot change the visible history"); + } + + frame.ages = {0}; + frame.levels = {-100, -100, -20, -100, -100}; + const auto peak = buildWaterfallPlot(frame, 5, 80, 3); + const auto tip = std::find_if(peak.begin(), peak.end(), [](const auto& point) { return point.x == 2; }); + require(tip != peak.end() && tip->db > -22.22f && tip->db < -20, + "terminal softening retains a clear isolated peak within 2.22 dB"); + + frame.columns = 6; + frame.levels = {-85, -50, -70, -15, -30, -88}; + for (int width : {6, 37, 80}) { + const auto forward = buildWaterfallPlot(frame, width, 80, 3); + std::reverse(frame.levels.begin(), frame.levels.end()); + const auto backward = buildWaterfallPlot(frame, width, 80, 3); + std::reverse(frame.levels.begin(), frame.levels.end()); + require(forward.size() == backward.size(), "rising and falling slopes have equal stroke weight"); + for (const auto& point : forward) { + require(std::any_of(backward.begin(), backward.end(), [&](const auto& other) { + return other.x == width - 1 - point.x && other.y == point.y; + }), "ridge joins mirror cleanly without one-sided tails"); + } + int previousTop = -1, previousBottom = -1; + for (int x = 0; x < width; ++x) { + int top = 80, bottom = -1, count = 0; + for (const auto& point : forward) if (point.x == x) { + top = std::min(top, point.y); + bottom = std::max(bottom, point.y); + ++count; + } + require(count >= 2 && count == bottom - top + 1, + "ridge strokes keep their weight and contain no gaps within a dot column"); + require(x == 0 || (top <= previousBottom && bottom >= previousTop), + "adjacent dot columns stay connected even on steep slopes"); + previousTop = top; + previousBottom = bottom; + } + } + + const auto contrast = [](ThemeColor a, ThemeColor b) { + const auto luminance = [](ThemeColor color) { + const auto channel = [](uint8_t value) { + const double s = value / 255.0; + return s <= 0.04045 ? s / 12.92 : std::pow((s + 0.055) / 1.055, 2.4); + }; + return 0.2126 * channel(color.red) + 0.7152 * channel(color.green) + 0.0722 * channel(color.blue); + }; + const double x = luminance(a), y = luminance(b); + return (std::max(x, y) + 0.05) / (std::min(x, y) + 0.05); + }; + auto theme = defaultTuiTheme(); + for (auto background : {ThemeColor{7, 12, 16}, ThemeColor{245, 245, 245}}) { + theme.waterfallBackground = background; + for (bool heat : {false, true}) for (float db : {-100.0f, -60.0f, -20.0f}) { + require(contrast(waterfallRidgeColor(db, 0, 3, heat, theme), background) >= 4.49, + "current Braille ridge stays readable on dark and light themes"); + require(contrast(waterfallRidgeColor(db, 3, 3, heat, theme), background) >= 2.99, + "older heat ridges retain visible contrast"); + } + } + + const auto small = waterfallPlotRequest(40, 10, true, 64); + const auto large = waterfallPlotRequest(100, 28, true, 64); + require(small.ridges < large.ridges && small.columns < large.columns, + "request history and frequency resolution that fit the terminal panel"); + require(waterfallPlotRequest(200, 100, false, 16).ridges == 16 && + waterfallPlotRequest(200, 100, false, 32).ridges == 32 && + waterfallPlotRequest(200, 200, false, 64).ridges == 64, + "large panels respect every density preset"); +} + +void testWaterfallAnimation() { + using namespace Prism::Tui; + const auto request = waterfallPlotRequest(100, 28, true, 32); + const auto audio = sineChunk(1000, 0.5f, 48000 * 7, 48000); + for (size_t fps : {30, 60, 120}) { + Visualizer::WaterfallAnalyzer analyzer; + Visualizer::WaterfallConfig config; + config.historySeconds = 3; + analyzer.configure(config); + analyzer.processStereo(audio.left.data(), audio.right.data(), 48000 * 4); + std::map previous; + for (size_t offset = 48000 * 4; offset < audio.left.size(); offset += 48000 / fps) { + analyzer.processStereo(audio.left.data() + offset, audio.right.data() + offset, 48000 / fps); + const auto frame = analyzer.getFrame(request.ridges, request.columns); + require(frame.ages.back() > 3 - 3.0f / (request.ridges - 1) - 0.02f, + "adaptive density spans the selected duration at every refresh rate"); + std::map current; + for (const auto& point : buildWaterfallPlot(frame, 186, 100, 3)) { + if (point.x == 0) current.emplace(std::llround((frame.audioSeconds - point.age) * 48000), point.y); + } + for (const auto& [stamp, y] : previous) { + const double age = frame.audioSeconds - stamp / 48000.0; + if (age < 0.5 || age > 2.5) continue; + const auto found = current.find(stamp); + require(found != current.end(), "interior history slices never flicker out during scrolling"); + require(found->second <= y && y - found->second <= 1, + "historical ridges move backward smoothly, never jump or reverse"); + } + previous = std::move(current); + } + } +} + } // namespace int main() { + testWaterfall(); + testWaterfallRendering(); + testWaterfallAnimation(); testCli(); testOutputSwitching(); testProjectionAndLayout(); From 31edf75ac10fe26151de6d80fd2d15f13488d1c9 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:59:33 -0400 Subject: [PATCH 05/27] tidal support --- README.md | 12 + native/src/windows_capture.cpp | 165 ++++--- native/src/windows_capture_stub.cpp | 10 +- package.json | 2 + scripts/run-now-playing-ui-tests.mjs | 45 ++ scripts/run-tests.mjs | 2 + scripts/run-tidal-provider-tests.mjs | 44 ++ src/main/index.ts | 4 + src/main/services/localMediaMpris.ts | 112 +++++ src/main/services/macSpotifyProvider.ts | 121 +----- src/main/services/nowPlayingManager.ts | 17 +- src/main/services/nowPlayingProvider.ts | 2 +- src/main/services/tidalProvider.ts | 406 ++++++++++++++++++ src/renderer/components/AstraScopeModule.tsx | 33 +- .../LocalNowPlayingProviderDetails.tsx | 42 ++ .../components/NowPlayingConfigWindow.tsx | 133 +----- src/renderer/utils/localNowPlaying.ts | 81 ++++ src/types/nativeWindowsMedia.ts | 6 +- src/types/nowPlaying.ts | 10 +- test/now-playing-manager.test.ts | 54 ++- test/now-playing-ui.test.tsx | 69 +++ test/tidal-provider.test.ts | 336 +++++++++++++++ 22 files changed, 1383 insertions(+), 323 deletions(-) create mode 100644 scripts/run-now-playing-ui-tests.mjs create mode 100644 scripts/run-tidal-provider-tests.mjs create mode 100644 src/main/services/localMediaMpris.ts create mode 100644 src/main/services/tidalProvider.ts create mode 100644 src/renderer/components/LocalNowPlayingProviderDetails.tsx create mode 100644 src/renderer/utils/localNowPlaying.ts create mode 100644 test/now-playing-ui.test.tsx create mode 100644 test/tidal-provider.test.ts diff --git a/README.md b/README.md index 93b1c69..1bb0783 100644 --- a/README.md +++ b/README.md @@ -324,6 +324,18 @@ The TUI build downloads the pinned FTXUI source through CMake. Linux also requir The DAW plugins build with CMake from the [`plugin/`](plugin/) directory. See [`plugin/README.md`](plugin/README.md) for per-platform build and installation details. +## TIDAL Now Playing + +Prism reads playback from a local TIDAL client without account setup in Prism or additional helper software. Open the Now Playing configuration to set provider priority or retry detection. + +| Platform | Track information and progress | Playback controls | Artwork | +| --- | --- | --- | --- | +| macOS | TIDAL desktop, while it owns system Now Playing | Use the TIDAL app | Only when supplied by macOS | +| Windows | TIDAL desktop media session | Play, pause, next, previous | When supplied by TIDAL | +| Linux | Dedicated TIDAL clients exposing MPRIS, including TIDAL Hi-Fi | Play, pause, next, previous | When supplied by the client | + +Linux requires `gdbus`, a desktop session bus, and MPRIS enabled in the client; generic browser tabs are excluded. On macOS, Prism uses a small built-in script to read Apple's private MediaRemote interface. OS updates can affect compatibility; the provider reports errors and can be retried. Missing artwork, album names, or duration do not prevent track display. This integration does not stream music or browse the TIDAL catalog. + ## Astra Integration Prism can optionally connect to [Astra](https://github.com/Boof2015/astra) through its local API to show currently playing music, cover art, track information, and playback controls alongside your scopes. diff --git a/native/src/windows_capture.cpp b/native/src/windows_capture.cpp index e30cd34..1420e97 100644 --- a/native/src/windows_capture.cpp +++ b/native/src/windows_capture.cpp @@ -34,6 +34,7 @@ #include #include #include +#include #include #include #include @@ -226,24 +227,38 @@ class ScopedRoInit { bool usable_; }; -bool isSpotifySession(const GlobalSystemMediaTransportControlsSession& session) { +template +auto awaitMediaOperation(const Operation& operation) { + if (operation.wait_for(std::chrono::seconds(5)) == winrt::Windows::Foundation::AsyncStatus::Started) { + operation.Cancel(); + throw std::runtime_error("Windows media request timed out. Retry after checking the player."); + } + return operation.GetResults(); +} + +bool isProviderSession(const GlobalSystemMediaTransportControlsSession& session, const std::string& provider) { if (!session) { return false; } const std::string sourceId = toLowerAscii(winrt::to_string(session.SourceAppUserModelId())); - return sourceId.find("spotify") != std::string::npos; + if (provider == "spotify") return sourceId.find("spotify") != std::string::npos; + if (provider != "tidal") return false; + return sourceId == "com.squirrel.tidal.tidal" || sourceId == "tidal" || + sourceId == "tidal.exe" || sourceId == "com.tidal.desktop" || + (sourceId.rfind("tidalmusicas.tidal_", 0) == 0 && + sourceId.size() > 6 && sourceId.compare(sourceId.size() - 6, 6, "!tidal") == 0); } -std::optional findSpotifySession( - const GlobalSystemMediaTransportControlsSessionManager& manager) { +std::optional findProviderSession( + const GlobalSystemMediaTransportControlsSessionManager& manager, const std::string& provider) { const auto currentSession = manager.GetCurrentSession(); - if (isSpotifySession(currentSession)) { + if (isProviderSession(currentSession, provider)) { return currentSession; } for (const auto& session : manager.GetSessions()) { - if (isSpotifySession(session)) { + if (isProviderSession(session, provider)) { return session; } } @@ -284,30 +299,35 @@ std::string base64Encode(const std::vector& data) { // Thumbnail is fetched on a background thread to avoid blocking the NAPI call // thread with cross-process WinRT async I/O. std::mutex s_thumbMutex; -std::string s_thumbTrackKey; -std::string s_thumbDataUrl; -bool s_thumbFetching = false; +struct ThumbnailCache { + std::string trackKey; + std::string dataUrl; + bool fetching = false; + std::chrono::steady_clock::time_point retryAfter{}; +}; +// One slot per provider; Spotify and TIDAL must not evict each other's artwork. +std::map s_thumbnailCaches; void launchThumbnailFetch( - const std::string& trackKey, + const std::string& provider, const std::string& trackKey, winrt::Windows::Media::Control::GlobalSystemMediaTransportControlsSessionMediaProperties props) { const auto thumbnailRef = props.Thumbnail(); if (!thumbnailRef) { std::lock_guard lock(s_thumbMutex); - s_thumbFetching = false; + s_thumbnailCaches[provider].fetching = false; return; } - std::thread([trackKey, thumbnailRef]() { + std::thread([provider, trackKey, thumbnailRef]() { std::string result; try { using winrt::Windows::Storage::Streams::DataReader; - const HRESULT hr = RoInitialize(RO_INIT_MULTITHREADED); - if (SUCCEEDED(hr) || hr == RPC_E_CHANGED_MODE) { - const auto stream = thumbnailRef.OpenReadAsync().get(); + ScopedRoInit init; + if (init.usable()) { + const auto stream = awaitMediaOperation(thumbnailRef.OpenReadAsync()); const uint64_t size = stream.Size(); if (size > 0 && size <= 4u * 1024u * 1024u) { const auto reader = DataReader(stream); - const uint32_t loaded = reader.LoadAsync(static_cast(size)).get(); + const uint32_t loaded = awaitMediaOperation(reader.LoadAsync(static_cast(size))); if (loaded > 0) { std::vector bytes(loaded); reader.ReadBytes(bytes); @@ -318,39 +338,40 @@ void launchThumbnailFetch( result = "data:" + mimeType + ";base64," + base64Encode(bytes); } } - if (SUCCEEDED(hr)) { - RoUninitialize(); - } } } catch (...) {} std::lock_guard lock(s_thumbMutex); - if (s_thumbTrackKey == trackKey) { - s_thumbDataUrl = std::move(result); + auto& cache = s_thumbnailCaches[provider]; + if (cache.trackKey == trackKey) { + cache.dataUrl = std::move(result); + cache.retryAfter = std::chrono::steady_clock::now() + std::chrono::seconds(30); } - s_thumbFetching = false; + cache.fetching = false; }).detach(); } std::string getOrFetchThumbnail( - const std::string& trackKey, + const std::string& provider, const std::string& trackKey, winrt::Windows::Media::Control::GlobalSystemMediaTransportControlsSessionMediaProperties props) { bool shouldFetch = false; std::string result; { std::lock_guard lock(s_thumbMutex); - if (s_thumbTrackKey == trackKey) { - result = s_thumbDataUrl; - } else { - s_thumbTrackKey = trackKey; - s_thumbDataUrl.clear(); - if (!s_thumbFetching) { - s_thumbFetching = true; - shouldFetch = true; - } + auto& cache = s_thumbnailCaches[provider]; + if (cache.trackKey != trackKey) { + cache.trackKey = trackKey; + cache.dataUrl.clear(); + cache.retryAfter = {}; + } + result = cache.dataUrl; + if (result.empty() && !cache.fetching && std::chrono::steady_clock::now() >= cache.retryAfter) { + cache.fetching = true; + cache.retryAfter = std::chrono::steady_clock::now() + std::chrono::seconds(30); + shouldFetch = true; } } if (shouldFetch) { - launchThumbnailFetch(trackKey, std::move(props)); + launchThumbnailFetch(provider, trackKey, std::move(props)); } return result; } @@ -1061,7 +1082,7 @@ Napi::Value WindowsMediaGetSupport(const Napi::CallbackInfo& info) { hresultMessage("RoInitialize(RO_INIT_MULTITHREADED)", init.result())); } - auto manager = GlobalSystemMediaTransportControlsSessionManager::RequestAsync().get(); + auto manager = awaitMediaOperation(GlobalSystemMediaTransportControlsSessionManager::RequestAsync()); (void)manager; return createWindowsMediaSupport(env, true); } catch (const winrt::hresult_error& error) { @@ -1076,7 +1097,7 @@ Napi::Value WindowsMediaGetSupport(const Napi::CallbackInfo& info) { } } -Napi::Value WindowsMediaGetSpotifyPlaybackState(const Napi::CallbackInfo& info) { +Napi::Value WindowsMediaGetPlaybackState(const Napi::CallbackInfo& info, const std::string& provider) { Napi::Env env = info.Env(); try { @@ -1086,15 +1107,15 @@ Napi::Value WindowsMediaGetSpotifyPlaybackState(const Napi::CallbackInfo& info) hresultMessage("RoInitialize(RO_INIT_MULTITHREADED)", init.result())); } - const auto manager = GlobalSystemMediaTransportControlsSessionManager::RequestAsync().get(); - const auto session = findSpotifySession(manager); + const auto manager = awaitMediaOperation(GlobalSystemMediaTransportControlsSessionManager::RequestAsync()); + const auto session = findProviderSession(manager, provider); if (!session.has_value()) { return env.Null(); } const auto playbackInfo = session->GetPlaybackInfo(); const auto timeline = session->GetTimelineProperties(); - const auto mediaProperties = session->TryGetMediaPropertiesAsync().get(); + const auto mediaProperties = awaitMediaOperation(session->TryGetMediaPropertiesAsync()); const auto durationMs = std::max( 0, @@ -1102,23 +1123,17 @@ Napi::Value WindowsMediaGetSpotifyPlaybackState(const Napi::CallbackInfo& info) timeline.EndTime() - timeline.StartTime()) .count()); - // Position() is stamped at LastUpdatedTime(); extrapolate forward when playing. - int64_t positionMs; - if (playbackInfo.PlaybackStatus() == - GlobalSystemMediaTransportControlsSessionPlaybackStatus::Playing) { - const auto elapsed = winrt::clock::now() - timeline.LastUpdatedTime(); - const auto extrapolated = timeline.Position() + elapsed; - positionMs = std::min( - durationMs, - std::max( - 0, - std::chrono::duration_cast(extrapolated).count())); - } else { - positionMs = std::max( - 0, - std::chrono::duration_cast(timeline.Position()) - .count()); + // Position() is stamped at LastUpdatedTime(). Some players omit the timeline; + // never extrapolate from the default Windows epoch or clamp unknown duration to zero. + int64_t positionMs = std::max(0, + std::chrono::duration_cast(timeline.Position()).count()); + if (playbackInfo.PlaybackStatus() == GlobalSystemMediaTransportControlsSessionPlaybackStatus::Playing && + timeline.LastUpdatedTime().time_since_epoch().count() > 0) { + positionMs += std::max(0, + std::chrono::duration_cast( + winrt::clock::now() - timeline.LastUpdatedTime()).count()); } + if (durationMs > 0) positionMs = std::min(durationMs, positionMs); Napi::Object payload = Napi::Object::New(env); payload.Set( @@ -1136,9 +1151,10 @@ Napi::Value WindowsMediaGetSpotifyPlaybackState(const Napi::CallbackInfo& info) Napi::String::New(env, winrt::to_string(session->SourceAppUserModelId()))); const std::string trackKey = + provider + "\n" + winrt::to_string(session->SourceAppUserModelId()) + "\n" + winrt::to_string(mediaProperties.Title()) + "\n" + - winrt::to_string(mediaProperties.Artist()); - const std::string artworkDataUrl = getOrFetchThumbnail(trackKey, mediaProperties); + winrt::to_string(mediaProperties.Artist()) + "\n" + winrt::to_string(mediaProperties.AlbumTitle()); + const std::string artworkDataUrl = getOrFetchThumbnail(provider, trackKey, mediaProperties); payload.Set( "artworkDataUrl", artworkDataUrl.empty() ? env.Null() : Napi::String::New(env, artworkDataUrl)); @@ -1158,10 +1174,10 @@ Napi::Value WindowsMediaGetSpotifyPlaybackState(const Napi::CallbackInfo& info) } } -Napi::Value WindowsMediaSendSpotifyControl(const Napi::CallbackInfo& info) { +Napi::Value WindowsMediaSendControl(const Napi::CallbackInfo& info, const std::string& provider) { Napi::Env env = info.Env(); if (info.Length() < 1 || !info[0].IsString()) { - Napi::TypeError::New(env, "Expected a Spotify control command.").ThrowAsJavaScriptException(); + Napi::TypeError::New(env, "Expected a media control command.").ThrowAsJavaScriptException(); return env.Null(); } @@ -1174,27 +1190,27 @@ Napi::Value WindowsMediaSendSpotifyControl(const Napi::CallbackInfo& info) { hresultMessage("RoInitialize(RO_INIT_MULTITHREADED)", init.result())); } - const auto manager = GlobalSystemMediaTransportControlsSessionManager::RequestAsync().get(); - const auto session = findSpotifySession(manager); + const auto manager = awaitMediaOperation(GlobalSystemMediaTransportControlsSessionManager::RequestAsync()); + const auto session = findProviderSession(manager, provider); if (!session.has_value()) { - throw std::runtime_error("Spotify is not running."); + throw std::runtime_error(provider + " is not running."); } bool accepted = false; if (command == "play") { - accepted = session->TryPlayAsync().get(); + accepted = awaitMediaOperation(session->TryPlayAsync()); } else if (command == "pause") { - accepted = session->TryPauseAsync().get(); + accepted = awaitMediaOperation(session->TryPauseAsync()); } else if (command == "next") { - accepted = session->TrySkipNextAsync().get(); + accepted = awaitMediaOperation(session->TrySkipNextAsync()); } else if (command == "previous") { - accepted = session->TrySkipPreviousAsync().get(); + accepted = awaitMediaOperation(session->TrySkipPreviousAsync()); } else { - throw std::runtime_error("Unsupported Spotify control command."); + throw std::runtime_error("Unsupported media control command."); } if (!accepted) { - throw std::runtime_error("Spotify did not allow Prism to complete that request."); + throw std::runtime_error(provider + " did not allow Prism to complete that request."); } return Napi::Boolean::New(env, true); @@ -1211,6 +1227,19 @@ Napi::Value WindowsMediaSendSpotifyControl(const Napi::CallbackInfo& info) { return env.Null(); } } +Napi::Value WindowsMediaGetSpotifyPlaybackState(const Napi::CallbackInfo& info) { + return WindowsMediaGetPlaybackState(info, "spotify"); +} +Napi::Value WindowsMediaGetTidalPlaybackState(const Napi::CallbackInfo& info) { + return WindowsMediaGetPlaybackState(info, "tidal"); +} +Napi::Value WindowsMediaSendSpotifyControl(const Napi::CallbackInfo& info) { + return WindowsMediaSendControl(info, "spotify"); +} +Napi::Value WindowsMediaSendTidalControl(const Napi::CallbackInfo& info) { + return WindowsMediaSendControl(info, "tidal"); +} + #endif } // namespace @@ -1225,6 +1254,8 @@ void RegisterWindowsMedia(Napi::Env env, Napi::Object exports) { mediaExports.Set( "sendSpotifyControl", Napi::Function::New(env, WindowsMediaSendSpotifyControl)); + mediaExports.Set("getTidalPlaybackState", Napi::Function::New(env, WindowsMediaGetTidalPlaybackState)); + mediaExports.Set("sendTidalControl", Napi::Function::New(env, WindowsMediaSendTidalControl)); exports.Set("windowsMedia", mediaExports); } #endif diff --git a/native/src/windows_capture_stub.cpp b/native/src/windows_capture_stub.cpp index de22197..227851a 100644 --- a/native/src/windows_capture_stub.cpp +++ b/native/src/windows_capture_stub.cpp @@ -12,11 +12,11 @@ Napi::Value MediaGetSupport(const Napi::CallbackInfo& info) { return support; } -Napi::Value GetSpotifyPlaybackState(const Napi::CallbackInfo& info) { +Napi::Value GetPlaybackState(const Napi::CallbackInfo& info) { return info.Env().Null(); } -Napi::Value SendSpotifyControl(const Napi::CallbackInfo& info) { +Napi::Value SendControl(const Napi::CallbackInfo& info) { Napi::Error::New( info.Env(), "Native Windows media-session integration is unavailable on this platform.") .ThrowAsJavaScriptException(); @@ -30,7 +30,9 @@ void RegisterWindowsMedia(Napi::Env env, Napi::Object exports) { mediaExports.Set("getSupport", Napi::Function::New(env, MediaGetSupport)); mediaExports.Set( "getSpotifyPlaybackState", - Napi::Function::New(env, GetSpotifyPlaybackState)); - mediaExports.Set("sendSpotifyControl", Napi::Function::New(env, SendSpotifyControl)); + Napi::Function::New(env, GetPlaybackState)); + mediaExports.Set("sendSpotifyControl", Napi::Function::New(env, SendControl)); + mediaExports.Set("getTidalPlaybackState", Napi::Function::New(env, GetPlaybackState)); + mediaExports.Set("sendTidalControl", Napi::Function::New(env, SendControl)); exports.Set("windowsMedia", mediaExports); } diff --git a/package.json b/package.json index 28f00f6..9958c87 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "test:capture-channel-selection-native": "node --test test/capture-channel-selection-native.test.mjs", "test:daw-bridge": "node scripts/run-daw-bridge-tests.mjs", "test:spotify-provider": "node scripts/run-mac-spotify-provider-tests.mjs", + "test:tidal-provider": "node scripts/run-tidal-provider-tests.mjs", + "test:now-playing-ui": "node scripts/run-now-playing-ui-tests.mjs", "test:now-playing": "node scripts/run-now-playing-manager-tests.mjs", "test:profiles": "node scripts/run-profile-library-tests.mjs", "test:themes": "node scripts/run-theme-library-tests.mjs", diff --git a/scripts/run-now-playing-ui-tests.mjs b/scripts/run-now-playing-ui-tests.mjs new file mode 100644 index 0000000..7445335 --- /dev/null +++ b/scripts/run-now-playing-ui-tests.mjs @@ -0,0 +1,45 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawn } from 'node:child_process' +import { build } from 'esbuild' + +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))) +const tempDir = await mkdtemp(join(tmpdir(), 'prism-now-playing-ui-tests-')) +const bundledTestPath = join(tempDir, 'now-playing-ui.test.mjs') +const entryPoint = join(rootDir, 'test', 'now-playing-ui.test.tsx') + +let exitCode = 1 + +try { + await build({ + entryPoints: [entryPoint], + outfile: bundledTestPath, + bundle: true, + platform: 'node', + format: 'esm', + banner: { js: "import { createRequire as createTestRequire } from 'node:module'; const require = createTestRequire(import.meta.url);" }, + target: 'node23', + sourcemap: 'inline', + }) + + exitCode = await new Promise((resolve) => { + const child = spawn(process.execPath, ['--test', bundledTestPath], { + stdio: 'inherit', + cwd: rootDir, + }) + + child.on('exit', (code) => { + resolve(code ?? 1) + }) + + child.on('error', () => { + resolve(1) + }) + }) +} finally { + await rm(tempDir, { recursive: true, force: true }) +} + +process.exit(exitCode) diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 1076eec..47861a9 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -9,7 +9,9 @@ const testScripts = [ 'test:capture-channel-selection-native', 'test:daw-bridge', 'test:spotify-provider', + 'test:tidal-provider', 'test:now-playing', + 'test:now-playing-ui', 'test:profiles', 'test:themes', 'test:secret-vault', diff --git a/scripts/run-tidal-provider-tests.mjs b/scripts/run-tidal-provider-tests.mjs new file mode 100644 index 0000000..d50be0e --- /dev/null +++ b/scripts/run-tidal-provider-tests.mjs @@ -0,0 +1,44 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawn } from 'node:child_process' +import { build } from 'esbuild' + +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))) +const tempDir = await mkdtemp(join(tmpdir(), 'prism-tidal-provider-tests-')) +const bundledTestPath = join(tempDir, 'tidal-provider.test.mjs') +const entryPoint = join(rootDir, 'test', 'tidal-provider.test.ts') + +let exitCode = 1 + +try { + await build({ + entryPoints: [entryPoint], + outfile: bundledTestPath, + bundle: true, + platform: 'node', + format: 'esm', + target: 'node23', + sourcemap: 'inline', + }) + + exitCode = await new Promise((resolve) => { + const child = spawn(process.execPath, ['--test', bundledTestPath], { + stdio: 'inherit', + cwd: rootDir, + }) + + child.on('exit', (code) => { + resolve(code ?? 1) + }) + + child.on('error', () => { + resolve(1) + }) + }) +} finally { + await rm(tempDir, { recursive: true, force: true }) +} + +process.exit(exitCode) diff --git a/src/main/index.ts b/src/main/index.ts index 60e99d6..ba173fc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -48,6 +48,7 @@ import { loadNativeWindowsMediaApi } from './nativeWindowsMedia' import { loadNativeWindowChromeApi } from './nativeWindowChrome' import { NowPlayingManager } from './services/nowPlayingManager' import { AstraIntegrationService } from './services/astraIntegration' +import { TidalProvider } from './services/tidalProvider' import { MacSpotifyProvider } from './services/macSpotifyProvider' import { SecretVault } from './services/secretVault' import { checkForUpdates, resolveSafeReleaseUrl } from './services/updates' @@ -977,6 +978,9 @@ function getNowPlayingManager(): NowPlayingManager { new MacSpotifyProvider({ windowsMediaApi: getNativeWindowsMediaApi(), }), + new TidalProvider({ + windowsMediaApi: getNativeWindowsMediaApi(), + }), ], }) nowPlayingManager.subscribe((state) => { diff --git a/src/main/services/localMediaMpris.ts b/src/main/services/localMediaMpris.ts new file mode 100644 index 0000000..3eb052f --- /dev/null +++ b/src/main/services/localMediaMpris.ts @@ -0,0 +1,112 @@ +import type { NowPlayingControlCommand } from '../../types/nowPlaying' + +const MPRIS_PLAYER_INTERFACE = 'org.mpris.MediaPlayer2.Player' +const MPRIS_PLAYER_OBJECT_PATH = '/org/mpris/MediaPlayer2' +const SESSION_DBUS_INTERFACE = 'org.freedesktop.DBus' +const SESSION_DBUS_OBJECT_PATH = '/org/freedesktop/DBus' + +function normalizeString(value: string | undefined): string { + return (value ?? '').trim() +} + +function escapeRegexLiteral(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +// g_variant_print uses either quote style depending on the text being encoded. +const GVARIANT_STRING = String.raw`(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)")` +function decodeGVariantString(value: string): string { + return value.replace(/\\([\\'"nrtbf])/g, (_match, escaped: string) => ({ + n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', + }[escaped] ?? escaped)) +} +function decodedMatch(match: RegExpMatchArray | null): string { + return normalizeString(decodeGVariantString(match?.[1] ?? match?.[2] ?? '')) +} +export function extractGdbusStringVariant(output: string, key: string): string { + return decodedMatch(output.match(new RegExp(`'${escapeRegexLiteral(key)}': <(?:@s )?${GVARIANT_STRING}>`))) +} +export function extractGdbusObjectPathVariant(output: string, key: string): string { + return decodedMatch(output.match(new RegExp(`'${escapeRegexLiteral(key)}': <(?:objectpath |@o )${GVARIANT_STRING}>`))) +} + +export function extractGdbusInt64Variant(output: string, key: string): number { + const match = output.match(new RegExp(`'${escapeRegexLiteral(key)}': <(?:@x |@t |int64 |uint64 )?(-?\\d+)>`)) + if (!match) { + return 0 + } + + const numeric = Number.parseInt(match[1], 10) + if (!Number.isFinite(numeric)) { + return 0 + } + + return Math.max(0, numeric) +} + +export function extractGdbusStringArrayVariant(output: string, key: string): string[] { + const match = output.match(new RegExp(`'${escapeRegexLiteral(key)}': <(?:@as )?\\[([\\s\\S]*?)\\]>`)) + if (!match?.[1]) { + return [] + } + + return parseLinuxBusNames(match[1]) +} + +export function parseLinuxBusNames(output: string): string[] { + return Array.from(output.matchAll(new RegExp(GVARIANT_STRING, 'g')), decodedMatch).filter(Boolean) +} + +export function buildLinuxListNamesArgs(): string[] { + return [ + 'call', + '--session', + '--dest', + SESSION_DBUS_INTERFACE, + '--object-path', + SESSION_DBUS_OBJECT_PATH, + '--method', + `${SESSION_DBUS_INTERFACE}.ListNames`, + ] +} + +export function buildLinuxPropertiesArgs(busName: string, interfaceName = MPRIS_PLAYER_INTERFACE): string[] { + return [ + 'call', + '--session', + '--dest', + busName, + '--object-path', + MPRIS_PLAYER_OBJECT_PATH, + '--method', + 'org.freedesktop.DBus.Properties.GetAll', + interfaceName, + ] +} + +export function buildLinuxCommandArgs(busName: string, command: NowPlayingControlCommand): string[] { + const methodName = (() => { + switch (command) { + case 'play': + return 'Play' + case 'pause': + return 'Pause' + case 'next': + return 'Next' + case 'previous': + return 'Previous' + } + })() + + return [ + 'call', + '--session', + '--dest', + busName, + '--object-path', + MPRIS_PLAYER_OBJECT_PATH, + '--method', + `${MPRIS_PLAYER_INTERFACE}.${methodName}`, + ] +} + diff --git a/src/main/services/macSpotifyProvider.ts b/src/main/services/macSpotifyProvider.ts index f1c06f7..cf1ebe7 100644 --- a/src/main/services/macSpotifyProvider.ts +++ b/src/main/services/macSpotifyProvider.ts @@ -1,3 +1,13 @@ +import { + extractGdbusStringVariant, + extractGdbusObjectPathVariant, + extractGdbusInt64Variant, + extractGdbusStringArrayVariant, + parseLinuxBusNames, + buildLinuxListNamesArgs, + buildLinuxPropertiesArgs, + buildLinuxCommandArgs, +} from './localMediaMpris' import { access } from 'node:fs/promises' import { execFile } from 'node:child_process' import { homedir } from 'node:os' @@ -53,10 +63,6 @@ const SLOW_POLL_MS = 5000 const SPOTIFY_ARTWORK_COLOR = '#1ed760' const SPOTIFY_DELIMITER = '\u001f' const SPOTIFY_APP_BUNDLE_ID = 'com.spotify.client' -const MPRIS_PLAYER_INTERFACE = 'org.mpris.MediaPlayer2.Player' -const MPRIS_PLAYER_OBJECT_PATH = '/org/mpris/MediaPlayer2' -const SESSION_DBUS_INTERFACE = 'org.freedesktop.DBus' -const SESSION_DBUS_OBJECT_PATH = '/org/freedesktop/DBus' const SPOTIFY_MPRIS_NAME_PATTERN = /^org\.mpris\.MediaPlayer2\.spotify(?:\..+)?$/i const execFileAsync = promisify(execFile) @@ -194,54 +200,6 @@ function createTrackId(title: string, artist: string, album: string): string { return `spotify-local:${title}\n${artist}\n${album}` } -function escapeRegexLiteral(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - -function decodeGVariantString(value: string): string { - return value - .replace(/\\\\/g, '\\') - .replace(/\\'/g, '\'') - .replace(/\\n/g, '\n') - .replace(/\\r/g, '\r') - .replace(/\\t/g, '\t') -} - -function extractGdbusStringVariant(output: string, key: string): string { - const match = output.match(new RegExp(`'${escapeRegexLiteral(key)}': <(?:@s )?'((?:\\\\.|[^'])*)'>`)) - return normalizeString(match?.[1] ? decodeGVariantString(match[1]) : '') -} - -function extractGdbusObjectPathVariant(output: string, key: string): string { - const match = output.match(new RegExp(`'${escapeRegexLiteral(key)}': `)) - return normalizeString(match?.[1] ? decodeGVariantString(match[1]) : '') -} - -function extractGdbusInt64Variant(output: string, key: string): number { - const match = output.match(new RegExp(`'${escapeRegexLiteral(key)}': <(?:@x |@t |int64 |uint64 )?(-?\\d+)>`)) - if (!match) { - return 0 - } - - const numeric = Number.parseInt(match[1], 10) - if (!Number.isFinite(numeric)) { - return 0 - } - - return Math.max(0, numeric) -} - -function extractGdbusStringArrayVariant(output: string, key: string): string[] { - const match = output.match(new RegExp(`'${escapeRegexLiteral(key)}': <(?:@as )?\\[([\\s\\S]*?)\\]>`)) - if (!match?.[1]) { - return [] - } - - return Array.from(match[1].matchAll(/'((?:\\.|[^'])*)'/g), (entry) => { - return normalizeString(decodeGVariantString(entry[1] ?? '')) - }).filter(Boolean) -} - function parseLinuxPlaybackState(value: string): LocalSpotifySnapshot['playbackState'] { switch (value.toLowerCase()) { case 'playing': @@ -389,12 +347,6 @@ function parseWindowsSpotifyStatusPayload( } } -function parseLinuxBusNames(output: string): string[] { - return Array.from(output.matchAll(/'((?:\\.|[^'])*)'/g), (entry) => { - return normalizeString(decodeGVariantString(entry[1] ?? '')) - }).filter(Boolean) -} - function getLinuxSpotifyBusName(output: string): string | null { return parseLinuxBusNames(output).find((name) => SPOTIFY_MPRIS_NAME_PATTERN.test(name)) ?? null } @@ -531,59 +483,6 @@ async function defaultCommandRunner(command: string, args: string[]): Promise { - switch (command) { - case 'play': - return 'Play' - case 'pause': - return 'Pause' - case 'next': - return 'Next' - case 'previous': - return 'Previous' - } - })() - - return [ - 'call', - '--session', - '--dest', - busName, - '--object-path', - MPRIS_PLAYER_OBJECT_PATH, - '--method', - `${MPRIS_PLAYER_INTERFACE}.${methodName}`, - ] -} - export class SpotifyProvider implements NowPlayingProviderService<'spotify'> { readonly providerId = 'spotify' diff --git a/src/main/services/nowPlayingManager.ts b/src/main/services/nowPlayingManager.ts index a0e4f0e..d790214 100644 --- a/src/main/services/nowPlayingManager.ts +++ b/src/main/services/nowPlayingManager.ts @@ -21,6 +21,7 @@ interface NowPlayingManagerOptions { type ProviderServiceMap = Partial<{ astra: NowPlayingProviderService<'astra'> spotify: NowPlayingProviderService<'spotify'> + tidal: NowPlayingProviderService<'tidal'> }> interface NowPlayingLocalState { @@ -96,8 +97,7 @@ function normalizeLocalState(raw: unknown): NowPlayingLocalState { } } -function createPlaceholderProviderState(providerId: Exclude): NowPlayingProviderState { - const definition = NOW_PLAYING_PROVIDER_DEFINITIONS[providerId] +function createUnavailableProviderState(providerId: NowPlayingProviderId): NowPlayingProviderState { return { providerId, connectionState: 'unavailable', @@ -105,8 +105,8 @@ function createPlaceholderProviderState(providerId: Exclude } else if (providerService.providerId === 'spotify') { acc.spotify = providerService as NowPlayingProviderService<'spotify'> + } else if (providerService.providerId === 'tidal') { + acc.tidal = providerService as NowPlayingProviderService<'tidal'> } providerService.subscribe(() => { if (!this.initialized) { @@ -220,6 +222,9 @@ export class NowPlayingManager { throw new Error(`${NOW_PLAYING_PROVIDER_DEFINITIONS[activeProviderId].label} controls are not available yet.`) } + if (!providerService.getProviderState().supportsTransportControls) { + throw new Error(`${NOW_PLAYING_PROVIDER_DEFINITIONS[activeProviderId].label} controls are unavailable on this platform.`) + } await providerService.sendControl(command) return this.getState() } @@ -231,7 +236,7 @@ export class NowPlayingManager { hasToken: false, }, spotify: this.providerServices.spotify?.getPublicConfig() ?? {}, - tidal: {}, + tidal: this.providerServices.tidal?.getPublicConfig() ?? {}, } const providers: NowPlayingProviderStateMap = { astra: this.providerServices.astra?.getProviderState() ?? { @@ -254,7 +259,7 @@ export class NowPlayingManager { available: false, supportsTransportControls: true, }, - tidal: createPlaceholderProviderState('tidal'), + tidal: this.providerServices.tidal?.getProviderState() ?? createUnavailableProviderState('tidal'), } const hasConfiguredProvider = this.providerPriority.some((providerId) => { diff --git a/src/main/services/nowPlayingProvider.ts b/src/main/services/nowPlayingProvider.ts index 6a500ef..60fd218 100644 --- a/src/main/services/nowPlayingProvider.ts +++ b/src/main/services/nowPlayingProvider.ts @@ -6,7 +6,7 @@ import type { NowPlayingProviderState, } from '../../types/nowPlaying' -export type ManagedNowPlayingProviderId = Exclude +export type ManagedNowPlayingProviderId = NowPlayingProviderId export interface NowPlayingProviderService { readonly providerId: K diff --git a/src/main/services/tidalProvider.ts b/src/main/services/tidalProvider.ts new file mode 100644 index 0000000..ddbfd5f --- /dev/null +++ b/src/main/services/tidalProvider.ts @@ -0,0 +1,406 @@ +import { execFile } from 'node:child_process' +import { access, open } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { setTimeout as delay } from 'node:timers/promises' +import type { NowPlayingControlCommand, NowPlayingProviderState, NowPlayingSnapshot } from '../../types/nowPlaying' +import type { NativeWindowsMediaAPI } from '../../types/nativeWindowsMedia' +import type { NowPlayingProviderService } from './nowPlayingProvider' +import { + buildLinuxCommandArgs, buildLinuxListNamesArgs, buildLinuxPropertiesArgs, + extractGdbusInt64Variant, extractGdbusObjectPathVariant, extractGdbusStringArrayVariant, + extractGdbusStringVariant, parseLinuxBusNames, +} from './localMediaMpris' + +const execFileAsync = promisify(execFile) +const MAX_ARTWORK_BYTES = 4 * 1024 * 1024 +const COMMAND_TIMEOUT_MS = 5000 +const TIDAL_BUNDLE_ID = 'com.tidal.desktop' +type CommandRunner = (command: string, args: string[], signal: AbortSignal) => Promise + +interface TidalProviderOptions { + platform?: NodeJS.Platform + now?: () => number + commandRunner?: CommandRunner + accessImpl?: typeof access + appPathCandidates?: string[] + fetchImpl?: typeof fetch + windowsMediaApi?: NativeWindowsMediaAPI | null +} + +// Executed by the system JXA host, without loading any bundled framework or helper. +// Keep reads optional: MediaRemote is private and fields vary by app and OS version. +export const TIDAL_MAC_STATUS_SCRIPT = ` +ObjC.import('Foundation'); +function optional(read, fallback) { + try { var value = read(); return value == null ? fallback : value; } catch (_) { return fallback; } +} +function run() { + var framework = $.NSBundle.bundleWithPath('/System/Library/PrivateFrameworks/MediaRemote.framework'); + if (!framework.load) throw new Error('TIDAL_MEDIA_UNAVAILABLE: macOS media information cannot be loaded.'); + var request = $.NSClassFromString('MRNowPlayingRequest'); + if (!request || !request.respondsToSelector('localNowPlayingItem') || + !request.respondsToSelector('localNowPlayingPlayerPath') || !request.respondsToSelector('localIsPlaying')) { + throw new Error('TIDAL_MEDIA_UNAVAILABLE: This macOS version does not expose local media information.'); + } + var item = request.localNowPlayingItem; + if (!item || !item.js) return 'null'; + var client = request.localNowPlayingPlayerPath.client; + var bundleIdentifier = optional(function () { return ObjC.unwrap(client.bundleIdentifier); }, ''); + var parentApplicationBundleIdentifier = optional(function () { return ObjC.unwrap(client.parentApplicationBundleIdentifier); }, ''); + if (bundleIdentifier !== 'com.tidal.desktop' && parentApplicationBundleIdentifier !== 'com.tidal.desktop') return 'null'; + var info = item.nowPlayingInfo; + function field(name) { return optional(function () { return ObjC.unwrap(info.objectForKey('kMRMediaRemoteNowPlayingInfo' + name)); }, null); } + var timestampMs = optional(function () { return Number(info.objectForKey('kMRMediaRemoteNowPlayingInfoTimestamp').timeIntervalSince1970) * 1000; }, null); + var artworkData = optional(function () { + var data = info.objectForKey('kMRMediaRemoteNowPlayingInfoArtworkData'); + return data.length > 0 && data.length <= 4194304 ? ObjC.unwrap(data.base64EncodedStringWithOptions(0)) : null; + }, null); + return JSON.stringify({ + bundleIdentifier: bundleIdentifier, parentApplicationBundleIdentifier: parentApplicationBundleIdentifier, + title: field('Title'), artist: field('Artist'), album: field('Album'), + id: field('ContentItemIdentifier'), playing: !!request.localIsPlaying, + duration: field('Duration'), elapsedTime: field('ElapsedTime'), timestampMs: timestampMs, + artworkData: artworkData, artworkMimeType: field('ArtworkMIMEType') + }); +} +` + +function string(value: unknown): string { return typeof value === 'string' ? value.trim() : '' } +function number(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : 0 +} +function errorMessage(error: unknown): string { + if (error && typeof error === 'object') { + const details = error as { killed?: boolean; stderr?: string } + if (details.killed) return 'The local media query timed out.' + if (details.stderr?.trim()) return details.stderr.trim() + } + return error instanceof Error ? error.message : String(error) +} +function isMissingSession(error: unknown): boolean { + return /ServiceUnknown|NameHasNoOwner|UnknownObject|was not provided by any .service/i.test(errorMessage(error)) +} +function isTidalIdentity(value: string): boolean { + return /^(tidal|tidal[- ]hi[- ]?fi|com\.mastermindzh\.tidal-hifi)(?:\.desktop)?$/i.test(value) +} +export function isTidalWindowsSession(sourceId: string): boolean { + return /^(com\.squirrel\.tidal\.tidal|tidal(?:\.exe)?|com\.tidal\.desktop)$/i.test(sourceId) + || /^TIDALMusicAS\.TIDAL_[a-z0-9]+!TIDAL$/i.test(sourceId) +} +function dataArtwork(value: unknown): string | null { + const data = string(value) + return data.length <= MAX_ARTWORK_BYTES * 4 / 3 + 100 + && /^data:image\/(png|jpeg|jpg|webp|gif);base64,[a-z0-9+/=]+$/i.test(data) ? data : null +} +function snapshot(fields: { + title: unknown; artist?: unknown; album?: unknown; id?: unknown + playbackState: NowPlayingSnapshot['playbackState']; currentTime: number; duration: number + artworkDataUrl?: string | null +}, now: number): NowPlayingSnapshot | null { + const title = string(fields.title) + if (!title) return null + const artist = string(fields.artist) + const album = string(fields.album) + return { + playbackState: fields.playbackState, + currentTime: fields.duration > 0 ? Math.min(fields.currentTime, fields.duration) : fields.currentTime, + duration: fields.duration, updatedAt: now, queueLength: 0, outputDeviceLabel: null, + visualizerLineColor: '#f4f6f8', + currentTrack: { + id: `tidal:${string(fields.id) || `${title}\n${artist}\n${album}`}`, + title, artist, album, isFavorite: false, artworkDataUrl: fields.artworkDataUrl ?? null, + }, + } +} + +export function parseTidalMacStatus(output: string, now: number): NowPlayingSnapshot | null { + const raw: unknown = JSON.parse(output) + if (raw === null) return null + if (typeof raw !== 'object' || Array.isArray(raw)) throw new Error('Invalid TIDAL macOS media response.') + const data = raw as Record + if (data.bundleIdentifier !== TIDAL_BUNDLE_ID && data.parentApplicationBundleIdentifier !== TIDAL_BUNDLE_ID) return null + const elapsed = number(data.elapsedTime) + const timestamp = number(data.timestampMs) + // Without a reported position there is nothing to extrapolate. + const advance = data.playing === true && typeof data.elapsedTime === 'number' && timestamp > 0 + ? Math.max(0, now - timestamp) / 1000 : 0 + return snapshot({ + ...data, title: data.title, playbackState: data.playing === true ? 'playing' : 'paused', + currentTime: elapsed + advance, duration: number(data.duration), + artworkDataUrl: dataArtwork(`data:${string(data.artworkMimeType)};base64,${string(data.artworkData)}`), + }, now) +} + +export class TidalProvider implements NowPlayingProviderService<'tidal'> { + readonly providerId = 'tidal' + private readonly platform: NodeJS.Platform + private readonly now: () => number + private readonly run: CommandRunner + private readonly options: TidalProviderOptions + private readonly listeners = new Set<() => void>() + private readonly consumers = new Set() + private readonly lifetime = new AbortController() + private poll: AbortController | null = null + private chain: Promise = Promise.resolve() + private initialization: Promise | null = null + private selectedBus: string | null = null + private artworkCache: { key: string; data: string | null; retryAt: number } | null = null + private state: NowPlayingProviderState = { + providerId: 'tidal', connectionState: 'unavailable', available: false, isConfigured: false, + supportsTransportControls: false, snapshot: null, lastError: null, lastControlError: null, + } + + constructor(options: TidalProviderOptions = {}) { + this.options = options + this.platform = options.platform ?? process.platform + this.now = options.now ?? Date.now + this.run = options.commandRunner ?? (async (command, args, signal) => { + const { stdout } = await execFileAsync(command, args, { + signal, timeout: COMMAND_TIMEOUT_MS, maxBuffer: 6 * 1024 * 1024, + }) + return stdout.trim() + }) + } + + getPublicConfig(): Record { return {} } + getProviderState(): NowPlayingProviderState { return structuredClone(this.state) } + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + private publish(update: Partial, signal = this.lifetime.signal): void { + if (signal.aborted || this.lifetime.signal.aborted) return + this.state = { ...this.state, ...update } + for (const listener of this.listeners) listener() + } + private enqueue(work: () => Promise): Promise { + const next = this.chain.catch(() => undefined).then(work) + this.chain = next + return next + } + initialize(): Promise { + this.initialization ??= this.enqueue(() => this.probe()).then(() => { this.startPolling() }) + return this.initialization + } + async dispose(): Promise { + this.lifetime.abort() + this.stopPolling() + this.listeners.clear() + this.consumers.clear() + await this.chain.catch(() => undefined) + } + async saveConfig(): Promise { throw new Error('TIDAL does not require configuration.') } + async setConsumerActive(consumerId: number, active: boolean): Promise { + if (this.lifetime.signal.aborted) return + if (active) this.consumers.add(consumerId) + else this.consumers.delete(consumerId) + if (!this.consumers.size) { + this.stopPolling() + this.selectedBus = null + this.artworkCache = null + this.publish({ snapshot: null, connectionState: this.state.available ? 'disabled' : 'unavailable' }) + } else if (this.initialization) { + await this.initialization + this.startPolling() + } + } + async retry(): Promise { + if (this.lifetime.signal.aborted) return + await this.initialize() + this.stopPolling() + await this.enqueue(() => this.probe()) + if (!this.state.available) throw new Error(this.state.lastError ?? 'TIDAL integration is unavailable.') + this.artworkCache = null + const refresh = this.startPolling() + await refresh + } + private async probe(): Promise { + const signal = this.lifetime.signal + signal.throwIfAborted() + try { + if (this.platform === 'darwin') { + const candidates = this.options.appPathCandidates ?? ['/Applications/TIDAL.app', join(homedir(), 'Applications/TIDAL.app')] + const installed = await Promise.all(candidates.map(path => (this.options.accessImpl ?? access)(path).then(() => true, () => false))) + if (!installed.some(Boolean)) throw new Error('Install TIDAL.app, then click Retry to enable local track information.') + parseTidalMacStatus(await this.run('/usr/bin/osascript', ['-l', 'JavaScript', '-e', TIDAL_MAC_STATUS_SCRIPT], signal), this.now()) + } else if (this.platform === 'linux') { + await this.run('gdbus', buildLinuxListNamesArgs(), signal) + } else if (this.platform === 'win32') { + const api = this.options.windowsMediaApi + if (!api?.getTidalPlaybackState || !api.sendTidalControl) throw new Error('TIDAL Windows media integration is missing. Rebuild or update Prism, then click Retry.') + const support = api.getSupport() + if (!support.available) throw new Error(support.reason ?? 'Windows system media controls are unavailable. Click Retry after restoring access.') + } else { + throw new Error('Local TIDAL integration is supported on macOS, Windows, and Linux.') + } + this.publish({ available: true, isConfigured: true, supportsTransportControls: this.platform !== 'darwin', + connectionState: 'disabled', snapshot: null, lastError: null, lastControlError: null }, signal) + } catch (error) { + this.publish({ available: false, isConfigured: false, supportsTransportControls: false, + connectionState: 'unavailable', snapshot: null, lastError: this.describeError(error) }, signal) + } + } + private describeError(error: unknown): string { + const message = errorMessage(error) + if (this.platform === 'darwin') return `TIDAL track information is unavailable. ${message} Retry after checking TIDAL and macOS compatibility.` + if (this.platform === 'linux') return `Could not access TIDAL through the Linux session media bus. ${message} Check gdbus and your client's MPRIS support, then click Retry.` + return message + } + private stopPolling(): void { + this.poll?.abort() + this.poll = null + } + private startPolling(): Promise | undefined { + if (this.poll || !this.consumers.size || !this.state.available || this.lifetime.signal.aborted) return + const controller = new AbortController() + this.poll = controller + this.publish({ connectionState: 'connecting' }, controller.signal) + const first = this.enqueue(() => this.refresh(controller.signal)) + void (async () => { + await first + while (!controller.signal.aborted && this.state.available) { + await delay(this.state.snapshot?.playbackState === 'playing' ? 1500 : 5000, undefined, { signal: controller.signal }) + await this.enqueue(() => this.refresh(controller.signal)) + } + })().catch(() => undefined).finally(() => { if (this.poll === controller) this.poll = null }) + return first + } + private async refresh(signal: AbortSignal): Promise { + if (signal.aborted || this.lifetime.signal.aborted) return + try { + const { state, artworkUrl } = await this.readSnapshot(signal) + if (!state) this.artworkCache = null + if (state?.currentTrack && this.artworkCache?.key === `${state.currentTrack.id}\n${artworkUrl}`) { + state.currentTrack.artworkDataUrl = this.artworkCache.data + } + this.publish({ snapshot: state, connectionState: state ? 'connected' : 'disabled', lastError: null }, signal) + if (state?.currentTrack && artworkUrl && !signal.aborted) { + const artwork = await this.readArtwork(`${state.currentTrack.id}\n${artworkUrl}`, artworkUrl, signal) + if (artwork) this.publish({ snapshot: { ...state, currentTrack: { ...state.currentTrack, artworkDataUrl: artwork } } }, signal) + } + } catch (error) { + if (signal.aborted || this.lifetime.signal.aborted) return + this.selectedBus = null + this.artworkCache = null + const unavailable = this.platform === 'darwin' && /TIDAL_MEDIA_UNAVAILABLE|selector|not a function/i.test(errorMessage(error)) + this.publish({ snapshot: null, connectionState: unavailable ? 'unavailable' : 'error', lastError: this.describeError(error), + ...(unavailable ? { available: false, isConfigured: false, supportsTransportControls: false } : {}) }, signal) + } + } + private async readSnapshot(signal: AbortSignal): Promise<{ state: NowPlayingSnapshot | null; artworkUrl?: string }> { + if (this.platform === 'darwin') { + return { state: parseTidalMacStatus(await this.run('/usr/bin/osascript', ['-l', 'JavaScript', '-e', TIDAL_MAC_STATUS_SCRIPT], signal), this.now()) } + } + if (this.platform === 'win32') { + const data = this.options.windowsMediaApi!.getTidalPlaybackState!() + if (!data || !isTidalWindowsSession(data.sourceAppUserModelId)) return { state: null } + const status = string(data.playbackStatus).toLowerCase() + return { state: snapshot({ ...data, id: `${data.sourceAppUserModelId}\n${data.title}\n${data.artist}\n${data.album}`, + playbackState: status === 'playing' ? 'playing' : status === 'paused' ? 'paused' : 'stopped', + duration: number(data.durationMs) / 1000, currentTime: number(data.positionMs) / 1000, + artworkDataUrl: dataArtwork(data.artworkDataUrl) }, this.now()) } + } + const names = parseLinuxBusNames(await this.run('gdbus', buildLinuxListNamesArgs(), signal)) + .filter(name => name.startsWith('org.mpris.MediaPlayer2.')).sort() + const candidates: { bus: string; state: NowPlayingSnapshot | null; artworkUrl: string }[] = [] + for (const bus of names) { + signal.throwIfAborted() + let identified = false + try { + const identity = await this.run('gdbus', buildLinuxPropertiesArgs(bus, 'org.mpris.MediaPlayer2'), signal) + if (!this.matchesLinuxIdentity(identity)) continue + identified = true + const data = await this.run('gdbus', buildLinuxPropertiesArgs(bus), signal) + const status = extractGdbusStringVariant(data, 'PlaybackStatus') + candidates.push({ bus, artworkUrl: extractGdbusStringVariant(data, 'mpris:artUrl'), state: snapshot({ + title: extractGdbusStringVariant(data, 'xesam:title'), artist: extractGdbusStringArrayVariant(data, 'xesam:artist').join(', '), + album: extractGdbusStringVariant(data, 'xesam:album'), + id: `${bus}\n${extractGdbusObjectPathVariant(data, 'mpris:trackid')}\n${extractGdbusStringVariant(data, 'xesam:title')}\n${extractGdbusStringArrayVariant(data, 'xesam:artist').join(', ')}\n${extractGdbusStringVariant(data, 'xesam:album')}`, + playbackState: status === 'Playing' ? 'playing' : status === 'Paused' ? 'paused' : 'stopped', + duration: extractGdbusInt64Variant(data, 'mpris:length') / 1_000_000, + currentTime: extractGdbusInt64Variant(data, 'Position') / 1_000_000, + }, this.now()) }) + } catch (error) { + if (signal.aborted) throw error + if ((identified || bus === this.selectedBus) && !isMissingSession(error)) throw error + // Unrelated or disappearing media clients must not break TIDAL discovery. + } + } + candidates.sort((a, b) => Number(b.state?.playbackState === 'playing') - Number(a.state?.playbackState === 'playing') + || Number(b.bus === this.selectedBus) - Number(a.bus === this.selectedBus)) + const selected = candidates.find(candidate => candidate.state) ?? candidates[0] + if (!signal.aborted) this.selectedBus = selected?.bus ?? null + return selected ?? { state: null } + } + private matchesLinuxIdentity(output: string): boolean { + return isTidalIdentity(extractGdbusStringVariant(output, 'Identity')) + || isTidalIdentity(extractGdbusStringVariant(output, 'DesktopEntry')) + } + async sendControl(command: NowPlayingControlCommand): Promise { + return this.enqueue(async () => { + const signal = this.lifetime.signal + signal.throwIfAborted() + try { + if (!['play', 'pause', 'next', 'previous'].includes(command)) throw new Error('Unsupported TIDAL control command.') + if (!this.state.supportsTransportControls) throw new Error('TIDAL transport controls are unavailable on this platform. Use the TIDAL app.') + if (this.platform === 'linux') { + const bus = this.selectedBus + if (!bus) throw new Error('No active TIDAL media session. Start playback in TIDAL, then click Retry.') + const identity = await this.run('gdbus', buildLinuxPropertiesArgs(bus, 'org.mpris.MediaPlayer2'), signal) + if (!this.matchesLinuxIdentity(identity)) throw new Error('The selected media session no longer belongs to TIDAL.') + await this.run('gdbus', buildLinuxCommandArgs(bus, command), signal) + } else { + if (!this.options.windowsMediaApi!.sendTidalControl!(command)) throw new Error('TIDAL did not accept the playback command.') + } + this.publish({ lastControlError: null }, signal) + } catch (error) { + this.publish({ lastControlError: errorMessage(error) }, signal) + throw error + } + }).then(async () => { if (this.poll) await this.enqueue(() => this.refresh(this.poll?.signal ?? this.lifetime.signal)) }) + } + private async readArtwork(key: string, url: string, signal: AbortSignal): Promise { + if (this.artworkCache?.key === key && this.artworkCache.retryAt > this.now()) return this.artworkCache.data + let data: string | null = null + try { + const parsed = new URL(url) + const artworkSignal = AbortSignal.any([signal, AbortSignal.timeout(COMMAND_TIMEOUT_MS)]) + if (parsed.protocol === 'file:' && !parsed.host) { + const handle = await open(fileURLToPath(parsed), 'r') + try { + artworkSignal.throwIfAborted() + const info = await handle.stat() + if (info.isFile() && info.size > 0 && info.size <= MAX_ARTWORK_BYTES) { + const buffer = Buffer.alloc(info.size) + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0) + const mime = /\.png$/i.test(parsed.pathname) ? 'image/png' : /\.webp$/i.test(parsed.pathname) ? 'image/webp' : 'image/jpeg' + data = bytesRead ? `data:${mime};base64,${buffer.subarray(0, bytesRead).toString('base64')}` : null + } + } finally { await handle.close() } + } else if (parsed.protocol === 'https:' || parsed.protocol === 'http:') { + const response = await (this.options.fetchImpl ?? fetch)(url, { signal: artworkSignal }) + const mime = response.headers.get('content-type')?.split(';')[0].trim() ?? '' + if (response.ok && /^image\/(png|jpeg|jpg|webp|gif)$/i.test(mime) && response.body) { + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let total = 0 + try { + while (true) { + const next = await reader.read() + if (next.done) break + total += next.value.length + if (total > MAX_ARTWORK_BYTES) throw new Error('Artwork is too large.') + chunks.push(next.value) + } + data = total ? `data:${mime};base64,${Buffer.concat(chunks).toString('base64')}` : null + } finally { await reader.cancel().catch(() => undefined) } + } else { await response.body?.cancel() } + } + } catch { /* Artwork is optional; never discard a valid track when loading it fails. */ } + if (!signal.aborted) this.artworkCache = { key, data, retryAt: data ? Infinity : this.now() + 30_000 } + return data + } +} diff --git a/src/renderer/components/AstraScopeModule.tsx b/src/renderer/components/AstraScopeModule.tsx index c3457f8..e2206f8 100644 --- a/src/renderer/components/AstraScopeModule.tsx +++ b/src/renderer/components/AstraScopeModule.tsx @@ -1,3 +1,4 @@ +import { getLocalAvailabilityDetail } from '../utils/localNowPlaying' import { useEffect, useMemo, useState, type CSSProperties, type JSX } from 'react' import type { NowPlayingProviderId } from '../../types/nowPlaying' import type { ScopeSettings } from '../../types/settings' @@ -56,6 +57,16 @@ function getFallbackTitle( return 'Nothing playing' } + if (providerId === 'tidal') { + switch (connectionState) { + case 'disabled': return 'TIDAL is idle' + case 'connecting': return 'Checking TIDAL' + case 'error': return 'TIDAL connection failed' + case 'unavailable': return 'TIDAL unavailable' + case 'connected': return 'Nothing playing' + } + } + if (providerId === 'spotify') { switch (connectionState) { case 'disabled': @@ -110,6 +121,14 @@ function getFallbackDetail( return '' } + if (providerId === 'tidal') { + if (connectionState === 'connected') return '' + if (connectionState === 'error' || connectionState === 'unavailable') return getLocalAvailabilityDetail(platform, 'tidal') + return platform === 'darwin' + ? 'Start playback in TIDAL so it appears in macOS Now Playing. Track information only.' + : 'Start playback in a compatible local TIDAL app.' + } + if (providerId === 'spotify') { switch (connectionState) { case 'disabled': @@ -216,9 +235,6 @@ export default function AstraScopeModule({ const providerState = displayProviderId ? nowPlayingState.providers[displayProviderId] : null - const providerDefinition = displayProviderId - ? nowPlayingState.definitions[displayProviderId] - : null const platform = window.electronAPI.platform useEffect(() => { @@ -296,6 +312,7 @@ export default function AstraScopeModule({ ) } + const showControls = settings.showControls && Boolean(providerState?.supportsTransportControls) const toggleCommand = snapshot?.playbackState === 'playing' ? 'pause' : 'play' const toggleLabel = snapshot?.playbackState === 'playing' ? 'Pause' : 'Play' const toggleIcon = snapshot?.playbackState === 'playing' ? '\u23F8' : '\u25B6' @@ -303,7 +320,7 @@ export default function AstraScopeModule({ const shouldShowTransport = settings.showProgress || settings.showTime const shouldShowBody = shouldShowMeta || shouldShowTransport - || settings.showControls + || showControls || Boolean(errorMessage) const isCoverArtOnly = settings.showCoverArt && !shouldShowBody const cardClassName = [ @@ -368,12 +385,12 @@ export default function AstraScopeModule({
)} - {settings.showControls && ( + {showControls && (
+
+ {!provider.available && ( +
{getLocalAvailabilityDetail(platform, providerId)}
+ )} + {(provider.lastError || provider.lastControlError) && ( +
+ {provider.lastError ?? provider.lastControlError} +
+ )} +
+ ) +} diff --git a/src/renderer/components/NowPlayingConfigWindow.tsx b/src/renderer/components/NowPlayingConfigWindow.tsx index 698749e..30aeaad 100644 --- a/src/renderer/components/NowPlayingConfigWindow.tsx +++ b/src/renderer/components/NowPlayingConfigWindow.tsx @@ -1,3 +1,5 @@ +import { LocalNowPlayingProviderDetails } from './LocalNowPlayingProviderDetails' +import { getLocalIntegrationLabel, getLocalUnavailableMetaText } from '../utils/localNowPlaying' import { useCallback, useEffect, @@ -95,82 +97,6 @@ function getErrorMessage(error: unknown, fallback: string): string { : fallback } -function isMacOSPlatform(platform: string): boolean { - return platform === 'darwin' -} - -function isLinuxPlatform(platform: string): boolean { - return platform === 'linux' -} - -function isWindowsPlatform(platform: string): boolean { - return platform === 'win32' -} - -function getSpotifyIntegrationLabel(platform: string): string { - if (isMacOSPlatform(platform)) { - return 'Local macOS app' - } - - if (isLinuxPlatform(platform)) { - return 'Local Linux MPRIS' - } - - if (isWindowsPlatform(platform)) { - return 'Local Windows media session' - } - - return 'Local Spotify integration' -} - -function getSpotifyUnavailableMetaText(platform: string): string { - if (isMacOSPlatform(platform)) { - return 'Local macOS app unavailable' - } - - if (isLinuxPlatform(platform)) { - return 'Local Linux MPRIS unavailable' - } - - if (isWindowsPlatform(platform)) { - return 'Local Windows media session unavailable' - } - - return 'Local Spotify integration unavailable' -} - -function getSpotifyAvailabilityDetail(platform: string): string { - if (isMacOSPlatform(platform)) { - return 'Install Spotify.app in /Applications to enable this provider.' - } - - if (isLinuxPlatform(platform)) { - return 'This provider needs a Linux desktop session with Spotify MPRIS access.' - } - - if (isWindowsPlatform(platform)) { - return 'This provider needs Windows system media controls to expose a Spotify session.' - } - - return 'This provider is currently available on macOS, Linux, and Windows.' -} - -function getSpotifyProviderCopy(platform: string): string { - if (isMacOSPlatform(platform)) { - return 'No Spotify developer account or API setup is required. Prism reads the local Spotify macOS app directly.' - } - - if (isLinuxPlatform(platform)) { - return 'No Spotify developer account or API setup is required. Prism reads Spotify through the local Linux MPRIS session.' - } - - if (isWindowsPlatform(platform)) { - return 'No Spotify developer account or API setup is required. Prism reads Spotify through the local Windows media session.' - } - - return 'No Spotify developer account or API setup is required. On supported systems, Prism reads the local Spotify app directly.' -} - function getProviderStatusLabel( definition: NowPlayingProviderDefinition, provider: NowPlayingProviderState, @@ -211,8 +137,8 @@ function getProviderMetaText( } if (!provider.available) { - return provider.providerId === 'spotify' - ? getSpotifyUnavailableMetaText(platform) + return definition.authMode === 'local' + ? getLocalUnavailableMetaText(platform, provider.providerId) : 'Unavailable on this device' } @@ -221,10 +147,10 @@ function getProviderMetaText( } if (definition.authMode === 'local') { - const integrationLabel = getSpotifyIntegrationLabel(platform) + const integrationLabel = getLocalIntegrationLabel(platform, provider.providerId) switch (provider.connectionState) { case 'disabled': - return `${integrationLabel} · Waiting for Spotify` + return `${integrationLabel} · Waiting for ${definition.label}` case 'connecting': return `${integrationLabel} · Checking playback` case 'connected': @@ -234,7 +160,7 @@ function getProviderMetaText( case 'error': return `${integrationLabel} · Needs attention` case 'unavailable': - return getSpotifyUnavailableMetaText(platform) + return getLocalUnavailableMetaText(platform, provider.providerId) } } @@ -475,7 +401,7 @@ export default function NowPlayingConfigWindow(): JSX.Element {
{nowPlayingState.onboardingRequired - ? 'Start with Astra or the local Spotify integration. TIDAL stays visible here for future priority.' + ? 'Connect Astra or use local Spotify or TIDAL playback. Drag providers to set their priority.' : 'The highest configured provider that starts playing takes over immediately.'}
@@ -651,42 +577,13 @@ export default function NowPlayingConfigWindow(): JSX.Element {
) : ( -
-
- {definition.description} -
-
- {getSpotifyProviderCopy(platform)} -
-
- -
- {!provider.available ? ( -
- {getSpotifyAvailabilityDetail(platform)} -
- ) : null} - {provider.lastError || provider.lastControlError ? ( -
- {provider.lastError ?? provider.lastControlError} -
- ) : null} -
+ showBanner({ tone: 'error', message, actions: [] })} + /> ) ) : null} diff --git a/src/renderer/utils/localNowPlaying.ts b/src/renderer/utils/localNowPlaying.ts new file mode 100644 index 0000000..6f5db7d --- /dev/null +++ b/src/renderer/utils/localNowPlaying.ts @@ -0,0 +1,81 @@ +import { NOW_PLAYING_PROVIDER_DEFINITIONS, type NowPlayingProviderId } from '../../types/nowPlaying' + +function isMacOSPlatform(platform: string): boolean { return platform === 'darwin' } +function isLinuxPlatform(platform: string): boolean { return platform === 'linux' } +function isWindowsPlatform(platform: string): boolean { return platform === 'win32' } + +export function getLocalIntegrationLabel(platform: string, providerId: NowPlayingProviderId): string { + if (providerId === 'tidal' && platform === 'darwin') return 'Local macOS track information' + if (isMacOSPlatform(platform)) { + return 'Local macOS app' + } + + if (isLinuxPlatform(platform)) { + return 'Local Linux MPRIS' + } + + if (isWindowsPlatform(platform)) { + return 'Local Windows media session' + } + + return `Local ${NOW_PLAYING_PROVIDER_DEFINITIONS[providerId].label} integration` +} + +export function getLocalUnavailableMetaText(platform: string, providerId: NowPlayingProviderId): string { + if (isMacOSPlatform(platform)) { + return 'Local macOS app unavailable' + } + + if (isLinuxPlatform(platform)) { + return 'Local Linux MPRIS unavailable' + } + + if (isWindowsPlatform(platform)) { + return 'Local Windows media session unavailable' + } + + return `Local ${NOW_PLAYING_PROVIDER_DEFINITIONS[providerId].label} integration unavailable` +} + +export function getLocalAvailabilityDetail(platform: string, providerId: NowPlayingProviderId): string { + if (providerId === 'tidal') { + if (platform === 'darwin') return 'Install TIDAL.app and click Retry. Track information requires compatible macOS system media access.' + if (platform === 'linux') return 'Open a compatible TIDAL client, such as TIDAL Hi-Fi, with MPRIS enabled. Prism needs gdbus and access to the desktop session bus.' + if (platform === 'win32') return 'Open TIDAL on this PC and start playback so Windows can expose its media session, then click Retry.' + } + if (isMacOSPlatform(platform)) { + return 'Install Spotify.app in /Applications to enable this provider.' + } + + if (isLinuxPlatform(platform)) { + return 'This provider needs a Linux desktop session with Spotify MPRIS access.' + } + + if (isWindowsPlatform(platform)) { + return 'This provider needs Windows system media controls to expose a Spotify session.' + } + + return 'This provider is currently available on macOS, Linux, and Windows.' +} + +export function getLocalProviderCopy(platform: string, providerId: NowPlayingProviderId): string { + if (providerId === 'tidal') { + if (platform === 'darwin') return 'No account setup in Prism is required. Shows track information and progress while TIDAL owns system Now Playing. Use TIDAL for playback controls; artwork appears only when macOS supplies it.' + if (platform === 'linux') return 'No account setup in Prism is required. Reads playback and controls from compatible dedicated TIDAL clients over MPRIS, including TIDAL Hi-Fi. Browser tabs are not supported.' + return 'No account setup in Prism is required. Reads playback and controls from the local TIDAL Windows media session.' + } + if (isMacOSPlatform(platform)) { + return 'No Spotify developer account or API setup is required. Prism reads the local Spotify macOS app directly.' + } + + if (isLinuxPlatform(platform)) { + return 'No Spotify developer account or API setup is required. Prism reads Spotify through the local Linux MPRIS session.' + } + + if (isWindowsPlatform(platform)) { + return 'No Spotify developer account or API setup is required. Prism reads Spotify through the local Windows media session.' + } + + return 'No Spotify developer account or API setup is required. On supported systems, Prism reads the local Spotify app directly.' +} + diff --git a/src/types/nativeWindowsMedia.ts b/src/types/nativeWindowsMedia.ts index c47b5a2..068d413 100644 --- a/src/types/nativeWindowsMedia.ts +++ b/src/types/nativeWindowsMedia.ts @@ -3,7 +3,7 @@ import type { NowPlayingControlCommand } from './nowPlaying' export type NativeWindowsMediaSupport = NativeCaptureSupport -export interface NativeWindowsSpotifyPlaybackState { +export interface NativeWindowsPlaybackState { album: string artworkDataUrl: string | null artist: string @@ -14,7 +14,11 @@ export interface NativeWindowsSpotifyPlaybackState { title: string } +export type NativeWindowsSpotifyPlaybackState = NativeWindowsPlaybackState + export interface NativeWindowsMediaAPI { + getTidalPlaybackState?: () => NativeWindowsPlaybackState | null + sendTidalControl?: (command: NowPlayingControlCommand) => boolean getSupport: () => NativeWindowsMediaSupport getSpotifyPlaybackState: () => NativeWindowsSpotifyPlaybackState | null sendSpotifyControl: (command: NowPlayingControlCommand) => boolean diff --git a/src/types/nowPlaying.ts b/src/types/nowPlaying.ts index a69efde..8dcd603 100644 --- a/src/types/nowPlaying.ts +++ b/src/types/nowPlaying.ts @@ -99,10 +99,10 @@ export const NOW_PLAYING_PROVIDER_DEFINITIONS: NowPlayingProviderDefinitionMap = tidal: { id: 'tidal', label: 'TIDAL', - description: 'TIDAL integration is planned but not implemented yet.', - authMode: 'none', - available: false, - comingSoon: true, - supportsTransportControls: false, + description: 'Read local TIDAL playback, with transport controls on Windows and compatible Linux clients.', + authMode: 'local', + available: true, + comingSoon: false, + supportsTransportControls: true, }, } diff --git a/test/now-playing-manager.test.ts b/test/now-playing-manager.test.ts index f7aa9e0..3244f7b 100644 --- a/test/now-playing-manager.test.ts +++ b/test/now-playing-manager.test.ts @@ -47,7 +47,7 @@ function cloneProviderState(state: NowPlayingProviderState): NowPlayingProviderS } } -class StubProviderService implements NowPlayingProviderService { +class StubProviderService implements NowPlayingProviderService { readonly providerId: K publicConfig: NowPlayingProviderConfigMap[K] providerState: NowPlayingProviderState @@ -136,11 +136,14 @@ async function createHarness(options?: { astraConfig?: AstraIntegrationPublicConfig astraState?: Partial spotifyState?: Partial + tidalState?: Partial }): Promise<{ astra: StubProviderService<'astra'> cleanup: () => Promise manager: NowPlayingManager spotify: StubProviderService<'spotify'> + tidal: StubProviderService<'tidal'> + localStatePath: string }> { const rootDir = await mkdtemp(join(tmpdir(), 'prism-now-playing-manager-')) const astra = new StubProviderService('astra', { @@ -164,12 +167,19 @@ async function createHarness(options?: { }), }) + const tidal = new StubProviderService('tidal', { + publicConfig: {}, + providerState: createProviderState('tidal', options?.tidalState), + }) + const localStatePath = join(rootDir, 'now-playing-state.json') return { + tidal, + localStatePath, astra, cleanup: () => rm(rootDir, { recursive: true, force: true }), manager: new NowPlayingManager({ localStatePath: join(rootDir, 'now-playing-state.json'), - providerServices: [astra, spotify], + providerServices: [astra, spotify, tidal], }), spotify, } @@ -335,3 +345,43 @@ test('manager forwards save, retry, and controls to the active provider services await harness.cleanup() } }) + + +test('TIDAL participates in priority, configuration, lifecycle, retry and controls', async () => { + const active = { + available: true, isConfigured: true, supportsTransportControls: true, + connectionState: 'connected' as const, + snapshot: { + playbackState: 'playing' as const, currentTime: 3, duration: 120, queueLength: 0, + outputDeviceLabel: null, visualizerLineColor: '#fff', updatedAt: 100, + currentTrack: { id: 'track', title: 'Track', artist: '', album: '', isFavorite: false, artworkDataUrl: null }, + }, + } + const harness = await createHarness({ spotifyState: active, tidalState: active }) + try { + await harness.manager.initialize() + assert.equal(harness.manager.getState().activeProviderId, 'spotify') + await harness.manager.setProviderPriority(['tidal', 'spotify', 'astra']) + assert.equal(harness.manager.getState().activeProviderId, 'tidal') + assert.equal(harness.manager.getState().onboardingRequired, false) + await harness.manager.setConsumerActive(99, true) + await harness.manager.retryProvider('tidal') + await harness.manager.sendControl('next') + assert.equal(harness.tidal.initializeCalls, 1) + assert.deepEqual(harness.tidal.consumerCalls, [{ consumerId: 99, active: true }]) + assert.equal(harness.tidal.retryCalls, 1) + assert.equal(harness.spotify.retryCalls, 0) + assert.deepEqual(harness.tidal.controlCalls, ['next']) + assert.deepEqual(harness.spotify.controlCalls, []) + const restored = new NowPlayingManager({ localStatePath: harness.localStatePath, providerServices: [harness.tidal] }) + await restored.initialize() + assert.deepEqual(restored.getState().providerPriority, ['tidal', 'spotify', 'astra']) + harness.tidal.providerState.supportsTransportControls = false + await assert.rejects(harness.manager.sendControl('pause'), /controls are unavailable/) + harness.tidal.providerState.snapshot = null + harness.tidal.emit() + assert.equal(harness.manager.getState().activeProviderId, 'spotify') + await harness.manager.dispose() + assert.equal(harness.tidal.disposeCalls, 1) + } finally { await harness.cleanup() } +}) diff --git a/test/now-playing-ui.test.tsx b/test/now-playing-ui.test.tsx new file mode 100644 index 0000000..99b8260 --- /dev/null +++ b/test/now-playing-ui.test.tsx @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { Children, isValidElement, type ReactNode } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { LocalNowPlayingProviderDetails } from '../src/renderer/components/LocalNowPlayingProviderDetails' +import AstraScopeModule from '../src/renderer/components/AstraScopeModule' +import { useNowPlayingStore } from '../src/renderer/stores/nowPlayingStore' +import { createDefaultProfile } from '../src/shared/profileState' +import { createDefaultTheme, resolveTheme } from '../src/shared/themeState' +import type { NowPlayingProviderState } from '../src/types/nowPlaying' + +const provider: NowPlayingProviderState = { + providerId: 'tidal', available: true, isConfigured: true, supportsTransportControls: false, + connectionState: 'connected', lastError: null, lastControlError: null, + snapshot: { playbackState: 'paused', currentTime: 10, duration: 0, updatedAt: 1000, + queueLength: 0, outputDeviceLabel: null, visualizerLineColor: '#fff', + currentTrack: { id: 'tidal:1', title: 'TIDAL Track', artist: 'Artist', album: '', isFavorite: false, artworkDataUrl: null } }, +} + +function findButton(node: ReactNode): { disabled?: boolean; onClick: () => void } | null { + for (const child of Children.toArray(node)) { + if (!isValidElement<{ children?: ReactNode; disabled?: boolean; onClick: () => void }>(child)) continue + if (child.type === 'button') return child.props + const nested = findButton(child.props.children) + if (nested) return nested + } + return null +} + +test('TIDAL Retry is enabled when unavailable and calls TIDAL, never Spotify', async () => { + const calls: string[] = [] + const errors: string[] = [] + const component = LocalNowPlayingProviderDetails({ providerId: 'tidal', provider: { ...provider, available: false }, platform: 'darwin', + retryProvider: async id => { calls.push(id); throw new Error('TIDAL unavailable') }, onError: message => errors.push(message) }) + const button = findButton(component)! + assert.ok(button) + assert.notEqual(button.disabled, true) + button.onClick() + await Promise.resolve() + assert.deepEqual(calls, ['tidal']) + assert.deepEqual(errors, ['TIDAL unavailable']) + const html = renderToStaticMarkup(component) + assert.doesNotMatch(html, /Coming Soon|Spotify/) + assert.match(html, /Use TIDAL for playback controls/) +}) + +test('Now Playing renders TIDAL track data and uses runtime control support', () => { + const oldWindow = globalThis.window + const previous = { ...useNowPlayingStore.getInitialState() } + const initial = useNowPlayingStore.getInitialState() + Object.assign(globalThis, { window: { electronAPI: { platform: 'darwin' } } }) + try { + Object.assign(initial, { nowPlayingState: { ...previous.nowPlayingState, + activeProviderId: 'tidal', hasConfiguredProvider: true, onboardingRequired: false, + providers: { ...previous.nowPlayingState.providers, tidal: provider } } }) + const settings = createDefaultProfile().scopeSettings.nowPlaying + const theme = resolveTheme(createDefaultTheme(), 'dark').nowPlaying + const render = () => renderToStaticMarkup() + assert.match(render(), /TIDAL Track/) + assert.match(render(), /--:--/) + assert.doesNotMatch(render(), /aria-label="(?:Next track|Previous track|Play)"/) + Object.assign(initial, { nowPlayingState: { ...initial.nowPlayingState, + providers: { ...previous.nowPlayingState.providers, tidal: { ...provider, supportsTransportControls: true } } } }) + assert.match(render(), /aria-label="Next track"/) + } finally { + Object.assign(initial, previous) + Object.assign(globalThis, { window: oldWindow }) + } +}) diff --git a/test/tidal-provider.test.ts b/test/tidal-provider.test.ts new file mode 100644 index 0000000..4be9543 --- /dev/null +++ b/test/tidal-provider.test.ts @@ -0,0 +1,336 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { setImmediate } from 'node:timers/promises' +import { parseTidalMacStatus, TidalProvider, isTidalWindowsSession } from '../src/main/services/tidalProvider' +import { extractGdbusStringVariant, extractGdbusStringArrayVariant } from '../src/main/services/localMediaMpris' +import type { NativeWindowsMediaAPI, NativeWindowsPlaybackState } from '../src/types/nativeWindowsMedia' + +const now = 100_000 +const mac = { bundleIdentifier: 'com.tidal.desktop', title: 'A "title"\nwith Unicode — 音楽', artist: 'Artist', + playing: true, elapsedTime: 10, duration: 90, timestampMs: now - 2000 } +const windows: NativeWindowsPlaybackState = { sourceAppUserModelId: 'com.squirrel.TIDAL.TIDAL', + title: 'Track', artist: 'Artist', album: '', positionMs: 2000, durationMs: 0, + playbackStatus: 'Playing', artworkDataUrl: null } +async function until(condition: () => boolean): Promise { + for (let i = 0; i < 300; i++) { if (condition()) return; await setImmediate() } + assert.ok(condition(), 'provider did not reach expected state') +} +function macProvider(read: () => string = () => JSON.stringify(mac)) { + return new TidalProvider({ platform: 'darwin', now: () => now, + accessImpl: async () => undefined, commandRunner: async () => read() }) +} + +test('Mac parses track info, extrapolates playing only, and tolerates missing metadata', () => { + const parsed = parseTidalMacStatus(JSON.stringify(mac), now)! + assert.equal(parsed.currentTrack?.title, mac.title) + assert.equal(parsed.currentTrack?.album, '') + assert.equal(parsed.currentTrack?.artworkDataUrl, null) + assert.equal(parsed.currentTime, 12) + assert.equal(parseTidalMacStatus(JSON.stringify({ ...mac, playing: false }), now)?.currentTime, 10) + assert.equal(parseTidalMacStatus(JSON.stringify({ ...mac, elapsedTime: 89 }), now)?.currentTime, 90) + assert.equal(parseTidalMacStatus(JSON.stringify({ ...mac, duration: 0 }), now)?.currentTime, 12) + assert.equal(parseTidalMacStatus(JSON.stringify({ ...mac, elapsedTime: undefined }), now)?.currentTime, 0) + assert.equal(parseTidalMacStatus(JSON.stringify({ ...mac, timestampMs: now + 999 }), now)?.currentTime, 10) + assert.equal(parseTidalMacStatus(JSON.stringify({ ...mac, artist: null, duration: null }), now)?.currentTrack?.artist, '') +}) + +test('Mac filters unrelated sessions and accepts the TIDAL parent app identity', () => { + assert.equal(parseTidalMacStatus('null', now), null) + assert.equal(parseTidalMacStatus(JSON.stringify({ ...mac, bundleIdentifier: 'com.spotify.client' }), now), null) + assert.ok(parseTidalMacStatus(JSON.stringify({ ...mac, bundleIdentifier: 'com.tidal.helper', parentApplicationBundleIdentifier: 'com.tidal.desktop' }), now)) + assert.throws(() => parseTidalMacStatus('bad json', now)) + assert.throws(() => parseTidalMacStatus('[]', now)) + assert.equal(parseTidalMacStatus(JSON.stringify({ ...mac, title: null }), now), null) +}) + +test('Mac uses only supplied image bytes and rejects non-image artwork', () => { + const withArt = { ...mac, artworkData: 'aGVsbG8=', artworkMimeType: 'image/png' } + assert.equal(parseTidalMacStatus(JSON.stringify(withArt), now)?.currentTrack?.artworkDataUrl, 'data:image/png;base64,aGVsbG8=') + assert.equal(parseTidalMacStatus(JSON.stringify({ ...withArt, artworkMimeType: 'text/html' }), now)?.currentTrack?.artworkDataUrl, null) +}) + +test('Mac provider clears a lost session, recovers, and rejects all controls', async () => { + let data = JSON.stringify(mac) + const provider = macProvider(() => data) + try { + await provider.initialize() + assert.equal(provider.getProviderState().supportsTransportControls, false) + await provider.setConsumerActive(1, true) + await until(() => provider.getProviderState().connectionState === 'connected') + await assert.rejects(provider.sendControl('play'), /unavailable on this platform/) + data = 'null' + await provider.retry() + assert.equal(provider.getProviderState().snapshot, null) + assert.equal(provider.getProviderState().connectionState, 'disabled') + data = JSON.stringify(mac) + await provider.retry() + assert.equal(provider.getProviderState().snapshot?.currentTrack?.title, mac.title) + await provider.setConsumerActive(1, false) + assert.equal(provider.getProviderState().snapshot, null) + } finally { await provider.dispose() } +}) + +test('unavailable Mac probe can be retried after the system integration recovers', async () => { + let broken = true + const provider = macProvider(() => { if (broken) throw new Error('TIDAL_MEDIA_UNAVAILABLE: no selector'); return JSON.stringify(mac) }) + try { + await provider.initialize() + assert.equal(provider.getProviderState().available, false) + await provider.setConsumerActive(1, true) + await assert.rejects(provider.retry(), /macOS/) + broken = false + await provider.retry() + assert.equal(provider.getProviderState().connectionState, 'connected') + } finally { await provider.dispose() } +}) + +test('missing TIDAL installation reports unavailable without launching the app', async () => { + let calls = 0 + const provider = new TidalProvider({ platform: 'darwin', accessImpl: async () => { throw new Error('ENOENT') }, + commandRunner: async () => { calls++; return 'null' } }) + try { + await provider.initialize() + assert.equal(provider.getProviderState().available, false) + assert.match(provider.getProviderState().lastError!, /Install TIDAL.app/) + assert.equal(calls, 0) + } finally { await provider.dispose() } +}) + +test('Windows preserves unknown duration and metadata; commands target TIDAL only', async () => { + let data: NativeWindowsPlaybackState | null = windows + let accept = false + const calls: string[] = [] + const api: NativeWindowsMediaAPI = { + getSupport: () => ({ available: true, reason: null }), + getSpotifyPlaybackState: () => { throw new Error('wrong provider') }, + sendSpotifyControl: () => { throw new Error('wrong provider') }, + getTidalPlaybackState: () => data, + sendTidalControl: command => { calls.push(command); return accept }, + } + const provider = new TidalProvider({ platform: 'win32', windowsMediaApi: api }) + try { + await provider.initialize() + await provider.setConsumerActive(1, true) + await until(() => provider.getProviderState().connectionState === 'connected') + assert.equal(provider.getProviderState().snapshot?.currentTime, 2) + assert.equal(provider.getProviderState().snapshot?.duration, 0) + assert.equal(provider.getProviderState().snapshot?.playbackState, 'playing') + await assert.rejects(provider.sendControl('next'), /did not accept/) + assert.match(provider.getProviderState().lastControlError!, /did not accept/) + accept = true + data = { ...windows, playbackStatus: 'Paused' } + await provider.sendControl('pause') + assert.equal(provider.getProviderState().snapshot?.playbackState, 'paused') + assert.equal(provider.getProviderState().lastControlError, null) + assert.deepEqual(calls, ['next', 'pause']) + data = null + await provider.retry() + assert.equal(provider.getProviderState().snapshot, null) + data = { ...windows, sourceAppUserModelId: 'Spotify.exe' } + await provider.retry() + assert.equal(provider.getProviderState().snapshot, null) + } finally { await provider.dispose() } +}) + +test('Windows session matching excludes browsers and substring matches', () => { + for (const id of ['com.squirrel.TIDAL.TIDAL', 'TIDAL.exe', 'TIDALMusicAS.TIDAL_abcdef!TIDAL']) assert.equal(isTidalWindowsSession(id), true) + for (const id of ['Chrome.exe', 'something-tidal', 'com.squirrel.tidal.tidal.other']) assert.equal(isTidalWindowsSession(id), false) +}) + +test('older native addons fail closed without affecting Spotify', async () => { + const provider = new TidalProvider({ platform: 'win32', windowsMediaApi: { + getSupport: () => ({ available: true, reason: null }), getSpotifyPlaybackState: () => null, sendSpotifyControl: () => true, + } }) + try { + await provider.initialize() + assert.equal(provider.getProviderState().available, false) + assert.match(provider.getProviderState().lastError!, /Rebuild or update Prism/) + } finally { await provider.dispose() } +}) + +const prefix = 'org.mpris.MediaPlayer2.' +function metadata(title: string, status = 'Playing', art = ''): string { + return `({'PlaybackStatus': <'${status}'>, 'Position': , 'Metadata': <{'xesam:title': <'${title}'>, 'xesam:artist': <['Artist']>, 'mpris:artUrl': <'${art}'>, 'mpris:length': }>},)` +} +function linuxHarness(fetchImpl?: typeof fetch) { + let busError = false + const sessions = new Map([ + ['browser', { identity: 'Chromium', data: metadata('TIDAL in browser') }], + ['a', { identity: 'TIDAL Hi-Fi', data: metadata('First', 'Paused') }], + ['b', { identity: 'TIDAL', data: metadata('Second') }], + ]) + const commands: { bus: string; method: string }[] = [] + const provider = new TidalProvider({ platform: 'linux', fetchImpl, commandRunner: async (_command, args) => { + const method = args[args.indexOf('--method') + 1] + if (busError) throw new Error('Cannot connect to session bus') + if (method.endsWith('.ListNames')) return `([${[...sessions.keys()].map(key => `'${prefix}${key}'`).join(', ')}],)` + const bus = args[args.indexOf('--dest') + 1].slice(prefix.length) + const session = sessions.get(bus) + if (!session) throw new Error('NameHasNoOwner') + if (method.endsWith('.GetAll')) return args.at(-1) === 'org.mpris.MediaPlayer2' + ? `({'Identity': <'${session.identity}'>},)` : session.data + commands.push({ bus, method }) + return '()' + } }) + return { provider, sessions, commands, setBusError: (value: boolean) => { busError = value } } +} + +test('Linux prefers playing dedicated clients, keeps ties stable, and routes controls to that session', async () => { + const { provider, sessions, commands } = linuxHarness() + try { + await provider.initialize() + await provider.setConsumerActive(1, true) + await until(() => provider.getProviderState().snapshot?.currentTrack?.title === 'Second') + assert.equal(provider.getProviderState().snapshot?.currentTime, 2) + assert.equal(provider.getProviderState().snapshot?.duration, 180) + sessions.get('a')!.data = metadata('First') + await provider.sendControl('pause') // refresh both playing; retain b + assert.equal(provider.getProviderState().snapshot?.currentTrack?.title, 'Second') + assert.deepEqual(commands, [{ bus: 'b', method: 'org.mpris.MediaPlayer2.Player.Pause' }]) + sessions.delete('b') + await assert.rejects(provider.sendControl('next'), /NameHasNoOwner/) + await provider.retry() + assert.equal(provider.getProviderState().snapshot?.currentTrack?.title, 'First') + sessions.delete('a') + await provider.retry() + assert.equal(provider.getProviderState().snapshot, null) + await assert.rejects(provider.sendControl('play'), /No active TIDAL/) + } finally { await provider.dispose() } +}) + +test('Linux reports unavailable bus and recovers through Retry', async () => { + const harness = linuxHarness() + harness.setBusError(true) + try { + await harness.provider.initialize() + assert.equal(harness.provider.getProviderState().available, false) + harness.setBusError(false) + await harness.provider.setConsumerActive(1, true) + await harness.provider.retry() + assert.equal(harness.provider.getProviderState().connectionState, 'connected') + } finally { await harness.provider.dispose() } +}) + +test('Linux checks identity again before sending a control', async () => { + const { provider, sessions, commands } = linuxHarness() + try { + await provider.initialize() + await provider.setConsumerActive(1, true) + await until(() => provider.getProviderState().connectionState === 'connected') + sessions.get('b')!.identity = 'Chromium' + await assert.rejects(provider.sendControl('next'), /no longer belongs to TIDAL/) + assert.equal(commands.length, 0) + } finally { await provider.dispose() } +}) + +test('dispose aborts active requests and suppresses late results', async () => { + let release!: (value: string) => void + let activeSignal: AbortSignal | undefined + let calls = 0 + let emissions = 0 + const provider = new TidalProvider({ platform: 'darwin', accessImpl: async () => undefined, + commandRunner: async (_command, _args, signal) => { + if (++calls === 1) return 'null' + activeSignal = signal + return new Promise(resolve => { release = resolve }) + } }) + provider.subscribe(() => { emissions++ }) + await provider.initialize() + await provider.setConsumerActive(1, true) + await until(() => Boolean(release)) + const beforeDispose = emissions + const disposing = provider.dispose() + assert.equal(activeSignal?.aborted, true) + release(JSON.stringify(mac)) + await disposing + assert.equal(emissions, beforeDispose) + assert.equal(provider.getProviderState().snapshot, null) +}) + +test('removing the final consumer cancels reads without affecting other consumers', async () => { + const provider = macProvider() + try { + await provider.setConsumerActive(1, true) + await provider.setConsumerActive(2, true) + await provider.initialize() + await until(() => provider.getProviderState().connectionState === 'connected') + await provider.setConsumerActive(1, false) + assert.ok(provider.getProviderState().snapshot) + await provider.setConsumerActive(2, false) + assert.equal(provider.getProviderState().snapshot, null) + await provider.setConsumerActive(3, true) + await until(() => provider.getProviderState().connectionState === 'connected') + } finally { await provider.dispose() } +}) + + +test('shared MPRIS parsing preserves quotes, escapes, Unicode and artist arrays', () => { + const data = String.raw`({'xesam:title': <"Don't Stop \"Now\" — 音楽">, 'xesam:artist': <["D'Angelo", 'Artist\\Name']>},)` + assert.equal(extractGdbusStringVariant(data, 'xesam:title'), 'Don\'t Stop "Now" — 音楽') + assert.deepEqual(extractGdbusStringArrayVariant(data, 'xesam:artist'), ["D'Angelo", 'Artist\\Name']) +}) + +test('Linux artwork is cached without flicker and discarded when the track changes', async () => { + let fetches = 0 + const { provider, sessions } = linuxHarness(async () => { + fetches++ + return new Response('cover', { headers: { 'content-type': 'image/png' } }) + }) + sessions.get('b')!.data = metadata('Second', 'Playing', 'https://example.test/cover') + try { + await provider.initialize() + await provider.setConsumerActive(1, true) + await until(() => Boolean(provider.getProviderState().snapshot?.currentTrack?.artworkDataUrl)) + assert.equal(fetches, 1) + const updates: (string | null | undefined)[] = [] + const unsubscribe = provider.subscribe(() => updates.push(provider.getProviderState().snapshot?.currentTrack?.artworkDataUrl)) + await provider.sendControl('play') + assert.equal(fetches, 1) + assert.ok(updates.every(Boolean), 'cached artwork should not disappear during refresh') + unsubscribe() + sessions.get('b')!.data = metadata('Third') + await provider.sendControl('next') + assert.equal(provider.getProviderState().snapshot?.currentTrack?.title, 'Third') + assert.equal(provider.getProviderState().snapshot?.currentTrack?.artworkDataUrl, null) + } finally { await provider.dispose() } +}) + +test('failed or non-image artwork does not drop the track and retries are bounded', async () => { + let fetches = 0 + const { provider, sessions } = linuxHarness(async () => { + fetches++ + return new Response('not an image', { headers: { 'content-type': 'text/html' } }) + }) + sessions.get('b')!.data = metadata('Second', 'Playing', 'https://example.test/cover') + try { + await provider.initialize() + await provider.setConsumerActive(1, true) + await until(() => fetches > 0) + await provider.sendControl('play') + assert.equal(fetches, 1) + assert.equal(provider.getProviderState().connectionState, 'connected') + assert.equal(provider.getProviderState().snapshot?.currentTrack?.artworkDataUrl, null) + await provider.retry() + assert.equal(fetches, 2) + } finally { await provider.dispose() } +}) + +test('disposing while artwork is loading aborts the fetch and suppresses stale updates', async () => { + let fetchSignal: AbortSignal | undefined + let started = false + const { provider, sessions } = linuxHarness(async (_url, init) => { + started = true + fetchSignal = init?.signal ?? undefined + return new Promise((_resolve, reject) => fetchSignal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true })) + }) + sessions.get('b')!.data = metadata('Second', 'Playing', 'https://example.test/cover') + let emissions = 0 + provider.subscribe(() => { emissions++ }) + await provider.initialize() + await provider.setConsumerActive(1, true) + await until(() => started) + const before = emissions + await provider.dispose() + assert.equal(fetchSignal?.aborted, true) + assert.equal(emissions, before) +}) From 18bda056bc747d9ff3633828f18a22973fae6d01 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:37:07 -0400 Subject: [PATCH 06/27] waterfall vst --- .github/workflows/main.yml | 16 +- .github/workflows/release.yml | 20 +- README.md | 5 +- plugin/CMakeLists.txt | 25 ++- plugin/README.md | 34 +++- plugin/Source/PluginEditor.cpp | 21 +- plugin/Source/PluginProcessor.cpp | 2 + plugin/Source/PluginProcessor.h | 4 + plugin/Source/ScopeEngine.h | 3 + plugin/Source/WaterfallEngine.h | 102 ++++++++++ plugin/Tests/WaterfallTests.cpp | 185 ++++++++++++++++++ .../linux/install-vst3-from-package.sh | 1 + resources/installer/linux/install-vst3.sh | 1 + .../linux/remove-vst3-from-system.sh | 1 + resources/installer/windows-vst3.nsh | 3 +- src/plugin-ui/BridgeWaterfallAnalyzer.ts | 60 ++++++ src/plugin-ui/NativeFrameScheduler.ts | 15 ++ src/plugin-ui/PluginWebViewDataSource.ts | 4 + src/plugin-ui/WaterfallScope.tsx | 54 +++++ src/plugin-ui/juceBridge.ts | 87 ++++++++ src/plugin-ui/main.tsx | 22 ++- src/plugin-ui/waterfallOptions.ts | 10 + test/renderer-helpers.test.ts | 72 ++++++- 23 files changed, 709 insertions(+), 38 deletions(-) create mode 100644 plugin/Source/WaterfallEngine.h create mode 100644 plugin/Tests/WaterfallTests.cpp create mode 100644 src/plugin-ui/BridgeWaterfallAnalyzer.ts create mode 100644 src/plugin-ui/NativeFrameScheduler.ts create mode 100644 src/plugin-ui/WaterfallScope.tsx create mode 100644 src/plugin-ui/waterfallOptions.ts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b37a0d1..4852a61 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -269,14 +269,14 @@ jobs: for /f "usebackq tokens=*" %%i in (`vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set VSINSTALL=%%i call "%VSINSTALL%\VC\Auxiliary\Build\vcvars64.bat" cmake --build plugin/build --target PrismInstallerPlugins --config Release --parallel 4 - cmake --build plugin/build --target PrismBridgeTests --config Release --parallel 4 - ctest --test-dir plugin/build -C Release --output-on-failure -R PrismBridgeProcessorTests + cmake --build plugin/build --target PrismBridgeTests PrismWaterfallTests --config Release --parallel 4 + ctest --test-dir plugin/build -C Release --output-on-failure -R "Prism(BridgeProcessor|Waterfall)Tests" - - name: Test Prism Bridge processor + - name: Test Prism Bridge and Waterfall plugins if: runner.os != 'Windows' run: | - cmake --build plugin/build --target PrismBridgeTests --config Release --parallel 4 - ctest --test-dir plugin/build -C Release --output-on-failure -R PrismBridgeProcessorTests + cmake --build plugin/build --target PrismBridgeTests PrismWaterfallTests --config Release --parallel 4 + ctest --test-dir plugin/build -C Release --output-on-failure -R "Prism(BridgeProcessor|Waterfall)Tests" - name: Stage plugins for Windows installer if: runner.os == 'Windows' @@ -284,13 +284,13 @@ jobs: run: | rm -rf plugin/dist-installer mkdir -p plugin/dist-installer/VST3 - for art in PrismSpectrum PrismOscilloscope PrismVUMeter PrismLUFSMeter PrismVectorscope PrismSpectrogram PrismWaveform PrismBridge; do + for art in PrismSpectrum PrismOscilloscope PrismVUMeter PrismLUFSMeter PrismVectorscope PrismSpectrogram PrismWaveform PrismWaterfall PrismBridge; do vst3_dir="plugin/build/${art}_artefacts/Release/VST3" [ -d "$vst3_dir" ] && find "$vst3_dir" -maxdepth 1 -name "*.vst3" -exec cp -R {} plugin/dist-installer/VST3/ \; done vst3_count="$(find plugin/dist-installer/VST3 -maxdepth 1 -name "*.vst3" | wc -l | tr -d ' ')" - if [ "$vst3_count" -ne 8 ]; then - echo "ERROR: expected 8 VST3 plugins, found $vst3_count" + if [ "$vst3_count" -ne 9 ]; then + echo "ERROR: expected 9 VST3 plugins, found $vst3_count" exit 1 fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 94ef743..5e2ace5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -214,14 +214,14 @@ jobs: for /f "usebackq tokens=*" %%i in (`vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set VSINSTALL=%%i call "%VSINSTALL%\VC\Auxiliary\Build\vcvars64.bat" cmake --build plugin/build --target PrismInstallerPlugins --config Release --parallel 4 - cmake --build plugin/build --target PrismBridgeTests --config Release --parallel 4 - ctest --test-dir plugin/build -C Release --output-on-failure -R PrismBridgeProcessorTests + cmake --build plugin/build --target PrismBridgeTests PrismWaterfallTests --config Release --parallel 4 + ctest --test-dir plugin/build -C Release --output-on-failure -R "Prism(BridgeProcessor|Waterfall)Tests" - - name: Test Prism Bridge processor + - name: Test Prism Bridge and Waterfall plugins if: runner.os != 'Windows' run: | - cmake --build plugin/build --target PrismBridgeTests --config Release --parallel 4 - ctest --test-dir plugin/build -C Release --output-on-failure -R PrismBridgeProcessorTests + cmake --build plugin/build --target PrismBridgeTests PrismWaterfallTests --config Release --parallel 4 + ctest --test-dir plugin/build -C Release --output-on-failure -R "Prism(BridgeProcessor|Waterfall)Tests" - name: Show sccache stats if: runner.os != 'Linux' @@ -234,7 +234,7 @@ jobs: mkdir -p plugin/dist-installer mkdir -p plugin/dist-installer/VST3 [ "$RUNNER_OS" = "macOS" ] && mkdir -p plugin/dist-installer/AU - for art in PrismSpectrum PrismOscilloscope PrismVUMeter PrismLUFSMeter PrismVectorscope PrismSpectrogram PrismWaveform PrismBridge; do + for art in PrismSpectrum PrismOscilloscope PrismVUMeter PrismLUFSMeter PrismVectorscope PrismSpectrogram PrismWaveform PrismWaterfall PrismBridge; do vst3_dir="plugin/build/${art}_artefacts/Release/VST3" [ -d "$vst3_dir" ] && find "$vst3_dir" -maxdepth 1 -name "*.vst3" -exec cp -R {} plugin/dist-installer/VST3/ \; if [ "$RUNNER_OS" = "macOS" ]; then @@ -245,8 +245,8 @@ jobs: echo "--- staged VST3 ---" ls -la plugin/dist-installer/VST3 vst3_count="$(find plugin/dist-installer/VST3 -maxdepth 1 -name "*.vst3" | wc -l | tr -d ' ')" - if [ "$vst3_count" -ne 8 ]; then - echo "ERROR: expected 8 VST3 plugins, found $vst3_count" + if [ "$vst3_count" -ne 9 ]; then + echo "ERROR: expected 9 VST3 plugins, found $vst3_count" exit 1 fi if [ "$RUNNER_OS" = "Linux" ]; then @@ -263,8 +263,8 @@ jobs: echo "--- staged AU ---" ls -la plugin/dist-installer/AU au_count="$(find plugin/dist-installer/AU -maxdepth 1 -name "*.component" | wc -l | tr -d ' ')" - if [ "$au_count" -ne 8 ]; then - echo "ERROR: expected 8 AU plugins, found $au_count" + if [ "$au_count" -ne 9 ]; then + echo "ERROR: expected 9 AU plugins, found $au_count" exit 1 fi fi diff --git a/README.md b/README.md index 1bb0783..1af5bb2 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Prism includes eight real-time scopes and meters: * **Spectrum Analyzer** — FFT spectrum with calibrated dBFS levels, configurable FFT size, spectral tilt, Log/Mel/Linear scales, heatmap and fill modes, and peak/pitch readouts * **Oscilloscope** — Time-domain waveform with fundamental-frequency pitch locking and sub-sample triggering for a stable display * **Vectorscope** — Full-band stereo phase analysis with XY, Polar, and M/S Linear views, calibrated references, adjustable zoom, and optional multiband RGB -* **Waterfall** — Layered spectrum history with 1–30 seconds of audio, adaptive ridge density, theme or heat colors, and frequency/time guides in the desktop app and TUI +* **Waterfall** — Layered spectrum history with 1–30 seconds of audio, adaptive ridge density, theme or heat colors, and frequency/time guides in the desktop app, TUI, and DAW plugin * **Spectrogram** — Scrolling frequency-over-time display with Log/Mel/Linear scales, stereo-energy analysis, and frequency reassignment in Sharp and Sharper modes * **VU Meter** — 300 ms metering with adjustable 0 VU reference, stereo correlation, and needle or bar displays * **Loudness Meter** — ITU-R BS.1770 momentary, short-term, and integrated LUFS metering with BS.1770 true-peak activity @@ -65,6 +65,7 @@ Most of Prism's analysis runs in native C++, with the same DSP implementations r * Foreground ridges hide covered sections of older lines * Available through the desktop add-scope menu and TUI layout editor (shortcut **8**) * Desktop popouts and saved profiles are supported; existing layouts stay unchanged +* Dedicated Prism Waterfall VST3/AU plugin shares the desktop renderer and native history, with settings saved in DAW projects ### Spectrogram @@ -156,7 +157,7 @@ Most of Prism's analysis runs in native C++, with the same DSP implementations r ### DAW plugins -* All seven Prism analyzers are built from the same native DSP source used by the desktop application +* All eight Prism analyzers are built from the same native DSP source used by the desktop application * Prism Bridge streams one DAW track or bus into the standalone app without changing the track audio * VST3 on Windows, macOS, and Linux * AU on macOS diff --git a/plugin/CMakeLists.txt b/plugin/CMakeLists.txt index 6b72411..cb6b9a0 100644 --- a/plugin/CMakeLists.txt +++ b/plugin/CMakeLists.txt @@ -126,6 +126,7 @@ function(add_prism_scope TARGET PRODUCT PLUGIN_CODE SCOPE_DEFINE) ${PRISM_NATIVE_DIR}/multiband.cpp ${PRISM_NATIVE_DIR}/spectrogram.cpp ${PRISM_NATIVE_DIR}/waveform.cpp + ${PRISM_NATIVE_DIR}/waterfall.cpp ${PRISM_NATIVE_DIR}/dsp_utils.cpp) target_include_directories(${TARGET} PRIVATE Source ${PRISM_NATIVE_DIR}) @@ -198,6 +199,7 @@ add_prism_scope(PrismLUFSMeter "Prism Loudness Meter" Pluf "PRISM_SCOPE_LUFSM add_prism_scope(PrismVectorscope "Prism Vectorscope" Pvct "PRISM_SCOPE_VECTORSCOPE=1") add_prism_scope(PrismSpectrogram "Prism Spectrogram" Pspg "PRISM_SCOPE_SPECTROGRAM=1") add_prism_scope(PrismWaveform "Prism Waveform" Pwav "PRISM_SCOPE_WAVEFORM=1") +add_prism_scope(PrismWaterfall "Prism Waterfall" Pwfl "PRISM_SCOPE_WATERFALL=1") # Prism Bridge is intentionally a small native-only pass-through plug-in. It has # no standalone target and never creates a webview or performs networking on the @@ -233,15 +235,29 @@ target_link_libraries(PrismBridge PRIVATE juce::juce_recommended_lto_flags juce::juce_recommended_warning_flags) -if(APPLE AND TARGET PrismBridge_AU) +if(APPLE) # Keep local/CI AU bundles structurally valid before the release pipeline # replaces this ad-hoc signature with the distribution identity. - add_custom_command(TARGET PrismBridge_AU POST_BUILD - COMMAND codesign --force --deep --sign - "$" - VERBATIM) + foreach(au_target IN ITEMS PrismBridge_AU PrismWaterfall_AU) + if(TARGET ${au_target}) + add_custom_command(TARGET ${au_target} POST_BUILD + COMMAND codesign --force --deep --sign - "$" + VERBATIM) + endif() + endforeach() endif() if(BUILD_TESTING) + juce_add_console_app(PrismWaterfallTests PRODUCT_NAME "Prism Waterfall Tests") + target_sources(PrismWaterfallTests PRIVATE Tests/WaterfallTests.cpp) + target_include_directories(PrismWaterfallTests PRIVATE Source ${PRISM_NATIVE_DIR} ${JUCE_SOURCE_DIR}/modules) + target_compile_definitions(PrismWaterfallTests PRIVATE + JUCE_GLOBAL_MODULE_SETTINGS_INCLUDED=1 + JUCE_WEB_BROWSER=1 + JucePlugin_Name="Prism Waterfall") + target_link_libraries(PrismWaterfallTests PRIVATE PrismWaterfall) + add_test(NAME PrismWaterfallTests COMMAND PrismWaterfallTests) + juce_add_console_app(PrismBridgeTests PRODUCT_NAME "Prism Bridge Tests") target_sources(PrismBridgeTests PRIVATE Tests/BridgeProcessorTests.cpp @@ -267,6 +283,7 @@ set(PRISM_SCOPE_TARGETS PrismVectorscope PrismSpectrogram PrismWaveform + PrismWaterfall PrismBridge) add_custom_target(PrismInstallerPlugins) diff --git a/plugin/README.md b/plugin/README.md index 17dc52d..2c1befd 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -2,7 +2,7 @@ JUCE 8 plugins (VST3 / AU / Standalone) that render Prism's scopes inside a DAW — one plugin per scope: **spectrum, oscilloscope, vectorscope, spectrogram, VU meter, -loudness meter, waveform** — plus the native **Prism Bridge** pass-through plugin. +loudness meter, waveform, waterfall** — plus the native **Prism Bridge** pass-through plugin. The analyzers **reuse Prism's existing C++ DSP** (`native/src/*.cpp`) and the **existing React canvas UI** (`src/plugin-ui`, importing the unchanged visualizers from `src/renderer/visualizers/`). VST3 is built for macOS, Windows, @@ -12,14 +12,16 @@ and Linux; AU is macOS-only. ``` DAW track audio - → processBlock (RT thread): mix to mono, write to lock-free FIFO [Source/PluginProcessor.cpp] - → 60 Hz timer (message thread): drain FIFO → Visualizer::Spectrum [Source/PluginEditor.cpp + native/src/spectrum.cpp] - → emit "spectrumFrame" (base64 Float32 magnitudes) to the webview - → juceBridge.ts decodes → BridgeSpectrumAnalyzer (a SpectrumNativeAnalyzer shim) - → SpectrumAnalyzer.ts renders to canvas (unchanged Electron code) [src/plugin-ui] + → processBlock (RT thread): copy stereo into a lock-free FIFO [Source/PluginProcessor.cpp] + → display callback (message thread): drain FIFO → native ScopeEngine [Source/PluginEditor.cpp] + → emit scope frames (compact base64 Float32 arrays) to the webview + → juceBridge.ts decodes → the scope's Bridge*Analyzer adapter + → the existing desktop visualizer renders to canvas [src/plugin-ui] ``` No DSP or allocation runs on the realtime audio thread; audio passes through unmodified. +Waterfall records history at 60 slices per second of audio, independently of the +display rate, and repaints when a native plot frame arrives. ## Build & run (macOS) @@ -36,7 +38,7 @@ cmake -B plugin/build -S plugin -DCMAKE_BUILD_TYPE=Release # embeds the bundle ( cmake --build plugin/build --config Release ``` -`COPY_PLUGIN_AFTER_BUILD` installs all seven analyzers plus Prism Bridge into your user plugin folders: +`COPY_PLUGIN_AFTER_BUILD` installs all eight analyzers plus Prism Bridge into your user plugin folders: - AU: `~/Library/Audio/Plug-Ins/Components/Prism *.component` - VST3: `~/Library/Audio/Plug-Ins/VST3/Prism *.vst3` @@ -102,20 +104,34 @@ cmake --build plugin/build --target PrismInstallerPlugins --parallel ``` Built bundles land under `plugin/build/Prism*_artefacts/Release/VST3/`. Copy the -eight `Prism *.vst3` directories to one of the standard Linux VST3 scan paths: +nine `Prism *.vst3` directories to one of the standard Linux VST3 scan paths: - User-local: `$HOME/.vst3` - System-wide: `/usr/lib/vst3` - System-wide local: `/usr/local/lib/vst3` The Linux `.deb` and `.rpm` release packages install Prism's VST3 bundles to -`/usr/lib/vst3` and remove only those eight bundles on package removal. The Linux +`/usr/lib/vst3` and remove only those nine bundles on package removal. The Linux `tar.gz` release includes `resources/plugins/install-vst3.sh`, which installs to `$HOME/.vst3` by default or `/usr/lib/vst3` with `--system`. The AppImage is portable app-only and does not install DAW plugins. ## Notes +Waterfall's native engine/processor tests run with `ctest --test-dir plugin/build +-C Release -R PrismWaterfallTests --output-on-failure` after building the +`PrismWaterfallTests` target. Run that executable with `--ui` for a silent native +editor smoke test of live rendering, color controls, and resizing; it saves canvas +snapshots in the test application's temporary directory. + +- **Waterfall:** reuses the desktop Canvas renderer and the native 60-slice/second + stereo analyzer. The webview requests only the visible ridge count and frequency + resolution. History duration, density, Theme/Heat colors, guides, FFT, frequency + scale/range, smoothing, and tilt are saved per instance in DAW state. Audio gaps, + editor reopen, sample-rate changes, and FFT-size changes start fresh history; + duration and appearance changes preserve available history. Freeze/inspect and + angled 3D remain future work. + - **Prism Bridge:** start the standalone Prism application, insert Bridge on a mono or stereo track/bus, then select that instance in Prism's **DAW Bridges** input group. Bridge is bit-transparent, ignores offline rendering, and sends diff --git a/plugin/Source/PluginEditor.cpp b/plugin/Source/PluginEditor.cpp index f63424b..5b5649f 100644 --- a/plugin/Source/PluginEditor.cpp +++ b/plugin/Source/PluginEditor.cpp @@ -6,6 +6,7 @@ #include "VectorscopeEngine.h" #include "SpectrogramEngine.h" #include "WaveformEngine.h" +#include "WaterfallEngine.h" #include #include #include @@ -131,7 +132,9 @@ namespace std::unique_ptr makeEngine() { -#if defined(PRISM_SCOPE_WAVEFORM) && PRISM_SCOPE_WAVEFORM +#if defined(PRISM_SCOPE_WATERFALL) && PRISM_SCOPE_WATERFALL + return std::make_unique(); +#elif defined(PRISM_SCOPE_WAVEFORM) && PRISM_SCOPE_WAVEFORM return std::make_unique(); #elif defined(PRISM_SCOPE_SPECTROGRAM) && PRISM_SCOPE_SPECTROGRAM return std::make_unique(); @@ -277,6 +280,7 @@ namespace .withEventListener("prismConfig", [&editor](juce::var v) { editor.onPrismConfig(std::move(v)); }) .withEventListener("prismReady", [&editor](juce::var) { editor.onPrismReady(); }) .withEventListener("prismSpectrogramConfig", [&editor](juce::var v) { editor.onScopeNativeConfig(std::move(v)); }) + .withEventListener("prismWaterfallConfig", [&editor](juce::var v) { editor.onScopeNativeConfig(std::move(v)); }) .withEventListener("prismScopeMeasurement", [&editor](juce::var v) { editor.onScopeMeasurement(std::move(v)); }) .withEventListener("prismSettingsPanel", [&editor](juce::var v) { editor.onSettingsPanel(std::move(v)); }); @@ -596,6 +600,10 @@ void PrismSpectrumEditor::onPrismReady() #endif #endif webViewReady = true; +#if defined(PRISM_SCOPE_WATERFALL) && PRISM_SCOPE_WATERFALL + // Samples buffered while the editor was closed are not current history. + processorRef.restartAudioHistory(); +#endif pushRestoreSettings(); sendAppDefaults(); startFrameDriver(); @@ -707,7 +715,16 @@ void PrismSpectrumEditor::renderFrame() const int drained = processorRef.drainStereo(drainLeft.data(), drainRight.data(), (int) drainLeft.size()); -#if JUCE_LINUX +#if defined(PRISM_SCOPE_WATERFALL) && PRISM_SCOPE_WATERFALL + if (processorRef.consumeAudioDiscontinuity()) + { + engine->resetAudioHistory(); + webView->emitEventIfBrowserIsVisible(engine->frameEventId(), engine->buildFrame(sampleRate)); + return; + } +#endif + +#if JUCE_LINUX && !defined(PRISM_SCOPE_WATERFALL) if (drained <= 0) return; #endif diff --git a/plugin/Source/PluginProcessor.cpp b/plugin/Source/PluginProcessor.cpp index 1a8c299..bdc54fa 100644 --- a/plugin/Source/PluginProcessor.cpp +++ b/plugin/Source/PluginProcessor.cpp @@ -15,6 +15,7 @@ void PrismSpectrumProcessor::prepareToPlay(double sampleRate, int) { currentSampleRate.store(sampleRate); fifo.reset(); + audioDiscontinuity.store(true); } bool PrismSpectrumProcessor::isBusesLayoutSupported(const BusesLayout& layouts) const @@ -42,6 +43,7 @@ void PrismSpectrumProcessor::pushStereoToFifo(const float* left, const float* ri std::memcpy(rightBuffer.data() + start2, right + size1, (size_t) size2 * sizeof(float)); } fifo.finishedWrite(size1 + size2); + if (size1 + size2 < num) audioDiscontinuity.store(true); } int PrismSpectrumProcessor::drainStereo(float* destLeft, float* destRight, int maxSamples) noexcept diff --git a/plugin/Source/PluginProcessor.h b/plugin/Source/PluginProcessor.h index bc234ab..9ad6bd8 100644 --- a/plugin/Source/PluginProcessor.h +++ b/plugin/Source/PluginProcessor.h @@ -49,6 +49,9 @@ class PrismSpectrumProcessor : public juce::AudioProcessor /** Copy up to `maxSamples` of buffered L/R audio into the destinations; returns count. */ int drainStereo(float* destLeft, float* destRight, int maxSamples) noexcept; + bool consumeAudioDiscontinuity() noexcept { return audioDiscontinuity.exchange(false); } + void restartAudioHistory() noexcept { audioDiscontinuity.store(true); } + /** Persisted UI settings as a JSON string (set from the editor, read on save). */ void setSettingsJson(const juce::String& json); juce::String getSettingsJson() const; @@ -59,6 +62,7 @@ class PrismSpectrumProcessor : public juce::AudioProcessor juce::AbstractFifo fifo { 1 << 16 }; std::vector leftBuffer, rightBuffer; // backing storage for `fifo` std::atomic currentSampleRate { 48000.0 }; + std::atomic audioDiscontinuity { true }; juce::CriticalSection settingsLock; juce::String settingsJson; diff --git a/plugin/Source/ScopeEngine.h b/plugin/Source/ScopeEngine.h index 3558067..7f6a307 100644 --- a/plugin/Source/ScopeEngine.h +++ b/plugin/Source/ScopeEngine.h @@ -44,6 +44,9 @@ class ScopeEngine /** Temporary UI interaction state. Most scopes do not need DSP changes. */ virtual void setMeasurementActive(bool) {} + /** Audio was interrupted or discarded. History scopes must start a new span. */ + virtual void resetAudioHistory() {} + /** Feed audio (called off the realtime thread). numSamples may be 0. */ virtual void process(const float* left, const float* right, int numSamples) = 0; diff --git a/plugin/Source/WaterfallEngine.h b/plugin/Source/WaterfallEngine.h new file mode 100644 index 0000000..847919d --- /dev/null +++ b/plugin/Source/WaterfallEngine.h @@ -0,0 +1,102 @@ +#pragma once + +#include "ScopeEngine.h" +#include "waterfall.h" +#include +#include + +// The native analyzer keeps the 60 Hz audio timeline. Only the visible ridges +// and frequency columns cross into the webview; paint rate never samples history. +class WaterfallEngine : public ScopeEngine +{ +public: + const char* scopeId() const override { return "waterfall"; } + juce::Identifier frameEventId() const override { return { "waterfallFrame" }; } + PreferredSize preferredSize() const override { return { 760, 360, 360, 180 }; } + + void setSampleRate(double rate) override + { + if (std::isfinite(rate) && rate > 0 && (float) rate != config.sampleRate) + { + config.sampleRate = (float) rate; + analyzer.configure(config); + } + } + + void configure(const juce::var& settings) override + { + if (! settings.isObject()) return; + applyAnalysisSettings(settings); + const bool audible = settings.getProperty("frequencyRangeMode", "extended").toString() == "audible"; + config.minFrequency = audible ? 20.0f : 10.0f; + config.maxFrequency = audible ? 20000.0f : 24000.0f; + analyzer.configure(config); + } + + void configureNative(const juce::var& payload) override + { + if (! payload.isObject()) return; + revision = (int) payload.getProperty("revision", revision); + if ((bool) payload.getProperty("reset", false)) analyzer.reset(); + const auto options = payload.getProperty("config", juce::var()); + if (options.isObject()) + { + applyAnalysisSettings(options); + // Host sample rate always wins over the webview's initial estimate. + config.minFrequency = number(options, "minFrequency", config.minFrequency); + config.maxFrequency = number(options, "maxFrequency", config.maxFrequency); + analyzer.configure(config); + } + ridges = (size_t) juce::jlimit(2, 64, (int) payload.getProperty("ridges", (int) ridges)); + columns = (size_t) juce::jlimit(2, 512, (int) payload.getProperty("columns", (int) columns)); + } + + void resetAudioHistory() override { analyzer.reset(); } + + void process(const float* left, const float* right, int count) override + { + if (count > 0) analyzer.processStereo(left, right, (size_t) count); + } + + juce::var buildFrame(double) override + { + const auto frame = analyzer.getFrame(ridges, columns); + auto* object = new juce::DynamicObject(); + object->setProperty("sampleRate", config.sampleRate); + object->setProperty("revision", revision); + object->setProperty("columns", (int) frame.columns); + object->setProperty("audioSeconds", frame.audioSeconds); + object->setProperty("levels", encode(frame.levels)); + object->setProperty("ages", encode(frame.ages)); + object->setProperty("frequencies", encode(frame.frequencies)); + return juce::var(object); + } + +private: + static float number(const juce::var& object, const char* key, float fallback) + { + const auto value = object.getProperty(key, fallback); + if (! (value.isInt() || value.isInt64() || value.isDouble())) return fallback; + const auto result = (float) value; + return std::isfinite(result) ? result : fallback; + } + + void applyAnalysisSettings(const juce::var& settings) + { + config.fftSize = (size_t) std::clamp(number(settings, "fftSize", (float) config.fftSize), 0.0f, 16384.0f); + config.historySeconds = number(settings, "historySeconds", config.historySeconds); + config.smoothing = number(settings, "smoothing", config.smoothing); + config.tiltDbPerOctave = number(settings, "tiltDbPerOctave", config.tiltDbPerOctave); + config.scaleMode = settings.getProperty("scaleMode", juce::String(config.scaleMode)).toString().toStdString(); + } + + static juce::String encode(const std::vector& values) + { + return values.empty() ? juce::String() : juce::Base64::toBase64(values.data(), values.size() * sizeof(float)); + } + + Visualizer::WaterfallAnalyzer analyzer; + Visualizer::WaterfallConfig config; + size_t ridges = 32, columns = 256; + int revision = 0; +}; diff --git a/plugin/Tests/WaterfallTests.cpp b/plugin/Tests/WaterfallTests.cpp new file mode 100644 index 0000000..5d6e3d4 --- /dev/null +++ b/plugin/Tests/WaterfallTests.cpp @@ -0,0 +1,185 @@ +#include "WaterfallEngine.h" +#include "PluginProcessor.h" +#include +#include +#include +#include + +namespace { +int failures = 0; +void expect(bool value, const char* message) { + if (!value) { std::cerr << "FAIL: " << message << '\n'; ++failures; } +} +std::vector floats(const juce::var& frame, const char* key) { + juce::MemoryOutputStream bytes; + expect(juce::Base64::convertFromBase64(bytes, frame.getProperty(key, "").toString()), "frame base64 decodes"); + std::vector result(bytes.getDataSize() / sizeof(float)); + if (!result.empty()) std::memcpy(result.data(), bytes.getData(), bytes.getDataSize()); + return result; +} +} + +// Optional, silent editor smoke test: exercise the real processor, DSP, native +// bridge and embedded WKWebView without requiring a DAW or an audio device. +int showEditor() { + juce::ScopedJuceInitialiser_GUI initialise; + juce::Process::makeForegroundProcess(); + struct Preview : juce::DocumentWindow, juce::Timer { + PrismSpectrumProcessor processor; + std::unique_ptr editor; + int64_t samples = 0; + int stage = 0; + bool waiting = false; + Preview() : DocumentWindow("Prism Waterfall — plugin preview", juce::Colours::black, DocumentWindow::closeButton) { + processor.prepareToPlay(48000, 800); + processor.setSettingsJson(R"({"historySeconds":5,"colorMode":"heat"})"); + editor.reset(processor.createEditorIfNeeded()); + setUsingNativeTitleBar(true); + setContentNonOwned(editor.get(), true); + setResizable(true, false); + centreWithSize(editor->getWidth(), editor->getHeight()); + setVisible(true); + toFront(true); + startTimerHz(60); + } + ~Preview() override { stopTimer(); clearContentComponent(); } + void closeButtonPressed() override { juce::MessageManager::getInstance()->stopDispatchLoop(); } + void inspect() { + juce::WebBrowserComponent* browser = nullptr; + for (auto* child : editor->getChildren()) + if (auto* web = dynamic_cast(child)) browser = web; + expect(browser != nullptr, "native plugin editor creates its webview"); + if (!browser) { closeButtonPressed(); return; } + waiting = true; + const int step = stage++; + const juce::String actions = step == 0 + ? "document.querySelector('button[aria-label=Settings]').click();" + : step == 1 + ? "const select=[...document.querySelectorAll('select')].find(s=>[...s.options].some(o=>o.value==='heat'));if(!select)throw Error('Missing Waterfall color control');select.value='theme';select.dispatchEvent(new Event('change',{bubbles:true}));document.querySelector('button[aria-label=Settings]').click();" + : ""; + browser->evaluateJavascript("(()=>{if(!window.__waterfallQA){window.__waterfallQA={frames:0,raf:0,errors:[]};window.__JUCE__.backend.addEventListener('waterfallFrame',f=>{window.__waterfallQA.frames++;window.__waterfallQA.frame={revision:f.revision,columns:f.columns,audioSeconds:f.audioSeconds,ageBytes:f.ages.length};});window.addEventListener('error',e=>window.__waterfallQA.errors.push(e.message));const tick=()=>{window.__waterfallQA.raf++;requestAnimationFrame(tick)};requestAnimationFrame(tick);}const canvas=document.querySelector('canvas');if(!canvas)throw Error('No scope canvas');const result={stats:window.__waterfallQA,scope:window.__JUCE__.initialisationData.prismScope[0],png:canvas.toDataURL(),width:canvas.width,height:canvas.height,ink:canvas.getContext('2d').getImageData(0,0,canvas.width,canvas.height).data.some((v,i)=>i%4===3&&v>0)};" + actions + "return JSON.stringify(result)})()", + [this, step](auto result) { + const auto* value = result.getResult(); + expect(value != nullptr, "native webview script evaluates"); + if (value) { + const auto data = juce::JSON::parse(value->toString()); + std::cout << "UI diagnostics: " << juce::JSON::toString(data["stats"]) << " ink=" << data["ink"].toString() << '\n'; + expect((bool) data["ink"], "native Waterfall canvas actually paints"); + expect(data["scope"].toString() == "waterfall", "native editor selects Waterfall UI"); + expect((int) data["width"] > 300 && (int) data["height"] > 100, "scope canvas has a usable viewport"); + const auto directory = juce::File::getSpecialLocation(juce::File::tempDirectory).getChildFile("prism-waterfall-plugin-qa"); + directory.createDirectory(); + juce::MemoryOutputStream png; + juce::Base64::convertFromBase64(png, data["png"].toString().fromFirstOccurrenceOf(",", false, false)); + const auto file = directory.getChildFile("native-editor-" + juce::String(step) + ".png"); + expect(file.replaceWithData(png.getData(), png.getDataSize()), "native editor snapshot saves"); + std::cout << file.getFullPathName() << '\n'; + } + if (step == 2) { + expect(juce::JSON::parse(processor.getSettingsJson())["colorMode"].toString() == "theme", + "native editor settings control persists to the processor"); + setSize(440, 240); + } + waiting = false; + if (step == 3) closeButtonPressed(); + }); + } + void timerCallback() override { + juce::AudioBuffer audio(2, 800); + juce::MidiBuffer midi; + for (int i = 0; i < 800; ++i, ++samples) { + const double t = samples / 48000.0; + const double beat = std::exp(-std::fmod(t * 2, 1.0) * 10); + const auto tone = [&](double hz) { return std::sin(t * 2 * juce::MathConstants::pi * hz); }; + const float value = (float) (0.3 * tone(65) * (0.3 + beat) + 0.12 * tone(330) + + 0.13 * tone(1320) * (0.6 + 0.4 * std::sin(t * 2)) + 0.08 * tone(4200) * beat); + audio.setSample(0, i, value); + audio.setSample(1, i, -value); + } + processor.processBlock(audio, midi); + if (!waiting && samples >= 48000 * (5 + stage * 3)) inspect(); + if (samples >= 48000 * 120) closeButtonPressed(); + } + } preview; + juce::MessageManager::getInstance()->runDispatchLoop(); + return failures ? 1 : 0; +} + +int main(int argc, char** argv) { + if (argc > 1 && juce::String(argv[1]) == "--ui") return showEditor(); + WaterfallEngine engine; + engine.configure(juce::JSON::parse(R"({"fftSize":2048,"historySeconds":3,"smoothing":0,"tiltDbPerOctave":0})")); + engine.configureNative(juce::JSON::parse(R"({"ridges":32,"columns":256,"revision":1})")); + std::vector left(48000 * 6), right(left.size()); + for (size_t i = 0; i < left.size(); ++i) { + left[i] = 0.5f * std::sin((double) i * 2 * juce::MathConstants::pi * 1007.8125 / 48000); + right[i] = -left[i]; + } + engine.process(left.data(), right.data(), (int) left.size()); + auto frame = engine.buildFrame(48000); + auto levels = floats(frame, "levels"); + auto ages = floats(frame, "ages"); + expect(!levels.empty() && levels.size() == ages.size() * 256, "compact plot payload dimensions match"); + const float peak = *std::max_element(levels.begin(), levels.end()); + expect(peak > -6.4f && peak < -5.8f, "anti-phase stereo remains calibrated and visible"); + expect((double) frame["audioSeconds"] == 6 && ages.back() > 2.8f, "history uses audio duration"); + expect((int) frame["revision"] == 1, "responses echo the viewport revision"); + engine.process(nullptr, nullptr, 0); + expect(juce::JSON::toString(engine.buildFrame(48000)) == juce::JSON::toString(frame), "suspended input holds its plot"); + + engine.configure(juce::JSON::parse(R"({"historySeconds":3,"colorMode":"heat","density":"dense"})")); + expect(floats(engine.buildFrame(48000), "levels") == levels, "appearance preserves history"); + engine.configureNative(juce::JSON::parse(R"({"ridges":8,"columns":40,"revision":2})")); + frame = engine.buildFrame(48000); + expect((int) frame["columns"] == 40 && floats(frame, "levels").size() <= 8 * 40, "resizing only requests visible data"); + expect((double) frame["audioSeconds"] == 6 && floats(frame, "ages").back() > 2.5f, "small plots retain the full selected timespan"); + engine.configure(juce::JSON::parse(R"({"historySeconds":30})")); + expect((double) engine.buildFrame(48000)["audioSeconds"] == 6, "extending duration preserves available history"); + engine.resetAudioHistory(); + expect(floats(engine.buildFrame(48000), "ages").empty(), "audio discontinuity clears history"); + engine.process(left.data(), right.data(), 48000); + engine.setSampleRate(44100); + expect((double) engine.buildFrame(44100)["audioSeconds"] == 0, "sample-rate changes reset history"); + engine.configureNative(juce::JSON::parse(R"({"config":{"sampleRate":96000},"revision":3})")); + expect((double) engine.buildFrame(96000)["sampleRate"] == 44100, "host sample rate wins over webview config"); + engine.process(left.data(), right.data(), 44100); + engine.configure(juce::JSON::parse(R"({"fftSize":4096})")); + expect(floats(engine.buildFrame(44100), "ages").empty(), "FFT-size changes reset history"); + engine.process(left.data(), right.data(), 44100); + engine.configureNative(juce::JSON::parse(R"({"reset":true,"revision":4})")); + expect((double) engine.buildFrame(44100)["audioSeconds"] == 0, "webview reset starts a new history span"); + + PrismSpectrumProcessor processor; + processor.prepareToPlay(48000, 512); + expect(processor.consumeAudioDiscontinuity(), "prepare marks a fresh audio span"); + juce::MidiBuffer midi; + for (int channels : {1, 2}) { + juce::AudioBuffer buffer(channels, 512), original; + for (int channel = 0; channel < channels; ++channel) + std::memcpy(buffer.getWritePointer(channel), channel == 0 ? left.data() : right.data(), 512 * sizeof(float)); + original.makeCopyOf(buffer); + processor.processBlock(buffer, midi); + for (int channel = 0; channel < channels; ++channel) + expect(std::memcmp(buffer.getReadPointer(channel), original.getReadPointer(channel), 512 * sizeof(float)) == 0, + "mono and stereo host audio pass through bit-identically"); + float drainedLeft[512], drainedRight[512]; + expect(processor.drainStereo(drainedLeft, drainedRight, 512) == 512, "FIFO preserves sample count"); + expect(std::memcmp(drainedRight, channels == 1 ? left.data() : right.data(), 512 * sizeof(float)) == 0, + "mono duplicates correctly and stereo preserves phase"); + } + juce::AudioBuffer overflow(2, 70000); + overflow.clear(); + processor.processBlock(overflow, midi); + expect(processor.consumeAudioDiscontinuity() && !processor.consumeAudioDiscontinuity(), "FIFO overrun is reported once"); + processor.restartAudioHistory(); + expect(processor.consumeAudioDiscontinuity(), "editor reopen discards stale buffered history"); + const juce::String settings = R"({"historySeconds":17,"colorMode":"heat","density":"dense","fftSize":8192,"scaleMode":"mel","smoothing":0.7,"tiltDbPerOctave":4,"showGrid":false})"; + processor.setSettingsJson(settings); + juce::MemoryBlock state; + processor.getStateInformation(state); + PrismSpectrumProcessor restored; + restored.setStateInformation(state.getData(), (int) state.getSize()); + expect(restored.getSettingsJson() == settings, "Waterfall settings round-trip through DAW project state"); + std::cout << (failures ? "Waterfall plugin tests failed\n" : "Waterfall plugin tests passed\n"); + return failures ? 1 : 0; +} diff --git a/resources/installer/linux/install-vst3-from-package.sh b/resources/installer/linux/install-vst3-from-package.sh index 5efbc2b..32379fb 100755 --- a/resources/installer/linux/install-vst3-from-package.sh +++ b/resources/installer/linux/install-vst3-from-package.sh @@ -97,6 +97,7 @@ install_plugin "Prism Loudness Meter.vst3" install_plugin "Prism Vectorscope.vst3" install_plugin "Prism Spectrogram.vst3" install_plugin "Prism Waveform.vst3" +install_plugin "Prism Waterfall.vst3" install_plugin "Prism Bridge.vst3" chmod -R a+rX "$DEST_DIR"/Prism*.vst3 2>/dev/null || true diff --git a/resources/installer/linux/install-vst3.sh b/resources/installer/linux/install-vst3.sh index 55935c4..df5a674 100755 --- a/resources/installer/linux/install-vst3.sh +++ b/resources/installer/linux/install-vst3.sh @@ -94,6 +94,7 @@ install_plugin "Prism Loudness Meter.vst3" install_plugin "Prism Vectorscope.vst3" install_plugin "Prism Spectrogram.vst3" install_plugin "Prism Waveform.vst3" +install_plugin "Prism Waterfall.vst3" install_plugin "Prism Bridge.vst3" chmod -R u+rwX,go+rX "$DEST_DIR"/Prism*.vst3 2>/dev/null || true diff --git a/resources/installer/linux/remove-vst3-from-system.sh b/resources/installer/linux/remove-vst3-from-system.sh index 10559b5..d059962 100755 --- a/resources/installer/linux/remove-vst3-from-system.sh +++ b/resources/installer/linux/remove-vst3-from-system.sh @@ -52,6 +52,7 @@ remove_plugin "Prism Loudness Meter.vst3" remove_plugin "Prism Vectorscope.vst3" remove_plugin "Prism Spectrogram.vst3" remove_plugin "Prism Waveform.vst3" +remove_plugin "Prism Waterfall.vst3" remove_plugin "Prism Bridge.vst3" remove_tui_link diff --git a/resources/installer/windows-vst3.nsh b/resources/installer/windows-vst3.nsh index c66f47f..711a853 100644 --- a/resources/installer/windows-vst3.nsh +++ b/resources/installer/windows-vst3.nsh @@ -60,7 +60,7 @@ Pop $PRISM_VST_CHECKBOX ${NSD_Check} $PRISM_VST_CHECKBOX - ${NSD_CreateLabel} 0 22u 100% 80u "Installs seven Prism analyzer plugins plus Prism Bridge to:$\r$\n$\r$\n $COMMONFILES64\VST3$\r$\n$\r$\nDAWs (FL Studio, Ableton, Logic, Reaper, etc.) will find them on the next plugin rescan.$\r$\n$\r$\nPrism Bridge sends one inserted track or bus to the standalone Prism app. Uncheck to install only the desktop app." + ${NSD_CreateLabel} 0 22u 100% 80u "Installs eight Prism analyzer plugins plus Prism Bridge to:$\r$\n$\r$\n $COMMONFILES64\VST3$\r$\n$\r$\nDAWs (FL Studio, Ableton, Logic, Reaper, etc.) will find them on the next plugin rescan.$\r$\n$\r$\nPrism Bridge sends one inserted track or bus to the standalone Prism app. Uncheck to install only the desktop app." Pop $0 nsDialogs::Show @@ -159,5 +159,6 @@ RMDir /r "$COMMONFILES64\VST3\Prism Vectorscope.vst3" RMDir /r "$COMMONFILES64\VST3\Prism Spectrogram.vst3" RMDir /r "$COMMONFILES64\VST3\Prism Waveform.vst3" + RMDir /r "$COMMONFILES64\VST3\Prism Waterfall.vst3" RMDir /r "$COMMONFILES64\VST3\Prism Bridge.vst3" !macroend diff --git a/src/plugin-ui/BridgeWaterfallAnalyzer.ts b/src/plugin-ui/BridgeWaterfallAnalyzer.ts new file mode 100644 index 0000000..af257b3 --- /dev/null +++ b/src/plugin-ui/BridgeWaterfallAnalyzer.ts @@ -0,0 +1,60 @@ +import type { WaterfallFrame, WaterfallNativeAnalyzer, WaterfallNativeConfig } from '../types/waterfall' +import { DEFAULT_WATERFALL_SETTINGS } from '../types/waterfall' +import { emitToHost, type WaterfallPluginFrame } from './juceBridge' + +export interface WaterfallFrameRequest { + revision: number + ridges: number + columns: number + config: WaterfallNativeConfig +} + +/** Native history stays in the plugin; the webview retains only its latest plot. */ +export class BridgeWaterfallAnalyzer implements WaterfallNativeAnalyzer { + private revision = 0 + private ridges = 32 + private columns = 256 + private config: WaterfallNativeConfig = { + ...DEFAULT_WATERFALL_SETTINGS, sampleRate: 48000, minFrequency: 10, maxFrequency: 24000, + } + private frame: WaterfallFrame | null = null + + constructor(private readonly send = (payload: unknown): void => emitToHost('prismWaterfallConfig', payload)) {} + + configure(config: WaterfallNativeConfig): void { + this.config = { ...config } + this.send({ revision: ++this.revision, config: this.config }) + } + + processStereo(_left: Float32Array, _right: Float32Array): void {} + isAvailable(): boolean { return true } + + pushFrame(frame: WaterfallPluginFrame): void { + // Ignore in-flight responses to earlier settings, viewport sizes or resets. + if (frame.revision === this.revision) this.frame = frame + } + + getFrame(ridges: number, columns: number): WaterfallFrame { + const nextRidges = Math.max(2, Math.min(64, Math.round(ridges))) + const nextColumns = Math.max(2, Math.min(512, Math.round(columns))) + if (nextRidges !== this.ridges || nextColumns !== this.columns) { + this.ridges = nextRidges + this.columns = nextColumns + this.send({ revision: ++this.revision, ridges: this.ridges, columns: this.columns }) + } + // Retain the previous plot during a resize, including while audio is held. + return this.frame ?? { + columns: this.columns, audioSeconds: 0, + levels: new Float32Array(0), ages: new Float32Array(0), frequencies: new Float32Array(0), + } + } + + reset(): void { + this.frame = null + this.send({ revision: ++this.revision, reset: true }) + } + + getRequest(): WaterfallFrameRequest { + return { revision: this.revision, ridges: this.ridges, columns: this.columns, config: { ...this.config } } + } +} diff --git a/src/plugin-ui/NativeFrameScheduler.ts b/src/plugin-ui/NativeFrameScheduler.ts new file mode 100644 index 0000000..c638bbc --- /dev/null +++ b/src/plugin-ui/NativeFrameScheduler.ts @@ -0,0 +1,15 @@ +import { FrameScheduler, type FrameSchedulerCallback } from '../renderer/visualizers/frameScheduler' + +/** JUCE supplies the display clock; embedded WebKit may suspend browser RAF. */ +export class NativeFrameScheduler extends FrameScheduler { + private readonly nativeCallbacks = new Set() + + override subscribe(callback: FrameSchedulerCallback): () => void { + this.nativeCallbacks.add(callback) + return () => { this.nativeCallbacks.delete(callback) } + } + + dispatchFrame(): void { + for (const callback of [...this.nativeCallbacks]) callback() + } +} diff --git a/src/plugin-ui/PluginWebViewDataSource.ts b/src/plugin-ui/PluginWebViewDataSource.ts index 0d26995..d506c3a 100644 --- a/src/plugin-ui/PluginWebViewDataSource.ts +++ b/src/plugin-ui/PluginWebViewDataSource.ts @@ -85,6 +85,10 @@ export class PluginWebViewDataSource implements SpectrumAnalyzerDataSource { return [] } + getPendingWaterfallSamples(): SpectrumStereoChunk[] { + return [] + } + getSampleRate(): number { return this.sessionState.sampleRate } diff --git a/src/plugin-ui/WaterfallScope.tsx b/src/plugin-ui/WaterfallScope.tsx new file mode 100644 index 0000000..5eb9772 --- /dev/null +++ b/src/plugin-ui/WaterfallScope.tsx @@ -0,0 +1,54 @@ +import { useEffect, useRef, type JSX } from 'react' +import { Waterfall } from '../renderer/visualizers/Waterfall' +import type { WaterfallSettings } from '../types/waterfall' +import type { ResolvedWaterfallTheme } from '../types/theme' +import type { BridgeWaterfallAnalyzer } from './BridgeWaterfallAnalyzer' +import type { PluginWebViewDataSource } from './PluginWebViewDataSource' +import { waterfallSettingsToOptions } from './waterfallOptions' +import { applyPluginScopeCanvasLayout } from './scopeCanvasLayout' +import type { NativeFrameScheduler } from './NativeFrameScheduler' + +interface Props { + dataSource: PluginWebViewDataSource + nativeAnalyzer: BridgeWaterfallAnalyzer + frameScheduler: NativeFrameScheduler + settings: WaterfallSettings + theme: ResolvedWaterfallTheme +} + +export default function WaterfallScope({ dataSource, nativeAnalyzer, frameScheduler, settings, theme }: Props): JSX.Element { + const containerRef = useRef(null) + const canvasRef = useRef(null) + const vizRef = useRef(null) + + useEffect(() => { + const container = containerRef.current, canvas = canvasRef.current + if (!container || !canvas) return + const viz = new Waterfall(canvas, { + ...waterfallSettingsToOptions(settings, theme), dataSource, nativeAnalyzer, frameScheduler, + }) + vizRef.current = viz + const resize = (): void => { + if (applyPluginScopeCanvasLayout(container, canvas, 0).changed) viz.resize() + } + resize() + viz.start() + const observer = new ResizeObserver(resize) + observer.observe(container) + return () => { + observer.disconnect() + viz.dispose() + vizRef.current = null + } + // Settings and theme updates use setOptions and preserve native history. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dataSource, nativeAnalyzer, frameScheduler]) + + useEffect(() => { + vizRef.current?.setOptions(waterfallSettingsToOptions(settings, theme)) + }, [settings, theme]) + + return
+ +
+} diff --git a/src/plugin-ui/juceBridge.ts b/src/plugin-ui/juceBridge.ts index af4dd2b..851428c 100644 --- a/src/plugin-ui/juceBridge.ts +++ b/src/plugin-ui/juceBridge.ts @@ -1,3 +1,7 @@ +import type { WaterfallFrame } from '../types/waterfall' +import type { WaterfallFrameRequest } from './BridgeWaterfallAnalyzer' +import { frequencyAtNormalizedPosition } from '../types/frequencyScale' + /** * Bridge between the JUCE 8 plugin host (C++) and this webview UI. * @@ -103,6 +107,89 @@ export function base64ToFloat32Array(b64: string): Float32Array { return new Float32Array(bytes.buffer, 0, byteLength >> 2) } +export interface WaterfallPluginFrame extends WaterfallFrame { + sampleRate: number + revision: number +} + +export function decodeWaterfallFrame(payload: unknown): WaterfallPluginFrame | null { + if (!payload || typeof payload !== 'object') return null + const p = payload as Record + const { columns, audioSeconds, sampleRate, revision } = p + if (typeof columns !== 'number' || !Number.isInteger(columns) || columns < 2 || columns > 512 + || typeof audioSeconds !== 'number' || !Number.isFinite(audioSeconds) || audioSeconds < 0 + || typeof sampleRate !== 'number' || !Number.isFinite(sampleRate) || sampleRate < 8000 || sampleRate > 384000 + || typeof revision !== 'number' || !Number.isSafeInteger(revision) || revision < 0) return null + const decode = (value: unknown, limit: number): Float32Array | null => { + if (typeof value !== 'string' || value.length > Math.ceil(limit * 4 / 3) * 4) return null + try { + const binary = atob(value) + if (binary.length % 4 !== 0) return null + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)) + const result = new Float32Array(bytes.buffer) + return result.length <= limit && result.every(Number.isFinite) ? result : null + } catch { return null } + } + const ages = decode(p.ages, 64), frequencies = decode(p.frequencies, columns) + if (!ages || !frequencies || frequencies.length !== columns + || ages.some((age, i) => age < 0 || age > 30.1 || (i > 0 && age < ages[i - 1])) + || frequencies.some((hz, i) => hz <= 0 || (i > 0 && hz < frequencies[i - 1]))) return null + const levels = decode(p.levels, ages.length * columns) + if (!levels || levels.length !== ages.length * columns) return null + return { levels, ages, frequencies, columns, audioSeconds, sampleRate, revision } +} + +export function connectWaterfallBridge(handlers: { + onFrame: (frame: WaterfallPluginFrame) => void + getRequest: () => WaterfallFrameRequest +}): () => void { + let disposed = false + let listenerId: number | null = null + let mockRaf: number | null = null + void ensureBackend().then((backend) => { + if (disposed) return + if (backend) { + listenerId = backend.addEventListener('waterfallFrame', (payload) => { + const frame = decodeWaterfallFrame(payload) + if (frame) handlers.onFrame(frame) + }) + return + } + console.warn('[prism-plugin] no JUCE host — using synthetic waterfall (browser dev mode)') + const started = performance.now() + const tick = (): void => { + if (disposed) return + const { config, columns, ridges, revision } = handlers.getRequest() + const audioSeconds = (performance.now() - started) / 1000 + const spacing = config.historySeconds / (ridges - 1) + const anchor = Math.floor(audioSeconds / spacing) * spacing + const ages = new Float32Array(Array.from({ length: ridges }, (_, i) => + i === 0 ? 0 : audioSeconds - anchor + (i - 1) * spacing).filter((age) => age <= audioSeconds && age <= config.historySeconds)) + const frequencies = Float32Array.from({ length: columns }, (_, x) => + frequencyAtNormalizedPosition(x / (columns - 1), config.minFrequency, config.maxFrequency, config.scaleMode)) + const levels = new Float32Array(ages.length * columns) + for (let row = 0; row < ages.length; row++) { + const time = audioSeconds - ages[row] + for (let x = 0; x < columns; x++) { + const octave = Math.log2(frequencies[x] / 80) + levels[row * columns + x] = -85 + + 46 * Math.exp(-octave * octave * 1.8) * (0.8 + 0.2 * Math.sin(time * 3)) + + 30 * Math.exp(-Math.pow((octave - 3.8 - Math.sin(time) * 0.15) * 3, 2)) + + 22 * Math.exp(-Math.pow((octave - 6) * 2, 2)) * (0.6 + 0.4 * Math.sin(time * 2)) + } + } + handlers.onFrame({ levels, ages, frequencies, columns, audioSeconds, sampleRate: config.sampleRate, revision }) + mockRaf = requestAnimationFrame(tick) + } + mockRaf = requestAnimationFrame(tick) + }) + return () => { + disposed = true + if (mockRaf !== null) cancelAnimationFrame(mockRaf) + if (listenerId !== null) window.__JUCE__?.backend?.removeEventListener?.(listenerId) + } +} + export function decodeSpectrumFrame(payload: unknown): SpectrumFrame | null { if (typeof payload !== 'object' || payload === null) return null const { sampleRate, magnitudes, side, channelMax } = payload as SpectrumFramePayload diff --git a/src/plugin-ui/main.tsx b/src/plugin-ui/main.tsx index 7b2cb3d..da2f829 100644 --- a/src/plugin-ui/main.tsx +++ b/src/plugin-ui/main.tsx @@ -13,6 +13,7 @@ import LUFSMeterScope from './LUFSMeterScope' import VectorscopeScope from './VectorscopeScope' import SpectrogramScope from './SpectrogramScope' import WaveformScope from './WaveformScope' +import WaterfallScope from './WaterfallScope' import { BridgeSpectrumAnalyzer } from './BridgeSpectrumAnalyzer' import { BridgeOscilloscopeAnalyzer } from './BridgeOscilloscopeAnalyzer' import { BridgeVUMeterAnalyzer } from './BridgeVUMeterAnalyzer' @@ -20,8 +21,10 @@ import { BridgeLUFSMeterAnalyzer } from './BridgeLUFSMeterAnalyzer' import { BridgeVectorscopeAnalyzer } from './BridgeVectorscopeAnalyzer' import { BridgeSpectrogramAnalyzer } from './BridgeSpectrogramAnalyzer' import { BridgeWaveformAnalyzer } from './BridgeWaveformAnalyzer' +import { BridgeWaterfallAnalyzer } from './BridgeWaterfallAnalyzer' import { PluginWebViewDataSource } from './PluginWebViewDataSource' -import { connectOscilloscopeBridge, connectSpectrumBridge, connectVUMeterBridge, connectLUFSMeterBridge, connectVectorscopeBridge, connectSpectrogramBridge, connectWaveformBridge } from './juceBridge' +import { NativeFrameScheduler } from './NativeFrameScheduler' +import { connectOscilloscopeBridge, connectSpectrumBridge, connectVUMeterBridge, connectLUFSMeterBridge, connectVectorscopeBridge, connectSpectrogramBridge, connectWaveformBridge, connectWaterfallBridge } from './juceBridge' // The C++ plugin tells us which scope it is via JUCE initialisation data. // JUCE stores each value as an array (e.g. prismScope = ["oscilloscope"]). @@ -36,6 +39,23 @@ function getScopeKind(): string { const dataSource = new PluginWebViewDataSource() function buildApp(): JSX.Element { + if (getScopeKind() === 'waterfall') { + const analyzer = new BridgeWaterfallAnalyzer() + const frameScheduler = new NativeFrameScheduler() + connectWaterfallBridge({ + onFrame: (frame) => { + dataSource.setSampleRate(frame.sampleRate) + analyzer.pushFrame(frame) + dataSource.setPlaying(true) + frameScheduler.dispatchFrame() + }, + getRequest: () => analyzer.getRequest(), + }) + return ( + + )} /> + } + if (getScopeKind() === 'waveform') { const analyzer = new BridgeWaveformAnalyzer() connectWaveformBridge({ diff --git a/src/plugin-ui/waterfallOptions.ts b/src/plugin-ui/waterfallOptions.ts new file mode 100644 index 0000000..9c0b323 --- /dev/null +++ b/src/plugin-ui/waterfallOptions.ts @@ -0,0 +1,10 @@ +import type { WaterfallSettings } from '../types/waterfall' +import type { ResolvedWaterfallTheme } from '../types/theme' +import type { WaterfallOptions } from '../renderer/visualizers/Waterfall' + +export function waterfallSettingsToOptions(settings: WaterfallSettings, theme: ResolvedWaterfallTheme): WaterfallOptions { + return { + ...settings, lineColor: theme.line, backgroundColor: theme.background, + gridColor: theme.guides, labelColor: theme.labels, heatColors: theme.heatColors, + } +} diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index 070cdab..8737533 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -132,7 +132,10 @@ import { LUFSMeter, formatMaxTruePeakDb } from '../src/renderer/visualizers/LUFS import { Oscilloscope } from '../src/renderer/visualizers/Oscilloscope' import { SpectrumAnalyzer, type SpectrumAnalyzerOptions } from '../src/renderer/visualizers/SpectrumAnalyzer' import { BridgeSpectrumAnalyzer } from '../src/plugin-ui/BridgeSpectrumAnalyzer' -import { decodeLUFSMeterFrame, decodeSpectrumFrame } from '../src/plugin-ui/juceBridge' +import { BridgeWaterfallAnalyzer } from '../src/plugin-ui/BridgeWaterfallAnalyzer' +import { NativeFrameScheduler } from '../src/plugin-ui/NativeFrameScheduler' +import { waterfallSettingsToOptions } from '../src/plugin-ui/waterfallOptions' +import { decodeLUFSMeterFrame, decodeSpectrumFrame, decodeWaterfallFrame } from '../src/plugin-ui/juceBridge' import { formatSpectrumPeakDbfs } from '../src/plugin-ui/peakOverlay' import { spectrogramSettingsToOptions } from '../src/plugin-ui/spectrogramOptions' import { spectrumSettingsToOptions } from '../src/plugin-ui/spectrumOptions' @@ -7535,3 +7538,70 @@ test('Waterfall plot smoothing retains its frequency shape across plot resolutio const small = sample(129), large = sample(513) for (let i = 0; i < small.length; ++i) assert.ok(Math.abs(small[i] - large[i * 4]) < 0.75) }) + +test('Waterfall plugin decodes bounded native snapshots and rejects malformed frames', () => { + const encode = (values: number[]): string => Buffer.from(new Float32Array(values).buffer).toString('base64') + const payload = { + sampleRate: 48000, revision: 3, columns: 3, audioSeconds: 6, + ages: encode([0, 2.5, 5]), frequencies: encode([20, 1000, 20000]), + levels: encode([-80, -6, -80, -70, -6, -70, -60, -6, -60]), + } + const decoded = decodeWaterfallFrame(payload) + assert.ok(decoded) + assert.deepEqual(Array.from(decoded.ages), [0, 2.5, 5]) + assert.equal(decoded.levels.length, 9) + assert.equal(decoded.levels[1], -6) + assert.equal(decoded.revision, 3) + assert.ok(decodeWaterfallFrame({ ...payload, ages: '', levels: '' }), 'empty history is a valid snapshot') + for (const invalid of [ + null, {}, { ...payload, columns: 513 }, { ...payload, sampleRate: Infinity }, + { ...payload, levels: 'invalid' }, { ...payload, ages: encode([2, 1]) }, + { ...payload, levels: encode([NaN, ...new Array(8).fill(-80)]) }, + { ...payload, frequencies: encode([20, 20000]) }, { ...payload, revision: -1 }, + { ...payload, ages: encode([0, 31, 32]) }, + { ...payload, levels: Buffer.from([0, 0, 0, 0, 1]).toString('base64') }, + { ...payload, levels: 'A'.repeat(200000) }, + ]) assert.equal(decodeWaterfallFrame(invalid), null) +}) + +test('Waterfall plugin retains one snapshot and ignores responses preceding a resize or reset', () => { + const commands: unknown[] = [] + const analyzer = new BridgeWaterfallAnalyzer((command) => commands.push(command)) + analyzer.configure({ sampleRate: 48000, fftSize: 2048, historySeconds: 5, smoothing: 0.9, + tiltDbPerOctave: 2, scaleMode: 'log', minFrequency: 10, maxFrequency: 24000 }) + analyzer.getFrame(8, 3) + const frame = { ...waterfallFrame(3, [0, 2, 4], () => -30), sampleRate: 48000, revision: analyzer.getRequest().revision } + analyzer.pushFrame(frame) + assert.equal(analyzer.getFrame(8, 3), frame) + assert.equal(commands.length, 2, 'unchanged viewport does not send repeated requests') + assert.equal(analyzer.getFrame(16, 7), frame, 'keep available history while the native viewport catches up') + analyzer.pushFrame({ ...frame, audioSeconds: 999 }) + assert.equal(analyzer.getFrame(16, 7), frame, 'stale response is discarded') + const resized = { ...waterfallFrame(7, [0, 2, 4], () => -20), sampleRate: 48000, revision: analyzer.getRequest().revision } + analyzer.pushFrame(resized) + assert.equal(analyzer.getFrame(16, 7), resized) + analyzer.reset() + analyzer.pushFrame(resized) + assert.equal(analyzer.getFrame(16, 7).ages.length, 0, 'late frames cannot resurrect cleared history') + assert.deepEqual(commands[commands.length - 1], { revision: analyzer.getRequest().revision, reset: true }) +}) + +test('Waterfall plugin uses the desktop appearance and normalized settings', () => { + const profile = createDefaultProfile('Default') + const theme = resolveTheme(createDefaultTheme()) + assert.deepEqual(waterfallSettingsToOptions(profile.scopeSettings.waterfall, theme.waterfall), + scopeSettingsToOptions('waterfall', profile.scopeSettings.waterfall, theme.waterfall)) +}) + +test('plugin native frame clock paints without browser animation callbacks and stops on disposal', () => { + const scheduler = new NativeFrameScheduler() + let paints = 0 + const unsubscribe = scheduler.subscribe(() => { paints++ }) + assert.equal(paints, 0) + scheduler.dispatchFrame() + scheduler.dispatchFrame() + assert.equal(paints, 2) + unsubscribe() + scheduler.dispatchFrame() + assert.equal(paints, 2) +}) From 074a279fed0096fbe939fd79263287048835d9de Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:05:05 -0400 Subject: [PATCH 07/27] 32bit wav support --- README.md | 8 ++- src/main/audioClipLibrary.ts | 42 +++++++++------ src/renderer/audio/RollingAudioBuffer.ts | 22 ++++---- src/renderer/components/BottomBar.tsx | 21 +++++++- src/renderer/components/Toolbar.tsx | 9 ++-- src/renderer/stores/audioStore.ts | 50 ++++++++++++++---- src/shared/audioClipEncoding.ts | 29 ++++++++++ src/types/audioClip.ts | 14 ++++- test/audio-clip-library.test.ts | 64 ++++++++++++++++++++-- test/audio-store.test.ts | 67 ++++++++++++++++++++++++ test/rolling-audio-buffer.test.ts | 30 ++++------- 11 files changed, 288 insertions(+), 68 deletions(-) create mode 100644 src/shared/audioClipEncoding.ts diff --git a/README.md b/README.md index 1af5bb2..902503c 100644 --- a/README.md +++ b/README.md @@ -149,9 +149,9 @@ Most of Prism's analysis runs in native C++, with the same DSP implementations r ### Rolling capture * Keeps the previous 5, 10, 30, or 60 seconds of audio available without starting a recording beforehand -* Buffer is stored as PCM and exported as a standard RIFF/WAV file +* Buffer retains 32-bit floating-point samples and exports standard RIFF/WAV files * Mono and stereo capture are supported -* Exported clips use 16-bit PCM +* Choose **16-bit PCM** (default) or **32-bit float** WAV export in Rolling Capture settings; the choice is remembered across launches * The export path supports source sample rates up to 384 kHz * Captured audio can be dragged directly out of Prism as a file @@ -207,6 +207,10 @@ Prism can continuously keep the last **5, 10, 30, or 60 seconds** of audio in me When you hear something you want to keep, drag the buffered audio out of Prism as a WAV file. There is no need to start recording beforehand. +Choose **16-bit PCM** for smaller files or **32-bit float** to preserve captured floating-point precision and samples above full scale for DAW editing. Changing the format applies to the next export without clearing buffered audio. Exports retain the source sample rate and do not normalize or dither audio. + +The float buffer uses approximately 23 MB for 60 seconds of stereo at 48 kHz. Rolling Capture allocates no recorder buffer while off. + ## DAW Plugins Every Prism scope is also available as a DAW plugin. diff --git a/src/main/audioClipLibrary.ts b/src/main/audioClipLibrary.ts index a7f694a..7cb694e 100644 --- a/src/main/audioClipLibrary.ts +++ b/src/main/audioClipLibrary.ts @@ -1,9 +1,7 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import type { AudioClipDragPayload } from '../types/audioClip' +import { isAudioClipFormat, type AudioClipDragPayload } from '../types/audioClip' -const WAV_HEADER_BYTES = 44 -const PCM_BITS_PER_SAMPLE = 16 const MAX_SAMPLE_RATE = 384000 const MAX_CLIP_SECONDS = 60 @@ -13,6 +11,9 @@ export function validateAudioClipDragPayload(raw: unknown): AudioClipDragPayload } const candidate = raw as Partial + if (!isAudioClipFormat(candidate.format)) { + throw new Error('The audio clip format is invalid.') + } if (!(candidate.pcmBytes instanceof Uint8Array)) { throw new Error('The audio clip is missing PCM sample data.') } @@ -34,12 +35,14 @@ export function validateAudioClipDragPayload(raw: unknown): AudioClipDragPayload throw new Error('The audio clip duration is invalid.') } - const expectedBytes = candidate.frameCount! * candidate.channelCount * (PCM_BITS_PER_SAMPLE / 8) + const bytesPerSample = candidate.format === 'float32' ? 4 : 2 + const expectedBytes = candidate.frameCount! * candidate.channelCount * bytesPerSample if (candidate.pcmBytes.byteLength !== expectedBytes) { throw new Error('The audio clip PCM data length is invalid.') } return { + format: candidate.format, pcmBytes: candidate.pcmBytes, sampleRate: candidate.sampleRate!, channelCount: candidate.channelCount, @@ -47,26 +50,35 @@ export function validateAudioClipDragPayload(raw: unknown): AudioClipDragPayload } } -export function encodePcm16Wav(payload: AudioClipDragPayload): Buffer { +export function encodeAudioClipWav(payload: AudioClipDragPayload): Buffer { const validated = validateAudioClipDragPayload(payload) + const isFloat = validated.format === 'float32' + const bitsPerSample = isFloat ? 32 : 16 + const headerBytes = isFloat ? 58 : 44 const dataBytes = validated.pcmBytes.byteLength - const blockAlign = validated.channelCount * (PCM_BITS_PER_SAMPLE / 8) - const wav = Buffer.allocUnsafe(WAV_HEADER_BYTES + dataBytes) + const blockAlign = validated.channelCount * (bitsPerSample / 8) + const wav = Buffer.allocUnsafe(headerBytes + dataBytes) wav.write('RIFF', 0, 'ascii') - wav.writeUInt32LE(36 + dataBytes, 4) + wav.writeUInt32LE(headerBytes - 8 + dataBytes, 4) wav.write('WAVE', 8, 'ascii') wav.write('fmt ', 12, 'ascii') - wav.writeUInt32LE(16, 16) - wav.writeUInt16LE(1, 20) + wav.writeUInt32LE(isFloat ? 18 : 16, 16) + wav.writeUInt16LE(isFloat ? 3 : 1, 20) wav.writeUInt16LE(validated.channelCount, 22) wav.writeUInt32LE(validated.sampleRate, 24) wav.writeUInt32LE(validated.sampleRate * blockAlign, 28) wav.writeUInt16LE(blockAlign, 32) - wav.writeUInt16LE(PCM_BITS_PER_SAMPLE, 34) - wav.write('data', 36, 'ascii') - wav.writeUInt32LE(dataBytes, 40) - wav.set(validated.pcmBytes, WAV_HEADER_BYTES) + wav.writeUInt16LE(bitsPerSample, 34) + if (isFloat) { + wav.writeUInt16LE(0, 36) // No format extension. + wav.write('fact', 38, 'ascii') + wav.writeUInt32LE(4, 42) + wav.writeUInt32LE(validated.frameCount, 46) + } + wav.write('data', headerBytes - 8, 'ascii') + wav.writeUInt32LE(dataBytes, headerBytes - 4) + wav.set(validated.pcmBytes, headerBytes) return wav } @@ -96,7 +108,7 @@ export class AudioClipLibrary { writeClip(raw: unknown): string { const payload = validateAudioClipDragPayload(raw) - const wav = encodePcm16Wav(payload) + const wav = encodeAudioClipWav(payload) this.ensureDirectory() const baseName = buildAudioClipBaseName(this.now()) diff --git a/src/renderer/audio/RollingAudioBuffer.ts b/src/renderer/audio/RollingAudioBuffer.ts index 289b54f..c505f28 100644 --- a/src/renderer/audio/RollingAudioBuffer.ts +++ b/src/renderer/audio/RollingAudioBuffer.ts @@ -3,12 +3,8 @@ import type { RollingCaptureDurationSeconds, } from '../../types/audioClip' -function floatToPcm16(sample: number): number { - if (!Number.isFinite(sample)) return 0 - const clamped = Math.max(-1, Math.min(1, sample)) - return clamped < 0 - ? Math.round(clamped * 32768) - : Math.round(clamped * 32767) +function finiteSample(sample: number): number { + return Number.isFinite(sample) ? sample : 0 } function normalizeChannelCount(channelCount: number): 1 | 2 { @@ -16,7 +12,7 @@ function normalizeChannelCount(channelCount: number): 1 | 2 { } export class RollingAudioBuffer { - private samples: Int16Array + private samples: Float32Array private capacityFrames: number private writeFrameIndex = 0 private bufferedFrames = 0 @@ -34,7 +30,7 @@ export class RollingAudioBuffer { this.sampleRate = Math.max(1, Math.floor(sampleRate) || 1) this.channelCount = normalizeChannelCount(channelCount) this.capacityFrames = Math.max(1, Math.floor(durationSeconds * this.sampleRate)) - this.samples = new Int16Array(this.capacityFrames * this.channelCount) + this.samples = new Float32Array(this.capacityFrames * this.channelCount) } get durationSeconds(): RollingCaptureDurationSeconds { @@ -75,9 +71,9 @@ export class RollingAudioBuffer { for (let offset = 0; offset < contiguousFrames; offset += 1) { const sourceIndex = sourceFrameIndex + offset const destinationIndex = (this.writeFrameIndex + offset) * this.channelCount - this.samples[destinationIndex] = floatToPcm16(left[sourceIndex] ?? 0) + this.samples[destinationIndex] = finiteSample(left[sourceIndex] ?? 0) if (this.channelCount === 2) { - this.samples[destinationIndex + 1] = floatToPcm16(right[sourceIndex] ?? 0) + this.samples[destinationIndex + 1] = finiteSample(right[sourceIndex] ?? 0) } } @@ -96,7 +92,7 @@ export class RollingAudioBuffer { const nextCapacityFrames = Math.max(1, Math.floor(durationSeconds * this.sampleRate)) const framesToKeep = Math.min(this.bufferedFrames, nextCapacityFrames) - const nextSamples = new Int16Array(nextCapacityFrames * this.channelCount) + const nextSamples = new Float32Array(nextCapacityFrames * this.channelCount) if (framesToKeep > 0) { const startFrame = ( @@ -115,7 +111,7 @@ export class RollingAudioBuffer { snapshot(): RollingAudioSnapshot | null { if (this.bufferedFrames <= 0) return null - const pcmSamples = new Int16Array(this.bufferedFrames * this.channelCount) + const pcmSamples = new Float32Array(this.bufferedFrames * this.channelCount) const startFrame = ( this.writeFrameIndex - this.bufferedFrames + this.capacityFrames ) % this.capacityFrames @@ -130,7 +126,7 @@ export class RollingAudioBuffer { } private copyFramesTo( - destination: Int16Array, + destination: Float32Array, startFrame: number, frameCount: number, ): void { diff --git a/src/renderer/components/BottomBar.tsx b/src/renderer/components/BottomBar.tsx index 45a7dcb..72f427d 100644 --- a/src/renderer/components/BottomBar.tsx +++ b/src/renderer/components/BottomBar.tsx @@ -11,7 +11,7 @@ import { getRendererWindowCapabilities } from '../windowCapabilities' import { getHorizontalWheelScrollResult } from '../utils/horizontalWheelScroll' import type { ScopeKind } from '../../types/scope' import { VISUALIZER_FRAME_TARGETS, type VisualizerFrameTarget } from '../../types/performance' -import { ROLLING_CAPTURE_DURATIONS } from '../../types/audioClip' +import { AUDIO_CLIP_FORMAT_LABELS, ROLLING_CAPTURE_DURATIONS } from '../../types/audioClip' import { SCOPE_KINDS } from '../../types/scope' import { getCaptureRoutingStorageKey, @@ -208,6 +208,7 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): captureNotice, inputGainDb, rollingCaptureSeconds, + rollingCaptureFormat, rollingCaptureStatus, channelRoutingBySource, clearCaptureNotice, @@ -218,6 +219,7 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): setInputGain, setChannelRouting, setRollingCaptureSeconds, + setRollingCaptureFormat, revealRollingCaptureFolder, } = useAudioStore() const showBanner = useUiStore((s) => s.showBanner) @@ -922,6 +924,23 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): > Folder + +
+ WAV format +
+ {(['pcm16', 'float32'] as const).map((format) => ( + + ))} +
+
diff --git a/src/renderer/components/Toolbar.tsx b/src/renderer/components/Toolbar.tsx index 4f3fc3d..93d8175 100644 --- a/src/renderer/components/Toolbar.tsx +++ b/src/renderer/components/Toolbar.tsx @@ -1,3 +1,4 @@ +import { AUDIO_CLIP_FORMAT_LABELS } from '../../types/audioClip' import { useState, useEffect, @@ -181,6 +182,8 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps): const releaseName = useUpdateStore((s) => s.releaseName) const openReleasesPage = useUpdateStore((s) => s.openReleasesPage) const rollingCaptureSeconds = useAudioStore((s) => s.rollingCaptureSeconds) + const rollingCaptureFormat = useAudioStore((s) => s.rollingCaptureFormat) + const clipFormatLabel = AUDIO_CLIP_FORMAT_LABELS[rollingCaptureFormat] const rollingCaptureStatus = useAudioStore((s) => s.rollingCaptureStatus) const startRollingClipDrag = useAudioStore((s) => s.startRollingClipDrag) const [appBuildInfo, setAppBuildInfo] = useState(null) @@ -561,11 +564,11 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps): disabled={!rollingCaptureStatus.hasAudio} onDragStart={handleAudioClipDragStart} title={rollingCaptureStatus.ready - ? `Drag the latest ${rollingCaptureSeconds} seconds as a WAV file` + ? `Drag the latest ${rollingCaptureSeconds} seconds as a ${clipFormatLabel} WAV file` : rollingCaptureStatus.hasAudio - ? `Buffer filling; drag the audio captured so far (up to ${rollingCaptureSeconds} seconds)` + ? `Buffer filling; drag the audio captured so far (up to ${rollingCaptureSeconds} seconds) as a ${clipFormatLabel} WAV file` : 'Waiting for captured audio'} - aria-label={`Drag the latest ${rollingCaptureSeconds} seconds as a WAV file`} + aria-label={`Drag the latest ${rollingCaptureSeconds} seconds as a ${clipFormatLabel} WAV file`} >