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