rolling recording buffer

This commit is contained in:
Boof2015
2026-08-16 14:38:12 -04:00
parent 90cde23f4d
commit f6d9719c43
22 changed files with 1125 additions and 4 deletions
+75
View File
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import {
AudioClipLibrary,
buildAudioClipBaseName,
encodePcm16Wav,
validateAudioClipDragPayload,
} from '../src/main/audioClipLibrary'
import type { AudioClipDragPayload } from '../src/types/audioClip'
function clipPayload(overrides: Partial<AudioClipDragPayload> = {}): AudioClipDragPayload {
return {
pcmBytes: new Uint8Array([0x00, 0x80, 0xff, 0x7f, 0x00, 0x00, 0x01, 0x00]),
sampleRate: 48000,
channelCount: 2,
frameCount: 2,
...overrides,
}
}
test('validates rolling clip metadata and exact PCM byte count', () => {
assert.deepEqual(validateAudioClipDragPayload(clipPayload()), clipPayload())
assert.throws(() => validateAudioClipDragPayload(null), /payload is invalid/i)
assert.throws(() => validateAudioClipDragPayload(clipPayload({ sampleRate: 384001 })), /sample rate/i)
assert.throws(() => validateAudioClipDragPayload({
...clipPayload(),
channelCount: 3,
}), /channel count/i)
assert.throws(() => validateAudioClipDragPayload(clipPayload({
frameCount: 48000 * 60 + 1,
})), /duration/i)
assert.throws(() => validateAudioClipDragPayload(clipPayload({
pcmBytes: new Uint8Array(6),
})), /data length/i)
})
test('encodes a standard little-endian 16-bit PCM WAV', () => {
const payload = clipPayload()
const wav = encodePcm16Wav(payload)
assert.equal(wav.toString('ascii', 0, 4), 'RIFF')
assert.equal(wav.readUInt32LE(4), 36 + payload.pcmBytes.byteLength)
assert.equal(wav.toString('ascii', 8, 12), 'WAVE')
assert.equal(wav.toString('ascii', 12, 16), 'fmt ')
assert.equal(wav.readUInt16LE(20), 1)
assert.equal(wav.readUInt16LE(22), 2)
assert.equal(wav.readUInt32LE(24), 48000)
assert.equal(wav.readUInt32LE(28), 192000)
assert.equal(wav.readUInt16LE(32), 4)
assert.equal(wav.readUInt16LE(34), 16)
assert.equal(wav.toString('ascii', 36, 40), 'data')
assert.equal(wav.readUInt32LE(40), payload.pcmBytes.byteLength)
assert.deepEqual(Array.from(wav.subarray(44)), Array.from(payload.pcmBytes))
})
test('writes persistent clips with safe timestamped collision-resistant names', async (t) => {
const parent = await mkdtemp(join(tmpdir(), 'prism-audio-clips-'))
t.after(async () => rm(parent, { recursive: true, force: true }))
const directory = join(parent, 'Prism Captures')
const now = new Date('2026-08-16T17:42:03.123Z')
const library = new AudioClipLibrary(directory, () => now)
assert.equal(buildAudioClipBaseName(now), 'Prism Clip 2026-08-16 17-42-03.123')
const firstPath = library.writeClip(clipPayload())
const secondPath = library.writeClip(clipPayload())
assert.equal(firstPath, join(directory, 'Prism Clip 2026-08-16 17-42-03.123.wav'))
assert.equal(secondPath, join(directory, 'Prism Clip 2026-08-16 17-42-03.123 (2).wav'))
assert.deepEqual(await readFile(firstPath), encodePcm16Wav(clipPayload()))
assert.deepEqual(await readFile(secondPath), encodePcm16Wav(clipPayload()))
})
+1
View File
@@ -1,6 +1,7 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { AudioRouter } from '../src/renderer/audio/AudioRouter'
import './rolling-audio-buffer.test'
function createChunk(value: number, length = 4): Float32Array {
return new Float32Array(Array.from({ length }, () => value))
+62
View File
@@ -4,6 +4,7 @@ import { audioCapture } from '../src/renderer/audio/AudioCapture'
import {
loadAudioPreferences,
normalizeAudioPreferences,
normalizeRollingCaptureSeconds,
startAudioDeviceWatcher,
useAudioStore,
type PersistedAudioState,
@@ -29,6 +30,7 @@ function audioPreferences(overrides: Partial<PersistedAudioState> = {}): Persist
captureMode: 'system',
selectedSystemSourceId: DEFAULT_SYSTEM_SOURCE_ID,
selectedDeviceId: null,
rollingCaptureSeconds: null,
...overrides,
}
}
@@ -279,6 +281,7 @@ function resetStores(): void {
audioCapture.setSelectedDeviceId(null)
audioCapture.setCaptureMode('system')
audioCapture.setInputGain(0)
audioCapture.setRollingCaptureSeconds(null)
useAudioStore.setState({
...initialAudioState,
systemSources: [],
@@ -297,6 +300,8 @@ function resetStores(): void {
activeSourceId: null,
activeSourceLabel: null,
inputGainDb: 0,
rollingCaptureSeconds: null,
rollingCaptureStatus: audioCapture.getRollingCaptureStatus(),
})
useUiStore.setState({
@@ -477,14 +482,40 @@ test('normalizeAudioPreferences preserves valid persisted selector values', () =
captureMode: 'device',
selectedSystemSourceId: 'speaker',
selectedDeviceId: 'mic-1',
rollingCaptureSeconds: 30,
}), audioPreferences({
inputGainDb: -3,
captureMode: 'device',
selectedSystemSourceId: 'speaker',
selectedDeviceId: 'mic-1',
rollingCaptureSeconds: 30,
}))
})
test('normalizeRollingCaptureSeconds accepts only supported durations', () => {
assert.equal(normalizeRollingCaptureSeconds(5), 5)
assert.equal(normalizeRollingCaptureSeconds(60), 60)
assert.equal(normalizeRollingCaptureSeconds(15), null)
assert.equal(normalizeRollingCaptureSeconds('10'), null)
})
test('rolling capture opt-in does not allocate a buffer while capture is idle', () => {
resetStores()
try {
audioCapture.setRollingCaptureSeconds(60)
const enabledStatus = audioCapture.getRollingCaptureStatus()
assert.equal(enabledStatus.durationSeconds, 60)
assert.equal(enabledStatus.allocatedBytes, 0)
assert.equal(enabledStatus.hasAudio, false)
audioCapture.setRollingCaptureSeconds(null)
assert.equal(audioCapture.getRollingCaptureStatus().allocatedBytes, 0)
} finally {
resetStores()
}
})
test('normalizeAudioPreferences clamps out-of-range trim values', () => {
assert.deepEqual(normalizeAudioPreferences({ inputGainDb: -18 }), audioPreferences({ inputGainDb: -12 }))
assert.deepEqual(normalizeAudioPreferences({ inputGainDb: 18 }), audioPreferences({ inputGainDb: 12 }))
@@ -531,6 +562,37 @@ test('audio store persists normalized trim values and forwards them to audioCapt
}
})
test('audio store persists rolling capture opt-in and forwards duration changes', () => {
resetStores()
const fakeStorage = installFakeLocalStorage()
const originalSetRollingCaptureSeconds = audioCapture.setRollingCaptureSeconds
const forwardedValues: Array<number | null> = []
audioCapture.setRollingCaptureSeconds = (duration) => {
forwardedValues.push(duration)
}
try {
useAudioStore.getState().setRollingCaptureSeconds(30)
assert.equal(useAudioStore.getState().rollingCaptureSeconds, 30)
assert.deepEqual(forwardedValues, [30])
assert.equal(fakeStorage.getItem('prism:audio'), storedAudioPreferences({
rollingCaptureSeconds: 30,
}))
useAudioStore.getState().setRollingCaptureSeconds(null)
assert.equal(useAudioStore.getState().rollingCaptureSeconds, null)
assert.deepEqual(forwardedValues, [30, null])
assert.equal(fakeStorage.getItem('prism:audio'), storedAudioPreferences())
} finally {
audioCapture.setRollingCaptureSeconds = originalSetRollingCaptureSeconds
fakeStorage.restore()
resetStores()
}
})
test('audio store persists custom output source selections', async () => {
resetStores()
const fakeStorage = installFakeLocalStorage()
+12 -3
View File
@@ -256,6 +256,7 @@ test('tray state validation and menu model expose capture, visibility, and check
captureMode: 'system',
selectedSystemSourceId: 'output-1',
selectedDeviceId: null,
rollingCaptureSeconds: 30,
systemSources: [{ id: 'output-1', label: 'Studio Output' }],
inputSources: [{ id: '', label: 'Default Input' }],
})
@@ -277,19 +278,27 @@ test('tray state validation and menu model expose capture, visibility, and check
assert.equal(model.mainWindowActionLabel, 'Show Prism')
assert.equal(model.captureActionLabel, 'Stop Capture')
assert.equal(model.rendererState.hasUnsavedProfileChanges, true)
assert.equal(model.rendererState.rollingCaptureSeconds, 30)
assert.equal(normalizeTrayRendererState({ profiles: 'invalid', captureStatus: 'bad' }).captureStatus, 'idle')
const invalidState = normalizeTrayRendererState({
profiles: 'invalid',
captureStatus: 'bad',
rollingCaptureSeconds: 15,
})
assert.equal(invalidState.captureStatus, 'idle')
assert.equal(invalidState.rollingCaptureSeconds, null)
})
test('tray renderer commands remain queued until the renderer is ready', () => {
const queue = new TrayRendererCommandQueue()
const received: string[] = []
queue.enqueue({ type: 'open-settings' })
queue.enqueue({ type: 'set-rolling-capture', durationSeconds: 10 })
queue.enqueue({ type: 'set-capture-running', running: false })
queue.flush((command) => received.push(command.type))
assert.deepEqual(received, ['open-settings', 'set-capture-running'])
assert.deepEqual(received, ['open-settings', 'set-rolling-capture', 'set-capture-running'])
queue.flush((command) => received.push(command.type))
assert.deepEqual(received, ['open-settings', 'set-capture-running'])
assert.deepEqual(received, ['open-settings', 'set-rolling-capture', 'set-capture-running'])
})
test('tray assets resolve for development and packaged builds', () => {
+22
View File
@@ -3692,6 +3692,28 @@ test('toolbar uses the Prism logo support link and static package icons are conf
}))
})
test('rolling capture exposes persisted duration controls and a native toolbar drag target', async () => {
const bottomBarSource = await readFile(join(process.cwd(), 'src', 'renderer', 'components', 'BottomBar.tsx'), 'utf8')
const toolbarSource = await readFile(join(process.cwd(), 'src', 'renderer', 'components', 'Toolbar.tsx'), 'utf8')
const preloadSource = await readFile(join(process.cwd(), 'src', 'preload', 'index.ts'), 'utf8')
const mainSource = await readFile(join(process.cwd(), 'src', 'main', 'index.ts'), 'utf8')
const trayBridgeSource = await readFile(join(process.cwd(), 'src', 'renderer', 'components', 'TrayControlBridge.tsx'), 'utf8')
assert.match(bottomBarSource, /ROLLING_CAPTURE_DURATIONS\.map/)
assert.match(bottomBarSource, /setRollingCaptureSeconds\(null\)/)
assert.match(bottomBarSource, /revealRollingCaptureFolder/)
assert.match(toolbarSource, /className=\{`toolbar__clip-chip/)
assert.match(toolbarSource, /draggable=\{rollingCaptureStatus\.hasAudio\}/)
assert.match(toolbarSource, /onDragStart=\{handleAudioClipDragStart\}/)
assert.match(preloadSource, /ipcRenderer\.send\('audio-clips:start-drag', payload\)/)
assert.match(mainSource, /event\.sender\.startDrag/)
assert.match(mainSource, /Prism Captures/)
assert.match(mainSource, /label: 'Rolling Capture'/)
assert.match(mainSource, /type: 'set-rolling-capture'/)
assert.match(trayBridgeSource, /audio\.setRollingCaptureSeconds\(command\.durationSeconds\)/)
assert.match(trayBridgeSource, /rollingCaptureSeconds,/)
})
test('resolveWindowCapabilities detects native Wayland sessions on Linux', () => {
assert.deepEqual(
resolveWindowCapabilities({
+128
View File
@@ -0,0 +1,128 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { RollingAudioBuffer } from '../src/renderer/audio/RollingAudioBuffer'
function pcm16(sample: number): number {
if (!Number.isFinite(sample)) return 0
const clamped = Math.max(-1, Math.min(1, sample))
return clamped < 0
? Math.round(clamped * 32768)
: Math.round(clamped * 32767)
}
test('rolling audio buffer allocates exact fixed PCM capacity and quantizes stereo', () => {
const buffer = new RollingAudioBuffer(5, 2, 2)
assert.equal(buffer.allocatedBytes, 5 * 2 * 2 * 2)
assert.equal(buffer.frameCount, 0)
assert.equal(buffer.snapshot(), null)
buffer.append(
new Float32Array([-2, -0.5, 0, 0.5, 2, Number.NaN]),
new Float32Array([1, 0.25, 0, -0.25, -1, Number.POSITIVE_INFINITY]),
2,
)
const snapshot = buffer.snapshot()
assert.ok(snapshot)
assert.equal(snapshot.channelCount, 2)
assert.equal(snapshot.sampleRate, 2)
assert.equal(snapshot.frameCount, 6)
assert.deepEqual(Array.from(snapshot.pcmSamples), [
-32768, 32767,
-16384, 8192,
0, 0,
16384, -8192,
32767, -32768,
0, 0,
])
})
test('rolling audio buffer keeps chronological newest frames across wrapping', () => {
const buffer = new RollingAudioBuffer(5, 2, 2)
const left = new Float32Array(Array.from({ length: 12 }, (_, index) => index / 20))
const right = new Float32Array(Array.from({ length: 12 }, (_, index) => -index / 20))
buffer.append(left.subarray(0, 6), right.subarray(0, 6), 2)
buffer.append(left.subarray(6), right.subarray(6), 2)
const snapshot = buffer.snapshot()
assert.ok(snapshot)
assert.equal(snapshot.frameCount, 10)
assert.equal(buffer.isReady, true)
const expected: number[] = []
for (let index = 2; index < 12; index += 1) {
expected.push(pcm16(left[index]), pcm16(right[index]))
}
assert.deepEqual(Array.from(snapshot.pcmSamples), expected)
})
test('rolling audio buffer supports mono and ignores mismatched channel chunks', () => {
const buffer = new RollingAudioBuffer(5, 2, 1)
buffer.append(new Float32Array([0.25, -0.25]), new Float32Array([1, 1]), 2)
assert.equal(buffer.frameCount, 0)
buffer.append(new Float32Array([0.25, -0.25]), new Float32Array(), 1)
const snapshot = buffer.snapshot()
assert.ok(snapshot)
assert.equal(snapshot.channelCount, 1)
assert.deepEqual(Array.from(snapshot.pcmSamples), [8192, -8192])
})
test('rolling audio buffer preserves newest audio while growing and shrinking', () => {
const buffer = new RollingAudioBuffer(5, 2, 1)
const initial = new Float32Array(Array.from({ length: 10 }, (_, index) => index / 20))
buffer.append(initial, new Float32Array(), 1)
buffer.resize(10)
assert.equal(buffer.allocatedBytes, 10 * 2 * 1 * 2)
assert.equal(buffer.frameCount, 10)
assert.equal(buffer.isReady, false)
const appended = new Float32Array(Array.from({ length: 12 }, (_, index) => (index + 10) / 40))
buffer.append(appended, new Float32Array(), 1)
buffer.resize(5)
const snapshot = buffer.snapshot()
assert.ok(snapshot)
assert.equal(snapshot.frameCount, 10)
assert.equal(buffer.isReady, true)
assert.deepEqual(
Array.from(snapshot.pcmSamples),
Array.from(appended.subarray(2), pcm16),
)
})
test('rolling audio buffer keeps only the tail of chunks larger than capacity', () => {
const buffer = new RollingAudioBuffer(5, 2, 1)
const input = new Float32Array(Array.from({ length: 14 }, (_, index) => index / 20))
buffer.append(input, new Float32Array(), 1)
const snapshot = buffer.snapshot()
assert.ok(snapshot)
assert.deepEqual(
Array.from(snapshot.pcmSamples),
Array.from(input.subarray(4), pcm16),
)
})
test('rolling audio buffer append stays below the one millisecond chunk budget', () => {
const buffer = new RollingAudioBuffer(60, 48000, 2)
const left = new Float32Array(128).fill(0.25)
const right = new Float32Array(128).fill(-0.25)
for (let index = 0; index < 100; index += 1) {
buffer.append(left, right, 2)
}
const durations: number[] = []
for (let index = 0; index < 1000; index += 1) {
const startedAt = performance.now()
buffer.append(left, right, 2)
durations.push(performance.now() - startedAt)
}
durations.sort((leftDuration, rightDuration) => leftDuration - rightDuration)
const p95 = durations[Math.ceil(durations.length * 0.95) - 1] ?? Number.POSITIVE_INFINITY
assert.ok(p95 < 1, `expected rolling buffer p95 under 1ms, received ${p95.toFixed(3)}ms`)
})