diff --git a/electron/audio/capture-preload-utils.cjs b/electron/audio/capture-preload-utils.cjs new file mode 100644 index 0000000..6c972fb --- /dev/null +++ b/electron/audio/capture-preload-utils.cjs @@ -0,0 +1,24 @@ +function serializeError(error) { + return { + message: error instanceof Error ? error.message : String(error), + name: error && typeof error.name === "string" ? error.name : "", + }; +} + +function handleRecorderStop({ sendChain, id, getStopEpoch, cleanup, send }) { + return sendChain + .then(() => { + const stopEpoch = getStopEpoch(); + cleanup(); + send("audio:stopped", id, stopEpoch); + }) + .catch((error) => { + const stopEpoch = getStopEpoch(); + cleanup(); + const detail = serializeError(error); + send("audio:error", id, detail.message, detail.name); + send("audio:stopped", id, stopEpoch); + }); +} + +module.exports = { handleRecorderStop, serializeError }; diff --git a/electron/audio/capture-preload-utils.test.ts b/electron/audio/capture-preload-utils.test.ts new file mode 100644 index 0000000..0e443c2 --- /dev/null +++ b/electron/audio/capture-preload-utils.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import test from "node:test"; + +const require = createRequire(import.meta.url); +const { handleRecorderStop } = require("./capture-preload-utils.cjs"); + +test("audio stop reports a rejected chunk flush", async () => { + const events: unknown[][] = []; + let cleaned = false; + await handleRecorderStop({ + sendChain: Promise.reject(new Error("chunk flush failed")), + id: "segment-0001", + getStopEpoch: () => 123, + cleanup: () => { + cleaned = true; + }, + send: (...args: unknown[]) => { + events.push(args); + }, + }); + assert.equal(cleaned, true); + assert.deepEqual(events, [ + ["audio:error", "segment-0001", "chunk flush failed", "Error"], + ["audio:stopped", "segment-0001", 123], + ]); +}); + +test("audio stop acknowledges a successful chunk flush", async () => { + const events: unknown[][] = []; + let cleaned = false; + await handleRecorderStop({ + sendChain: Promise.resolve(), + id: "segment-0001", + getStopEpoch: () => 123, + cleanup: () => { + cleaned = true; + }, + send: (...args: unknown[]) => { + events.push(args); + }, + }); + assert.equal(cleaned, true); + assert.deepEqual(events, [["audio:stopped", "segment-0001", 123]]); +}); diff --git a/electron/audio/capture-preload.cjs b/electron/audio/capture-preload.cjs index 2c32d24..3f6d786 100644 --- a/electron/audio/capture-preload.cjs +++ b/electron/audio/capture-preload.cjs @@ -5,6 +5,7 @@ // mirror electron/audio/recorder.ts. const { ipcRenderer } = require("electron"); const { readFile } = require("node:fs/promises"); +const { handleRecorderStop, serializeError } = require("./capture-preload-utils.cjs"); /** @type {MediaRecorder | null} */ let recorder = null; @@ -29,13 +30,6 @@ function microphoneConstraints(deviceId) { }; } -function serializeError(error) { - return { - message: error instanceof Error ? error.message : String(error), - name: error && typeof error.name === "string" ? error.name : "", - }; -} - async function enumerateMicrophones() { if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return []; const devices = await navigator.mediaDevices.enumerateDevices(); @@ -136,10 +130,12 @@ ipcRenderer.on("audio:start", async (_event, opts) => { }; recorder.onstop = () => { // Wait for every queued chunk (including the final one) to be sent. - sendChain.then(() => { - const stopEpoch = requestedStopEpoch || epochNow(); - cleanup(); - ipcRenderer.send("audio:stopped", id, stopEpoch); + void handleRecorderStop({ + sendChain, + id, + getStopEpoch: () => requestedStopEpoch || epochNow(), + cleanup, + send: (...args) => ipcRenderer.send(...args), }); }; recorder.onerror = (e) => { diff --git a/package.json b/package.json index dd1ac9a..2014835 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "check:lockfile": "node scripts/check-lockfile-portability.mjs", "typecheck": "tsc --noEmit", "typecheck:evals": "tsc --noEmit -p evals/tsconfig.json", - "test": "node --experimental-transform-types --no-warnings --import ./evals/register.mjs --test evals/builder-imports.test.ts common/architecture-registry.test.ts electron/architectures/catalogue-registry.test.ts common/audio.test.ts common/microphone.test.ts common/screen.test.ts common/narration.test.ts common/sensitive.test.ts common/skill.test.ts electron/recording-controls-bounds.test.ts electron/recorder-window-sizing.test.ts electron/recording-privacy.test.ts electron/crash-guards.test.ts electron/copilot-signin-auth.test.ts electron/copilot-signin-flow.test.ts electron/copilot-signin-process.test.ts electron/microsoft-signin-hint.test.ts src/analysis-recovery.test.ts electron/recorder/controller.test.ts electron/recorder/session-store.test.ts electron/frames/extractor.test.ts electron/pipeline.test.ts electron/narration/audio-analysis.test.ts electron/narration/analyze-gate.test.ts electron/narration/transcribe.test.ts electron/narration/whisper.test.ts electron/sensitive/scanner.test.ts electron/sensitive/secrets.test.ts electron/sensitive/tessdata-source.test.ts electron/sensitive/ocr.test.ts electron/sensitive/frame-redact.test.ts electron/sensitive/frame-heuristics.test.ts electron/terminal/terminal.test.ts electron/sessions.test.ts electron/debug-bundle.test.ts electron/skillbuilder/plan-tools.test.ts electron/skillbuilder/placement.test.ts electron/skillbuilder/preview.test.ts src/skill-placement.test.ts src/skill-review-state.test.ts scripts/compliance.test.mjs scripts/install-windows-dependencies.test.mjs", + "test": "node --experimental-transform-types --no-warnings --import ./evals/register.mjs --test evals/builder-imports.test.ts common/architecture-registry.test.ts electron/architectures/catalogue-registry.test.ts electron/audio/capture-preload-utils.test.ts common/audio.test.ts common/microphone.test.ts common/screen.test.ts common/narration.test.ts common/sensitive.test.ts common/skill.test.ts electron/recording-controls-bounds.test.ts electron/recorder-window-sizing.test.ts electron/recording-privacy.test.ts electron/crash-guards.test.ts electron/copilot-signin-auth.test.ts electron/copilot-signin-flow.test.ts electron/copilot-signin-process.test.ts electron/microsoft-signin-hint.test.ts src/analysis-recovery.test.ts electron/recorder/controller.test.ts electron/recorder/session-store.test.ts electron/frames/extractor.test.ts electron/pipeline.test.ts electron/narration/audio-analysis.test.ts electron/narration/analyze-gate.test.ts electron/narration/transcribe.test.ts electron/narration/whisper.test.ts electron/sensitive/scanner.test.ts electron/sensitive/secrets.test.ts electron/sensitive/tessdata-source.test.ts electron/sensitive/ocr.test.ts electron/sensitive/frame-redact.test.ts electron/sensitive/frame-heuristics.test.ts electron/terminal/terminal.test.ts electron/sessions.test.ts electron/debug-bundle.test.ts electron/skillbuilder/plan-tools.test.ts electron/skillbuilder/placement.test.ts electron/skillbuilder/preview.test.ts src/skill-placement.test.ts src/skill-review-state.test.ts scripts/compliance.test.mjs scripts/install-windows-dependencies.test.mjs", "eval": "node --experimental-transform-types --no-warnings --import ./evals/register.mjs evals/run.ts", "eval:terminal": "node --experimental-transform-types --no-warnings --import ./evals/register.mjs evals/run.ts --only=terminal-output-required,terminal-failure-recovery,terminal-sensitive-output", "eval:builder": "node --experimental-transform-types --no-warnings --import ./evals/register.mjs evals/builder/run.ts", diff --git a/vite.config.ts b/vite.config.ts index cefdf0a..bf0ade8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -38,6 +38,10 @@ function copyStaticAssets(): void { path.join(rootDir, "electron", "audio", "capture-preload.cjs"), path.join(out, "audio", "capture-preload.cjs"), ); + copyFileSync( + path.join(rootDir, "electron", "audio", "capture-preload-utils.cjs"), + path.join(out, "audio", "capture-preload-utils.cjs"), + ); } export default defineConfig({