mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-19 12:04:18 +02:00
rolling recording buffer
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"build:icons": "node scripts/build/generate-prism-icons.mjs",
|
"build:icons": "node scripts/build/generate-prism-icons.mjs",
|
||||||
"test:audio-router": "node scripts/run-audio-router-tests.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:audio-store": "node scripts/run-audio-store-tests.mjs",
|
||||||
"test:astra": "node scripts/run-astra-integration-tests.mjs",
|
"test:astra": "node scripts/run-astra-integration-tests.mjs",
|
||||||
"test:capture-support": "node scripts/run-capture-support-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 { existsSync, readFileSync } from 'fs'
|
||||||
import { extname, join, resolve } from 'path'
|
import { extname, join, resolve } from 'path'
|
||||||
import type { AppBuildInfo } from '../types/appBuildInfo'
|
import type { AppBuildInfo } from '../types/appBuildInfo'
|
||||||
|
import { ROLLING_CAPTURE_DURATIONS } from '../types/audioClip'
|
||||||
import type { NowPlayingControlCommand, NowPlayingState } from '../types/nowPlaying'
|
import type { NowPlayingControlCommand, NowPlayingState } from '../types/nowPlaying'
|
||||||
import type {
|
import type {
|
||||||
ScopePopoutAudioBatch,
|
ScopePopoutAudioBatch,
|
||||||
@@ -35,6 +36,7 @@ import {
|
|||||||
} from '../shared/windowGeometry'
|
} from '../shared/windowGeometry'
|
||||||
import { calculateResizedWindowBounds } from '../shared/windowResize'
|
import { calculateResizedWindowBounds } from '../shared/windowResize'
|
||||||
import { FileBackedProfileLibrary } from './profileLibrary'
|
import { FileBackedProfileLibrary } from './profileLibrary'
|
||||||
|
import { AudioClipLibrary } from './audioClipLibrary'
|
||||||
import { loadNativeWindowsMediaApi } from './nativeWindowsMedia'
|
import { loadNativeWindowsMediaApi } from './nativeWindowsMedia'
|
||||||
import { loadNativeWindowChromeApi } from './nativeWindowChrome'
|
import { loadNativeWindowChromeApi } from './nativeWindowChrome'
|
||||||
import { NowPlayingManager } from './services/nowPlayingManager'
|
import { NowPlayingManager } from './services/nowPlayingManager'
|
||||||
@@ -118,6 +120,7 @@ let secretVault: SecretVault | null = null
|
|||||||
let nativeWindowsMediaApi: NativeWindowsMediaAPI | null | undefined
|
let nativeWindowsMediaApi: NativeWindowsMediaAPI | null | undefined
|
||||||
let nativeWindowChromeApi: NativeWindowChromeAPI | null | undefined
|
let nativeWindowChromeApi: NativeWindowChromeAPI | null | undefined
|
||||||
let loginItemService: LoginItemService | null = null
|
let loginItemService: LoginItemService | null = null
|
||||||
|
let audioClipLibrary: AudioClipLibrary | null = null
|
||||||
let desktopIntegrationPreferences: DesktopIntegrationPreferences = {
|
let desktopIntegrationPreferences: DesktopIntegrationPreferences = {
|
||||||
...DEFAULT_DESKTOP_INTEGRATION_PREFERENCES,
|
...DEFAULT_DESKTOP_INTEGRATION_PREFERENCES,
|
||||||
}
|
}
|
||||||
@@ -344,6 +347,16 @@ function getWindowStateStore(): FileBackedWindowStateStore {
|
|||||||
return windowStateStore
|
return windowStateStore
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAudioClipLibrary(): AudioClipLibrary {
|
||||||
|
if (!audioClipLibrary) {
|
||||||
|
audioClipLibrary = new AudioClipLibrary(
|
||||||
|
join(app.getPath('documents'), 'Prism Captures'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return audioClipLibrary
|
||||||
|
}
|
||||||
|
|
||||||
function getStaticAppIconPath(): string | undefined {
|
function getStaticAppIconPath(): string | undefined {
|
||||||
const candidates = app.isPackaged
|
const candidates = app.isPackaged
|
||||||
? [join(process.resourcesPath, STATIC_APP_ICON_FILENAME)]
|
? [join(process.resourcesPath, STATIC_APP_ICON_FILENAME)]
|
||||||
@@ -364,6 +377,22 @@ function getStaticWindowIconOptions(): Pick<BrowserWindowConstructorOptions, 'ic
|
|||||||
return icon ? { icon } : {}
|
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 {
|
function applyStaticDockIcon(): void {
|
||||||
if (process.platform !== 'darwin' || app.isPackaged) {
|
if (process.platform !== 'darwin' || app.isPackaged) {
|
||||||
return
|
return
|
||||||
@@ -635,6 +664,28 @@ function createNativeTrayMenu(model: ReturnType<typeof buildTrayMenuModel>): Ele
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loginStatus = loginItemStatusLabel(model.desktopIntegration)
|
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[] = [
|
const windowItems: MenuItemConstructorOptions[] = [
|
||||||
{
|
{
|
||||||
label: 'Always on Top',
|
label: 'Always on Top',
|
||||||
@@ -714,6 +765,7 @@ function createNativeTrayMenu(model: ReturnType<typeof buildTrayMenuModel>): Ele
|
|||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ label: 'Profile', submenu: profileItems },
|
{ label: 'Profile', submenu: profileItems },
|
||||||
{ label: 'Audio Source', submenu: audioSourceItems },
|
{ label: 'Audio Source', submenu: audioSourceItems },
|
||||||
|
{ label: 'Rolling Capture', submenu: rollingCaptureItems },
|
||||||
{
|
{
|
||||||
label: model.captureActionLabel,
|
label: model.captureActionLabel,
|
||||||
enabled: model.captureActionEnabled,
|
enabled: model.captureActionEnabled,
|
||||||
@@ -2232,6 +2284,37 @@ function setupIPC(): void {
|
|||||||
return getAppBuildInfo()
|
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 () => {
|
ipcMain.handle('updates:check', async () => {
|
||||||
return checkForUpdates(app.getVersion())
|
return checkForUpdates(app.getVersion())
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
type TrayProfileOption,
|
type TrayProfileOption,
|
||||||
type TrayRendererState,
|
type TrayRendererState,
|
||||||
} from '../../types/desktopIntegration'
|
} from '../../types/desktopIntegration'
|
||||||
|
import { isRollingCaptureDuration } from '../../types/audioClip'
|
||||||
|
|
||||||
const MAX_TRAY_ITEMS = 64
|
const MAX_TRAY_ITEMS = 64
|
||||||
const MAX_LABEL_LENGTH = 96
|
const MAX_LABEL_LENGTH = 96
|
||||||
@@ -87,6 +88,9 @@ export function normalizeTrayRendererState(value: unknown): TrayRendererState {
|
|||||||
selectedDeviceId: typeof candidate.selectedDeviceId === 'string'
|
selectedDeviceId: typeof candidate.selectedDeviceId === 'string'
|
||||||
? candidate.selectedDeviceId
|
? candidate.selectedDeviceId
|
||||||
: null,
|
: null,
|
||||||
|
rollingCaptureSeconds: isRollingCaptureDuration(candidate.rollingCaptureSeconds)
|
||||||
|
? candidate.rollingCaptureSeconds
|
||||||
|
: null,
|
||||||
systemSources: normalizeSources(candidate.systemSources),
|
systemSources: normalizeSources(candidate.systemSources),
|
||||||
inputSources: normalizeSources(candidate.inputSources),
|
inputSources: normalizeSources(candidate.inputSources),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { contextBridge, ipcRenderer } from 'electron'
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
import type { AppBuildInfo } from '../types/appBuildInfo'
|
import type { AppBuildInfo } from '../types/appBuildInfo'
|
||||||
|
import type { AudioClipDragPayload } from '../types/audioClip'
|
||||||
import type { CaptureBackendSupport } from '../types/capture'
|
import type { CaptureBackendSupport } from '../types/capture'
|
||||||
import type { NativeCaptureAPI } from '../types/nativeCapture'
|
import type { NativeCaptureAPI } from '../types/nativeCapture'
|
||||||
import type {
|
import type {
|
||||||
@@ -103,6 +104,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
setWindowBackground: (state: WindowBackgroundState) => ipcRenderer.invoke('window:set-background', state) as Promise<WindowBackgroundSnapshot>,
|
setWindowBackground: (state: WindowBackgroundState) => ipcRenderer.invoke('window:set-background', state) as Promise<WindowBackgroundSnapshot>,
|
||||||
isCursorInsideWindow: () => ipcRenderer.invoke('window:is-cursor-inside') as Promise<boolean>,
|
isCursorInsideWindow: () => ipcRenderer.invoke('window:is-cursor-inside') as Promise<boolean>,
|
||||||
getCaptureBackendSupport: async () => getCaptureBackendSupport(process.platform, nativeCaptureAPI) as CaptureBackendSupport,
|
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>,
|
getNowPlayingState: () => ipcRenderer.invoke('now-playing:get-state') as Promise<NowPlayingState>,
|
||||||
setNowPlayingConsumerActive: (active: boolean) => ipcRenderer.invoke('now-playing:set-active', active) 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]) => {
|
saveNowPlayingProviderConfig: <K extends NowPlayingProviderId>(providerId: K, config: NowPlayingProviderConfigMutationMap[K]) => {
|
||||||
|
|||||||
@@ -6,7 +6,13 @@
|
|||||||
|
|
||||||
import { audioRouter } from './AudioRouter'
|
import { audioRouter } from './AudioRouter'
|
||||||
import { nativeVisualizerTransport } from './NativeVisualizerTransport'
|
import { nativeVisualizerTransport } from './NativeVisualizerTransport'
|
||||||
|
import { RollingAudioBuffer } from './RollingAudioBuffer'
|
||||||
import { applyInputGainToStereoSamples, inputGainDbToLinear } from './inputGain'
|
import { applyInputGainToStereoSamples, inputGainDbToLinear } from './inputGain'
|
||||||
|
import type {
|
||||||
|
RollingAudioSnapshot,
|
||||||
|
RollingCaptureDurationSeconds,
|
||||||
|
RollingCaptureStatus,
|
||||||
|
} from '../../types/audioClip'
|
||||||
import type {
|
import type {
|
||||||
CaptureBackendKind,
|
CaptureBackendKind,
|
||||||
CaptureBackendSupport,
|
CaptureBackendSupport,
|
||||||
@@ -149,6 +155,7 @@ export interface CaptureManagerStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type StatusListener = (status: CaptureManagerStatus) => void
|
type StatusListener = (status: CaptureManagerStatus) => void
|
||||||
|
type RollingCaptureStatusListener = (status: RollingCaptureStatus) => void
|
||||||
|
|
||||||
const DEFAULT_SYSTEM_SOURCE_ID = '__default_system_output__'
|
const DEFAULT_SYSTEM_SOURCE_ID = '__default_system_output__'
|
||||||
|
|
||||||
@@ -641,6 +648,9 @@ class AudioCapture {
|
|||||||
private inputGainDb = 0
|
private inputGainDb = 0
|
||||||
private inputGainLinear = 1
|
private inputGainLinear = 1
|
||||||
private statusListeners = new Set<StatusListener>()
|
private statusListeners = new Set<StatusListener>()
|
||||||
|
private rollingCaptureSeconds: RollingCaptureDurationSeconds | null = null
|
||||||
|
private rollingAudioBuffer: RollingAudioBuffer | null = null
|
||||||
|
private rollingCaptureStatusListeners = new Set<RollingCaptureStatusListener>()
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.deviceInputBackend = new DeviceInputCaptureBackend(this.deviceInputRuntime)
|
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> {
|
async refreshBackendSupport(): Promise<CaptureBackendSupport> {
|
||||||
this.backendSupport = null
|
this.backendSupport = null
|
||||||
this.backendSupportPromise = null
|
this.backendSupportPromise = null
|
||||||
@@ -711,6 +729,10 @@ class AudioCapture {
|
|||||||
backendStatus.channelCount,
|
backendStatus.channelCount,
|
||||||
backendStatus.kind,
|
backendStatus.kind,
|
||||||
)
|
)
|
||||||
|
this.beginRollingCaptureSession(
|
||||||
|
backendStatus.sampleRate,
|
||||||
|
backendStatus.channelCount,
|
||||||
|
)
|
||||||
nativeVisualizerTransport.reset(audioRouter.getSessionState())
|
nativeVisualizerTransport.reset(audioRouter.getSessionState())
|
||||||
this.emitStatus()
|
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> {
|
private async ensureBackendSupport(): Promise<CaptureBackendSupport> {
|
||||||
if (this.backendSupport) {
|
if (this.backendSupport) {
|
||||||
return this.backendSupport
|
return this.backendSupport
|
||||||
@@ -836,6 +895,11 @@ class AudioCapture {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async stopActiveCapture(): Promise<void> {
|
private async stopActiveCapture(): Promise<void> {
|
||||||
|
if (this.rollingAudioBuffer) {
|
||||||
|
this.rollingAudioBuffer = null
|
||||||
|
this.emitRollingCaptureStatus()
|
||||||
|
}
|
||||||
|
|
||||||
if (this.sessionId !== null) {
|
if (this.sessionId !== null) {
|
||||||
audioRouter.endSession()
|
audioRouter.endSession()
|
||||||
nativeVisualizerTransport.reset(audioRouter.getSessionState())
|
nativeVisualizerTransport.reset(audioRouter.getSessionState())
|
||||||
@@ -864,6 +928,31 @@ class AudioCapture {
|
|||||||
capturedAt: chunk.capturedAt,
|
capturedAt: chunk.capturedAt,
|
||||||
sequence: chunk.sequence,
|
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 {
|
setInputGain(db: number): void {
|
||||||
@@ -878,6 +967,20 @@ class AudioCapture {
|
|||||||
listener(status)
|
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()
|
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 { getHorizontalWheelScrollResult } from '../utils/horizontalWheelScroll'
|
||||||
import type { ScopeKind } from '../../types/scope'
|
import type { ScopeKind } from '../../types/scope'
|
||||||
import { VISUALIZER_FRAME_TARGETS, type VisualizerFrameTarget } from '../../types/performance'
|
import { VISUALIZER_FRAME_TARGETS, type VisualizerFrameTarget } from '../../types/performance'
|
||||||
|
import { ROLLING_CAPTURE_DURATIONS } from '../../types/audioClip'
|
||||||
import { SCOPE_KINDS } from '../../types/scope'
|
import { SCOPE_KINDS } from '../../types/scope'
|
||||||
import type { WindowBackgroundMode, WindowBackgroundState } from '../../types/windowState'
|
import type { WindowBackgroundMode, WindowBackgroundState } from '../../types/windowState'
|
||||||
import ThemedSelect from './ThemedSelect'
|
import ThemedSelect from './ThemedSelect'
|
||||||
@@ -178,11 +179,15 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
|
|||||||
captureError,
|
captureError,
|
||||||
captureNotice,
|
captureNotice,
|
||||||
inputGainDb,
|
inputGainDb,
|
||||||
|
rollingCaptureSeconds,
|
||||||
|
rollingCaptureStatus,
|
||||||
clearCaptureNotice,
|
clearCaptureNotice,
|
||||||
selectSystemSource,
|
selectSystemSource,
|
||||||
selectDevice,
|
selectDevice,
|
||||||
startCapture,
|
startCapture,
|
||||||
setInputGain,
|
setInputGain,
|
||||||
|
setRollingCaptureSeconds,
|
||||||
|
revealRollingCaptureFolder,
|
||||||
} = useAudioStore()
|
} = useAudioStore()
|
||||||
const showBanner = useUiStore((s) => s.showBanner)
|
const showBanner = useUiStore((s) => s.showBanner)
|
||||||
const desktopIntegration = useDesktopIntegrationStore((s) => s.snapshot)
|
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> => {
|
const handleReloadThemes = async (): Promise<void> => {
|
||||||
if (isRefreshingThemes) {
|
if (isRefreshingThemes) {
|
||||||
return
|
return
|
||||||
@@ -387,6 +404,13 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
|
|||||||
.map((providerId) => nowPlayingState.definitions[providerId].label)
|
.map((providerId) => nowPlayingState.definitions[providerId].label)
|
||||||
.join(' → ')}`
|
.join(' → ')}`
|
||||||
const captureMessage = captureError ?? captureNotice
|
const captureMessage = captureError ?? captureNotice
|
||||||
|
const rollingCaptureStatusLabel = rollingCaptureSeconds === null
|
||||||
|
? 'Off'
|
||||||
|
: rollingCaptureStatus.ready
|
||||||
|
? 'Ready'
|
||||||
|
: rollingCaptureStatus.hasAudio
|
||||||
|
? 'Filling'
|
||||||
|
: 'Waiting'
|
||||||
const nowPlayingErrorMessage = currentNowPlayingProvider?.lastError ?? currentNowPlayingProvider?.lastControlError ?? null
|
const nowPlayingErrorMessage = currentNowPlayingProvider?.lastError ?? currentNowPlayingProvider?.lastControlError ?? null
|
||||||
const nowPlayingDetail = nowPlayingErrorMessage
|
const nowPlayingDetail = nowPlayingErrorMessage
|
||||||
? `${currentNowPlayingProviderId ? `${nowPlayingState.definitions[currentNowPlayingProviderId].label} · ` : ''}${nowPlayingErrorMessage}`
|
? `${currentNowPlayingProviderId ? `${nowPlayingState.definitions[currentNowPlayingProviderId].label} · ` : ''}${nowPlayingErrorMessage}`
|
||||||
@@ -724,6 +748,55 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
|
|||||||
|
|
||||||
<div className="bottom-bar__divider" />
|
<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">
|
<section className="bottom-bar__section bottom-bar__section--performance">
|
||||||
<div className="bottom-bar__section-title">Performance</div>
|
<div className="bottom-bar__section-title">Performance</div>
|
||||||
<div className="bottom-bar__section-body">
|
<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 type { AppBuildInfo } from '../../types/appBuildInfo'
|
||||||
import { useSettingsStore } from '../stores/settingsStore'
|
import { useSettingsStore } from '../stores/settingsStore'
|
||||||
import { useUpdateStore } from '../stores/updateStore'
|
import { useUpdateStore } from '../stores/updateStore'
|
||||||
import { useUiStore } from '../stores/uiStore'
|
import { useUiStore } from '../stores/uiStore'
|
||||||
|
import { useAudioStore } from '../stores/audioStore'
|
||||||
import { getRendererWindowCapabilities } from '../windowCapabilities'
|
import { getRendererWindowCapabilities } from '../windowCapabilities'
|
||||||
import PrismLogo from './PrismLogo'
|
import PrismLogo from './PrismLogo'
|
||||||
|
|
||||||
@@ -171,6 +180,9 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
|
|||||||
const latestTag = useUpdateStore((s) => s.latestTag)
|
const latestTag = useUpdateStore((s) => s.latestTag)
|
||||||
const releaseName = useUpdateStore((s) => s.releaseName)
|
const releaseName = useUpdateStore((s) => s.releaseName)
|
||||||
const openReleasesPage = useUpdateStore((s) => s.openReleasesPage)
|
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 [appBuildInfo, setAppBuildInfo] = useState<AppBuildInfo | null>(null)
|
||||||
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(false)
|
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(false)
|
||||||
const [showReposition, setShowReposition] = useState(false)
|
const [showReposition, setShowReposition] = useState(false)
|
||||||
@@ -440,6 +452,11 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
|
|||||||
})
|
})
|
||||||
}, [activeProfileId, profiles])
|
}, [activeProfileId, profiles])
|
||||||
|
|
||||||
|
const handleAudioClipDragStart = useCallback((event: ReactDragEvent<HTMLButtonElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
startRollingClipDrag()
|
||||||
|
}, [startRollingClipDrag])
|
||||||
|
|
||||||
const handleDragStart = useCallback((event: ReactPointerEvent<HTMLButtonElement>): void => {
|
const handleDragStart = useCallback((event: ReactPointerEvent<HTMLButtonElement>): void => {
|
||||||
if (useNativeDragRegions || event.button !== 0) return
|
if (useNativeDragRegions || event.button !== 0) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -536,6 +553,25 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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__spacer" />
|
||||||
|
|
||||||
<div className="toolbar__actions">
|
<div className="toolbar__actions">
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ async function handleTrayCommand(command: TrayRendererCommand): Promise<void> {
|
|||||||
await useAudioStore.getState().startCapture()
|
await useAudioStore.getState().startCapture()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (command.type === 'set-rolling-capture') {
|
||||||
|
audio.setRollingCaptureSeconds(command.durationSeconds)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (command.type === 'set-capture-running') {
|
if (command.type === 'set-capture-running') {
|
||||||
if (command.running) {
|
if (command.running) {
|
||||||
await audio.startCapture()
|
await audio.startCapture()
|
||||||
@@ -65,6 +69,7 @@ export default function TrayControlBridge({ ready }: TrayControlBridgeProps): JS
|
|||||||
const captureMode = useAudioStore((state) => state.captureMode)
|
const captureMode = useAudioStore((state) => state.captureMode)
|
||||||
const selectedSystemSourceId = useAudioStore((state) => state.selectedSystemSourceId)
|
const selectedSystemSourceId = useAudioStore((state) => state.selectedSystemSourceId)
|
||||||
const selectedDeviceId = useAudioStore((state) => state.selectedDeviceId)
|
const selectedDeviceId = useAudioStore((state) => state.selectedDeviceId)
|
||||||
|
const rollingCaptureSeconds = useAudioStore((state) => state.rollingCaptureSeconds)
|
||||||
const systemSources = useAudioStore((state) => state.systemSources)
|
const systemSources = useAudioStore((state) => state.systemSources)
|
||||||
const devices = useAudioStore((state) => state.devices)
|
const devices = useAudioStore((state) => state.devices)
|
||||||
|
|
||||||
@@ -103,6 +108,7 @@ export default function TrayControlBridge({ ready }: TrayControlBridgeProps): JS
|
|||||||
captureMode,
|
captureMode,
|
||||||
selectedSystemSourceId,
|
selectedSystemSourceId,
|
||||||
selectedDeviceId,
|
selectedDeviceId,
|
||||||
|
rollingCaptureSeconds,
|
||||||
systemSources: visibleSystemSources.map((source) => ({
|
systemSources: visibleSystemSources.map((source) => ({
|
||||||
id: source.id,
|
id: source.id,
|
||||||
label: source.label,
|
label: source.label,
|
||||||
@@ -127,6 +133,7 @@ export default function TrayControlBridge({ ready }: TrayControlBridgeProps): JS
|
|||||||
devices,
|
devices,
|
||||||
hasUnsavedProfileChanges,
|
hasUnsavedProfileChanges,
|
||||||
profiles,
|
profiles,
|
||||||
|
rollingCaptureSeconds,
|
||||||
selectedDeviceId,
|
selectedDeviceId,
|
||||||
selectedSystemSourceId,
|
selectedSystemSourceId,
|
||||||
systemSources,
|
systemSources,
|
||||||
|
|||||||
Vendored
+6
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { VisualizerDSP } from './audio/native/visualizer-dsp'
|
import type { VisualizerDSP } from './audio/native/visualizer-dsp'
|
||||||
import type { AppBuildInfo } from '../types/appBuildInfo'
|
import type { AppBuildInfo } from '../types/appBuildInfo'
|
||||||
|
import type { AudioClipDragPayload } from '../types/audioClip'
|
||||||
import type { CaptureBackendSupport } from '../types/capture'
|
import type { CaptureBackendSupport } from '../types/capture'
|
||||||
import type { NativeCaptureAPI } from '../types/nativeCapture'
|
import type { NativeCaptureAPI } from '../types/nativeCapture'
|
||||||
import type {
|
import type {
|
||||||
@@ -79,6 +80,11 @@ declare global {
|
|||||||
setWindowBackground: (state: WindowBackgroundState) => Promise<WindowBackgroundSnapshot>
|
setWindowBackground: (state: WindowBackgroundState) => Promise<WindowBackgroundSnapshot>
|
||||||
isCursorInsideWindow: () => Promise<boolean>
|
isCursorInsideWindow: () => Promise<boolean>
|
||||||
getCaptureBackendSupport: () => Promise<CaptureBackendSupport>
|
getCaptureBackendSupport: () => Promise<CaptureBackendSupport>
|
||||||
|
audioClips: {
|
||||||
|
startDrag: (payload: AudioClipDragPayload) => void
|
||||||
|
revealFolder: () => Promise<void>
|
||||||
|
onDragError: (callback: (message: string) => void) => () => void
|
||||||
|
}
|
||||||
getNowPlayingState: () => Promise<NowPlayingState>
|
getNowPlayingState: () => Promise<NowPlayingState>
|
||||||
setNowPlayingConsumerActive: (active: boolean) => Promise<NowPlayingState>
|
setNowPlayingConsumerActive: (active: boolean) => Promise<NowPlayingState>
|
||||||
saveNowPlayingProviderConfig: <K extends NowPlayingProviderId>(providerId: K, config: NowPlayingProviderConfigMutationMap[K]) => Promise<NowPlayingState>
|
saveNowPlayingProviderConfig: <K extends NowPlayingProviderId>(providerId: K, config: NowPlayingProviderConfigMutationMap[K]) => Promise<NowPlayingState>
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { audioCapture, type CaptureManagerStatus } from '../audio/AudioCapture'
|
import { audioCapture, type CaptureManagerStatus } from '../audio/AudioCapture'
|
||||||
|
import {
|
||||||
|
isRollingCaptureDuration,
|
||||||
|
type RollingCaptureDurationSeconds,
|
||||||
|
type RollingCaptureStatus,
|
||||||
|
} from '../../types/audioClip'
|
||||||
import type {
|
import type {
|
||||||
CaptureBackendKind,
|
CaptureBackendKind,
|
||||||
CaptureBackendSupport,
|
CaptureBackendSupport,
|
||||||
@@ -20,6 +25,7 @@ export interface PersistedAudioState {
|
|||||||
captureMode: CaptureMode
|
captureMode: CaptureMode
|
||||||
selectedSystemSourceId: string
|
selectedSystemSourceId: string
|
||||||
selectedDeviceId: string | null
|
selectedDeviceId: string | null
|
||||||
|
rollingCaptureSeconds: RollingCaptureDurationSeconds | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RefreshSourceOptions {
|
interface RefreshSourceOptions {
|
||||||
@@ -48,7 +54,12 @@ interface AudioState {
|
|||||||
activeSourceId: string | null
|
activeSourceId: string | null
|
||||||
activeSourceLabel: string | null
|
activeSourceLabel: string | null
|
||||||
inputGainDb: number
|
inputGainDb: number
|
||||||
|
rollingCaptureSeconds: RollingCaptureDurationSeconds | null
|
||||||
|
rollingCaptureStatus: RollingCaptureStatus
|
||||||
setInputGain: (db: number) => void
|
setInputGain: (db: number) => void
|
||||||
|
setRollingCaptureSeconds: (duration: RollingCaptureDurationSeconds | null) => void
|
||||||
|
startRollingClipDrag: () => boolean
|
||||||
|
revealRollingCaptureFolder: () => Promise<void>
|
||||||
clearCaptureNotice: () => void
|
clearCaptureNotice: () => void
|
||||||
refreshSystemSources: (options?: RefreshSourceOptions) => Promise<void>
|
refreshSystemSources: (options?: RefreshSourceOptions) => Promise<void>
|
||||||
refreshDevices: (options?: RefreshSourceOptions) => Promise<void>
|
refreshDevices: (options?: RefreshSourceOptions) => Promise<void>
|
||||||
@@ -193,6 +204,12 @@ function normalizeDeviceId(raw: unknown): string | null {
|
|||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeRollingCaptureSeconds(
|
||||||
|
raw: unknown,
|
||||||
|
): RollingCaptureDurationSeconds | null {
|
||||||
|
return isRollingCaptureDuration(raw) ? raw : null
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeAudioPreferences(raw: unknown): PersistedAudioState {
|
export function normalizeAudioPreferences(raw: unknown): PersistedAudioState {
|
||||||
const parsed = typeof raw === 'object' && raw !== null
|
const parsed = typeof raw === 'object' && raw !== null
|
||||||
? raw as Partial<PersistedAudioState>
|
? raw as Partial<PersistedAudioState>
|
||||||
@@ -203,6 +220,7 @@ export function normalizeAudioPreferences(raw: unknown): PersistedAudioState {
|
|||||||
captureMode: normalizeCaptureMode(parsed.captureMode),
|
captureMode: normalizeCaptureMode(parsed.captureMode),
|
||||||
selectedSystemSourceId: normalizeSystemSourceId(parsed.selectedSystemSourceId),
|
selectedSystemSourceId: normalizeSystemSourceId(parsed.selectedSystemSourceId),
|
||||||
selectedDeviceId: normalizeDeviceId(parsed.selectedDeviceId),
|
selectedDeviceId: normalizeDeviceId(parsed.selectedDeviceId),
|
||||||
|
rollingCaptureSeconds: normalizeRollingCaptureSeconds(parsed.rollingCaptureSeconds),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,12 +229,14 @@ function buildAudioPreferences(
|
|||||||
captureMode: CaptureMode,
|
captureMode: CaptureMode,
|
||||||
selectedSystemSourceId: string | null,
|
selectedSystemSourceId: string | null,
|
||||||
selectedDeviceId: string | null,
|
selectedDeviceId: string | null,
|
||||||
|
rollingCaptureSeconds: RollingCaptureDurationSeconds | null,
|
||||||
): PersistedAudioState {
|
): PersistedAudioState {
|
||||||
return normalizeAudioPreferences({
|
return normalizeAudioPreferences({
|
||||||
inputGainDb,
|
inputGainDb,
|
||||||
captureMode,
|
captureMode,
|
||||||
selectedSystemSourceId,
|
selectedSystemSourceId,
|
||||||
selectedDeviceId,
|
selectedDeviceId,
|
||||||
|
rollingCaptureSeconds,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,6 +272,7 @@ audioCapture.setInputGain(storedPreferences.inputGainDb)
|
|||||||
audioCapture.setSelectedSystemSourceId(storedPreferences.selectedSystemSourceId)
|
audioCapture.setSelectedSystemSourceId(storedPreferences.selectedSystemSourceId)
|
||||||
audioCapture.setSelectedDeviceId(storedPreferences.selectedDeviceId)
|
audioCapture.setSelectedDeviceId(storedPreferences.selectedDeviceId)
|
||||||
audioCapture.setCaptureMode(storedPreferences.captureMode)
|
audioCapture.setCaptureMode(storedPreferences.captureMode)
|
||||||
|
audioCapture.setRollingCaptureSeconds(storedPreferences.rollingCaptureSeconds)
|
||||||
|
|
||||||
export const useAudioStore = create<AudioState>((set, get) => ({
|
export const useAudioStore = create<AudioState>((set, get) => ({
|
||||||
systemSources: [],
|
systemSources: [],
|
||||||
@@ -270,6 +291,8 @@ export const useAudioStore = create<AudioState>((set, get) => ({
|
|||||||
activeSourceId: null,
|
activeSourceId: null,
|
||||||
activeSourceLabel: null,
|
activeSourceLabel: null,
|
||||||
inputGainDb: storedPreferences.inputGainDb,
|
inputGainDb: storedPreferences.inputGainDb,
|
||||||
|
rollingCaptureSeconds: storedPreferences.rollingCaptureSeconds,
|
||||||
|
rollingCaptureStatus: audioCapture.getRollingCaptureStatus(),
|
||||||
|
|
||||||
setInputGain: (db: number) => {
|
setInputGain: (db: number) => {
|
||||||
const nextInputGainDb = normalizeInputGainDb(db)
|
const nextInputGainDb = normalizeInputGainDb(db)
|
||||||
@@ -283,11 +306,56 @@ export const useAudioStore = create<AudioState>((set, get) => ({
|
|||||||
currentState.captureMode,
|
currentState.captureMode,
|
||||||
currentState.selectedSystemSourceId,
|
currentState.selectedSystemSourceId,
|
||||||
currentState.selectedDeviceId,
|
currentState.selectedDeviceId,
|
||||||
|
currentState.rollingCaptureSeconds,
|
||||||
))
|
))
|
||||||
audioCapture.setInputGain(nextInputGainDb)
|
audioCapture.setInputGain(nextInputGainDb)
|
||||||
set({ inputGainDb: 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: () => {
|
clearCaptureNotice: () => {
|
||||||
set({ captureNotice: null })
|
set({ captureNotice: null })
|
||||||
},
|
},
|
||||||
@@ -320,6 +388,7 @@ export const useAudioStore = create<AudioState>((set, get) => ({
|
|||||||
currentState.captureMode,
|
currentState.captureMode,
|
||||||
nextSelectedSystemSourceId,
|
nextSelectedSystemSourceId,
|
||||||
currentState.selectedDeviceId,
|
currentState.selectedDeviceId,
|
||||||
|
currentState.rollingCaptureSeconds,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,6 +457,7 @@ export const useAudioStore = create<AudioState>((set, get) => ({
|
|||||||
currentState.captureMode,
|
currentState.captureMode,
|
||||||
currentState.selectedSystemSourceId,
|
currentState.selectedSystemSourceId,
|
||||||
nextSelectedDeviceId,
|
nextSelectedDeviceId,
|
||||||
|
currentState.rollingCaptureSeconds,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,6 +515,7 @@ export const useAudioStore = create<AudioState>((set, get) => ({
|
|||||||
'system',
|
'system',
|
||||||
nextSelectedSystemSourceId,
|
nextSelectedSystemSourceId,
|
||||||
currentState.selectedDeviceId,
|
currentState.selectedDeviceId,
|
||||||
|
currentState.rollingCaptureSeconds,
|
||||||
))
|
))
|
||||||
set({
|
set({
|
||||||
selectedSystemSourceId: nextSelectedSystemSourceId,
|
selectedSystemSourceId: nextSelectedSystemSourceId,
|
||||||
@@ -463,6 +534,7 @@ export const useAudioStore = create<AudioState>((set, get) => ({
|
|||||||
'device',
|
'device',
|
||||||
currentState.selectedSystemSourceId,
|
currentState.selectedSystemSourceId,
|
||||||
deviceId,
|
deviceId,
|
||||||
|
currentState.rollingCaptureSeconds,
|
||||||
))
|
))
|
||||||
set({
|
set({
|
||||||
selectedDeviceId: deviceId,
|
selectedDeviceId: deviceId,
|
||||||
@@ -480,6 +552,7 @@ export const useAudioStore = create<AudioState>((set, get) => ({
|
|||||||
mode,
|
mode,
|
||||||
currentState.selectedSystemSourceId,
|
currentState.selectedSystemSourceId,
|
||||||
currentState.selectedDeviceId,
|
currentState.selectedDeviceId,
|
||||||
|
currentState.rollingCaptureSeconds,
|
||||||
))
|
))
|
||||||
set({ captureMode: mode })
|
set({ captureMode: mode })
|
||||||
},
|
},
|
||||||
@@ -506,6 +579,7 @@ export const useAudioStore = create<AudioState>((set, get) => ({
|
|||||||
'device',
|
'device',
|
||||||
currentState.selectedSystemSourceId,
|
currentState.selectedSystemSourceId,
|
||||||
null,
|
null,
|
||||||
|
currentState.rollingCaptureSeconds,
|
||||||
))
|
))
|
||||||
set({
|
set({
|
||||||
selectedDeviceId: null,
|
selectedDeviceId: null,
|
||||||
@@ -676,3 +750,17 @@ audioCapture.subscribeStatus((status) => {
|
|||||||
...applyCaptureStatus(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);
|
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 {
|
.toolbar__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -755,6 +809,10 @@ button.toolbar__version:hover {
|
|||||||
.toolbar__version {
|
.toolbar__version {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbar__clip-prefix {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.scope-strip {
|
.scope-strip {
|
||||||
@@ -1924,6 +1982,10 @@ button.toolbar__version:hover {
|
|||||||
min-width: 360px;
|
min-width: 360px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bottom-bar__section--rolling-capture {
|
||||||
|
min-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
.bottom-bar__section--performance {
|
.bottom-bar__section--performance {
|
||||||
min-width: 328px;
|
min-width: 328px;
|
||||||
}
|
}
|
||||||
@@ -2355,6 +2417,7 @@ button.toolbar__version:hover {
|
|||||||
.toolbar__brand:focus-visible,
|
.toolbar__brand:focus-visible,
|
||||||
.toolbar__profile-button:focus-visible,
|
.toolbar__profile-button:focus-visible,
|
||||||
.toolbar__chip:focus-visible,
|
.toolbar__chip:focus-visible,
|
||||||
|
.toolbar__clip-chip:focus-visible,
|
||||||
.toolbar__icon-button:focus-visible,
|
.toolbar__icon-button:focus-visible,
|
||||||
.scope-strip__reorder-button:focus-visible,
|
.scope-strip__reorder-button:focus-visible,
|
||||||
.scope-strip__popout-button:focus-visible,
|
.scope-strip__popout-button:focus-visible,
|
||||||
@@ -2371,6 +2434,10 @@ button.toolbar__version:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.toolbar__clip-chip.is-filling:not(:disabled) .toolbar__clip-dot {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar__brand,
|
.toolbar__brand,
|
||||||
.toolbar__brand-logo,
|
.toolbar__brand-logo,
|
||||||
.toolbar__brand-heart {
|
.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 LoginLaunchMode = 'show' | 'tray'
|
||||||
|
|
||||||
export type LoginItemStatus =
|
export type LoginItemStatus =
|
||||||
@@ -39,6 +41,7 @@ export interface TrayRendererState {
|
|||||||
captureMode: 'system' | 'device'
|
captureMode: 'system' | 'device'
|
||||||
selectedSystemSourceId: string | null
|
selectedSystemSourceId: string | null
|
||||||
selectedDeviceId: string | null
|
selectedDeviceId: string | null
|
||||||
|
rollingCaptureSeconds: RollingCaptureDurationSeconds | null
|
||||||
systemSources: TrayAudioSourceOption[]
|
systemSources: TrayAudioSourceOption[]
|
||||||
inputSources: TrayAudioSourceOption[]
|
inputSources: TrayAudioSourceOption[]
|
||||||
}
|
}
|
||||||
@@ -47,6 +50,7 @@ export type TrayRendererCommand =
|
|||||||
| { type: 'load-profile'; profileId: string }
|
| { type: 'load-profile'; profileId: string }
|
||||||
| { type: 'select-system-source'; sourceId: string }
|
| { type: 'select-system-source'; sourceId: string }
|
||||||
| { type: 'select-input-source'; deviceId: string | null }
|
| { type: 'select-input-source'; deviceId: string | null }
|
||||||
|
| { type: 'set-rolling-capture'; durationSeconds: RollingCaptureDurationSeconds | null }
|
||||||
| { type: 'set-capture-running'; running: boolean }
|
| { type: 'set-capture-running'; running: boolean }
|
||||||
| { type: 'open-settings' }
|
| { type: 'open-settings' }
|
||||||
|
|
||||||
@@ -64,6 +68,7 @@ export const DEFAULT_TRAY_RENDERER_STATE: TrayRendererState = {
|
|||||||
captureMode: 'system',
|
captureMode: 'system',
|
||||||
selectedSystemSourceId: null,
|
selectedSystemSourceId: null,
|
||||||
selectedDeviceId: null,
|
selectedDeviceId: null,
|
||||||
|
rollingCaptureSeconds: null,
|
||||||
systemSources: [],
|
systemSources: [],
|
||||||
inputSources: [],
|
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 test from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { AudioRouter } from '../src/renderer/audio/AudioRouter'
|
import { AudioRouter } from '../src/renderer/audio/AudioRouter'
|
||||||
|
import './rolling-audio-buffer.test'
|
||||||
|
|
||||||
function createChunk(value: number, length = 4): Float32Array {
|
function createChunk(value: number, length = 4): Float32Array {
|
||||||
return new Float32Array(Array.from({ length }, () => value))
|
return new Float32Array(Array.from({ length }, () => value))
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { audioCapture } from '../src/renderer/audio/AudioCapture'
|
|||||||
import {
|
import {
|
||||||
loadAudioPreferences,
|
loadAudioPreferences,
|
||||||
normalizeAudioPreferences,
|
normalizeAudioPreferences,
|
||||||
|
normalizeRollingCaptureSeconds,
|
||||||
startAudioDeviceWatcher,
|
startAudioDeviceWatcher,
|
||||||
useAudioStore,
|
useAudioStore,
|
||||||
type PersistedAudioState,
|
type PersistedAudioState,
|
||||||
@@ -29,6 +30,7 @@ function audioPreferences(overrides: Partial<PersistedAudioState> = {}): Persist
|
|||||||
captureMode: 'system',
|
captureMode: 'system',
|
||||||
selectedSystemSourceId: DEFAULT_SYSTEM_SOURCE_ID,
|
selectedSystemSourceId: DEFAULT_SYSTEM_SOURCE_ID,
|
||||||
selectedDeviceId: null,
|
selectedDeviceId: null,
|
||||||
|
rollingCaptureSeconds: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -279,6 +281,7 @@ function resetStores(): void {
|
|||||||
audioCapture.setSelectedDeviceId(null)
|
audioCapture.setSelectedDeviceId(null)
|
||||||
audioCapture.setCaptureMode('system')
|
audioCapture.setCaptureMode('system')
|
||||||
audioCapture.setInputGain(0)
|
audioCapture.setInputGain(0)
|
||||||
|
audioCapture.setRollingCaptureSeconds(null)
|
||||||
useAudioStore.setState({
|
useAudioStore.setState({
|
||||||
...initialAudioState,
|
...initialAudioState,
|
||||||
systemSources: [],
|
systemSources: [],
|
||||||
@@ -297,6 +300,8 @@ function resetStores(): void {
|
|||||||
activeSourceId: null,
|
activeSourceId: null,
|
||||||
activeSourceLabel: null,
|
activeSourceLabel: null,
|
||||||
inputGainDb: 0,
|
inputGainDb: 0,
|
||||||
|
rollingCaptureSeconds: null,
|
||||||
|
rollingCaptureStatus: audioCapture.getRollingCaptureStatus(),
|
||||||
})
|
})
|
||||||
|
|
||||||
useUiStore.setState({
|
useUiStore.setState({
|
||||||
@@ -477,14 +482,40 @@ test('normalizeAudioPreferences preserves valid persisted selector values', () =
|
|||||||
captureMode: 'device',
|
captureMode: 'device',
|
||||||
selectedSystemSourceId: 'speaker',
|
selectedSystemSourceId: 'speaker',
|
||||||
selectedDeviceId: 'mic-1',
|
selectedDeviceId: 'mic-1',
|
||||||
|
rollingCaptureSeconds: 30,
|
||||||
}), audioPreferences({
|
}), audioPreferences({
|
||||||
inputGainDb: -3,
|
inputGainDb: -3,
|
||||||
captureMode: 'device',
|
captureMode: 'device',
|
||||||
selectedSystemSourceId: 'speaker',
|
selectedSystemSourceId: 'speaker',
|
||||||
selectedDeviceId: 'mic-1',
|
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', () => {
|
test('normalizeAudioPreferences clamps out-of-range trim values', () => {
|
||||||
assert.deepEqual(normalizeAudioPreferences({ inputGainDb: -18 }), audioPreferences({ inputGainDb: -12 }))
|
assert.deepEqual(normalizeAudioPreferences({ inputGainDb: -18 }), audioPreferences({ inputGainDb: -12 }))
|
||||||
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 () => {
|
test('audio store persists custom output source selections', async () => {
|
||||||
resetStores()
|
resetStores()
|
||||||
const fakeStorage = installFakeLocalStorage()
|
const fakeStorage = installFakeLocalStorage()
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ test('tray state validation and menu model expose capture, visibility, and check
|
|||||||
captureMode: 'system',
|
captureMode: 'system',
|
||||||
selectedSystemSourceId: 'output-1',
|
selectedSystemSourceId: 'output-1',
|
||||||
selectedDeviceId: null,
|
selectedDeviceId: null,
|
||||||
|
rollingCaptureSeconds: 30,
|
||||||
systemSources: [{ id: 'output-1', label: 'Studio Output' }],
|
systemSources: [{ id: 'output-1', label: 'Studio Output' }],
|
||||||
inputSources: [{ id: '', label: 'Default Input' }],
|
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.mainWindowActionLabel, 'Show Prism')
|
||||||
assert.equal(model.captureActionLabel, 'Stop Capture')
|
assert.equal(model.captureActionLabel, 'Stop Capture')
|
||||||
assert.equal(model.rendererState.hasUnsavedProfileChanges, true)
|
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', () => {
|
test('tray renderer commands remain queued until the renderer is ready', () => {
|
||||||
const queue = new TrayRendererCommandQueue()
|
const queue = new TrayRendererCommandQueue()
|
||||||
const received: string[] = []
|
const received: string[] = []
|
||||||
queue.enqueue({ type: 'open-settings' })
|
queue.enqueue({ type: 'open-settings' })
|
||||||
|
queue.enqueue({ type: 'set-rolling-capture', durationSeconds: 10 })
|
||||||
queue.enqueue({ type: 'set-capture-running', running: false })
|
queue.enqueue({ type: 'set-capture-running', running: false })
|
||||||
queue.flush((command) => received.push(command.type))
|
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))
|
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', () => {
|
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', () => {
|
test('resolveWindowCapabilities detects native Wayland sessions on Linux', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
resolveWindowCapabilities({
|
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