rolling recording buffer

This commit is contained in:
Boof2015
2026-08-16 14:38:12 -04:00
parent 90cde23f4d
commit f6d9719c43
22 changed files with 1125 additions and 4 deletions
+1
View File
@@ -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",
+44
View File
@@ -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)
+113
View File
@@ -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<AudioClipDragPayload>
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
}
}
+83
View File
@@ -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<BrowserWindowConstructorOptions, 'ic
return icon ? { icon } : {}
}
function getAudioClipDragIcon() {
const iconPath = getStaticAppIconPath()
if (iconPath) {
const icon = nativeImage.createFromPath(iconPath)
if (!icon.isEmpty()) return icon
}
const fallback = nativeImage.createFromDataURL(
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
)
if (fallback.isEmpty()) {
throw new Error('Prism could not create the audio clip drag icon.')
}
return fallback
}
function applyStaticDockIcon(): void {
if (process.platform !== 'darwin' || app.isPackaged) {
return
@@ -635,6 +664,28 @@ function createNativeTrayMenu(model: ReturnType<typeof buildTrayMenuModel>): 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<typeof buildTrayMenuModel>): 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())
})
+4
View File
@@ -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),
}
+10
View File
@@ -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<WindowBackgroundSnapshot>,
isCursorInsideWindow: () => ipcRenderer.invoke('window:is-cursor-inside') as Promise<boolean>,
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<void>,
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<NowPlayingState>,
setNowPlayingConsumerActive: (active: boolean) => ipcRenderer.invoke('now-playing:set-active', active) as Promise<NowPlayingState>,
saveNowPlayingProviderConfig: <K extends NowPlayingProviderId>(providerId: K, config: NowPlayingProviderConfigMutationMap[K]) => {
+103
View File
@@ -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<StatusListener>()
private rollingCaptureSeconds: RollingCaptureDurationSeconds | null = null
private rollingAudioBuffer: RollingAudioBuffer | null = null
private rollingCaptureStatusListeners = new Set<RollingCaptureStatusListener>()
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<CaptureBackendSupport> {
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<CaptureBackendSupport> {
if (this.backendSupport) {
return this.backendSupport
@@ -836,6 +895,11 @@ class AudioCapture {
}
private async stopActiveCapture(): Promise<void> {
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()
+153
View File
@@ -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,
)
}
}
+73
View File
@@ -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<void> => {
try {
await revealRollingCaptureFolder()
} catch (error) {
showBanner({
tone: 'error',
message: getErrorMessage(error, 'Could not open the Prism Captures folder.'),
actions: [],
})
}
}
const handleReloadThemes = async (): Promise<void> => {
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):
<div className="bottom-bar__divider" />
<section className="bottom-bar__section bottom-bar__section--rolling-capture">
<div className="bottom-bar__section-title">Rolling Capture</div>
<div className="bottom-bar__section-body">
<div className="bottom-bar__inline bottom-bar__inline--rolling-capture">
<div className="bottom-bar__inline bottom-bar__inline--chips">
<button
type="button"
className={`settings-chip ${rollingCaptureSeconds === null ? 'is-active' : ''}`.trim()}
onClick={() => setRollingCaptureSeconds(null)}
>
Off
</button>
{ROLLING_CAPTURE_DURATIONS.map((duration) => (
<button
key={duration}
type="button"
className={`settings-chip ${rollingCaptureSeconds === duration ? 'is-active' : ''}`.trim()}
onClick={() => setRollingCaptureSeconds(duration)}
>
{duration}s
</button>
))}
</div>
<div
className={`settings-status-pill ${rollingCaptureStatus.ready ? 'is-capturing' : ''}`.trim()}
title={rollingCaptureSeconds === null
? 'Rolling capture uses no recorder memory while off'
: `Keeps up to ${rollingCaptureSeconds} seconds in memory`}
>
<span className="settings-status-pill__dot" />
<span>{rollingCaptureStatusLabel}</span>
</div>
<button
type="button"
className="settings-chip"
onClick={() => {
void handleRevealRollingCaptureFolder()
}}
>
Folder
</button>
</div>
</div>
</section>
<div className="bottom-bar__divider" />
<section className="bottom-bar__section bottom-bar__section--performance">
<div className="bottom-bar__section-title">Performance</div>
<div className="bottom-bar__section-body">
+37 -1
View File
@@ -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<AppBuildInfo | null>(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<HTMLButtonElement>) => {
event.preventDefault()
startRollingClipDrag()
}, [startRollingClipDrag])
const handleDragStart = useCallback((event: ReactPointerEvent<HTMLButtonElement>): void => {
if (useNativeDragRegions || event.button !== 0) return
event.preventDefault()
@@ -536,6 +553,25 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
</button>
</div>
{rollingCaptureSeconds !== null ? (
<button
type="button"
className={`toolbar__clip-chip ${rollingCaptureStatus.ready ? 'is-ready' : 'is-filling'}`.trim()}
draggable={rollingCaptureStatus.hasAudio}
disabled={!rollingCaptureStatus.hasAudio}
onDragStart={handleAudioClipDragStart}
title={rollingCaptureStatus.ready
? `Drag the latest ${rollingCaptureSeconds} seconds as a WAV file`
: rollingCaptureStatus.hasAudio
? `Buffer filling; drag the audio captured so far (up to ${rollingCaptureSeconds} seconds)`
: 'Waiting for captured audio'}
aria-label={`Drag the latest ${rollingCaptureSeconds} seconds as a WAV file`}
>
<span className="toolbar__clip-dot" aria-hidden="true" />
<span className="toolbar__clip-prefix">Clip </span>{rollingCaptureSeconds}s
</button>
) : null}
<div className="toolbar__spacer" />
<div className="toolbar__actions">
@@ -47,6 +47,10 @@ async function handleTrayCommand(command: TrayRendererCommand): Promise<void> {
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,
+6
View File
@@ -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<WindowBackgroundSnapshot>
isCursorInsideWindow: () => Promise<boolean>
getCaptureBackendSupport: () => Promise<CaptureBackendSupport>
audioClips: {
startDrag: (payload: AudioClipDragPayload) => void
revealFolder: () => Promise<void>
onDragError: (callback: (message: string) => void) => () => void
}
getNowPlayingState: () => Promise<NowPlayingState>
setNowPlayingConsumerActive: (active: boolean) => Promise<NowPlayingState>
saveNowPlayingProviderConfig: <K extends NowPlayingProviderId>(providerId: K, config: NowPlayingProviderConfigMutationMap[K]) => Promise<NowPlayingState>
+88
View File
@@ -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<void>
clearCaptureNotice: () => void
refreshSystemSources: (options?: RefreshSourceOptions) => Promise<void>
refreshDevices: (options?: RefreshSourceOptions) => Promise<void>
@@ -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<PersistedAudioState>
@@ -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<AudioState>((set, get) => ({
systemSources: [],
@@ -270,6 +291,8 @@ export const useAudioStore = create<AudioState>((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<AudioState>((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<AudioState>((set, get) => ({
currentState.captureMode,
nextSelectedSystemSourceId,
currentState.selectedDeviceId,
currentState.rollingCaptureSeconds,
))
}
@@ -388,6 +457,7 @@ export const useAudioStore = create<AudioState>((set, get) => ({
currentState.captureMode,
currentState.selectedSystemSourceId,
nextSelectedDeviceId,
currentState.rollingCaptureSeconds,
))
}
@@ -445,6 +515,7 @@ export const useAudioStore = create<AudioState>((set, get) => ({
'system',
nextSelectedSystemSourceId,
currentState.selectedDeviceId,
currentState.rollingCaptureSeconds,
))
set({
selectedSystemSourceId: nextSelectedSystemSourceId,
@@ -463,6 +534,7 @@ export const useAudioStore = create<AudioState>((set, get) => ({
'device',
currentState.selectedSystemSourceId,
deviceId,
currentState.rollingCaptureSeconds,
))
set({
selectedDeviceId: deviceId,
@@ -480,6 +552,7 @@ export const useAudioStore = create<AudioState>((set, get) => ({
mode,
currentState.selectedSystemSourceId,
currentState.selectedDeviceId,
currentState.rollingCaptureSeconds,
))
set({ captureMode: mode })
},
@@ -506,6 +579,7 @@ export const useAudioStore = create<AudioState>((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: [],
})
})
}
+67
View File
@@ -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 {
+31
View File
@@ -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)
}
+5
View File
@@ -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: [],
}
+75
View File
@@ -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> = {}): 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()))
})
+1
View File
@@ -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))
+62
View File
@@ -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<PersistedAudioState> = {}): 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<number | null> = []
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()
+12 -3
View File
@@ -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', () => {
+22
View File
@@ -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({
+128
View File
@@ -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`)
})