mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
significantly improve latency
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
"build": "electron-vite build",
|
||||
"preview": "electron-vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:audio-router": "node scripts/run-audio-router-tests.mjs",
|
||||
"build:native": "cd native && node-gyp rebuild",
|
||||
"rebuild:native": "node -e \"const e=require('electron/package.json').version;const a=process.arch;const{execSync}=require('child_process');execSync('node-gyp rebuild --target='+e+' --arch='+a+' --dist-url=https://electronjs.org/headers',{stdio:'inherit',cwd:'native'})\"",
|
||||
"postinstall": "npm run rebuild:native || echo 'Native build failed, will use JS fallback'",
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
const rootDir = dirname(dirname(fileURLToPath(import.meta.url)))
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'prism-audio-router-tests-'))
|
||||
const bundledTestPath = join(tempDir, 'audio-router.test.mjs')
|
||||
const entryPoint = join(rootDir, 'test', 'audio-router.test.ts')
|
||||
|
||||
let exitCode = 1
|
||||
|
||||
try {
|
||||
await build({
|
||||
entryPoints: [entryPoint],
|
||||
outfile: bundledTestPath,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
target: 'node23',
|
||||
sourcemap: 'inline',
|
||||
})
|
||||
|
||||
exitCode = await new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, ['--test', bundledTestPath], {
|
||||
stdio: 'inherit',
|
||||
cwd: rootDir,
|
||||
})
|
||||
|
||||
child.on('exit', (code) => {
|
||||
resolve(code ?? 1)
|
||||
})
|
||||
|
||||
child.on('error', () => {
|
||||
resolve(1)
|
||||
})
|
||||
})
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
process.exit(exitCode)
|
||||
@@ -1,5 +1,6 @@
|
||||
import { app, BrowserWindow, desktopCapturer, ipcMain, session } from 'electron'
|
||||
import { join } from 'path'
|
||||
import type { CaptureBackendSupport, CaptureBackendSupportEntry } from '../types/capture'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let currentSettingsHeight = 0
|
||||
@@ -45,6 +46,47 @@ function createWindow(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function getNativeCaptureSupportEntry(): CaptureBackendSupportEntry {
|
||||
if (process.platform === 'darwin') {
|
||||
return {
|
||||
kind: 'native-macos',
|
||||
available: false,
|
||||
reason: 'Native macOS system audio capture is not implemented in this build.',
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
return {
|
||||
kind: 'native-windows',
|
||||
available: false,
|
||||
reason: 'Native Windows WASAPI loopback capture is not implemented in this build.',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'native-linux',
|
||||
available: false,
|
||||
reason: 'Native Linux monitor capture is not implemented in this build.',
|
||||
}
|
||||
}
|
||||
|
||||
function getCaptureBackendSupport(): CaptureBackendSupport {
|
||||
return {
|
||||
policyOptions: ['auto', 'native', 'electron'],
|
||||
nativeBackend: getNativeCaptureSupportEntry(),
|
||||
electronSystem: {
|
||||
kind: 'electron-system',
|
||||
available: true,
|
||||
reason: null,
|
||||
},
|
||||
electronDevice: {
|
||||
kind: 'electron-device',
|
||||
available: true,
|
||||
reason: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-grant media (microphone) permission for audio capture
|
||||
function setupPermissions(): void {
|
||||
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
|
||||
@@ -82,6 +124,10 @@ function setupIPC(): void {
|
||||
return sources.map((s) => ({ id: s.id, name: s.name }))
|
||||
})
|
||||
|
||||
ipcMain.handle('capture:get-backend-support', () => {
|
||||
return getCaptureBackendSupport()
|
||||
})
|
||||
|
||||
ipcMain.on('window:expand-settings', (_event, panelHeight: number) => {
|
||||
if (!mainWindow) return
|
||||
const [width, height] = mainWindow.getSize()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import type { CaptureBackendSupport } from '../types/capture'
|
||||
|
||||
// Expose Electron API to renderer
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
@@ -8,6 +9,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
toggleAlwaysOnTop: () => ipcRenderer.send('window:toggle-always-on-top'),
|
||||
isAlwaysOnTop: () => ipcRenderer.invoke('window:is-always-on-top'),
|
||||
getDesktopSources: () => ipcRenderer.invoke('audio:get-desktop-sources') as Promise<{ id: string; name: string }[]>,
|
||||
getCaptureBackendSupport: () => ipcRenderer.invoke('capture:get-backend-support') as Promise<CaptureBackendSupport>,
|
||||
expandSettings: (panelHeight: number) => ipcRenderer.send('window:expand-settings', panelHeight),
|
||||
collapseSettings: (panelHeight: number) => ipcRenderer.send('window:collapse-settings', panelHeight),
|
||||
setSettingsHeight: (panelHeight: number) => ipcRenderer.send('window:set-settings-height', panelHeight),
|
||||
|
||||
@@ -19,7 +19,10 @@ export default function App(): JSX.Element {
|
||||
|
||||
// Auto-capture on launch
|
||||
useEffect(() => {
|
||||
useAudioStore.getState().startCapture()
|
||||
const { isCapturing, captureStatus, startCapture } = useAudioStore.getState()
|
||||
if (!isCapturing && captureStatus !== 'connecting') {
|
||||
void startCapture()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
+512
-110
@@ -1,42 +1,251 @@
|
||||
/**
|
||||
* AudioCapture — captures system audio output via Electron's desktopCapturer,
|
||||
* with fallback to getUserMedia for virtual audio devices (BlackHole, etc).
|
||||
* Feeds captured samples into AudioRouter for distribution to visualizers.
|
||||
* AudioCapture — backend manager for Prism's live capture pipeline.
|
||||
* Stage 1 keeps Chromium capture as the working backend, while exposing
|
||||
* native-backend policy and support plumbing for future low-latency paths.
|
||||
*/
|
||||
|
||||
import { audioRouter } from './AudioRouter'
|
||||
import type {
|
||||
CaptureBackendKind,
|
||||
CaptureBackendPolicy,
|
||||
CaptureBackendSupport,
|
||||
CaptureBackendSupportEntry,
|
||||
CaptureMode,
|
||||
CaptureSourceDescriptor,
|
||||
} from '../../types/capture'
|
||||
|
||||
export type CaptureMode = 'system' | 'device'
|
||||
export type { CaptureMode } from '../../types/capture'
|
||||
|
||||
class AudioCapture {
|
||||
interface CaptureChunk {
|
||||
left: Float32Array
|
||||
right: Float32Array
|
||||
channelCount: number
|
||||
capturedAt: number
|
||||
sequence: number
|
||||
}
|
||||
|
||||
interface CaptureBackendStatus {
|
||||
kind: CaptureBackendKind
|
||||
active: boolean
|
||||
available: boolean
|
||||
reason: string | null
|
||||
sampleRate: number
|
||||
channelCount: number
|
||||
}
|
||||
|
||||
interface CaptureBackendStartRequest {
|
||||
deviceId?: string
|
||||
}
|
||||
|
||||
interface CaptureBackend {
|
||||
readonly kind: CaptureBackendKind
|
||||
start(request?: CaptureBackendStartRequest): Promise<void>
|
||||
stop(): Promise<void>
|
||||
listSources(): Promise<CaptureSourceDescriptor[]>
|
||||
subscribe(listener: (chunk: CaptureChunk) => void): () => void
|
||||
getStatus(): CaptureBackendStatus
|
||||
}
|
||||
|
||||
export interface CaptureManagerStatus {
|
||||
captureMode: CaptureMode
|
||||
backendPolicy: CaptureBackendPolicy
|
||||
activeBackendKind: CaptureBackendKind | null
|
||||
activeBackendReason: string | null
|
||||
backendSupport: CaptureBackendSupport | null
|
||||
sampleRate: number
|
||||
channelCount: number
|
||||
isCapturing: boolean
|
||||
}
|
||||
|
||||
type StatusListener = (status: CaptureManagerStatus) => void
|
||||
|
||||
const DEFAULT_BACKEND_POLICY: CaptureBackendPolicy = 'auto'
|
||||
|
||||
const DEFAULT_BACKEND_SUPPORT: CaptureBackendSupport = {
|
||||
policyOptions: ['auto', 'native', 'electron'],
|
||||
nativeBackend: {
|
||||
kind: window.electronAPI.platform === 'darwin'
|
||||
? 'native-macos'
|
||||
: window.electronAPI.platform === 'win32'
|
||||
? 'native-windows'
|
||||
: 'native-linux',
|
||||
available: false,
|
||||
reason: 'Native system audio capture is not implemented in this build.',
|
||||
},
|
||||
electronSystem: {
|
||||
kind: 'electron-system',
|
||||
available: true,
|
||||
reason: null,
|
||||
},
|
||||
electronDevice: {
|
||||
kind: 'electron-device',
|
||||
available: true,
|
||||
reason: null,
|
||||
},
|
||||
}
|
||||
|
||||
function toDeviceSourceDescriptor(device: MediaDeviceInfo): CaptureSourceDescriptor {
|
||||
return {
|
||||
id: device.deviceId,
|
||||
label: device.label || `Input ${device.deviceId.slice(0, 8)}`,
|
||||
kind: 'device',
|
||||
}
|
||||
}
|
||||
|
||||
class ElectronCaptureRuntime {
|
||||
private audioContext: AudioContext | null = null
|
||||
private stream: MediaStream | null = null
|
||||
private sourceNode: MediaStreamAudioSourceNode | null = null
|
||||
private workletNode: AudioWorkletNode | null = null
|
||||
private selectedDeviceId: string | null = null
|
||||
private captureMode: CaptureMode = 'system'
|
||||
private sessionId: number | null = null
|
||||
private workletLoaded = false
|
||||
private chunkListeners = new Set<(chunk: CaptureChunk) => void>()
|
||||
private active = false
|
||||
private currentConfigKey: string | null = null
|
||||
private sequence = 0
|
||||
private sampleRate = 48000
|
||||
private channelCount = 2
|
||||
|
||||
/**
|
||||
* Start capturing system audio output via desktopCapturer (ScreenCaptureKit on macOS 13+).
|
||||
* This captures all system audio without needing BlackHole or any virtual device.
|
||||
*/
|
||||
async startSystemAudio(): Promise<void> {
|
||||
this.stop()
|
||||
subscribe(listener: (chunk: CaptureChunk) => void): () => void {
|
||||
this.chunkListeners.add(listener)
|
||||
return () => {
|
||||
this.chunkListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
// Get a screen source ID from the main process
|
||||
async startSystem(): Promise<void> {
|
||||
await this.start({ mode: 'system' })
|
||||
}
|
||||
|
||||
async startDevice(deviceId?: string): Promise<void> {
|
||||
await this.start({ mode: 'device', deviceId })
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.active = false
|
||||
if (this.audioContext && this.audioContext.state === 'running') {
|
||||
await this.audioContext.suspend()
|
||||
}
|
||||
}
|
||||
|
||||
async listSystemSources(): Promise<CaptureSourceDescriptor[]> {
|
||||
const sources = await window.electronAPI.getDesktopSources()
|
||||
return sources.map((source) => ({
|
||||
id: source.id,
|
||||
label: source.name,
|
||||
kind: 'system',
|
||||
}))
|
||||
}
|
||||
|
||||
async listDeviceSources(): Promise<CaptureSourceDescriptor[]> {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||
return devices
|
||||
.filter((device) => device.kind === 'audioinput')
|
||||
.map((device) => toDeviceSourceDescriptor(device))
|
||||
}
|
||||
|
||||
getStatus(kind: CaptureBackendKind, reason: string | null = null): CaptureBackendStatus {
|
||||
return {
|
||||
kind,
|
||||
active: this.active,
|
||||
available: true,
|
||||
reason,
|
||||
sampleRate: this.sampleRate,
|
||||
channelCount: this.channelCount,
|
||||
}
|
||||
}
|
||||
|
||||
private async start(config: { mode: CaptureMode; deviceId?: string }): Promise<void> {
|
||||
const configKey = `${config.mode}:${config.deviceId ?? ''}`
|
||||
await this.ensureContext()
|
||||
|
||||
if (this.currentConfigKey !== configKey || !this.stream || !this.sourceNode) {
|
||||
const nextStream = config.mode === 'system'
|
||||
? await this.requestSystemStream()
|
||||
: await this.requestDeviceStream(config.deviceId)
|
||||
this.attachStream(nextStream, configKey)
|
||||
}
|
||||
|
||||
this.sequence = 0
|
||||
this.active = true
|
||||
if (this.audioContext && this.audioContext.state !== 'running') {
|
||||
await this.audioContext.resume()
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureContext(): Promise<void> {
|
||||
if (!this.audioContext) {
|
||||
this.audioContext = new AudioContext({ latencyHint: 'interactive' })
|
||||
this.sampleRate = Math.max(1, Math.floor(this.audioContext.sampleRate))
|
||||
}
|
||||
|
||||
if (!this.workletLoaded) {
|
||||
await this.audioContext.audioWorklet.addModule('./capture-worklet.js')
|
||||
this.workletLoaded = true
|
||||
}
|
||||
|
||||
if (!this.workletNode) {
|
||||
this.workletNode = new AudioWorkletNode(this.audioContext, 'capture-processor', {
|
||||
numberOfInputs: 1,
|
||||
numberOfOutputs: 0,
|
||||
channelCount: 2,
|
||||
})
|
||||
|
||||
this.workletNode.port.onmessage = (event: MessageEvent<{
|
||||
left: Float32Array
|
||||
right: Float32Array
|
||||
channelCount?: number
|
||||
}>) => {
|
||||
if (!this.active) return
|
||||
|
||||
const chunk: CaptureChunk = {
|
||||
left: event.data.left,
|
||||
right: event.data.right,
|
||||
channelCount: Math.max(1, Math.floor(event.data.channelCount ?? this.channelCount) || 1),
|
||||
capturedAt: performance.now(),
|
||||
sequence: ++this.sequence,
|
||||
}
|
||||
|
||||
for (const listener of this.chunkListeners) {
|
||||
listener(chunk)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private attachStream(stream: MediaStream, configKey: string): void {
|
||||
if (!this.audioContext || !this.workletNode) return
|
||||
|
||||
if (this.sourceNode) {
|
||||
this.sourceNode.disconnect()
|
||||
this.sourceNode = null
|
||||
}
|
||||
|
||||
if (this.stream && this.stream !== stream) {
|
||||
this.stream.getTracks().forEach((track) => track.stop())
|
||||
}
|
||||
|
||||
this.stream = stream
|
||||
this.sourceNode = this.audioContext.createMediaStreamSource(stream)
|
||||
this.sourceNode.connect(this.workletNode)
|
||||
|
||||
const audioTrack = stream.getAudioTracks()[0] ?? null
|
||||
const trackSettings = audioTrack?.getSettings()
|
||||
|
||||
this.channelCount = Math.max(
|
||||
1,
|
||||
Math.floor(trackSettings?.channelCount ?? this.sourceNode.channelCount ?? 2),
|
||||
)
|
||||
this.sampleRate = Math.max(1, Math.floor(this.audioContext.sampleRate))
|
||||
this.currentConfigKey = configKey
|
||||
}
|
||||
|
||||
private async requestSystemStream(): Promise<MediaStream> {
|
||||
const sources = await window.electronAPI.getDesktopSources()
|
||||
if (!sources.length) {
|
||||
throw new Error('No desktop sources available for system audio capture')
|
||||
}
|
||||
|
||||
// Create AudioContext
|
||||
this.audioContext = new AudioContext()
|
||||
await this.audioContext.audioWorklet.addModule('./capture-worklet.js')
|
||||
|
||||
// Request system audio via desktop capturer — must include video (Chromium requirement),
|
||||
// but we immediately discard the video track
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
mandatory: {
|
||||
chromeMediaSource: 'desktop',
|
||||
@@ -50,28 +259,14 @@ class AudioCapture {
|
||||
} as unknown as MediaTrackConstraints,
|
||||
})
|
||||
|
||||
// Drop the video track immediately — we only need audio
|
||||
this.stream.getVideoTracks().forEach((track) => track.stop())
|
||||
|
||||
this.captureMode = 'system'
|
||||
this.wireUpStream()
|
||||
stream.getVideoTracks().forEach((track) => track.stop())
|
||||
return stream
|
||||
}
|
||||
|
||||
/**
|
||||
* Start capturing from a specific audio input device (e.g. BlackHole, microphone).
|
||||
* Fallback for when system audio capture isn't available.
|
||||
*/
|
||||
async startDevice(deviceId?: string): Promise<void> {
|
||||
this.stop()
|
||||
|
||||
const targetDeviceId = deviceId ?? this.selectedDeviceId
|
||||
|
||||
this.audioContext = new AudioContext()
|
||||
await this.audioContext.audioWorklet.addModule('./capture-worklet.js')
|
||||
|
||||
private async requestDeviceStream(deviceId?: string): Promise<MediaStream> {
|
||||
const constraints: MediaStreamConstraints = {
|
||||
audio: {
|
||||
...(targetDeviceId ? { deviceId: { exact: targetDeviceId } } : {}),
|
||||
...(deviceId ? { deviceId: { exact: deviceId } } : {}),
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false,
|
||||
@@ -79,104 +274,219 @@ class AudioCapture {
|
||||
} as MediaTrackConstraints,
|
||||
}
|
||||
|
||||
this.stream = await navigator.mediaDevices.getUserMedia(constraints)
|
||||
return navigator.mediaDevices.getUserMedia(constraints)
|
||||
}
|
||||
}
|
||||
|
||||
this.captureMode = 'device'
|
||||
this.wireUpStream()
|
||||
class ElectronSystemCaptureBackend implements CaptureBackend {
|
||||
readonly kind = 'electron-system' as const
|
||||
|
||||
if (targetDeviceId) {
|
||||
this.selectedDeviceId = targetDeviceId
|
||||
constructor(private readonly runtime: ElectronCaptureRuntime) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
await this.runtime.startSystem()
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
await this.runtime.stop()
|
||||
}
|
||||
|
||||
async listSources(): Promise<CaptureSourceDescriptor[]> {
|
||||
return this.runtime.listSystemSources()
|
||||
}
|
||||
|
||||
subscribe(listener: (chunk: CaptureChunk) => void): () => void {
|
||||
return this.runtime.subscribe(listener)
|
||||
}
|
||||
|
||||
getStatus(): CaptureBackendStatus {
|
||||
return this.runtime.getStatus(this.kind)
|
||||
}
|
||||
}
|
||||
|
||||
class ElectronDeviceCaptureBackend implements CaptureBackend {
|
||||
readonly kind = 'electron-device' as const
|
||||
private lastDeviceId: string | undefined
|
||||
|
||||
constructor(private readonly runtime: ElectronCaptureRuntime) {}
|
||||
|
||||
async start(request?: CaptureBackendStartRequest): Promise<void> {
|
||||
this.lastDeviceId = request?.deviceId
|
||||
await this.runtime.startDevice(request?.deviceId)
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
await this.runtime.stop()
|
||||
}
|
||||
|
||||
async listSources(): Promise<CaptureSourceDescriptor[]> {
|
||||
return this.runtime.listDeviceSources()
|
||||
}
|
||||
|
||||
subscribe(listener: (chunk: CaptureChunk) => void): () => void {
|
||||
return this.runtime.subscribe(listener)
|
||||
}
|
||||
|
||||
getStatus(): CaptureBackendStatus {
|
||||
return this.runtime.getStatus(this.kind, this.lastDeviceId ? null : null)
|
||||
}
|
||||
}
|
||||
|
||||
class NativeUnavailableCaptureBackend implements CaptureBackend {
|
||||
readonly kind: CaptureBackendKind
|
||||
private readonly reason: string | null
|
||||
|
||||
constructor(private readonly supportEntry: CaptureBackendSupportEntry) {
|
||||
this.kind = supportEntry.kind
|
||||
this.reason = supportEntry.reason
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
throw new Error(this.reason ?? 'Native system audio capture is unavailable.')
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
// No-op stub until native capture backends are implemented.
|
||||
}
|
||||
|
||||
async listSources(): Promise<CaptureSourceDescriptor[]> {
|
||||
return []
|
||||
}
|
||||
|
||||
subscribe(): () => void {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
getStatus(): CaptureBackendStatus {
|
||||
return {
|
||||
kind: this.kind,
|
||||
active: false,
|
||||
available: this.supportEntry.available,
|
||||
reason: this.reason,
|
||||
sampleRate: 48000,
|
||||
channelCount: 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AudioCapture {
|
||||
private readonly electronRuntime = new ElectronCaptureRuntime()
|
||||
private readonly electronSystemBackend: CaptureBackend
|
||||
private readonly electronDeviceBackend: CaptureBackend
|
||||
|
||||
private backendSupport: CaptureBackendSupport | null = null
|
||||
private backendSupportPromise: Promise<CaptureBackendSupport> | null = null
|
||||
private nativeBackend: CaptureBackend | null = null
|
||||
private activeBackend: CaptureBackend | null = null
|
||||
|
||||
private selectedDeviceId: string | null = null
|
||||
private captureMode: CaptureMode = 'system'
|
||||
private backendPolicy: CaptureBackendPolicy = DEFAULT_BACKEND_POLICY
|
||||
private activeBackendReason: string | null = null
|
||||
private sessionId: number | null = null
|
||||
private statusListeners = new Set<StatusListener>()
|
||||
|
||||
constructor() {
|
||||
this.electronSystemBackend = new ElectronSystemCaptureBackend(this.electronRuntime)
|
||||
this.electronDeviceBackend = new ElectronDeviceCaptureBackend(this.electronRuntime)
|
||||
|
||||
this.electronSystemBackend.subscribe((chunk) => this.handleChunk(this.electronSystemBackend.kind, chunk))
|
||||
this.electronDeviceBackend.subscribe((chunk) => this.handleChunk(this.electronDeviceBackend.kind, chunk))
|
||||
}
|
||||
|
||||
subscribeStatus(listener: StatusListener): () => void {
|
||||
this.statusListeners.add(listener)
|
||||
listener(this.getStatus())
|
||||
return () => {
|
||||
this.statusListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start capture — uses system audio by default, falls back to device capture.
|
||||
*/
|
||||
async refreshBackendSupport(): Promise<CaptureBackendSupport> {
|
||||
this.backendSupportPromise = null
|
||||
return this.ensureBackendSupport()
|
||||
}
|
||||
|
||||
async startSystemAudio(): Promise<void> {
|
||||
this.captureMode = 'system'
|
||||
await this.start()
|
||||
}
|
||||
|
||||
async startDevice(deviceId?: string): Promise<void> {
|
||||
this.captureMode = 'device'
|
||||
if (deviceId) {
|
||||
this.selectedDeviceId = deviceId
|
||||
}
|
||||
await this.start()
|
||||
}
|
||||
|
||||
async start(deviceId?: string): Promise<void> {
|
||||
if (deviceId) {
|
||||
return this.startDevice(deviceId)
|
||||
this.selectedDeviceId = deviceId
|
||||
this.captureMode = 'device'
|
||||
}
|
||||
|
||||
try {
|
||||
await this.startSystemAudio()
|
||||
} catch (err) {
|
||||
console.warn('System audio capture failed, falling back to device capture:', err)
|
||||
await this.startDevice(deviceId)
|
||||
}
|
||||
}
|
||||
const support = await this.ensureBackendSupport()
|
||||
const requestedMode = this.captureMode
|
||||
const requestedDeviceId = requestedMode === 'device' ? this.selectedDeviceId ?? undefined : undefined
|
||||
const candidateBackends = this.resolveCandidateBackends(support, requestedMode)
|
||||
|
||||
private wireUpStream(): void {
|
||||
if (!this.audioContext || !this.stream) return
|
||||
let lastError: Error | null = null
|
||||
let nativeFallbackReason: string | null = null
|
||||
|
||||
this.sourceNode = this.audioContext.createMediaStreamSource(this.stream)
|
||||
const audioTrack = this.stream.getAudioTracks()[0] ?? null
|
||||
const trackSettings = audioTrack?.getSettings()
|
||||
const channelCount = Math.max(
|
||||
1,
|
||||
Math.floor(trackSettings?.channelCount ?? this.sourceNode.channelCount ?? 2)
|
||||
)
|
||||
const sampleRate = Math.max(
|
||||
1,
|
||||
Math.floor(trackSettings?.sampleRate ?? this.audioContext.sampleRate)
|
||||
)
|
||||
const sessionId = audioRouter.beginSession(sampleRate, channelCount)
|
||||
this.sessionId = sessionId
|
||||
|
||||
this.workletNode = new AudioWorkletNode(this.audioContext, 'capture-processor', {
|
||||
numberOfInputs: 1,
|
||||
numberOfOutputs: 0,
|
||||
channelCount: 2,
|
||||
})
|
||||
|
||||
this.workletNode.port.onmessage = (event: MessageEvent<{
|
||||
left: Float32Array
|
||||
right: Float32Array
|
||||
channelCount?: number
|
||||
}>) => {
|
||||
audioRouter.ingestChunk(event.data.left, event.data.right, {
|
||||
sessionId,
|
||||
channelCount: event.data.channelCount ?? channelCount,
|
||||
})
|
||||
for (const backend of candidateBackends) {
|
||||
try {
|
||||
await backend.start({ deviceId: requestedDeviceId })
|
||||
this.activeBackend = backend
|
||||
this.activeBackendReason = nativeFallbackReason
|
||||
const backendStatus = backend.getStatus()
|
||||
this.sessionId = audioRouter.beginSession(
|
||||
backendStatus.sampleRate,
|
||||
backendStatus.channelCount,
|
||||
backend.kind,
|
||||
)
|
||||
this.emitStatus()
|
||||
return
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown capture backend failure'
|
||||
lastError = error instanceof Error ? error : new Error(message)
|
||||
if (backend.kind.startsWith('native-')) {
|
||||
nativeFallbackReason = message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.sourceNode.connect(this.workletNode)
|
||||
console.log(
|
||||
`AudioCapture: session ${sessionId} started (${sampleRate}Hz, ${channelCount}ch, mode=${this.captureMode})`
|
||||
)
|
||||
this.activeBackendReason = nativeFallbackReason
|
||||
this.emitStatus()
|
||||
throw lastError ?? new Error('No capture backend succeeded.')
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.sessionId !== null) {
|
||||
console.log(`AudioCapture: ending session ${this.sessionId}`)
|
||||
audioRouter.endSession()
|
||||
this.sessionId = null
|
||||
}
|
||||
|
||||
if (this.workletNode) {
|
||||
this.workletNode.disconnect()
|
||||
this.workletNode.port.onmessage = null
|
||||
this.workletNode = null
|
||||
if (this.activeBackend) {
|
||||
void this.activeBackend.stop()
|
||||
}
|
||||
|
||||
if (this.sourceNode) {
|
||||
this.sourceNode.disconnect()
|
||||
this.sourceNode = null
|
||||
this.emitStatus()
|
||||
}
|
||||
|
||||
async listSources(mode: CaptureMode = this.captureMode): Promise<CaptureSourceDescriptor[]> {
|
||||
await this.ensureBackendSupport()
|
||||
if (mode === 'device') {
|
||||
return this.electronDeviceBackend.listSources()
|
||||
}
|
||||
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach((track) => track.stop())
|
||||
this.stream = null
|
||||
}
|
||||
|
||||
if (this.audioContext) {
|
||||
this.audioContext.close()
|
||||
this.audioContext = null
|
||||
}
|
||||
const activeSystemBackend = this.resolveCandidateBackends(this.backendSupport ?? DEFAULT_BACKEND_SUPPORT, 'system')[0]
|
||||
return activeSystemBackend.listSources()
|
||||
}
|
||||
|
||||
async listDevices(): Promise<MediaDeviceInfo[]> {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||
return devices.filter((d) => d.kind === 'audioinput')
|
||||
return devices.filter((device) => device.kind === 'audioinput')
|
||||
}
|
||||
|
||||
getSelectedDeviceId(): string | null {
|
||||
@@ -185,14 +495,106 @@ class AudioCapture {
|
||||
|
||||
setSelectedDeviceId(id: string | null): void {
|
||||
this.selectedDeviceId = id
|
||||
this.emitStatus()
|
||||
}
|
||||
|
||||
getCaptureMode(): CaptureMode {
|
||||
return this.captureMode
|
||||
}
|
||||
|
||||
setCaptureMode(mode: CaptureMode): void {
|
||||
this.captureMode = mode
|
||||
this.emitStatus()
|
||||
}
|
||||
|
||||
getBackendPolicy(): CaptureBackendPolicy {
|
||||
return this.backendPolicy
|
||||
}
|
||||
|
||||
setBackendPolicy(policy: CaptureBackendPolicy): void {
|
||||
this.backendPolicy = policy
|
||||
this.emitStatus()
|
||||
}
|
||||
|
||||
getSampleRate(): number {
|
||||
return this.audioContext?.sampleRate ?? 48000
|
||||
return this.activeBackend?.getStatus().sampleRate ?? 48000
|
||||
}
|
||||
|
||||
getStatus(): CaptureManagerStatus {
|
||||
const backendStatus = this.activeBackend?.getStatus()
|
||||
return {
|
||||
captureMode: this.captureMode,
|
||||
backendPolicy: this.backendPolicy,
|
||||
activeBackendKind: this.activeBackend?.kind ?? null,
|
||||
activeBackendReason: this.activeBackendReason,
|
||||
backendSupport: this.backendSupport,
|
||||
sampleRate: backendStatus?.sampleRate ?? 48000,
|
||||
channelCount: backendStatus?.channelCount ?? 2,
|
||||
isCapturing: Boolean(this.activeBackend?.getStatus().active && this.sessionId !== null),
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureBackendSupport(): Promise<CaptureBackendSupport> {
|
||||
if (this.backendSupport) {
|
||||
return this.backendSupport
|
||||
}
|
||||
|
||||
if (!this.backendSupportPromise) {
|
||||
this.backendSupportPromise = window.electronAPI.getCaptureBackendSupport()
|
||||
.catch(() => DEFAULT_BACKEND_SUPPORT)
|
||||
.then((support) => {
|
||||
this.backendSupport = support
|
||||
this.nativeBackend = new NativeUnavailableCaptureBackend(support.nativeBackend)
|
||||
this.emitStatus()
|
||||
return support
|
||||
})
|
||||
}
|
||||
|
||||
return this.backendSupportPromise
|
||||
}
|
||||
|
||||
private resolveCandidateBackends(
|
||||
support: CaptureBackendSupport,
|
||||
mode: CaptureMode,
|
||||
): CaptureBackend[] {
|
||||
if (mode === 'device') {
|
||||
return [this.electronDeviceBackend]
|
||||
}
|
||||
|
||||
const nativeBackend = this.nativeBackend ?? new NativeUnavailableCaptureBackend(support.nativeBackend)
|
||||
|
||||
switch (this.backendPolicy) {
|
||||
case 'electron':
|
||||
this.activeBackendReason = null
|
||||
return [this.electronSystemBackend]
|
||||
case 'native':
|
||||
case 'auto':
|
||||
if (support.nativeBackend.available) {
|
||||
return [nativeBackend, this.electronSystemBackend]
|
||||
}
|
||||
this.activeBackendReason = support.nativeBackend.reason
|
||||
return [this.electronSystemBackend]
|
||||
}
|
||||
}
|
||||
|
||||
private handleChunk(originKind: CaptureBackendKind, chunk: CaptureChunk): void {
|
||||
if (!this.activeBackend || this.activeBackend.kind !== originKind || this.sessionId === null) {
|
||||
return
|
||||
}
|
||||
|
||||
audioRouter.ingestChunk(chunk.left, chunk.right, {
|
||||
sessionId: this.sessionId,
|
||||
channelCount: chunk.channelCount,
|
||||
capturedAt: chunk.capturedAt,
|
||||
sequence: chunk.sequence,
|
||||
})
|
||||
}
|
||||
|
||||
private emitStatus(): void {
|
||||
const status = this.getStatus()
|
||||
for (const listener of this.statusListeners) {
|
||||
listener(status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,37 +1,246 @@
|
||||
/**
|
||||
* AudioRouter — distributes captured audio samples to per-scope pending buffers.
|
||||
* Pattern extracted from Astra's AudioEngine (lines 196-202, 470-561, 2997-3043).
|
||||
* AudioRouter — demand-aware audio chunk routing for Prism's visualizers.
|
||||
* Captured worklet chunks are stored in fixed-capacity rings so hidden scopes
|
||||
* do not accumulate backlog and queue overflow never reallocates.
|
||||
*/
|
||||
|
||||
import { SCOPE_KINDS, type ScopeKind } from '../../types/scope'
|
||||
import type { CaptureBackendKind } from '../../types/capture'
|
||||
|
||||
const MAX_PENDING_CHUNKS = 20
|
||||
const MAX_PENDING_SPECTRUM_CHUNKS = 96
|
||||
const MAX_PENDING_VECTORSCOPE_CHUNKS = 20
|
||||
const LATENCY_SAMPLE_WINDOW = 240
|
||||
|
||||
const SCOPE_RING_CAPACITY: Record<ScopeKind, number> = {
|
||||
spectrum: MAX_PENDING_SPECTRUM_CHUNKS,
|
||||
oscilloscope: MAX_PENDING_CHUNKS,
|
||||
vectorscope: MAX_PENDING_VECTORSCOPE_CHUNKS,
|
||||
spectrogram: MAX_PENDING_SPECTRUM_CHUNKS,
|
||||
vumeter: MAX_PENDING_VECTORSCOPE_CHUNKS,
|
||||
lufsmeter: MAX_PENDING_SPECTRUM_CHUNKS,
|
||||
waveform: MAX_PENDING_SPECTRUM_CHUNKS,
|
||||
}
|
||||
|
||||
export interface AudioSessionState {
|
||||
sessionId: number
|
||||
sampleRate: number
|
||||
channelCount: number
|
||||
capturing: boolean
|
||||
backendKind: CaptureBackendKind | null
|
||||
}
|
||||
|
||||
export interface VisualizerConsumerDemand {
|
||||
spectrum?: boolean
|
||||
oscilloscope?: boolean
|
||||
vectorscope?: boolean
|
||||
spectrogram?: boolean
|
||||
vumeter?: boolean
|
||||
lufsmeter?: boolean
|
||||
waveform?: boolean
|
||||
}
|
||||
|
||||
export interface AudioRouterScopeDiagnostics {
|
||||
lastCaptureToScopeMs: number | null
|
||||
p95CaptureToScopeMs: number | null
|
||||
drainedChunks: number
|
||||
overwriteCount: number
|
||||
queuedChunks: number
|
||||
lastSequence: number | null
|
||||
}
|
||||
|
||||
export interface AudioRouterDiagnostics {
|
||||
updatedAt: number
|
||||
activeDemand: VisualizerConsumerDemand
|
||||
overallP95CaptureToScopeMs: number | null
|
||||
totalOverwriteCount: number
|
||||
notCapturingDrops: number
|
||||
staleSessionDrops: number
|
||||
undemandedChunks: number
|
||||
scopes: Record<ScopeKind, AudioRouterScopeDiagnostics>
|
||||
}
|
||||
|
||||
interface AudioChunkMeta {
|
||||
sessionId?: number
|
||||
channelCount?: number
|
||||
capturedAt?: number
|
||||
sequence?: number
|
||||
}
|
||||
|
||||
class AudioRouter {
|
||||
private pendingOscilloscopeSamples: Float32Array[] = []
|
||||
private pendingSpectrumSamples: Float32Array[] = []
|
||||
private pendingSpectrogramSamples: Float32Array[] = []
|
||||
private pendingVectorscopeSamples: { left: Float32Array; right: Float32Array }[] = []
|
||||
private pendingVUMeterSamples: { left: Float32Array; right: Float32Array }[] = []
|
||||
private pendingLUFSMeterSamples: { left: Float32Array; right: Float32Array }[] = []
|
||||
private pendingWaveformSamples: Float32Array[] = []
|
||||
interface MonoChunkRecord {
|
||||
samples: Float32Array
|
||||
capturedAt: number
|
||||
sequence: number
|
||||
}
|
||||
|
||||
interface StereoChunkRecord {
|
||||
left: Float32Array
|
||||
right: Float32Array
|
||||
capturedAt: number
|
||||
sequence: number
|
||||
}
|
||||
|
||||
interface ScopeLatencyTracker {
|
||||
lastCaptureToScopeMs: number | null
|
||||
drainedChunks: number
|
||||
lastSequence: number | null
|
||||
latencyWindow: RollingLatencyWindow
|
||||
}
|
||||
|
||||
type ScopeRingMap = {
|
||||
spectrum: FixedChunkRing<MonoChunkRecord>
|
||||
oscilloscope: FixedChunkRing<MonoChunkRecord>
|
||||
vectorscope: FixedChunkRing<StereoChunkRecord>
|
||||
spectrogram: FixedChunkRing<MonoChunkRecord>
|
||||
vumeter: FixedChunkRing<StereoChunkRecord>
|
||||
lufsmeter: FixedChunkRing<StereoChunkRecord>
|
||||
waveform: FixedChunkRing<MonoChunkRecord>
|
||||
}
|
||||
|
||||
class FixedChunkRing<T> {
|
||||
private readonly buffer: Array<T | undefined>
|
||||
private start = 0
|
||||
private size = 0
|
||||
private overwriteCount = 0
|
||||
|
||||
constructor(private readonly capacity: number) {
|
||||
this.buffer = new Array<T | undefined>(Math.max(1, capacity))
|
||||
}
|
||||
|
||||
push(item: T): void {
|
||||
if (this.capacity <= 0) return
|
||||
|
||||
if (this.size < this.capacity) {
|
||||
const index = (this.start + this.size) % this.capacity
|
||||
this.buffer[index] = item
|
||||
this.size += 1
|
||||
return
|
||||
}
|
||||
|
||||
this.buffer[this.start] = item
|
||||
this.start = (this.start + 1) % this.capacity
|
||||
this.overwriteCount += 1
|
||||
}
|
||||
|
||||
drain(): T[] {
|
||||
if (this.size === 0) return []
|
||||
|
||||
const drained = new Array<T>(this.size)
|
||||
for (let index = 0; index < this.size; index += 1) {
|
||||
const bufferIndex = (this.start + index) % this.capacity
|
||||
const item = this.buffer[bufferIndex]
|
||||
if (item !== undefined) {
|
||||
drained[index] = item
|
||||
}
|
||||
this.buffer[bufferIndex] = undefined
|
||||
}
|
||||
|
||||
this.start = 0
|
||||
this.size = 0
|
||||
return drained.filter((item): item is T => item !== undefined)
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
if (this.size === 0) return
|
||||
for (let index = 0; index < this.size; index += 1) {
|
||||
const bufferIndex = (this.start + index) % this.capacity
|
||||
this.buffer[bufferIndex] = undefined
|
||||
}
|
||||
this.start = 0
|
||||
this.size = 0
|
||||
}
|
||||
|
||||
getSize(): number {
|
||||
return this.size
|
||||
}
|
||||
|
||||
getOverwriteCount(): number {
|
||||
return this.overwriteCount
|
||||
}
|
||||
}
|
||||
|
||||
class RollingLatencyWindow {
|
||||
private readonly values: number[]
|
||||
private cursor = 0
|
||||
private count = 0
|
||||
|
||||
constructor(size: number) {
|
||||
this.values = new Array<number>(Math.max(1, size))
|
||||
}
|
||||
|
||||
push(value: number): void {
|
||||
this.values[this.cursor] = value
|
||||
this.cursor = (this.cursor + 1) % this.values.length
|
||||
this.count = Math.min(this.count + 1, this.values.length)
|
||||
}
|
||||
|
||||
getP95(): number | null {
|
||||
if (this.count === 0) return null
|
||||
const sorted = this.values.slice(0, this.count).sort((left, right) => left - right)
|
||||
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * 0.95) - 1))
|
||||
return sorted[index] ?? null
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.cursor = 0
|
||||
this.count = 0
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyDemand(): VisualizerConsumerDemand {
|
||||
return {
|
||||
spectrum: false,
|
||||
oscilloscope: false,
|
||||
vectorscope: false,
|
||||
spectrogram: false,
|
||||
vumeter: false,
|
||||
lufsmeter: false,
|
||||
waveform: false,
|
||||
}
|
||||
}
|
||||
|
||||
function createScopeLatencyTracker(): ScopeLatencyTracker {
|
||||
return {
|
||||
lastCaptureToScopeMs: null,
|
||||
drainedChunks: 0,
|
||||
lastSequence: null,
|
||||
latencyWindow: new RollingLatencyWindow(LATENCY_SAMPLE_WINDOW),
|
||||
}
|
||||
}
|
||||
|
||||
export class AudioRouter {
|
||||
private readonly rings: ScopeRingMap = {
|
||||
spectrum: new FixedChunkRing<MonoChunkRecord>(SCOPE_RING_CAPACITY.spectrum),
|
||||
oscilloscope: new FixedChunkRing<MonoChunkRecord>(SCOPE_RING_CAPACITY.oscilloscope),
|
||||
vectorscope: new FixedChunkRing<StereoChunkRecord>(SCOPE_RING_CAPACITY.vectorscope),
|
||||
spectrogram: new FixedChunkRing<MonoChunkRecord>(SCOPE_RING_CAPACITY.spectrogram),
|
||||
vumeter: new FixedChunkRing<StereoChunkRecord>(SCOPE_RING_CAPACITY.vumeter),
|
||||
lufsmeter: new FixedChunkRing<StereoChunkRecord>(SCOPE_RING_CAPACITY.lufsmeter),
|
||||
waveform: new FixedChunkRing<MonoChunkRecord>(SCOPE_RING_CAPACITY.waveform),
|
||||
}
|
||||
|
||||
private readonly scopeLatency: Record<ScopeKind, ScopeLatencyTracker> = {
|
||||
spectrum: createScopeLatencyTracker(),
|
||||
oscilloscope: createScopeLatencyTracker(),
|
||||
vectorscope: createScopeLatencyTracker(),
|
||||
spectrogram: createScopeLatencyTracker(),
|
||||
vumeter: createScopeLatencyTracker(),
|
||||
lufsmeter: createScopeLatencyTracker(),
|
||||
waveform: createScopeLatencyTracker(),
|
||||
}
|
||||
|
||||
private readonly consumerDemand = new Map<string, VisualizerConsumerDemand>()
|
||||
|
||||
private _sampleRate = 48000
|
||||
private _capturing = false
|
||||
private _channelCount = 2
|
||||
private _sessionId = 0
|
||||
private _backendKind: CaptureBackendKind | null = null
|
||||
|
||||
private notCapturingDrops = 0
|
||||
private staleSessionDrops = 0
|
||||
private undemandedChunks = 0
|
||||
|
||||
private sessionListeners = new Set<(state: AudioSessionState) => void>()
|
||||
|
||||
private emitSessionState(): void {
|
||||
@@ -67,14 +276,16 @@ class AudioRouter {
|
||||
sampleRate: this._sampleRate,
|
||||
channelCount: this._channelCount,
|
||||
capturing: this._capturing,
|
||||
backendKind: this._backendKind,
|
||||
}
|
||||
}
|
||||
|
||||
beginSession(sampleRate: number, channelCount: number): number {
|
||||
beginSession(sampleRate: number, channelCount: number, backendKind: CaptureBackendKind | null = null): number {
|
||||
this._sessionId += 1
|
||||
this._sampleRate = sampleRate
|
||||
this._channelCount = Math.max(1, Math.floor(channelCount) || 1)
|
||||
this._capturing = true
|
||||
this._backendKind = backendKind
|
||||
this.reset()
|
||||
this.emitSessionState()
|
||||
return this._sessionId
|
||||
@@ -83,6 +294,7 @@ class AudioRouter {
|
||||
endSession(): void {
|
||||
this._sessionId += 1
|
||||
this._capturing = false
|
||||
this._backendKind = null
|
||||
this.reset()
|
||||
this.emitSessionState()
|
||||
}
|
||||
@@ -95,139 +307,243 @@ class AudioRouter {
|
||||
}
|
||||
}
|
||||
|
||||
setVisualizerConsumerDemand(consumerId: string, demand: VisualizerConsumerDemand): void {
|
||||
const normalized: VisualizerConsumerDemand = {
|
||||
spectrum: Boolean(demand.spectrum),
|
||||
oscilloscope: Boolean(demand.oscilloscope),
|
||||
vectorscope: Boolean(demand.vectorscope),
|
||||
spectrogram: Boolean(demand.spectrogram),
|
||||
vumeter: Boolean(demand.vumeter),
|
||||
lufsmeter: Boolean(demand.lufsmeter),
|
||||
waveform: Boolean(demand.waveform),
|
||||
}
|
||||
|
||||
const hasAnyDemand = Object.values(normalized).some(Boolean)
|
||||
if (hasAnyDemand) {
|
||||
this.consumerDemand.set(consumerId, normalized)
|
||||
} else {
|
||||
this.consumerDemand.delete(consumerId)
|
||||
}
|
||||
|
||||
this.pruneQueuesForDemand()
|
||||
}
|
||||
|
||||
clearVisualizerConsumerDemand(consumerId: string): void {
|
||||
if (this.consumerDemand.delete(consumerId)) {
|
||||
this.pruneQueuesForDemand()
|
||||
}
|
||||
}
|
||||
|
||||
ingestChunk(left: Float32Array, right: Float32Array, meta: AudioChunkMeta = {}): void {
|
||||
if (!this._capturing) return
|
||||
if (meta.sessionId !== undefined && meta.sessionId !== this._sessionId) return
|
||||
if (!this._capturing) {
|
||||
this.notCapturingDrops += 1
|
||||
return
|
||||
}
|
||||
|
||||
if (meta.sessionId !== undefined && meta.sessionId !== this._sessionId) {
|
||||
this.staleSessionDrops += 1
|
||||
return
|
||||
}
|
||||
|
||||
const effectiveChannelCount = Math.max(1, Math.floor(meta.channelCount ?? this._channelCount) || 1)
|
||||
this._channelCount = effectiveChannelCount
|
||||
const resolvedRight = effectiveChannelCount > 1 && right.length > 0 ? right : left
|
||||
|
||||
// Compute mono
|
||||
const resolvedRight = effectiveChannelCount > 1 && right.length > 0 ? right : left
|
||||
const len = Math.min(left.length, resolvedRight.length)
|
||||
if (len === 0) return
|
||||
|
||||
const mono = new Float32Array(len)
|
||||
for (let i = 0; i < len; i++) {
|
||||
mono[i] = (left[i] + resolvedRight[i]) / 2
|
||||
const activeDemand = this.getActiveDemand()
|
||||
const needsMono = Boolean(activeDemand.spectrum || activeDemand.spectrogram)
|
||||
const needsStereo = Boolean(activeDemand.vectorscope || activeDemand.vumeter || activeDemand.lufsmeter)
|
||||
const needsLeft = Boolean(activeDemand.oscilloscope || activeDemand.waveform)
|
||||
|
||||
if (!needsMono && !needsStereo && !needsLeft) {
|
||||
this.undemandedChunks += 1
|
||||
return
|
||||
}
|
||||
|
||||
// Oscilloscope — uses left channel
|
||||
if (this.pendingOscilloscopeSamples.length >= MAX_PENDING_CHUNKS) {
|
||||
this.pendingOscilloscopeSamples = this.pendingOscilloscopeSamples.slice(
|
||||
-Math.floor(MAX_PENDING_CHUNKS / 2)
|
||||
)
|
||||
}
|
||||
this.pendingOscilloscopeSamples.push(left.slice(0, len))
|
||||
const capturedAt = meta.capturedAt ?? performance.now()
|
||||
const sequence = meta.sequence ?? 0
|
||||
const leftSamples = left.length === len ? left : left.subarray(0, len)
|
||||
const rightSamples = resolvedRight.length === len ? resolvedRight : resolvedRight.subarray(0, len)
|
||||
|
||||
// Spectrum — uses mono
|
||||
if (this.pendingSpectrumSamples.length >= MAX_PENDING_SPECTRUM_CHUNKS) {
|
||||
this.pendingSpectrumSamples = this.pendingSpectrumSamples.slice(
|
||||
-Math.floor(MAX_PENDING_SPECTRUM_CHUNKS / 2)
|
||||
)
|
||||
let mono: Float32Array | null = null
|
||||
if (needsMono) {
|
||||
mono = new Float32Array(len)
|
||||
for (let index = 0; index < len; index += 1) {
|
||||
mono[index] = (leftSamples[index] + rightSamples[index]) * 0.5
|
||||
}
|
||||
}
|
||||
this.pendingSpectrumSamples.push(mono)
|
||||
|
||||
// Spectrogram — uses mono
|
||||
if (this.pendingSpectrogramSamples.length >= MAX_PENDING_SPECTRUM_CHUNKS) {
|
||||
this.pendingSpectrogramSamples = this.pendingSpectrogramSamples.slice(
|
||||
-Math.floor(MAX_PENDING_SPECTRUM_CHUNKS / 2)
|
||||
)
|
||||
if (activeDemand.oscilloscope) {
|
||||
this.rings.oscilloscope.push({ samples: leftSamples, capturedAt, sequence })
|
||||
}
|
||||
this.pendingSpectrogramSamples.push(mono)
|
||||
|
||||
// Vectorscope — uses stereo
|
||||
if (this.pendingVectorscopeSamples.length >= MAX_PENDING_VECTORSCOPE_CHUNKS) {
|
||||
this.pendingVectorscopeSamples = this.pendingVectorscopeSamples.slice(
|
||||
-Math.floor(MAX_PENDING_VECTORSCOPE_CHUNKS / 2)
|
||||
)
|
||||
if (activeDemand.spectrum && mono) {
|
||||
this.rings.spectrum.push({ samples: mono, capturedAt, sequence })
|
||||
}
|
||||
this.pendingVectorscopeSamples.push({
|
||||
left: left.slice(0, len),
|
||||
right: resolvedRight.slice(0, len),
|
||||
})
|
||||
|
||||
// VU Meter — uses stereo
|
||||
if (this.pendingVUMeterSamples.length >= MAX_PENDING_VECTORSCOPE_CHUNKS) {
|
||||
this.pendingVUMeterSamples = this.pendingVUMeterSamples.slice(
|
||||
-Math.floor(MAX_PENDING_VECTORSCOPE_CHUNKS / 2)
|
||||
)
|
||||
if (activeDemand.spectrogram && mono) {
|
||||
this.rings.spectrogram.push({ samples: mono, capturedAt, sequence })
|
||||
}
|
||||
this.pendingVUMeterSamples.push({
|
||||
left: left.slice(0, len),
|
||||
right: resolvedRight.slice(0, len),
|
||||
})
|
||||
|
||||
// LUFS Meter — uses stereo
|
||||
if (this.pendingLUFSMeterSamples.length >= MAX_PENDING_SPECTRUM_CHUNKS) {
|
||||
this.pendingLUFSMeterSamples = this.pendingLUFSMeterSamples.slice(
|
||||
-Math.floor(MAX_PENDING_SPECTRUM_CHUNKS / 2)
|
||||
)
|
||||
if (activeDemand.vectorscope) {
|
||||
this.rings.vectorscope.push({ left: leftSamples, right: rightSamples, capturedAt, sequence })
|
||||
}
|
||||
this.pendingLUFSMeterSamples.push({
|
||||
left: left.slice(0, len),
|
||||
right: resolvedRight.slice(0, len),
|
||||
})
|
||||
|
||||
// Waveform — uses left channel
|
||||
if (this.pendingWaveformSamples.length >= MAX_PENDING_SPECTRUM_CHUNKS) {
|
||||
this.pendingWaveformSamples = this.pendingWaveformSamples.slice(
|
||||
-Math.floor(MAX_PENDING_SPECTRUM_CHUNKS / 2)
|
||||
)
|
||||
if (activeDemand.vumeter) {
|
||||
this.rings.vumeter.push({ left: leftSamples, right: rightSamples, capturedAt, sequence })
|
||||
}
|
||||
|
||||
if (activeDemand.lufsmeter) {
|
||||
this.rings.lufsmeter.push({ left: leftSamples, right: rightSamples, capturedAt, sequence })
|
||||
}
|
||||
|
||||
if (activeDemand.waveform) {
|
||||
this.rings.waveform.push({ samples: leftSamples, capturedAt, sequence })
|
||||
}
|
||||
this.pendingWaveformSamples.push(new Float32Array(left))
|
||||
}
|
||||
|
||||
flushPendingOscilloscopeSamples(): Float32Array[] {
|
||||
const samples = this.pendingOscilloscopeSamples
|
||||
this.pendingOscilloscopeSamples = []
|
||||
return samples
|
||||
const records = this.rings.oscilloscope.drain()
|
||||
this.recordScopeDrain('oscilloscope', records)
|
||||
return records.map((record) => record.samples)
|
||||
}
|
||||
|
||||
flushPendingSpectrumSamples(): Float32Array[] {
|
||||
const samples = this.pendingSpectrumSamples
|
||||
this.pendingSpectrumSamples = []
|
||||
return samples
|
||||
const records = this.rings.spectrum.drain()
|
||||
this.recordScopeDrain('spectrum', records)
|
||||
return records.map((record) => record.samples)
|
||||
}
|
||||
|
||||
flushPendingSpectrogramSamples(): Float32Array[] {
|
||||
const samples = this.pendingSpectrogramSamples
|
||||
this.pendingSpectrogramSamples = []
|
||||
return samples
|
||||
const records = this.rings.spectrogram.drain()
|
||||
this.recordScopeDrain('spectrogram', records)
|
||||
return records.map((record) => record.samples)
|
||||
}
|
||||
|
||||
flushPendingVectorscopeSamples(): { left: Float32Array; right: Float32Array }[] {
|
||||
const samples = this.pendingVectorscopeSamples
|
||||
this.pendingVectorscopeSamples = []
|
||||
return samples
|
||||
const records = this.rings.vectorscope.drain()
|
||||
this.recordScopeDrain('vectorscope', records)
|
||||
return records.map((record) => ({ left: record.left, right: record.right }))
|
||||
}
|
||||
|
||||
flushPendingVUMeterSamples(): { left: Float32Array; right: Float32Array }[] {
|
||||
const samples = this.pendingVUMeterSamples
|
||||
this.pendingVUMeterSamples = []
|
||||
return samples
|
||||
const records = this.rings.vumeter.drain()
|
||||
this.recordScopeDrain('vumeter', records)
|
||||
return records.map((record) => ({ left: record.left, right: record.right }))
|
||||
}
|
||||
|
||||
flushPendingLUFSMeterSamples(): { left: Float32Array; right: Float32Array }[] {
|
||||
const samples = this.pendingLUFSMeterSamples
|
||||
this.pendingLUFSMeterSamples = []
|
||||
return samples
|
||||
const records = this.rings.lufsmeter.drain()
|
||||
this.recordScopeDrain('lufsmeter', records)
|
||||
return records.map((record) => ({ left: record.left, right: record.right }))
|
||||
}
|
||||
|
||||
flushPendingWaveformSamples(): Float32Array[] {
|
||||
const samples = this.pendingWaveformSamples
|
||||
this.pendingWaveformSamples = []
|
||||
return samples
|
||||
const records = this.rings.waveform.drain()
|
||||
this.recordScopeDrain('waveform', records)
|
||||
return records.map((record) => record.samples)
|
||||
}
|
||||
|
||||
getDiagnosticsSnapshot(): AudioRouterDiagnostics {
|
||||
const activeDemand = this.getActiveDemand()
|
||||
let totalOverwriteCount = 0
|
||||
let overallP95CaptureToScopeMs: number | null = null
|
||||
|
||||
const scopes = SCOPE_KINDS.reduce<Record<ScopeKind, AudioRouterScopeDiagnostics>>((result, scope) => {
|
||||
const scopeTracker = this.scopeLatency[scope]
|
||||
const overwriteCount = this.getRing(scope).getOverwriteCount()
|
||||
totalOverwriteCount += overwriteCount
|
||||
|
||||
const p95CaptureToScopeMs = scopeTracker.latencyWindow.getP95()
|
||||
if (p95CaptureToScopeMs !== null && activeDemand[scope]) {
|
||||
overallP95CaptureToScopeMs = overallP95CaptureToScopeMs === null
|
||||
? p95CaptureToScopeMs
|
||||
: Math.max(overallP95CaptureToScopeMs, p95CaptureToScopeMs)
|
||||
}
|
||||
|
||||
result[scope] = {
|
||||
lastCaptureToScopeMs: scopeTracker.lastCaptureToScopeMs,
|
||||
p95CaptureToScopeMs,
|
||||
drainedChunks: scopeTracker.drainedChunks,
|
||||
overwriteCount,
|
||||
queuedChunks: this.getRing(scope).getSize(),
|
||||
lastSequence: scopeTracker.lastSequence,
|
||||
}
|
||||
return result
|
||||
}, {} as Record<ScopeKind, AudioRouterScopeDiagnostics>)
|
||||
|
||||
return {
|
||||
updatedAt: performance.now(),
|
||||
activeDemand,
|
||||
overallP95CaptureToScopeMs,
|
||||
totalOverwriteCount,
|
||||
notCapturingDrops: this.notCapturingDrops,
|
||||
staleSessionDrops: this.staleSessionDrops,
|
||||
undemandedChunks: this.undemandedChunks,
|
||||
scopes,
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.pendingOscilloscopeSamples = []
|
||||
this.pendingSpectrumSamples = []
|
||||
this.pendingSpectrogramSamples = []
|
||||
this.pendingVectorscopeSamples = []
|
||||
this.pendingVUMeterSamples = []
|
||||
this.pendingLUFSMeterSamples = []
|
||||
this.pendingWaveformSamples = []
|
||||
this.clearAllRings()
|
||||
this.resetLatencyTrackers()
|
||||
}
|
||||
|
||||
private getActiveDemand(): VisualizerConsumerDemand {
|
||||
const aggregated = createEmptyDemand()
|
||||
for (const demand of this.consumerDemand.values()) {
|
||||
for (const scope of SCOPE_KINDS) {
|
||||
if (demand[scope]) {
|
||||
aggregated[scope] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return aggregated
|
||||
}
|
||||
|
||||
private pruneQueuesForDemand(): void {
|
||||
const activeDemand = this.getActiveDemand()
|
||||
for (const scope of SCOPE_KINDS) {
|
||||
if (!activeDemand[scope]) {
|
||||
this.getRing(scope).clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private clearAllRings(): void {
|
||||
for (const scope of SCOPE_KINDS) {
|
||||
this.getRing(scope).clear()
|
||||
}
|
||||
}
|
||||
|
||||
private resetLatencyTrackers(): void {
|
||||
for (const scope of SCOPE_KINDS) {
|
||||
const tracker = this.scopeLatency[scope]
|
||||
tracker.lastCaptureToScopeMs = null
|
||||
tracker.drainedChunks = 0
|
||||
tracker.lastSequence = null
|
||||
tracker.latencyWindow.reset()
|
||||
}
|
||||
}
|
||||
|
||||
private recordScopeDrain(scope: ScopeKind, records: Array<MonoChunkRecord | StereoChunkRecord>): void {
|
||||
if (records.length === 0) return
|
||||
|
||||
const tracker = this.scopeLatency[scope]
|
||||
const now = performance.now()
|
||||
for (const record of records) {
|
||||
const captureToScopeMs = Math.max(0, now - record.capturedAt)
|
||||
tracker.lastCaptureToScopeMs = captureToScopeMs
|
||||
tracker.latencyWindow.push(captureToScopeMs)
|
||||
tracker.lastSequence = record.sequence
|
||||
}
|
||||
tracker.drainedChunks += records.length
|
||||
}
|
||||
|
||||
private getRing(scope: ScopeKind): FixedChunkRing<MonoChunkRecord> | FixedChunkRing<StereoChunkRecord> {
|
||||
return this.rings[scope]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useMemo, useRef, type CSSProperties, type JSX, type ReactNode } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties, type JSX, type ReactNode } from 'react'
|
||||
import { useAudioStore } from '../stores/audioStore'
|
||||
import { useSettingsStore, type ScopeSettings } from '../stores/settingsStore'
|
||||
import { useThemeStore, PRESETS, PRESET_IDS } from '../stores/themeStore'
|
||||
import type { ScopeKind } from '../../types/scope'
|
||||
import { buildAnalyzerGridTemplateColumns } from '../analyzerLayout'
|
||||
import { audioRouter, type AudioRouterDiagnostics } from '../audio/AudioRouter'
|
||||
import type { CaptureBackendKind, CaptureBackendPolicy } from '../../types/capture'
|
||||
|
||||
const SCOPE_LABELS: Record<ScopeKind, string> = {
|
||||
spectrum: 'Spectrum',
|
||||
@@ -15,6 +17,23 @@ const SCOPE_LABELS: Record<ScopeKind, string> = {
|
||||
waveform: 'Waveform',
|
||||
}
|
||||
|
||||
function captureBackendLabel(kind: CaptureBackendKind | null): string {
|
||||
switch (kind) {
|
||||
case 'electron-system':
|
||||
return 'Electron System'
|
||||
case 'electron-device':
|
||||
return 'Electron Device'
|
||||
case 'native-macos':
|
||||
return 'Native macOS'
|
||||
case 'native-windows':
|
||||
return 'Native Windows'
|
||||
case 'native-linux':
|
||||
return 'Native Linux'
|
||||
default:
|
||||
return 'None'
|
||||
}
|
||||
}
|
||||
|
||||
function vectorscopeModeLabel(mode: ScopeSettings['vectorscope']['mode']): string {
|
||||
switch (mode) {
|
||||
case 'lissajous':
|
||||
@@ -480,17 +499,25 @@ export default function SettingsPanel({ onClose, onHeightChange }: SettingsPanel
|
||||
devices,
|
||||
selectedDeviceId,
|
||||
captureMode,
|
||||
capturePolicy,
|
||||
activeBackendKind,
|
||||
activeBackendReason,
|
||||
isCapturing,
|
||||
captureStatus,
|
||||
captureError,
|
||||
refreshDevices,
|
||||
refreshBackendSupport,
|
||||
selectDevice,
|
||||
setCaptureMode,
|
||||
setCapturePolicy,
|
||||
startCapture,
|
||||
} = useAudioStore()
|
||||
const { scopeSettings, updateScopeSettings, hiddenScopes, scopeOrder, widthWeights } = useSettingsStore()
|
||||
const { presetId, accent, setPreset, setCustomAccent, customAccent } = useThemeStore()
|
||||
const panelRef = useRef<HTMLDivElement | null>(null)
|
||||
const [routerDiagnostics, setRouterDiagnostics] = useState<AudioRouterDiagnostics>(
|
||||
() => audioRouter.getDiagnosticsSnapshot(),
|
||||
)
|
||||
|
||||
const visibleScopes = scopeOrder.filter((kind) => !hiddenScopes.has(kind))
|
||||
const scopeTrackStyle = useMemo(() => {
|
||||
@@ -500,8 +527,18 @@ export default function SettingsPanel({ onClose, onHeightChange }: SettingsPanel
|
||||
}, [visibleScopes, widthWeights])
|
||||
|
||||
useEffect(() => {
|
||||
void refreshDevices()
|
||||
}, [refreshDevices])
|
||||
void Promise.all([refreshDevices(), refreshBackendSupport()])
|
||||
}, [refreshBackendSupport, refreshDevices])
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = window.setInterval(() => {
|
||||
setRouterDiagnostics(audioRouter.getDiagnosticsSnapshot())
|
||||
}, 250)
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const panel = panelRef.current
|
||||
@@ -538,6 +575,10 @@ export default function SettingsPanel({ onClose, onHeightChange }: SettingsPanel
|
||||
await startCapture()
|
||||
}
|
||||
|
||||
const handlePolicyChange = async (value: string): Promise<void> => {
|
||||
await setCapturePolicy(value as CaptureBackendPolicy)
|
||||
}
|
||||
|
||||
const indicatorLabel = isCapturing
|
||||
? 'Capturing'
|
||||
: captureStatus === 'connecting'
|
||||
@@ -546,6 +587,10 @@ export default function SettingsPanel({ onClose, onHeightChange }: SettingsPanel
|
||||
? 'Capture Failed'
|
||||
: 'Idle'
|
||||
|
||||
const latencyLabel = routerDiagnostics.overallP95CaptureToScopeMs === null
|
||||
? 'Waiting for samples'
|
||||
: `${routerDiagnostics.overallP95CaptureToScopeMs.toFixed(1)} ms p95`
|
||||
|
||||
return (
|
||||
<div className="settings-panel" ref={panelRef}>
|
||||
<div className="settings-panel__utility-row">
|
||||
@@ -572,11 +617,38 @@ export default function SettingsPanel({ onClose, onHeightChange }: SettingsPanel
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="settings-control settings-control--stack">
|
||||
<span className="settings-control__label">Backend Policy</span>
|
||||
<select
|
||||
className="settings-control__select"
|
||||
value={capturePolicy}
|
||||
onChange={(event) => {
|
||||
void handlePolicyChange(event.target.value)
|
||||
}}
|
||||
>
|
||||
<option value="auto">Auto</option>
|
||||
<option value="native">Native</option>
|
||||
<option value="electron">Electron</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className={`settings-status-pill is-${captureStatus}`.trim()}>
|
||||
<span className="settings-status-pill__dot" />
|
||||
<span>{indicatorLabel}</span>
|
||||
</div>
|
||||
|
||||
<div className="settings-info-text">
|
||||
Active backend: {captureBackendLabel(activeBackendKind)}
|
||||
</div>
|
||||
|
||||
<div className="settings-info-text">
|
||||
Latency probe: {latencyLabel} · overwrites {routerDiagnostics.totalOverwriteCount} · stale drops {routerDiagnostics.staleSessionDrops}
|
||||
</div>
|
||||
|
||||
{activeBackendReason ? (
|
||||
<div className="settings-info-text">{activeBackendReason}</div>
|
||||
) : null}
|
||||
|
||||
{captureError ? (
|
||||
<div className="settings-error-text">{captureError}</div>
|
||||
) : null}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useThemeStore } from '../stores/themeStore'
|
||||
import type { ScopeKind } from '../../types/scope'
|
||||
import ScopeModule from './ScopeModule'
|
||||
import { buildAnalyzerGridTemplateColumns } from '../analyzerLayout'
|
||||
import { audioRouter } from '../audio/AudioRouter'
|
||||
|
||||
export default function Strip(): JSX.Element {
|
||||
const scopeOrder = useSettingsStore((s) => s.scopeOrder)
|
||||
@@ -15,7 +16,11 @@ export default function Strip(): JSX.Element {
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
const scopeRefs = useRef<Partial<Record<ScopeKind, HTMLDivElement | null>>>({})
|
||||
const [handleOffsets, setHandleOffsets] = useState<number[]>([])
|
||||
const visibleScopes = scopeOrder.filter((k) => !hiddenScopes.has(k))
|
||||
const visibleScopes = useMemo(
|
||||
() => scopeOrder.filter((k) => !hiddenScopes.has(k)),
|
||||
[hiddenScopes, scopeOrder],
|
||||
)
|
||||
const visibleScopeKey = useMemo(() => visibleScopes.join('|'), [visibleScopes])
|
||||
const gridTemplateColumns = useMemo(() => {
|
||||
return buildAnalyzerGridTemplateColumns(visibleScopes, widthWeights)
|
||||
}, [visibleScopes, widthWeights])
|
||||
@@ -40,6 +45,23 @@ export default function Strip(): JSX.Element {
|
||||
setHandleOffsets(nextOffsets)
|
||||
}, [visibleScopes])
|
||||
|
||||
useEffect(() => {
|
||||
const visibleScopeSet = new Set(visibleScopes)
|
||||
audioRouter.setVisualizerConsumerDemand('docked-strip', {
|
||||
spectrum: visibleScopeSet.has('spectrum'),
|
||||
oscilloscope: visibleScopeSet.has('oscilloscope'),
|
||||
vectorscope: visibleScopeSet.has('vectorscope'),
|
||||
spectrogram: visibleScopeSet.has('spectrogram'),
|
||||
vumeter: visibleScopeSet.has('vumeter'),
|
||||
lufsmeter: visibleScopeSet.has('lufsmeter'),
|
||||
waveform: visibleScopeSet.has('waveform'),
|
||||
})
|
||||
|
||||
return () => {
|
||||
audioRouter.clearVisualizerConsumerDemand('docked-strip')
|
||||
}
|
||||
}, [visibleScopeKey, visibleScopes])
|
||||
|
||||
useEffect(() => {
|
||||
const strip = stripRef.current
|
||||
if (!strip) return
|
||||
|
||||
Vendored
+2
@@ -1,6 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import type { VisualizerDSP } from './audio/native/visualizer-dsp'
|
||||
import type { CaptureBackendSupport } from '../types/capture'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -12,6 +13,7 @@ declare global {
|
||||
toggleAlwaysOnTop: () => void
|
||||
isAlwaysOnTop: () => Promise<boolean>
|
||||
getDesktopSources: () => Promise<{ id: string; name: string }[]>
|
||||
getCaptureBackendSupport: () => Promise<CaptureBackendSupport>
|
||||
expandSettings: (panelHeight: number) => void
|
||||
collapseSettings: (panelHeight: number) => void
|
||||
setSettingsHeight: (panelHeight: number) => void
|
||||
|
||||
@@ -1,59 +1,112 @@
|
||||
import { create } from 'zustand'
|
||||
import { audioCapture, type CaptureMode } from '../audio/AudioCapture'
|
||||
import { audioCapture, type CaptureManagerStatus } from '../audio/AudioCapture'
|
||||
import type {
|
||||
CaptureBackendKind,
|
||||
CaptureBackendPolicy,
|
||||
CaptureBackendSupport,
|
||||
CaptureMode,
|
||||
} from '../../types/capture'
|
||||
|
||||
interface AudioState {
|
||||
devices: MediaDeviceInfo[]
|
||||
selectedDeviceId: string | null
|
||||
captureMode: CaptureMode
|
||||
capturePolicy: CaptureBackendPolicy
|
||||
activeBackendKind: CaptureBackendKind | null
|
||||
activeBackendReason: string | null
|
||||
backendSupport: CaptureBackendSupport | null
|
||||
isCapturing: boolean
|
||||
captureStatus: 'idle' | 'connecting' | 'capturing' | 'error'
|
||||
captureError: string | null
|
||||
sampleRate: number
|
||||
channelCount: number
|
||||
refreshDevices: () => Promise<void>
|
||||
refreshBackendSupport: () => Promise<void>
|
||||
selectDevice: (deviceId: string) => Promise<void>
|
||||
setCaptureMode: (mode: CaptureMode) => void
|
||||
setCapturePolicy: (policy: CaptureBackendPolicy) => Promise<void>
|
||||
startCapture: () => Promise<void>
|
||||
stopCapture: () => void
|
||||
}
|
||||
|
||||
function applyCaptureStatus(status: CaptureManagerStatus): Partial<AudioState> {
|
||||
return {
|
||||
captureMode: status.captureMode,
|
||||
capturePolicy: status.backendPolicy,
|
||||
activeBackendKind: status.activeBackendKind,
|
||||
activeBackendReason: status.activeBackendReason,
|
||||
backendSupport: status.backendSupport,
|
||||
sampleRate: status.sampleRate,
|
||||
channelCount: status.channelCount,
|
||||
isCapturing: status.isCapturing,
|
||||
}
|
||||
}
|
||||
|
||||
export const useAudioStore = create<AudioState>((set, get) => ({
|
||||
devices: [],
|
||||
selectedDeviceId: null,
|
||||
captureMode: 'system',
|
||||
capturePolicy: 'auto',
|
||||
activeBackendKind: null,
|
||||
activeBackendReason: null,
|
||||
backendSupport: null,
|
||||
isCapturing: false,
|
||||
captureStatus: 'idle',
|
||||
captureError: null,
|
||||
sampleRate: 48000,
|
||||
channelCount: 2,
|
||||
|
||||
refreshDevices: async () => {
|
||||
const devices = await audioCapture.listDevices()
|
||||
set({ devices })
|
||||
},
|
||||
|
||||
refreshBackendSupport: async () => {
|
||||
const backendSupport = await audioCapture.refreshBackendSupport()
|
||||
set({ backendSupport })
|
||||
},
|
||||
|
||||
selectDevice: async (deviceId: string) => {
|
||||
set({ selectedDeviceId: deviceId, captureMode: 'device' })
|
||||
audioCapture.setSelectedDeviceId(deviceId)
|
||||
audioCapture.setCaptureMode('device')
|
||||
set({ selectedDeviceId: deviceId, captureMode: 'device' })
|
||||
},
|
||||
|
||||
setCaptureMode: (mode: CaptureMode) => {
|
||||
audioCapture.setCaptureMode(mode)
|
||||
set({ captureMode: mode })
|
||||
},
|
||||
|
||||
setCapturePolicy: async (policy: CaptureBackendPolicy) => {
|
||||
audioCapture.setBackendPolicy(policy)
|
||||
set({ capturePolicy: policy })
|
||||
await get().refreshBackendSupport()
|
||||
|
||||
const { isCapturing, captureMode } = get()
|
||||
if (isCapturing && captureMode === 'system') {
|
||||
await get().startCapture()
|
||||
}
|
||||
},
|
||||
|
||||
startCapture: async () => {
|
||||
set({ captureStatus: 'connecting', captureError: null })
|
||||
try {
|
||||
const { captureMode, selectedDeviceId } = get()
|
||||
const { captureMode, selectedDeviceId, capturePolicy } = get()
|
||||
audioCapture.setCaptureMode(captureMode)
|
||||
audioCapture.setBackendPolicy(capturePolicy)
|
||||
await get().refreshBackendSupport()
|
||||
|
||||
if (captureMode === 'system') {
|
||||
await audioCapture.start()
|
||||
await audioCapture.startSystemAudio()
|
||||
} else {
|
||||
await audioCapture.startDevice(selectedDeviceId ?? undefined)
|
||||
}
|
||||
|
||||
const status = audioCapture.getStatus()
|
||||
set({
|
||||
isCapturing: true,
|
||||
...applyCaptureStatus(status),
|
||||
captureStatus: 'capturing',
|
||||
captureError: null,
|
||||
sampleRate: audioCapture.getSampleRate(),
|
||||
captureMode: audioCapture.getCaptureMode(),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to start audio capture:', err)
|
||||
@@ -71,3 +124,10 @@ export const useAudioStore = create<AudioState>((set, get) => ({
|
||||
set({ isCapturing: false, captureStatus: 'idle', captureError: null })
|
||||
},
|
||||
}))
|
||||
|
||||
audioCapture.subscribeStatus((status) => {
|
||||
useAudioStore.setState((state) => ({
|
||||
...state,
|
||||
...applyCaptureStatus(status),
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -520,6 +520,12 @@ select {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.settings-info-text {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.settings-theme-swatches {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -65,6 +65,7 @@ export class VUMeter {
|
||||
private dataSource: VUMeterDataSource
|
||||
private animationId: number | null = null
|
||||
private isRunning = false
|
||||
private unsubscribeSessionChange: (() => void) | null = null
|
||||
|
||||
// Meter state
|
||||
private rmsL = METER_MIN_DB
|
||||
@@ -84,6 +85,9 @@ export class VUMeter {
|
||||
const { dataSource, ...optionOverrides } = options
|
||||
this.options = { ...defaultOptions, ...optionOverrides }
|
||||
this.dataSource = dataSource ?? defaultVUMeterDataSource
|
||||
this.unsubscribeSessionChange = audioRouter.subscribeToSessionChanges(() => {
|
||||
this.resetMeters()
|
||||
})
|
||||
}
|
||||
|
||||
private resetMeters(): void {
|
||||
@@ -606,5 +610,9 @@ export class VUMeter {
|
||||
|
||||
dispose(): void {
|
||||
this.stop()
|
||||
if (this.unsubscribeSessionChange) {
|
||||
this.unsubscribeSessionChange()
|
||||
this.unsubscribeSessionChange = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export type CaptureMode = 'system' | 'device'
|
||||
|
||||
export type CaptureBackendPolicy = 'auto' | 'native' | 'electron'
|
||||
|
||||
export type CaptureBackendKind =
|
||||
| 'electron-system'
|
||||
| 'electron-device'
|
||||
| 'native-macos'
|
||||
| 'native-windows'
|
||||
| 'native-linux'
|
||||
|
||||
export interface CaptureSourceDescriptor {
|
||||
id: string
|
||||
label: string
|
||||
kind: CaptureMode
|
||||
}
|
||||
|
||||
export interface CaptureBackendSupportEntry {
|
||||
kind: CaptureBackendKind
|
||||
available: boolean
|
||||
reason: string | null
|
||||
}
|
||||
|
||||
export interface CaptureBackendSupport {
|
||||
policyOptions: CaptureBackendPolicy[]
|
||||
nativeBackend: CaptureBackendSupportEntry
|
||||
electronSystem: CaptureBackendSupportEntry
|
||||
electronDevice: CaptureBackendSupportEntry
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { AudioRouter } from '../src/renderer/audio/AudioRouter'
|
||||
|
||||
function createChunk(value: number, length = 4): Float32Array {
|
||||
return new Float32Array(Array.from({ length }, () => value))
|
||||
}
|
||||
|
||||
test('routes chunks only to demanded scopes and prunes queues when demand is removed', () => {
|
||||
const router = new AudioRouter()
|
||||
const sessionId = router.beginSession(48000, 2, 'electron-system')
|
||||
|
||||
router.ingestChunk(createChunk(1), createChunk(1), {
|
||||
sessionId,
|
||||
channelCount: 2,
|
||||
sequence: 1,
|
||||
capturedAt: performance.now() - 5,
|
||||
})
|
||||
assert.equal(router.flushPendingSpectrumSamples().length, 0)
|
||||
assert.equal(router.getDiagnosticsSnapshot().undemandedChunks, 1)
|
||||
|
||||
router.setVisualizerConsumerDemand('test-consumer', { spectrum: true, waveform: true })
|
||||
router.ingestChunk(createChunk(2), createChunk(4), {
|
||||
sessionId,
|
||||
channelCount: 2,
|
||||
sequence: 2,
|
||||
capturedAt: performance.now() - 5,
|
||||
})
|
||||
|
||||
const spectrumChunks = router.flushPendingSpectrumSamples()
|
||||
const waveformChunks = router.flushPendingWaveformSamples()
|
||||
const oscilloscopeChunks = router.flushPendingOscilloscopeSamples()
|
||||
|
||||
assert.equal(spectrumChunks.length, 1)
|
||||
assert.equal(waveformChunks.length, 1)
|
||||
assert.equal(oscilloscopeChunks.length, 0)
|
||||
assert.deepEqual(Array.from(spectrumChunks[0]), [3, 3, 3, 3])
|
||||
assert.deepEqual(Array.from(waveformChunks[0]), [2, 2, 2, 2])
|
||||
|
||||
router.ingestChunk(createChunk(7), createChunk(9), {
|
||||
sessionId,
|
||||
channelCount: 2,
|
||||
sequence: 3,
|
||||
capturedAt: performance.now() - 5,
|
||||
})
|
||||
router.clearVisualizerConsumerDemand('test-consumer')
|
||||
|
||||
assert.equal(router.flushPendingSpectrumSamples().length, 0)
|
||||
assert.equal(router.flushPendingWaveformSamples().length, 0)
|
||||
})
|
||||
|
||||
test('keeps the newest chunks when a fixed-capacity ring overflows', () => {
|
||||
const router = new AudioRouter()
|
||||
const sessionId = router.beginSession(48000, 2, 'electron-system')
|
||||
router.setVisualizerConsumerDemand('test-consumer', { oscilloscope: true })
|
||||
|
||||
for (let sequence = 1; sequence <= 25; sequence += 1) {
|
||||
router.ingestChunk(createChunk(sequence), createChunk(sequence), {
|
||||
sessionId,
|
||||
channelCount: 2,
|
||||
sequence,
|
||||
capturedAt: performance.now() - 2,
|
||||
})
|
||||
}
|
||||
|
||||
const oscilloscopeChunks = router.flushPendingOscilloscopeSamples()
|
||||
assert.equal(oscilloscopeChunks.length, 20)
|
||||
assert.equal(oscilloscopeChunks[0]?.[0], 6)
|
||||
assert.equal(oscilloscopeChunks[19]?.[0], 25)
|
||||
|
||||
const diagnostics = router.getDiagnosticsSnapshot()
|
||||
assert.equal(diagnostics.scopes.oscilloscope.overwriteCount, 5)
|
||||
assert.ok((diagnostics.scopes.oscilloscope.p95CaptureToScopeMs ?? 0) >= 0)
|
||||
})
|
||||
|
||||
test('drops stale-session chunks before they reach scope queues', () => {
|
||||
const router = new AudioRouter()
|
||||
const sessionId = router.beginSession(48000, 1, 'electron-device')
|
||||
router.setVisualizerConsumerDemand('test-consumer', { vumeter: true })
|
||||
|
||||
router.ingestChunk(createChunk(1), createChunk(1), {
|
||||
sessionId: sessionId + 1,
|
||||
channelCount: 1,
|
||||
sequence: 1,
|
||||
capturedAt: performance.now() - 1,
|
||||
})
|
||||
|
||||
assert.equal(router.flushPendingVUMeterSamples().length, 0)
|
||||
assert.equal(router.getDiagnosticsSnapshot().staleSessionDrops, 1)
|
||||
})
|
||||
Reference in New Issue
Block a user