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