mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-18 11:34:19 +02:00
rolling recording buffer
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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]) => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+6
@@ -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>
|
||||
|
||||
@@ -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: [],
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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: [],
|
||||
}
|
||||
|
||||
@@ -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,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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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`)
|
||||
})
|
||||
Reference in New Issue
Block a user