summaryrefslogtreecommitdiff
path: root/apps/web/src/voiceover/__tests__
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-07-27 01:21:20 +0200
committercashmere <cashmere@autistici.org>2026-07-27 01:21:20 +0200
commit783e9ac8462751adc14d78b070095fd63e45bda0 (patch)
tree9e28a0937b9155c6b791af836c973f39ced9d541 /apps/web/src/voiceover/__tests__
parent30a36e45686db503fded68e4da5d4c465a06f978 (diff)
downloadopencut-783e9ac8462751adc14d78b070095fd63e45bda0.tar.gz
feat: voice-over recording into the timeline
MediaRecorder mic capture (defensive mimeType picking, permission/no-mic toasts) with elapsed display in the timeline toolbar. Stop produces an audio asset and inserts it at the playhead on an audio track as ONE BatchCommand undo step, freeze-frame precedent. voice-over action registered, no default keybinding. 20 tests; smoke-tested in Chromium with a fake mic end to end.
Diffstat (limited to 'apps/web/src/voiceover/__tests__')
-rw-r--r--apps/web/src/voiceover/__tests__/recorder.test.ts126
-rw-r--r--apps/web/src/voiceover/__tests__/voice-over-command.test.ts187
2 files changed, 313 insertions, 0 deletions
diff --git a/apps/web/src/voiceover/__tests__/recorder.test.ts b/apps/web/src/voiceover/__tests__/recorder.test.ts
new file mode 100644
index 00000000..1cc89f4c
--- /dev/null
+++ b/apps/web/src/voiceover/__tests__/recorder.test.ts
@@ -0,0 +1,126 @@
+import { describe, expect, test } from "bun:test";
+import {
+ formatElapsedSeconds,
+ getFileExtensionForMimeType,
+ getVoiceOverErrorMessage,
+ pickSupportedMimeType,
+ PREFERRED_MIME_TYPES,
+} from "@/voiceover/recorder";
+
+describe("pickSupportedMimeType", () => {
+ test("prefers webm/opus when everything is supported", () => {
+ const mimeType = pickSupportedMimeType({
+ isTypeSupported: () => true,
+ });
+ expect(mimeType).toBe("audio/webm;codecs=opus");
+ });
+
+ test("falls back to the first supported candidate", () => {
+ const mimeType = pickSupportedMimeType({
+ isTypeSupported: (candidate) => candidate === "audio/mp4",
+ });
+ expect(mimeType).toBe("audio/mp4");
+ });
+
+ test("returns null when nothing is supported", () => {
+ const mimeType = pickSupportedMimeType({
+ isTypeSupported: () => false,
+ });
+ expect(mimeType).toBeNull();
+ });
+
+ test("respects custom candidate order", () => {
+ const mimeType = pickSupportedMimeType({
+ isTypeSupported: () => true,
+ candidates: ["audio/ogg", "audio/webm"],
+ });
+ expect(mimeType).toBe("audio/ogg");
+ });
+
+ test("default candidates put webm before mp4", () => {
+ expect(PREFERRED_MIME_TYPES.indexOf("audio/webm;codecs=opus")).toBeLessThan(
+ PREFERRED_MIME_TYPES.indexOf("audio/mp4"),
+ );
+ });
+});
+
+describe("getFileExtensionForMimeType", () => {
+ test("maps known audio mime types", () => {
+ expect(getFileExtensionForMimeType({ mimeType: "audio/webm" })).toBe(
+ "webm",
+ );
+ expect(
+ getFileExtensionForMimeType({ mimeType: "audio/webm;codecs=opus" }),
+ ).toBe("webm");
+ expect(getFileExtensionForMimeType({ mimeType: "audio/ogg" })).toBe("ogg");
+ expect(getFileExtensionForMimeType({ mimeType: "audio/mp4" })).toBe("m4a");
+ expect(getFileExtensionForMimeType({ mimeType: "audio/mpeg" })).toBe(
+ "mp3",
+ );
+ });
+
+ test("falls back to a generic extension for unknown types", () => {
+ expect(getFileExtensionForMimeType({ mimeType: "audio/flac" })).toBe(
+ "audio",
+ );
+ expect(getFileExtensionForMimeType({ mimeType: "" })).toBe("audio");
+ });
+});
+
+describe("formatElapsedSeconds", () => {
+ test("formats zero and sub-second values", () => {
+ expect(formatElapsedSeconds({ seconds: 0 })).toBe("0:00");
+ expect(formatElapsedSeconds({ seconds: 0.9 })).toBe("0:00");
+ });
+
+ test("pads seconds", () => {
+ expect(formatElapsedSeconds({ seconds: 9.6 })).toBe("0:09");
+ });
+
+ test("formats minutes", () => {
+ expect(formatElapsedSeconds({ seconds: 65 })).toBe("1:05");
+ expect(formatElapsedSeconds({ seconds: 600 })).toBe("10:00");
+ });
+
+ test("clamps negative values", () => {
+ expect(formatElapsedSeconds({ seconds: -3 })).toBe("0:00");
+ });
+});
+
+describe("getVoiceOverErrorMessage", () => {
+ test("explains denied permission", () => {
+ const message = getVoiceOverErrorMessage({
+ error: new DOMException("denied", "NotAllowedError"),
+ });
+ expect(message).toContain("Microphone access denied");
+ });
+
+ test("explains a missing microphone", () => {
+ const message = getVoiceOverErrorMessage({
+ error: new DOMException("missing", "NotFoundError"),
+ });
+ expect(message).toContain("No microphone found");
+ });
+
+ test("explains a busy microphone", () => {
+ const message = getVoiceOverErrorMessage({
+ error: new DOMException("busy", "NotReadableError"),
+ });
+ expect(message).toContain("already in use");
+ });
+
+ test("surfaces generic error messages", () => {
+ const message = getVoiceOverErrorMessage({
+ error: new Error("Voice-over recording is not supported in this browser"),
+ });
+ expect(message).toBe(
+ "Voice-over recording is not supported in this browser",
+ );
+ });
+
+ test("handles non-error values", () => {
+ expect(getVoiceOverErrorMessage({ error: null })).toBe(
+ "Could not start voice-over recording.",
+ );
+ });
+});
diff --git a/apps/web/src/voiceover/__tests__/voice-over-command.test.ts b/apps/web/src/voiceover/__tests__/voice-over-command.test.ts
new file mode 100644
index 00000000..4ba2b3fd
--- /dev/null
+++ b/apps/web/src/voiceover/__tests__/voice-over-command.test.ts
@@ -0,0 +1,187 @@
+import { beforeEach, describe, expect, test } from "bun:test";
+import { EditorCore } from "@/core";
+import {
+ AddMediaAssetCommand,
+ BatchCommand,
+ InsertElementCommand,
+} from "@/commands";
+import { buildDefaultScene } from "@/timeline/scenes";
+import { buildElementFromMedia } from "@/timeline";
+import { mediaTimeFromSeconds, ZERO_MEDIA_TIME, type MediaTime } from "@/wasm";
+import { storageService } from "@/services/storage/service";
+
+const RECORDING_DURATION_SECONDS = 12.5;
+const RECORDING_DURATION = mediaTimeFromSeconds({
+ seconds: RECORDING_DURATION_SECONDS,
+});
+const PLAYHEAD_TIME = mediaTimeFromSeconds({ seconds: 2 });
+
+function seedEditorWithEmptyScene(): EditorCore {
+ const editor = EditorCore.getInstance();
+ const scene = buildDefaultScene({ name: "Main scene", isMain: true });
+ editor.project.setActiveProject({
+ project: {
+ metadata: {
+ id: "project-1",
+ name: "Test project",
+ duration: ZERO_MEDIA_TIME,
+ createdAt: new Date("2026-01-01T00:00:00Z"),
+ updatedAt: new Date("2026-01-01T00:00:00Z"),
+ },
+ scenes: [scene],
+ currentSceneId: scene.id,
+ settings: {
+ fps: { numerator: 30, denominator: 1 },
+ canvasSize: { width: 1920, height: 1080 },
+ background: { type: "color", color: "#000000" },
+ },
+ version: 1,
+ },
+ });
+ editor.scenes.setScenes({ scenes: [scene], activeSceneId: scene.id });
+ editor.media.setAssets({ assets: [] });
+ return editor;
+}
+
+/**
+ * Mirrors the stop-recording branch of the "voice-over" action handler in
+ * use-editor-actions.ts: one undoable batch that adds the recorded audio as a
+ * media asset and inserts it at the playhead with automatic audio placement.
+ */
+function buildVoiceOverBatch({
+ projectId,
+ startTime,
+}: {
+ projectId: string;
+ startTime: MediaTime;
+}): { batch: BatchCommand; assetId: string } {
+ const file = new File([new Blob(["fake-audio"])], "voice-over.webm", {
+ type: "audio/webm",
+ });
+ const addMediaCommand = new AddMediaAssetCommand({
+ projectId,
+ asset: {
+ file,
+ name: "Voice over",
+ type: "audio",
+ url: "blob:voice-over-test",
+ duration: RECORDING_DURATION_SECONDS,
+ },
+ });
+ const insertCommand = new InsertElementCommand({
+ element: buildElementFromMedia({
+ mediaId: addMediaCommand.getAssetId(),
+ mediaType: "audio",
+ name: "Voice over",
+ duration: RECORDING_DURATION,
+ startTime,
+ }),
+ placement: { mode: "auto", trackType: "audio" },
+ });
+ return {
+ batch: new BatchCommand([addMediaCommand, insertCommand]),
+ assetId: addMediaCommand.getAssetId(),
+ };
+}
+
+describe("voice-over batch command", () => {
+ beforeEach(() => {
+ EditorCore.reset();
+ // IndexedDB is unavailable under bun:test; the command treats a failed
+ // save as fatal and rolls back the asset, so stub persistence out.
+ storageService.saveMediaAsset = () => Promise.resolve();
+ storageService.deleteMediaAsset = () => Promise.resolve();
+ });
+
+ test("adds an audio asset and inserts it on a new audio track at the playhead", () => {
+ const editor = seedEditorWithEmptyScene();
+ const { batch, assetId } = buildVoiceOverBatch({
+ projectId: "project-1",
+ startTime: PLAYHEAD_TIME,
+ });
+
+ batch.execute();
+
+ const assets = editor.media.getAssets();
+ expect(assets).toHaveLength(1);
+ expect(assets[0].id).toBe(assetId);
+ expect(assets[0].type).toBe("audio");
+ expect(assets[0].name).toBe("Voice over");
+ expect(assets[0].duration).toBe(RECORDING_DURATION_SECONDS);
+
+ const { tracks } = editor.scenes.getActiveScene();
+ expect(tracks.audio).toHaveLength(1);
+ expect(tracks.audio[0].elements).toHaveLength(1);
+ const element = tracks.audio[0].elements[0];
+ expect(element.type).toBe("audio");
+ expect(element.startTime).toBe(PLAYHEAD_TIME);
+ expect(element.duration).toBe(RECORDING_DURATION);
+ if (element.type === "audio" && element.sourceType === "upload") {
+ expect(element.mediaId).toBe(assetId);
+ } else {
+ throw new Error("expected an upload audio element");
+ }
+ });
+
+ test("undo removes both the element and the asset as a single step", () => {
+ const editor = seedEditorWithEmptyScene();
+ const { batch } = buildVoiceOverBatch({
+ projectId: "project-1",
+ startTime: PLAYHEAD_TIME,
+ });
+
+ batch.execute();
+ batch.undo();
+
+ expect(editor.media.getAssets()).toHaveLength(0);
+ const { tracks } = editor.scenes.getActiveScene();
+ expect(
+ tracks.audio.flatMap((track) => track.elements),
+ ).toHaveLength(0);
+ });
+
+ test("redo re-inserts the element and restores the asset", () => {
+ const editor = seedEditorWithEmptyScene();
+ const { batch, assetId } = buildVoiceOverBatch({
+ projectId: "project-1",
+ startTime: PLAYHEAD_TIME,
+ });
+
+ batch.execute();
+ batch.undo();
+ batch.redo();
+
+ expect(editor.media.getAssets().map((asset) => asset.id)).toEqual([
+ assetId,
+ ]);
+ const { tracks } = editor.scenes.getActiveScene();
+ const elements = tracks.audio.flatMap((track) => track.elements);
+ expect(elements).toHaveLength(1);
+ expect(elements[0].startTime).toBe(PLAYHEAD_TIME);
+ });
+
+ test("reuses an existing audio track when the slot is free", () => {
+ const editor = seedEditorWithEmptyScene();
+ const first = buildVoiceOverBatch({
+ projectId: "project-1",
+ startTime: PLAYHEAD_TIME,
+ });
+ first.batch.execute();
+
+ const secondStart = mediaTimeFromSeconds({
+ seconds: 2 + RECORDING_DURATION_SECONDS + 1,
+ });
+ const second = buildVoiceOverBatch({
+ projectId: "project-1",
+ startTime: secondStart,
+ });
+ second.batch.execute();
+
+ const { tracks } = editor.scenes.getActiveScene();
+ expect(tracks.audio).toHaveLength(1);
+ expect(tracks.audio[0].elements).toHaveLength(2);
+ expect(
+ tracks.audio[0].elements.map((element) => element.startTime),
+ ).toEqual([PLAYHEAD_TIME, secondStart]);
+ });
+});