From f6d9719c43f402f66d93e9895c591ca206bacc0b Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:38:12 -0400 Subject: [PATCH] rolling recording buffer --- package.json | 1 + scripts/run-audio-clip-tests.mjs | 44 +++++ src/main/audioClipLibrary.ts | 113 +++++++++++++ src/main/index.ts | 83 ++++++++++ src/main/services/trayMenu.ts | 4 + src/preload/index.ts | 10 ++ src/renderer/audio/AudioCapture.ts | 103 ++++++++++++ src/renderer/audio/RollingAudioBuffer.ts | 153 ++++++++++++++++++ src/renderer/components/BottomBar.tsx | 73 +++++++++ src/renderer/components/Toolbar.tsx | 38 ++++- src/renderer/components/TrayControlBridge.tsx | 7 + src/renderer/env.d.ts | 6 + src/renderer/stores/audioStore.ts | 88 ++++++++++ src/renderer/styles/globals.css | 67 ++++++++ src/types/audioClip.ts | 31 ++++ src/types/desktopIntegration.ts | 5 + test/audio-clip-library.test.ts | 75 +++++++++ test/audio-router.test.ts | 1 + test/audio-store.test.ts | 62 +++++++ test/desktop-integration.test.ts | 15 +- test/renderer-helpers.test.ts | 22 +++ test/rolling-audio-buffer.test.ts | 128 +++++++++++++++ 22 files changed, 1125 insertions(+), 4 deletions(-) create mode 100644 scripts/run-audio-clip-tests.mjs create mode 100644 src/main/audioClipLibrary.ts create mode 100644 src/renderer/audio/RollingAudioBuffer.ts create mode 100644 src/types/audioClip.ts create mode 100644 test/audio-clip-library.test.ts create mode 100644 test/rolling-audio-buffer.test.ts diff --git a/package.json b/package.json index db96efc..fb28733 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "typecheck": "tsc --noEmit", "build:icons": "node scripts/build/generate-prism-icons.mjs", "test:audio-router": "node scripts/run-audio-router-tests.mjs", + "test:audio-clips": "node scripts/run-audio-clip-tests.mjs", "test:audio-store": "node scripts/run-audio-store-tests.mjs", "test:astra": "node scripts/run-astra-integration-tests.mjs", "test:capture-support": "node scripts/run-capture-support-tests.mjs", diff --git a/scripts/run-audio-clip-tests.mjs b/scripts/run-audio-clip-tests.mjs new file mode 100644 index 0000000..3cd1c2b --- /dev/null +++ b/scripts/run-audio-clip-tests.mjs @@ -0,0 +1,44 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawn } from 'node:child_process' +import { build } from 'esbuild' + +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))) +const tempDir = await mkdtemp(join(tmpdir(), 'prism-audio-clip-tests-')) +const bundledTestPath = join(tempDir, 'audio-clip-library.test.mjs') +const entryPoint = join(rootDir, 'test', 'audio-clip-library.test.ts') + +let exitCode = 1 + +try { + await build({ + entryPoints: [entryPoint], + outfile: bundledTestPath, + bundle: true, + platform: 'node', + format: 'esm', + target: 'node23', + sourcemap: 'inline', + }) + + exitCode = await new Promise((resolve) => { + const child = spawn(process.execPath, ['--test', bundledTestPath], { + stdio: 'inherit', + cwd: rootDir, + }) + + child.on('exit', (code) => { + resolve(code ?? 1) + }) + + child.on('error', () => { + resolve(1) + }) + }) +} finally { + await rm(tempDir, { recursive: true, force: true }) +} + +process.exit(exitCode) diff --git a/src/main/audioClipLibrary.ts b/src/main/audioClipLibrary.ts new file mode 100644 index 0000000..a7f694a --- /dev/null +++ b/src/main/audioClipLibrary.ts @@ -0,0 +1,113 @@ +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import type { AudioClipDragPayload } from '../types/audioClip' + +const WAV_HEADER_BYTES = 44 +const PCM_BITS_PER_SAMPLE = 16 +const MAX_SAMPLE_RATE = 384000 +const MAX_CLIP_SECONDS = 60 + +export function validateAudioClipDragPayload(raw: unknown): AudioClipDragPayload { + if (typeof raw !== 'object' || raw === null) { + throw new Error('The audio clip payload is invalid.') + } + + const candidate = raw as Partial + if (!(candidate.pcmBytes instanceof Uint8Array)) { + throw new Error('The audio clip is missing PCM sample data.') + } + if ( + !Number.isSafeInteger(candidate.sampleRate) + || candidate.sampleRate! < 1 + || candidate.sampleRate! > MAX_SAMPLE_RATE + ) { + throw new Error('The audio clip sample rate is invalid.') + } + if (candidate.channelCount !== 1 && candidate.channelCount !== 2) { + throw new Error('The audio clip channel count is invalid.') + } + if ( + !Number.isSafeInteger(candidate.frameCount) + || candidate.frameCount! < 1 + || candidate.frameCount! > candidate.sampleRate! * MAX_CLIP_SECONDS + ) { + throw new Error('The audio clip duration is invalid.') + } + + const expectedBytes = candidate.frameCount! * candidate.channelCount * (PCM_BITS_PER_SAMPLE / 8) + if (candidate.pcmBytes.byteLength !== expectedBytes) { + throw new Error('The audio clip PCM data length is invalid.') + } + + return { + pcmBytes: candidate.pcmBytes, + sampleRate: candidate.sampleRate!, + channelCount: candidate.channelCount, + frameCount: candidate.frameCount!, + } +} + +export function encodePcm16Wav(payload: AudioClipDragPayload): Buffer { + const validated = validateAudioClipDragPayload(payload) + const dataBytes = validated.pcmBytes.byteLength + const blockAlign = validated.channelCount * (PCM_BITS_PER_SAMPLE / 8) + const wav = Buffer.allocUnsafe(WAV_HEADER_BYTES + dataBytes) + + wav.write('RIFF', 0, 'ascii') + wav.writeUInt32LE(36 + dataBytes, 4) + wav.write('WAVE', 8, 'ascii') + wav.write('fmt ', 12, 'ascii') + wav.writeUInt32LE(16, 16) + wav.writeUInt16LE(1, 20) + wav.writeUInt16LE(validated.channelCount, 22) + wav.writeUInt32LE(validated.sampleRate, 24) + wav.writeUInt32LE(validated.sampleRate * blockAlign, 28) + wav.writeUInt16LE(blockAlign, 32) + wav.writeUInt16LE(PCM_BITS_PER_SAMPLE, 34) + wav.write('data', 36, 'ascii') + wav.writeUInt32LE(dataBytes, 40) + wav.set(validated.pcmBytes, WAV_HEADER_BYTES) + + return wav +} + +export function buildAudioClipBaseName(date: Date): string { + const timestamp = date.toISOString() + .replace('T', ' ') + .replace(/:/g, '-') + .replace('Z', '') + return `Prism Clip ${timestamp}` +} + +export class AudioClipLibrary { + constructor( + private readonly directory: string, + private readonly now: () => Date = () => new Date(), + ) {} + + getDirectory(): string { + return this.directory + } + + ensureDirectory(): string { + mkdirSync(this.directory, { recursive: true }) + return this.directory + } + + writeClip(raw: unknown): string { + const payload = validateAudioClipDragPayload(raw) + const wav = encodePcm16Wav(payload) + this.ensureDirectory() + + const baseName = buildAudioClipBaseName(this.now()) + let suffix = 1 + let filePath = join(this.directory, `${baseName}.wav`) + while (existsSync(filePath)) { + suffix += 1 + filePath = join(this.directory, `${baseName} (${suffix}).wav`) + } + + writeFileSync(filePath, wav, { flag: 'wx' }) + return filePath + } +} diff --git a/src/main/index.ts b/src/main/index.ts index b658128..305b2c0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -4,6 +4,7 @@ import { execFileSync } from 'child_process' import { existsSync, readFileSync } from 'fs' import { extname, join, resolve } from 'path' import type { AppBuildInfo } from '../types/appBuildInfo' +import { ROLLING_CAPTURE_DURATIONS } from '../types/audioClip' import type { NowPlayingControlCommand, NowPlayingState } from '../types/nowPlaying' import type { ScopePopoutAudioBatch, @@ -35,6 +36,7 @@ import { } from '../shared/windowGeometry' import { calculateResizedWindowBounds } from '../shared/windowResize' import { FileBackedProfileLibrary } from './profileLibrary' +import { AudioClipLibrary } from './audioClipLibrary' import { loadNativeWindowsMediaApi } from './nativeWindowsMedia' import { loadNativeWindowChromeApi } from './nativeWindowChrome' import { NowPlayingManager } from './services/nowPlayingManager' @@ -118,6 +120,7 @@ let secretVault: SecretVault | null = null let nativeWindowsMediaApi: NativeWindowsMediaAPI | null | undefined let nativeWindowChromeApi: NativeWindowChromeAPI | null | undefined let loginItemService: LoginItemService | null = null +let audioClipLibrary: AudioClipLibrary | null = null let desktopIntegrationPreferences: DesktopIntegrationPreferences = { ...DEFAULT_DESKTOP_INTEGRATION_PREFERENCES, } @@ -344,6 +347,16 @@ function getWindowStateStore(): FileBackedWindowStateStore { return windowStateStore } +function getAudioClipLibrary(): AudioClipLibrary { + if (!audioClipLibrary) { + audioClipLibrary = new AudioClipLibrary( + join(app.getPath('documents'), 'Prism Captures'), + ) + } + + return audioClipLibrary +} + function getStaticAppIconPath(): string | undefined { const candidates = app.isPackaged ? [join(process.resourcesPath, STATIC_APP_ICON_FILENAME)] @@ -364,6 +377,22 @@ function getStaticWindowIconOptions(): Pick): Ele } const loginStatus = loginItemStatusLabel(model.desktopIntegration) + const rollingCaptureItems: MenuItemConstructorOptions[] = [ + { + label: 'Off', + type: 'radio', + checked: model.rendererState.rollingCaptureSeconds === null, + enabled: model.rendererReady, + click: () => sendTrayRendererCommand({ + type: 'set-rolling-capture', + durationSeconds: null, + }), + }, + ...ROLLING_CAPTURE_DURATIONS.map((duration): MenuItemConstructorOptions => ({ + label: `${duration}s`, + type: 'radio', + checked: model.rendererState.rollingCaptureSeconds === duration, + enabled: model.rendererReady, + click: () => sendTrayRendererCommand({ + type: 'set-rolling-capture', + durationSeconds: duration, + }), + })), + ] const windowItems: MenuItemConstructorOptions[] = [ { label: 'Always on Top', @@ -714,6 +765,7 @@ function createNativeTrayMenu(model: ReturnType): Ele { type: 'separator' }, { label: 'Profile', submenu: profileItems }, { label: 'Audio Source', submenu: audioSourceItems }, + { label: 'Rolling Capture', submenu: rollingCaptureItems }, { label: model.captureActionLabel, enabled: model.captureActionEnabled, @@ -2232,6 +2284,37 @@ function setupIPC(): void { return getAppBuildInfo() }) + ipcMain.on('audio-clips:start-drag', (event, rawPayload: unknown) => { + const targetWindow = getWindowFromSender(event.sender) + if (!targetWindow || !isMainRendererWindow(targetWindow)) return + + try { + const filePath = getAudioClipLibrary().writeClip(rawPayload) + event.sender.startDrag({ + file: filePath, + icon: getAudioClipDragIcon(), + }) + } catch (error) { + const detail = error instanceof Error && error.message + ? error.message + : 'Unknown audio clip error.' + event.sender.send('audio-clips:drag-error', `Could not create the audio clip: ${detail}`) + } + }) + + ipcMain.handle('audio-clips:reveal-folder', async (event) => { + const targetWindow = getWindowFromSender(event.sender) + if (!targetWindow || !isMainRendererWindow(targetWindow)) { + throw new Error('The Prism Captures folder is unavailable from this window.') + } + + const folderPath = getAudioClipLibrary().ensureDirectory() + const openResult = await shell.openPath(folderPath) + if (openResult) { + throw new Error(openResult) + } + }) + ipcMain.handle('updates:check', async () => { return checkForUpdates(app.getVersion()) }) diff --git a/src/main/services/trayMenu.ts b/src/main/services/trayMenu.ts index fdf5c08..37445a5 100644 --- a/src/main/services/trayMenu.ts +++ b/src/main/services/trayMenu.ts @@ -5,6 +5,7 @@ import { type TrayProfileOption, type TrayRendererState, } from '../../types/desktopIntegration' +import { isRollingCaptureDuration } from '../../types/audioClip' const MAX_TRAY_ITEMS = 64 const MAX_LABEL_LENGTH = 96 @@ -87,6 +88,9 @@ export function normalizeTrayRendererState(value: unknown): TrayRendererState { selectedDeviceId: typeof candidate.selectedDeviceId === 'string' ? candidate.selectedDeviceId : null, + rollingCaptureSeconds: isRollingCaptureDuration(candidate.rollingCaptureSeconds) + ? candidate.rollingCaptureSeconds + : null, systemSources: normalizeSources(candidate.systemSources), inputSources: normalizeSources(candidate.inputSources), } diff --git a/src/preload/index.ts b/src/preload/index.ts index 457da30..aea8c8f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,6 @@ import { contextBridge, ipcRenderer } from 'electron' import type { AppBuildInfo } from '../types/appBuildInfo' +import type { AudioClipDragPayload } from '../types/audioClip' import type { CaptureBackendSupport } from '../types/capture' import type { NativeCaptureAPI } from '../types/nativeCapture' import type { @@ -103,6 +104,15 @@ contextBridge.exposeInMainWorld('electronAPI', { setWindowBackground: (state: WindowBackgroundState) => ipcRenderer.invoke('window:set-background', state) as Promise, isCursorInsideWindow: () => ipcRenderer.invoke('window:is-cursor-inside') as Promise, getCaptureBackendSupport: async () => getCaptureBackendSupport(process.platform, nativeCaptureAPI) as CaptureBackendSupport, + audioClips: { + startDrag: (payload: AudioClipDragPayload) => ipcRenderer.send('audio-clips:start-drag', payload), + revealFolder: () => ipcRenderer.invoke('audio-clips:reveal-folder') as Promise, + onDragError: (callback: (message: string) => void) => { + const handler = (_event: Electron.IpcRendererEvent, message: string): void => callback(message) + ipcRenderer.on('audio-clips:drag-error', handler) + return () => ipcRenderer.removeListener('audio-clips:drag-error', handler) + }, + }, getNowPlayingState: () => ipcRenderer.invoke('now-playing:get-state') as Promise, setNowPlayingConsumerActive: (active: boolean) => ipcRenderer.invoke('now-playing:set-active', active) as Promise, saveNowPlayingProviderConfig: (providerId: K, config: NowPlayingProviderConfigMutationMap[K]) => { diff --git a/src/renderer/audio/AudioCapture.ts b/src/renderer/audio/AudioCapture.ts index 0c01f54..1ad5267 100644 --- a/src/renderer/audio/AudioCapture.ts +++ b/src/renderer/audio/AudioCapture.ts @@ -6,7 +6,13 @@ import { audioRouter } from './AudioRouter' import { nativeVisualizerTransport } from './NativeVisualizerTransport' +import { RollingAudioBuffer } from './RollingAudioBuffer' import { applyInputGainToStereoSamples, inputGainDbToLinear } from './inputGain' +import type { + RollingAudioSnapshot, + RollingCaptureDurationSeconds, + RollingCaptureStatus, +} from '../../types/audioClip' import type { CaptureBackendKind, CaptureBackendSupport, @@ -149,6 +155,7 @@ export interface CaptureManagerStatus { } type StatusListener = (status: CaptureManagerStatus) => void +type RollingCaptureStatusListener = (status: RollingCaptureStatus) => void const DEFAULT_SYSTEM_SOURCE_ID = '__default_system_output__' @@ -641,6 +648,9 @@ class AudioCapture { private inputGainDb = 0 private inputGainLinear = 1 private statusListeners = new Set() + private rollingCaptureSeconds: RollingCaptureDurationSeconds | null = null + private rollingAudioBuffer: RollingAudioBuffer | null = null + private rollingCaptureStatusListeners = new Set() constructor() { this.deviceInputBackend = new DeviceInputCaptureBackend(this.deviceInputRuntime) @@ -663,6 +673,14 @@ class AudioCapture { } } + subscribeRollingCaptureStatus(listener: RollingCaptureStatusListener): () => void { + this.rollingCaptureStatusListeners.add(listener) + listener(this.getRollingCaptureStatus()) + return () => { + this.rollingCaptureStatusListeners.delete(listener) + } + } + async refreshBackendSupport(): Promise { this.backendSupport = null this.backendSupportPromise = null @@ -711,6 +729,10 @@ class AudioCapture { backendStatus.channelCount, backendStatus.kind, ) + this.beginRollingCaptureSession( + backendStatus.sampleRate, + backendStatus.channelCount, + ) nativeVisualizerTransport.reset(audioRouter.getSessionState()) this.emitStatus() } @@ -786,6 +808,43 @@ class AudioCapture { } } + getRollingCaptureStatus(): RollingCaptureStatus { + const buffer = this.rollingAudioBuffer + return { + durationSeconds: this.rollingCaptureSeconds, + hasAudio: Boolean(buffer && buffer.frameCount > 0), + ready: Boolean(buffer?.isReady), + allocatedBytes: buffer?.allocatedBytes ?? 0, + } + } + + setRollingCaptureSeconds(durationSeconds: RollingCaptureDurationSeconds | null): void { + if (durationSeconds === this.rollingCaptureSeconds) return + + this.rollingCaptureSeconds = durationSeconds + if (durationSeconds === null) { + this.rollingAudioBuffer = null + this.emitRollingCaptureStatus() + return + } + + if (this.rollingAudioBuffer) { + this.rollingAudioBuffer.resize(durationSeconds) + } else if (this.sessionId !== null && this.activeBackend) { + const status = this.activeBackend.getStatus() + this.rollingAudioBuffer = new RollingAudioBuffer( + durationSeconds, + status.sampleRate, + status.channelCount, + ) + } + this.emitRollingCaptureStatus() + } + + takeRollingCaptureSnapshot(): RollingAudioSnapshot | null { + return this.rollingAudioBuffer?.snapshot() ?? null + } + private async ensureBackendSupport(): Promise { if (this.backendSupport) { return this.backendSupport @@ -836,6 +895,11 @@ class AudioCapture { } private async stopActiveCapture(): Promise { + if (this.rollingAudioBuffer) { + this.rollingAudioBuffer = null + this.emitRollingCaptureStatus() + } + if (this.sessionId !== null) { audioRouter.endSession() nativeVisualizerTransport.reset(audioRouter.getSessionState()) @@ -864,6 +928,31 @@ class AudioCapture { capturedAt: chunk.capturedAt, sequence: chunk.sequence, }) + + let rollingAudioBuffer = this.rollingAudioBuffer + if (rollingAudioBuffer) { + const normalizedChannelCount = chunk.channelCount > 1 ? 2 : 1 + if (rollingAudioBuffer.channelCount !== normalizedChannelCount) { + const sampleRate = this.activeBackend.getStatus().sampleRate + rollingAudioBuffer = new RollingAudioBuffer( + this.rollingCaptureSeconds!, + sampleRate, + normalizedChannelCount, + ) + this.rollingAudioBuffer = rollingAudioBuffer + this.emitRollingCaptureStatus() + } + + const hadAudio = rollingAudioBuffer.frameCount > 0 + const wasReady = rollingAudioBuffer.isReady + rollingAudioBuffer.append(chunk.left, chunk.right, chunk.channelCount) + if ( + hadAudio !== (rollingAudioBuffer.frameCount > 0) + || wasReady !== rollingAudioBuffer.isReady + ) { + this.emitRollingCaptureStatus() + } + } } setInputGain(db: number): void { @@ -878,6 +967,20 @@ class AudioCapture { listener(status) } } + + private beginRollingCaptureSession(sampleRate: number, channelCount: number): void { + this.rollingAudioBuffer = this.rollingCaptureSeconds === null + ? null + : new RollingAudioBuffer(this.rollingCaptureSeconds, sampleRate, channelCount) + this.emitRollingCaptureStatus() + } + + private emitRollingCaptureStatus(): void { + const status = this.getRollingCaptureStatus() + for (const listener of this.rollingCaptureStatusListeners) { + listener(status) + } + } } export const audioCapture = new AudioCapture() diff --git a/src/renderer/audio/RollingAudioBuffer.ts b/src/renderer/audio/RollingAudioBuffer.ts new file mode 100644 index 0000000..289b54f --- /dev/null +++ b/src/renderer/audio/RollingAudioBuffer.ts @@ -0,0 +1,153 @@ +import type { + RollingAudioSnapshot, + RollingCaptureDurationSeconds, +} from '../../types/audioClip' + +function floatToPcm16(sample: number): number { + if (!Number.isFinite(sample)) return 0 + const clamped = Math.max(-1, Math.min(1, sample)) + return clamped < 0 + ? Math.round(clamped * 32768) + : Math.round(clamped * 32767) +} + +function normalizeChannelCount(channelCount: number): 1 | 2 { + return channelCount > 1 ? 2 : 1 +} + +export class RollingAudioBuffer { + private samples: Int16Array + private capacityFrames: number + private writeFrameIndex = 0 + private bufferedFrames = 0 + + readonly sampleRate: number + readonly channelCount: 1 | 2 + private _durationSeconds: RollingCaptureDurationSeconds + + constructor( + durationSeconds: RollingCaptureDurationSeconds, + sampleRate: number, + channelCount: number, + ) { + this._durationSeconds = durationSeconds + this.sampleRate = Math.max(1, Math.floor(sampleRate) || 1) + this.channelCount = normalizeChannelCount(channelCount) + this.capacityFrames = Math.max(1, Math.floor(durationSeconds * this.sampleRate)) + this.samples = new Int16Array(this.capacityFrames * this.channelCount) + } + + get durationSeconds(): RollingCaptureDurationSeconds { + return this._durationSeconds + } + + get frameCount(): number { + return this.bufferedFrames + } + + get isReady(): boolean { + return this.bufferedFrames >= this.capacityFrames + } + + get allocatedBytes(): number { + return this.samples.byteLength + } + + append(left: Float32Array, right: Float32Array, channelCount: number): void { + const effectiveChannels = normalizeChannelCount(channelCount) + if (effectiveChannels !== this.channelCount) return + + const availableFrames = this.channelCount === 1 + ? left.length + : Math.min(left.length, right.length) + if (availableFrames <= 0) return + + const framesToWrite = Math.min(availableFrames, this.capacityFrames) + let sourceFrameIndex = availableFrames - framesToWrite + let remainingFrames = framesToWrite + + while (remainingFrames > 0) { + const contiguousFrames = Math.min( + remainingFrames, + this.capacityFrames - this.writeFrameIndex, + ) + + for (let offset = 0; offset < contiguousFrames; offset += 1) { + const sourceIndex = sourceFrameIndex + offset + const destinationIndex = (this.writeFrameIndex + offset) * this.channelCount + this.samples[destinationIndex] = floatToPcm16(left[sourceIndex] ?? 0) + if (this.channelCount === 2) { + this.samples[destinationIndex + 1] = floatToPcm16(right[sourceIndex] ?? 0) + } + } + + this.writeFrameIndex = (this.writeFrameIndex + contiguousFrames) % this.capacityFrames + this.bufferedFrames = Math.min( + this.capacityFrames, + this.bufferedFrames + contiguousFrames, + ) + sourceFrameIndex += contiguousFrames + remainingFrames -= contiguousFrames + } + } + + resize(durationSeconds: RollingCaptureDurationSeconds): void { + if (durationSeconds === this._durationSeconds) return + + const nextCapacityFrames = Math.max(1, Math.floor(durationSeconds * this.sampleRate)) + const framesToKeep = Math.min(this.bufferedFrames, nextCapacityFrames) + const nextSamples = new Int16Array(nextCapacityFrames * this.channelCount) + + if (framesToKeep > 0) { + const startFrame = ( + this.writeFrameIndex - framesToKeep + this.capacityFrames + ) % this.capacityFrames + this.copyFramesTo(nextSamples, startFrame, framesToKeep) + } + + this._durationSeconds = durationSeconds + this.capacityFrames = nextCapacityFrames + this.samples = nextSamples + this.bufferedFrames = framesToKeep + this.writeFrameIndex = framesToKeep % nextCapacityFrames + } + + snapshot(): RollingAudioSnapshot | null { + if (this.bufferedFrames <= 0) return null + + const pcmSamples = new Int16Array(this.bufferedFrames * this.channelCount) + const startFrame = ( + this.writeFrameIndex - this.bufferedFrames + this.capacityFrames + ) % this.capacityFrames + this.copyFramesTo(pcmSamples, startFrame, this.bufferedFrames) + + return { + pcmSamples, + sampleRate: this.sampleRate, + channelCount: this.channelCount, + frameCount: this.bufferedFrames, + } + } + + private copyFramesTo( + destination: Int16Array, + startFrame: number, + frameCount: number, + ): void { + const firstFrameCount = Math.min(frameCount, this.capacityFrames - startFrame) + const firstSampleStart = startFrame * this.channelCount + const firstSampleCount = firstFrameCount * this.channelCount + destination.set( + this.samples.subarray(firstSampleStart, firstSampleStart + firstSampleCount), + 0, + ) + + const remainingFrames = frameCount - firstFrameCount + if (remainingFrames <= 0) return + + destination.set( + this.samples.subarray(0, remainingFrames * this.channelCount), + firstSampleCount, + ) + } +} diff --git a/src/renderer/components/BottomBar.tsx b/src/renderer/components/BottomBar.tsx index 0969f17..a8ef112 100644 --- a/src/renderer/components/BottomBar.tsx +++ b/src/renderer/components/BottomBar.tsx @@ -11,6 +11,7 @@ import { getRendererWindowCapabilities } from '../windowCapabilities' import { getHorizontalWheelScrollResult } from '../utils/horizontalWheelScroll' import type { ScopeKind } from '../../types/scope' import { VISUALIZER_FRAME_TARGETS, type VisualizerFrameTarget } from '../../types/performance' +import { ROLLING_CAPTURE_DURATIONS } from '../../types/audioClip' import { SCOPE_KINDS } from '../../types/scope' import type { WindowBackgroundMode, WindowBackgroundState } from '../../types/windowState' import ThemedSelect from './ThemedSelect' @@ -178,11 +179,15 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): captureError, captureNotice, inputGainDb, + rollingCaptureSeconds, + rollingCaptureStatus, clearCaptureNotice, selectSystemSource, selectDevice, startCapture, setInputGain, + setRollingCaptureSeconds, + revealRollingCaptureFolder, } = useAudioStore() const showBanner = useUiStore((s) => s.showBanner) const desktopIntegration = useDesktopIntegrationStore((s) => s.snapshot) @@ -310,6 +315,18 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): } } + const handleRevealRollingCaptureFolder = async (): Promise => { + try { + await revealRollingCaptureFolder() + } catch (error) { + showBanner({ + tone: 'error', + message: getErrorMessage(error, 'Could not open the Prism Captures folder.'), + actions: [], + }) + } + } + const handleReloadThemes = async (): Promise => { if (isRefreshingThemes) { return @@ -387,6 +404,13 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): .map((providerId) => nowPlayingState.definitions[providerId].label) .join(' → ')}` const captureMessage = captureError ?? captureNotice + const rollingCaptureStatusLabel = rollingCaptureSeconds === null + ? 'Off' + : rollingCaptureStatus.ready + ? 'Ready' + : rollingCaptureStatus.hasAudio + ? 'Filling' + : 'Waiting' const nowPlayingErrorMessage = currentNowPlayingProvider?.lastError ?? currentNowPlayingProvider?.lastControlError ?? null const nowPlayingDetail = nowPlayingErrorMessage ? `${currentNowPlayingProviderId ? `${nowPlayingState.definitions[currentNowPlayingProviderId].label} · ` : ''}${nowPlayingErrorMessage}` @@ -724,6 +748,55 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
+
+
Rolling Capture
+
+
+
+ + {ROLLING_CAPTURE_DURATIONS.map((duration) => ( + + ))} +
+ +
+ + {rollingCaptureStatusLabel} +
+ + +
+
+
+ +
+
Performance
diff --git a/src/renderer/components/Toolbar.tsx b/src/renderer/components/Toolbar.tsx index 0457bd7..325def8 100644 --- a/src/renderer/components/Toolbar.tsx +++ b/src/renderer/components/Toolbar.tsx @@ -1,8 +1,17 @@ -import { useState, useEffect, useCallback, useRef, type JSX, type PointerEvent as ReactPointerEvent } from 'react' +import { + useState, + useEffect, + useCallback, + useRef, + type DragEvent as ReactDragEvent, + type JSX, + type PointerEvent as ReactPointerEvent, +} from 'react' import type { AppBuildInfo } from '../../types/appBuildInfo' import { useSettingsStore } from '../stores/settingsStore' import { useUpdateStore } from '../stores/updateStore' import { useUiStore } from '../stores/uiStore' +import { useAudioStore } from '../stores/audioStore' import { getRendererWindowCapabilities } from '../windowCapabilities' import PrismLogo from './PrismLogo' @@ -171,6 +180,9 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps): const latestTag = useUpdateStore((s) => s.latestTag) const releaseName = useUpdateStore((s) => s.releaseName) const openReleasesPage = useUpdateStore((s) => s.openReleasesPage) + const rollingCaptureSeconds = useAudioStore((s) => s.rollingCaptureSeconds) + const rollingCaptureStatus = useAudioStore((s) => s.rollingCaptureStatus) + const startRollingClipDrag = useAudioStore((s) => s.startRollingClipDrag) const [appBuildInfo, setAppBuildInfo] = useState(null) const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(false) const [showReposition, setShowReposition] = useState(false) @@ -440,6 +452,11 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps): }) }, [activeProfileId, profiles]) + const handleAudioClipDragStart = useCallback((event: ReactDragEvent) => { + event.preventDefault() + startRollingClipDrag() + }, [startRollingClipDrag]) + const handleDragStart = useCallback((event: ReactPointerEvent): void => { if (useNativeDragRegions || event.button !== 0) return event.preventDefault() @@ -536,6 +553,25 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
+ {rollingCaptureSeconds !== null ? ( + + ) : null} +
diff --git a/src/renderer/components/TrayControlBridge.tsx b/src/renderer/components/TrayControlBridge.tsx index 20d4540..e0f596b 100644 --- a/src/renderer/components/TrayControlBridge.tsx +++ b/src/renderer/components/TrayControlBridge.tsx @@ -47,6 +47,10 @@ async function handleTrayCommand(command: TrayRendererCommand): Promise { await useAudioStore.getState().startCapture() return } + if (command.type === 'set-rolling-capture') { + audio.setRollingCaptureSeconds(command.durationSeconds) + return + } if (command.type === 'set-capture-running') { if (command.running) { await audio.startCapture() @@ -65,6 +69,7 @@ export default function TrayControlBridge({ ready }: TrayControlBridgeProps): JS const captureMode = useAudioStore((state) => state.captureMode) const selectedSystemSourceId = useAudioStore((state) => state.selectedSystemSourceId) const selectedDeviceId = useAudioStore((state) => state.selectedDeviceId) + const rollingCaptureSeconds = useAudioStore((state) => state.rollingCaptureSeconds) const systemSources = useAudioStore((state) => state.systemSources) const devices = useAudioStore((state) => state.devices) @@ -103,6 +108,7 @@ export default function TrayControlBridge({ ready }: TrayControlBridgeProps): JS captureMode, selectedSystemSourceId, selectedDeviceId, + rollingCaptureSeconds, systemSources: visibleSystemSources.map((source) => ({ id: source.id, label: source.label, @@ -127,6 +133,7 @@ export default function TrayControlBridge({ ready }: TrayControlBridgeProps): JS devices, hasUnsavedProfileChanges, profiles, + rollingCaptureSeconds, selectedDeviceId, selectedSystemSourceId, systemSources, diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index f0abf30..db54f7e 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -2,6 +2,7 @@ import type { VisualizerDSP } from './audio/native/visualizer-dsp' import type { AppBuildInfo } from '../types/appBuildInfo' +import type { AudioClipDragPayload } from '../types/audioClip' import type { CaptureBackendSupport } from '../types/capture' import type { NativeCaptureAPI } from '../types/nativeCapture' import type { @@ -79,6 +80,11 @@ declare global { setWindowBackground: (state: WindowBackgroundState) => Promise isCursorInsideWindow: () => Promise getCaptureBackendSupport: () => Promise + audioClips: { + startDrag: (payload: AudioClipDragPayload) => void + revealFolder: () => Promise + onDragError: (callback: (message: string) => void) => () => void + } getNowPlayingState: () => Promise setNowPlayingConsumerActive: (active: boolean) => Promise saveNowPlayingProviderConfig: (providerId: K, config: NowPlayingProviderConfigMutationMap[K]) => Promise diff --git a/src/renderer/stores/audioStore.ts b/src/renderer/stores/audioStore.ts index 9162a9e..a8ff56e 100644 --- a/src/renderer/stores/audioStore.ts +++ b/src/renderer/stores/audioStore.ts @@ -1,5 +1,10 @@ import { create } from 'zustand' import { audioCapture, type CaptureManagerStatus } from '../audio/AudioCapture' +import { + isRollingCaptureDuration, + type RollingCaptureDurationSeconds, + type RollingCaptureStatus, +} from '../../types/audioClip' import type { CaptureBackendKind, CaptureBackendSupport, @@ -20,6 +25,7 @@ export interface PersistedAudioState { captureMode: CaptureMode selectedSystemSourceId: string selectedDeviceId: string | null + rollingCaptureSeconds: RollingCaptureDurationSeconds | null } interface RefreshSourceOptions { @@ -48,7 +54,12 @@ interface AudioState { activeSourceId: string | null activeSourceLabel: string | null inputGainDb: number + rollingCaptureSeconds: RollingCaptureDurationSeconds | null + rollingCaptureStatus: RollingCaptureStatus setInputGain: (db: number) => void + setRollingCaptureSeconds: (duration: RollingCaptureDurationSeconds | null) => void + startRollingClipDrag: () => boolean + revealRollingCaptureFolder: () => Promise clearCaptureNotice: () => void refreshSystemSources: (options?: RefreshSourceOptions) => Promise refreshDevices: (options?: RefreshSourceOptions) => Promise @@ -193,6 +204,12 @@ function normalizeDeviceId(raw: unknown): string | null { : null } +export function normalizeRollingCaptureSeconds( + raw: unknown, +): RollingCaptureDurationSeconds | null { + return isRollingCaptureDuration(raw) ? raw : null +} + export function normalizeAudioPreferences(raw: unknown): PersistedAudioState { const parsed = typeof raw === 'object' && raw !== null ? raw as Partial @@ -203,6 +220,7 @@ export function normalizeAudioPreferences(raw: unknown): PersistedAudioState { captureMode: normalizeCaptureMode(parsed.captureMode), selectedSystemSourceId: normalizeSystemSourceId(parsed.selectedSystemSourceId), selectedDeviceId: normalizeDeviceId(parsed.selectedDeviceId), + rollingCaptureSeconds: normalizeRollingCaptureSeconds(parsed.rollingCaptureSeconds), } } @@ -211,12 +229,14 @@ function buildAudioPreferences( captureMode: CaptureMode, selectedSystemSourceId: string | null, selectedDeviceId: string | null, + rollingCaptureSeconds: RollingCaptureDurationSeconds | null, ): PersistedAudioState { return normalizeAudioPreferences({ inputGainDb, captureMode, selectedSystemSourceId, selectedDeviceId, + rollingCaptureSeconds, }) } @@ -252,6 +272,7 @@ audioCapture.setInputGain(storedPreferences.inputGainDb) audioCapture.setSelectedSystemSourceId(storedPreferences.selectedSystemSourceId) audioCapture.setSelectedDeviceId(storedPreferences.selectedDeviceId) audioCapture.setCaptureMode(storedPreferences.captureMode) +audioCapture.setRollingCaptureSeconds(storedPreferences.rollingCaptureSeconds) export const useAudioStore = create((set, get) => ({ systemSources: [], @@ -270,6 +291,8 @@ export const useAudioStore = create((set, get) => ({ activeSourceId: null, activeSourceLabel: null, inputGainDb: storedPreferences.inputGainDb, + rollingCaptureSeconds: storedPreferences.rollingCaptureSeconds, + rollingCaptureStatus: audioCapture.getRollingCaptureStatus(), setInputGain: (db: number) => { const nextInputGainDb = normalizeInputGainDb(db) @@ -283,11 +306,56 @@ export const useAudioStore = create((set, get) => ({ currentState.captureMode, currentState.selectedSystemSourceId, currentState.selectedDeviceId, + currentState.rollingCaptureSeconds, )) audioCapture.setInputGain(nextInputGainDb) set({ inputGainDb: nextInputGainDb }) }, + setRollingCaptureSeconds: (duration) => { + const nextDuration = normalizeRollingCaptureSeconds(duration) + const currentState = get() + if (currentState.rollingCaptureSeconds === nextDuration) return + + persistAudioPreferences(buildAudioPreferences( + currentState.inputGainDb, + currentState.captureMode, + currentState.selectedSystemSourceId, + currentState.selectedDeviceId, + nextDuration, + )) + audioCapture.setRollingCaptureSeconds(nextDuration) + set({ + rollingCaptureSeconds: nextDuration, + rollingCaptureStatus: audioCapture.getRollingCaptureStatus(), + }) + }, + + startRollingClipDrag: () => { + if (typeof window === 'undefined' || !window.electronAPI?.audioClips) return false + const snapshot = audioCapture.takeRollingCaptureSnapshot() + if (!snapshot) return false + + window.electronAPI.audioClips.startDrag({ + pcmBytes: new Uint8Array( + snapshot.pcmSamples.buffer, + snapshot.pcmSamples.byteOffset, + snapshot.pcmSamples.byteLength, + ), + sampleRate: snapshot.sampleRate, + channelCount: snapshot.channelCount, + frameCount: snapshot.frameCount, + }) + return true + }, + + revealRollingCaptureFolder: async () => { + if (typeof window === 'undefined' || !window.electronAPI?.audioClips) { + throw new Error('The Prism Captures folder is unavailable.') + } + await window.electronAPI.audioClips.revealFolder() + }, + clearCaptureNotice: () => { set({ captureNotice: null }) }, @@ -320,6 +388,7 @@ export const useAudioStore = create((set, get) => ({ currentState.captureMode, nextSelectedSystemSourceId, currentState.selectedDeviceId, + currentState.rollingCaptureSeconds, )) } @@ -388,6 +457,7 @@ export const useAudioStore = create((set, get) => ({ currentState.captureMode, currentState.selectedSystemSourceId, nextSelectedDeviceId, + currentState.rollingCaptureSeconds, )) } @@ -445,6 +515,7 @@ export const useAudioStore = create((set, get) => ({ 'system', nextSelectedSystemSourceId, currentState.selectedDeviceId, + currentState.rollingCaptureSeconds, )) set({ selectedSystemSourceId: nextSelectedSystemSourceId, @@ -463,6 +534,7 @@ export const useAudioStore = create((set, get) => ({ 'device', currentState.selectedSystemSourceId, deviceId, + currentState.rollingCaptureSeconds, )) set({ selectedDeviceId: deviceId, @@ -480,6 +552,7 @@ export const useAudioStore = create((set, get) => ({ mode, currentState.selectedSystemSourceId, currentState.selectedDeviceId, + currentState.rollingCaptureSeconds, )) set({ captureMode: mode }) }, @@ -506,6 +579,7 @@ export const useAudioStore = create((set, get) => ({ 'device', currentState.selectedSystemSourceId, null, + currentState.rollingCaptureSeconds, )) set({ selectedDeviceId: null, @@ -676,3 +750,17 @@ audioCapture.subscribeStatus((status) => { ...applyCaptureStatus(status), })) }) + +audioCapture.subscribeRollingCaptureStatus((rollingCaptureStatus) => { + useAudioStore.setState({ rollingCaptureStatus }) +}) + +if (typeof window !== 'undefined' && window.electronAPI?.audioClips) { + window.electronAPI.audioClips.onDragError((message) => { + useUiStore.getState().showBanner({ + tone: 'error', + message, + actions: [], + }) + }) +} diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index 2aed9fb..5613de8 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -687,6 +687,60 @@ button.toolbar__version:hover { box-shadow: inset 0 0 0 1px var(--glass-highlight); } +.toolbar__clip-chip { + min-height: 28px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid var(--control-border); + background: var(--control-bg); + box-shadow: inset 0 1px 0 var(--glass-highlight); + color: var(--text-secondary); + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + font-family: 'JetBrains Mono', monospace; + font-size: 9px; + letter-spacing: 0.1em; + text-transform: uppercase; + cursor: grab; + transition: color 120ms ease, border-color 120ms ease, background-color 120ms ease; + -webkit-app-region: no-drag; +} + +.toolbar__clip-chip:hover:not(:disabled), +.toolbar__clip-chip.is-ready { + color: var(--accent); + border-color: var(--control-border-active); + background: var(--control-bg-active); +} + +.toolbar__clip-chip:active:not(:disabled) { + cursor: grabbing; +} + +.toolbar__clip-chip:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.toolbar__clip-dot { + width: 6px; + height: 6px; + border-radius: 999px; + background: currentColor; + box-shadow: 0 0 7px currentColor; +} + +.toolbar__clip-chip.is-filling:not(:disabled) .toolbar__clip-dot { + animation: rollingCapturePulse 1.2s ease-in-out infinite; +} + +@keyframes rollingCapturePulse { + 0%, 100% { opacity: 0.4; } + 50% { opacity: 1; } +} + .toolbar__actions { display: flex; align-items: center; @@ -755,6 +809,10 @@ button.toolbar__version:hover { .toolbar__version { display: none; } + + .toolbar__clip-prefix { + display: none; + } } .scope-strip { @@ -1924,6 +1982,10 @@ button.toolbar__version:hover { min-width: 360px; } +.bottom-bar__section--rolling-capture { + min-width: 500px; +} + .bottom-bar__section--performance { min-width: 328px; } @@ -2355,6 +2417,7 @@ button.toolbar__version:hover { .toolbar__brand:focus-visible, .toolbar__profile-button:focus-visible, .toolbar__chip:focus-visible, +.toolbar__clip-chip:focus-visible, .toolbar__icon-button:focus-visible, .scope-strip__reorder-button:focus-visible, .scope-strip__popout-button:focus-visible, @@ -2371,6 +2434,10 @@ button.toolbar__version:hover { } @media (prefers-reduced-motion: reduce) { + .toolbar__clip-chip.is-filling:not(:disabled) .toolbar__clip-dot { + animation: none !important; + } + .toolbar__brand, .toolbar__brand-logo, .toolbar__brand-heart { diff --git a/src/types/audioClip.ts b/src/types/audioClip.ts new file mode 100644 index 0000000..b643da0 --- /dev/null +++ b/src/types/audioClip.ts @@ -0,0 +1,31 @@ +export const ROLLING_CAPTURE_DURATIONS = [5, 10, 30, 60] as const + +export type RollingCaptureDurationSeconds = typeof ROLLING_CAPTURE_DURATIONS[number] + +export interface RollingCaptureStatus { + durationSeconds: RollingCaptureDurationSeconds | null + hasAudio: boolean + ready: boolean + allocatedBytes: number +} + +export interface RollingAudioSnapshot { + pcmSamples: Int16Array + sampleRate: number + channelCount: 1 | 2 + frameCount: number +} + +export interface AudioClipDragPayload { + pcmBytes: Uint8Array + sampleRate: number + channelCount: 1 | 2 + frameCount: number +} + +export function isRollingCaptureDuration( + value: unknown, +): value is RollingCaptureDurationSeconds { + return typeof value === 'number' + && ROLLING_CAPTURE_DURATIONS.some((duration) => duration === value) +} diff --git a/src/types/desktopIntegration.ts b/src/types/desktopIntegration.ts index 3eb7927..21abdef 100644 --- a/src/types/desktopIntegration.ts +++ b/src/types/desktopIntegration.ts @@ -1,3 +1,5 @@ +import type { RollingCaptureDurationSeconds } from './audioClip' + export type LoginLaunchMode = 'show' | 'tray' export type LoginItemStatus = @@ -39,6 +41,7 @@ export interface TrayRendererState { captureMode: 'system' | 'device' selectedSystemSourceId: string | null selectedDeviceId: string | null + rollingCaptureSeconds: RollingCaptureDurationSeconds | null systemSources: TrayAudioSourceOption[] inputSources: TrayAudioSourceOption[] } @@ -47,6 +50,7 @@ export type TrayRendererCommand = | { type: 'load-profile'; profileId: string } | { type: 'select-system-source'; sourceId: string } | { type: 'select-input-source'; deviceId: string | null } + | { type: 'set-rolling-capture'; durationSeconds: RollingCaptureDurationSeconds | null } | { type: 'set-capture-running'; running: boolean } | { type: 'open-settings' } @@ -64,6 +68,7 @@ export const DEFAULT_TRAY_RENDERER_STATE: TrayRendererState = { captureMode: 'system', selectedSystemSourceId: null, selectedDeviceId: null, + rollingCaptureSeconds: null, systemSources: [], inputSources: [], } diff --git a/test/audio-clip-library.test.ts b/test/audio-clip-library.test.ts new file mode 100644 index 0000000..e462869 --- /dev/null +++ b/test/audio-clip-library.test.ts @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { + AudioClipLibrary, + buildAudioClipBaseName, + encodePcm16Wav, + validateAudioClipDragPayload, +} from '../src/main/audioClipLibrary' +import type { AudioClipDragPayload } from '../src/types/audioClip' + +function clipPayload(overrides: Partial = {}): AudioClipDragPayload { + return { + pcmBytes: new Uint8Array([0x00, 0x80, 0xff, 0x7f, 0x00, 0x00, 0x01, 0x00]), + sampleRate: 48000, + channelCount: 2, + frameCount: 2, + ...overrides, + } +} + +test('validates rolling clip metadata and exact PCM byte count', () => { + assert.deepEqual(validateAudioClipDragPayload(clipPayload()), clipPayload()) + assert.throws(() => validateAudioClipDragPayload(null), /payload is invalid/i) + assert.throws(() => validateAudioClipDragPayload(clipPayload({ sampleRate: 384001 })), /sample rate/i) + assert.throws(() => validateAudioClipDragPayload({ + ...clipPayload(), + channelCount: 3, + }), /channel count/i) + assert.throws(() => validateAudioClipDragPayload(clipPayload({ + frameCount: 48000 * 60 + 1, + })), /duration/i) + assert.throws(() => validateAudioClipDragPayload(clipPayload({ + pcmBytes: new Uint8Array(6), + })), /data length/i) +}) + +test('encodes a standard little-endian 16-bit PCM WAV', () => { + const payload = clipPayload() + const wav = encodePcm16Wav(payload) + + assert.equal(wav.toString('ascii', 0, 4), 'RIFF') + assert.equal(wav.readUInt32LE(4), 36 + payload.pcmBytes.byteLength) + assert.equal(wav.toString('ascii', 8, 12), 'WAVE') + assert.equal(wav.toString('ascii', 12, 16), 'fmt ') + assert.equal(wav.readUInt16LE(20), 1) + assert.equal(wav.readUInt16LE(22), 2) + assert.equal(wav.readUInt32LE(24), 48000) + assert.equal(wav.readUInt32LE(28), 192000) + assert.equal(wav.readUInt16LE(32), 4) + assert.equal(wav.readUInt16LE(34), 16) + assert.equal(wav.toString('ascii', 36, 40), 'data') + assert.equal(wav.readUInt32LE(40), payload.pcmBytes.byteLength) + assert.deepEqual(Array.from(wav.subarray(44)), Array.from(payload.pcmBytes)) +}) + +test('writes persistent clips with safe timestamped collision-resistant names', async (t) => { + const parent = await mkdtemp(join(tmpdir(), 'prism-audio-clips-')) + t.after(async () => rm(parent, { recursive: true, force: true })) + const directory = join(parent, 'Prism Captures') + const now = new Date('2026-08-16T17:42:03.123Z') + const library = new AudioClipLibrary(directory, () => now) + + assert.equal(buildAudioClipBaseName(now), 'Prism Clip 2026-08-16 17-42-03.123') + const firstPath = library.writeClip(clipPayload()) + const secondPath = library.writeClip(clipPayload()) + + assert.equal(firstPath, join(directory, 'Prism Clip 2026-08-16 17-42-03.123.wav')) + assert.equal(secondPath, join(directory, 'Prism Clip 2026-08-16 17-42-03.123 (2).wav')) + assert.deepEqual(await readFile(firstPath), encodePcm16Wav(clipPayload())) + assert.deepEqual(await readFile(secondPath), encodePcm16Wav(clipPayload())) +}) diff --git a/test/audio-router.test.ts b/test/audio-router.test.ts index c737ec6..4f179c4 100644 --- a/test/audio-router.test.ts +++ b/test/audio-router.test.ts @@ -1,6 +1,7 @@ import test from 'node:test' import assert from 'node:assert/strict' import { AudioRouter } from '../src/renderer/audio/AudioRouter' +import './rolling-audio-buffer.test' function createChunk(value: number, length = 4): Float32Array { return new Float32Array(Array.from({ length }, () => value)) diff --git a/test/audio-store.test.ts b/test/audio-store.test.ts index b5af98e..079ce8a 100644 --- a/test/audio-store.test.ts +++ b/test/audio-store.test.ts @@ -4,6 +4,7 @@ import { audioCapture } from '../src/renderer/audio/AudioCapture' import { loadAudioPreferences, normalizeAudioPreferences, + normalizeRollingCaptureSeconds, startAudioDeviceWatcher, useAudioStore, type PersistedAudioState, @@ -29,6 +30,7 @@ function audioPreferences(overrides: Partial = {}): Persist captureMode: 'system', selectedSystemSourceId: DEFAULT_SYSTEM_SOURCE_ID, selectedDeviceId: null, + rollingCaptureSeconds: null, ...overrides, } } @@ -279,6 +281,7 @@ function resetStores(): void { audioCapture.setSelectedDeviceId(null) audioCapture.setCaptureMode('system') audioCapture.setInputGain(0) + audioCapture.setRollingCaptureSeconds(null) useAudioStore.setState({ ...initialAudioState, systemSources: [], @@ -297,6 +300,8 @@ function resetStores(): void { activeSourceId: null, activeSourceLabel: null, inputGainDb: 0, + rollingCaptureSeconds: null, + rollingCaptureStatus: audioCapture.getRollingCaptureStatus(), }) useUiStore.setState({ @@ -477,14 +482,40 @@ test('normalizeAudioPreferences preserves valid persisted selector values', () = captureMode: 'device', selectedSystemSourceId: 'speaker', selectedDeviceId: 'mic-1', + rollingCaptureSeconds: 30, }), audioPreferences({ inputGainDb: -3, captureMode: 'device', selectedSystemSourceId: 'speaker', selectedDeviceId: 'mic-1', + rollingCaptureSeconds: 30, })) }) +test('normalizeRollingCaptureSeconds accepts only supported durations', () => { + assert.equal(normalizeRollingCaptureSeconds(5), 5) + assert.equal(normalizeRollingCaptureSeconds(60), 60) + assert.equal(normalizeRollingCaptureSeconds(15), null) + assert.equal(normalizeRollingCaptureSeconds('10'), null) +}) + +test('rolling capture opt-in does not allocate a buffer while capture is idle', () => { + resetStores() + try { + audioCapture.setRollingCaptureSeconds(60) + + const enabledStatus = audioCapture.getRollingCaptureStatus() + assert.equal(enabledStatus.durationSeconds, 60) + assert.equal(enabledStatus.allocatedBytes, 0) + assert.equal(enabledStatus.hasAudio, false) + + audioCapture.setRollingCaptureSeconds(null) + assert.equal(audioCapture.getRollingCaptureStatus().allocatedBytes, 0) + } finally { + resetStores() + } +}) + test('normalizeAudioPreferences clamps out-of-range trim values', () => { assert.deepEqual(normalizeAudioPreferences({ inputGainDb: -18 }), audioPreferences({ inputGainDb: -12 })) assert.deepEqual(normalizeAudioPreferences({ inputGainDb: 18 }), audioPreferences({ inputGainDb: 12 })) @@ -531,6 +562,37 @@ test('audio store persists normalized trim values and forwards them to audioCapt } }) +test('audio store persists rolling capture opt-in and forwards duration changes', () => { + resetStores() + const fakeStorage = installFakeLocalStorage() + const originalSetRollingCaptureSeconds = audioCapture.setRollingCaptureSeconds + const forwardedValues: Array = [] + + audioCapture.setRollingCaptureSeconds = (duration) => { + forwardedValues.push(duration) + } + + try { + useAudioStore.getState().setRollingCaptureSeconds(30) + + assert.equal(useAudioStore.getState().rollingCaptureSeconds, 30) + assert.deepEqual(forwardedValues, [30]) + assert.equal(fakeStorage.getItem('prism:audio'), storedAudioPreferences({ + rollingCaptureSeconds: 30, + })) + + useAudioStore.getState().setRollingCaptureSeconds(null) + + assert.equal(useAudioStore.getState().rollingCaptureSeconds, null) + assert.deepEqual(forwardedValues, [30, null]) + assert.equal(fakeStorage.getItem('prism:audio'), storedAudioPreferences()) + } finally { + audioCapture.setRollingCaptureSeconds = originalSetRollingCaptureSeconds + fakeStorage.restore() + resetStores() + } +}) + test('audio store persists custom output source selections', async () => { resetStores() const fakeStorage = installFakeLocalStorage() diff --git a/test/desktop-integration.test.ts b/test/desktop-integration.test.ts index 210a4b8..8743bb1 100644 --- a/test/desktop-integration.test.ts +++ b/test/desktop-integration.test.ts @@ -256,6 +256,7 @@ test('tray state validation and menu model expose capture, visibility, and check captureMode: 'system', selectedSystemSourceId: 'output-1', selectedDeviceId: null, + rollingCaptureSeconds: 30, systemSources: [{ id: 'output-1', label: 'Studio Output' }], inputSources: [{ id: '', label: 'Default Input' }], }) @@ -277,19 +278,27 @@ test('tray state validation and menu model expose capture, visibility, and check assert.equal(model.mainWindowActionLabel, 'Show Prism') assert.equal(model.captureActionLabel, 'Stop Capture') assert.equal(model.rendererState.hasUnsavedProfileChanges, true) + assert.equal(model.rendererState.rollingCaptureSeconds, 30) - assert.equal(normalizeTrayRendererState({ profiles: 'invalid', captureStatus: 'bad' }).captureStatus, 'idle') + const invalidState = normalizeTrayRendererState({ + profiles: 'invalid', + captureStatus: 'bad', + rollingCaptureSeconds: 15, + }) + assert.equal(invalidState.captureStatus, 'idle') + assert.equal(invalidState.rollingCaptureSeconds, null) }) test('tray renderer commands remain queued until the renderer is ready', () => { const queue = new TrayRendererCommandQueue() const received: string[] = [] queue.enqueue({ type: 'open-settings' }) + queue.enqueue({ type: 'set-rolling-capture', durationSeconds: 10 }) queue.enqueue({ type: 'set-capture-running', running: false }) queue.flush((command) => received.push(command.type)) - assert.deepEqual(received, ['open-settings', 'set-capture-running']) + assert.deepEqual(received, ['open-settings', 'set-rolling-capture', 'set-capture-running']) queue.flush((command) => received.push(command.type)) - assert.deepEqual(received, ['open-settings', 'set-capture-running']) + assert.deepEqual(received, ['open-settings', 'set-rolling-capture', 'set-capture-running']) }) test('tray assets resolve for development and packaged builds', () => { diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index 710a2a6..f773d25 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -3692,6 +3692,28 @@ test('toolbar uses the Prism logo support link and static package icons are conf })) }) +test('rolling capture exposes persisted duration controls and a native toolbar drag target', async () => { + const bottomBarSource = await readFile(join(process.cwd(), 'src', 'renderer', 'components', 'BottomBar.tsx'), 'utf8') + const toolbarSource = await readFile(join(process.cwd(), 'src', 'renderer', 'components', 'Toolbar.tsx'), 'utf8') + const preloadSource = await readFile(join(process.cwd(), 'src', 'preload', 'index.ts'), 'utf8') + const mainSource = await readFile(join(process.cwd(), 'src', 'main', 'index.ts'), 'utf8') + const trayBridgeSource = await readFile(join(process.cwd(), 'src', 'renderer', 'components', 'TrayControlBridge.tsx'), 'utf8') + + assert.match(bottomBarSource, /ROLLING_CAPTURE_DURATIONS\.map/) + assert.match(bottomBarSource, /setRollingCaptureSeconds\(null\)/) + assert.match(bottomBarSource, /revealRollingCaptureFolder/) + assert.match(toolbarSource, /className=\{`toolbar__clip-chip/) + assert.match(toolbarSource, /draggable=\{rollingCaptureStatus\.hasAudio\}/) + assert.match(toolbarSource, /onDragStart=\{handleAudioClipDragStart\}/) + assert.match(preloadSource, /ipcRenderer\.send\('audio-clips:start-drag', payload\)/) + assert.match(mainSource, /event\.sender\.startDrag/) + assert.match(mainSource, /Prism Captures/) + assert.match(mainSource, /label: 'Rolling Capture'/) + assert.match(mainSource, /type: 'set-rolling-capture'/) + assert.match(trayBridgeSource, /audio\.setRollingCaptureSeconds\(command\.durationSeconds\)/) + assert.match(trayBridgeSource, /rollingCaptureSeconds,/) +}) + test('resolveWindowCapabilities detects native Wayland sessions on Linux', () => { assert.deepEqual( resolveWindowCapabilities({ diff --git a/test/rolling-audio-buffer.test.ts b/test/rolling-audio-buffer.test.ts new file mode 100644 index 0000000..5ad69c8 --- /dev/null +++ b/test/rolling-audio-buffer.test.ts @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { RollingAudioBuffer } from '../src/renderer/audio/RollingAudioBuffer' + +function pcm16(sample: number): number { + if (!Number.isFinite(sample)) return 0 + const clamped = Math.max(-1, Math.min(1, sample)) + return clamped < 0 + ? Math.round(clamped * 32768) + : Math.round(clamped * 32767) +} + +test('rolling audio buffer allocates exact fixed PCM capacity and quantizes stereo', () => { + const buffer = new RollingAudioBuffer(5, 2, 2) + assert.equal(buffer.allocatedBytes, 5 * 2 * 2 * 2) + assert.equal(buffer.frameCount, 0) + assert.equal(buffer.snapshot(), null) + + buffer.append( + new Float32Array([-2, -0.5, 0, 0.5, 2, Number.NaN]), + new Float32Array([1, 0.25, 0, -0.25, -1, Number.POSITIVE_INFINITY]), + 2, + ) + + const snapshot = buffer.snapshot() + assert.ok(snapshot) + assert.equal(snapshot.channelCount, 2) + assert.equal(snapshot.sampleRate, 2) + assert.equal(snapshot.frameCount, 6) + assert.deepEqual(Array.from(snapshot.pcmSamples), [ + -32768, 32767, + -16384, 8192, + 0, 0, + 16384, -8192, + 32767, -32768, + 0, 0, + ]) +}) + +test('rolling audio buffer keeps chronological newest frames across wrapping', () => { + const buffer = new RollingAudioBuffer(5, 2, 2) + const left = new Float32Array(Array.from({ length: 12 }, (_, index) => index / 20)) + const right = new Float32Array(Array.from({ length: 12 }, (_, index) => -index / 20)) + + buffer.append(left.subarray(0, 6), right.subarray(0, 6), 2) + buffer.append(left.subarray(6), right.subarray(6), 2) + + const snapshot = buffer.snapshot() + assert.ok(snapshot) + assert.equal(snapshot.frameCount, 10) + assert.equal(buffer.isReady, true) + + const expected: number[] = [] + for (let index = 2; index < 12; index += 1) { + expected.push(pcm16(left[index]), pcm16(right[index])) + } + assert.deepEqual(Array.from(snapshot.pcmSamples), expected) +}) + +test('rolling audio buffer supports mono and ignores mismatched channel chunks', () => { + const buffer = new RollingAudioBuffer(5, 2, 1) + buffer.append(new Float32Array([0.25, -0.25]), new Float32Array([1, 1]), 2) + assert.equal(buffer.frameCount, 0) + + buffer.append(new Float32Array([0.25, -0.25]), new Float32Array(), 1) + const snapshot = buffer.snapshot() + assert.ok(snapshot) + assert.equal(snapshot.channelCount, 1) + assert.deepEqual(Array.from(snapshot.pcmSamples), [8192, -8192]) +}) + +test('rolling audio buffer preserves newest audio while growing and shrinking', () => { + const buffer = new RollingAudioBuffer(5, 2, 1) + const initial = new Float32Array(Array.from({ length: 10 }, (_, index) => index / 20)) + buffer.append(initial, new Float32Array(), 1) + + buffer.resize(10) + assert.equal(buffer.allocatedBytes, 10 * 2 * 1 * 2) + assert.equal(buffer.frameCount, 10) + assert.equal(buffer.isReady, false) + + const appended = new Float32Array(Array.from({ length: 12 }, (_, index) => (index + 10) / 40)) + buffer.append(appended, new Float32Array(), 1) + buffer.resize(5) + + const snapshot = buffer.snapshot() + assert.ok(snapshot) + assert.equal(snapshot.frameCount, 10) + assert.equal(buffer.isReady, true) + assert.deepEqual( + Array.from(snapshot.pcmSamples), + Array.from(appended.subarray(2), pcm16), + ) +}) + +test('rolling audio buffer keeps only the tail of chunks larger than capacity', () => { + const buffer = new RollingAudioBuffer(5, 2, 1) + const input = new Float32Array(Array.from({ length: 14 }, (_, index) => index / 20)) + buffer.append(input, new Float32Array(), 1) + + const snapshot = buffer.snapshot() + assert.ok(snapshot) + assert.deepEqual( + Array.from(snapshot.pcmSamples), + Array.from(input.subarray(4), pcm16), + ) +}) + +test('rolling audio buffer append stays below the one millisecond chunk budget', () => { + const buffer = new RollingAudioBuffer(60, 48000, 2) + const left = new Float32Array(128).fill(0.25) + const right = new Float32Array(128).fill(-0.25) + + for (let index = 0; index < 100; index += 1) { + buffer.append(left, right, 2) + } + + const durations: number[] = [] + for (let index = 0; index < 1000; index += 1) { + const startedAt = performance.now() + buffer.append(left, right, 2) + durations.push(performance.now() - startedAt) + } + durations.sort((leftDuration, rightDuration) => leftDuration - rightDuration) + + const p95 = durations[Math.ceil(durations.length * 0.95) - 1] ?? Number.POSITIVE_INFINITY + assert.ok(p95 < 1, `expected rolling buffer p95 under 1ms, received ${p95.toFixed(3)}ms`) +})