diff --git a/src/cli/CliExportRunner.audio.test.ts b/src/cli/CliExportRunner.audio.test.ts
new file mode 100644
index 0000000000..2cdbd5fbe4
--- /dev/null
+++ b/src/cli/CliExportRunner.audio.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from "vitest";
+import type { AxcutDocument } from "@/lib/ai-edition/schema";
+import { axcutSchemaVersion } from "@/lib/ai-edition/schema";
+import { buildNativeClipList } from "./CliExportRunner";
+
+const document: AxcutDocument = {
+ schemaVersion: axcutSchemaVersion,
+ project: {
+ id: "project",
+ title: "Audio mute",
+ createdAt: "2026-10-12T00:00:00.000Z",
+ updatedAt: "2026-10-12T00:00:00.000Z",
+ primaryAssetId: "recording",
+ },
+ assets: [
+ {
+ id: "recording",
+ kind: "video",
+ label: "recording.mp4",
+ originalPath: "/tmp/recording.mp4",
+ cameraTrack: null,
+ durationSec: 4,
+ video: { codec: "h264", width: 1920, height: 1080, fps: 30 },
+ },
+ ],
+ transcript: null,
+ timeline: {
+ clips: [
+ {
+ id: "clip",
+ assetId: "recording",
+ sourceStartSec: 0,
+ sourceEndSec: 4,
+ timelineStartSec: 0,
+ timelineEndSec: 4,
+ wordRefs: [],
+ origin: "user",
+ reason: "",
+ },
+ ],
+ gaps: [],
+ trimRanges: [],
+ muteRanges: [],
+ speedRanges: [],
+ captionRanges: [],
+ },
+ annotations: [],
+ zoomRanges: [],
+ audioTracks: [],
+ transcripts: [],
+ legacyEditor: null,
+};
+
+describe("CLI recording audio mute", () => {
+ it("keeps clip audio by default and disables it only for a muted project", () => {
+ expect(buildNativeClipList(document)[0]?.hasAudio).toBe(true);
+ expect(
+ buildNativeClipList({
+ ...document,
+ legacyEditor: { recordingAudioMuted: true },
+ })[0]?.hasAudio,
+ ).toBe(false);
+ });
+});
diff --git a/src/cli/CliExportRunner.tsx b/src/cli/CliExportRunner.tsx
index 85b0668519..27cff7088c 100644
--- a/src/cli/CliExportRunner.tsx
+++ b/src/cli/CliExportRunner.tsx
@@ -80,7 +80,9 @@ function replaceExtension(filePath: string, newExtension: string): string {
/** Mirrors ExportDialog.buildNativeClipList: trim-narrowed visible clips mapped
* onto the native multiclip contract. Kept in lock-step with
* buildSceneDescription so export and scene agree on the clip stream. */
-function buildNativeClipList(axcutDocument: AxcutDocument): CompositorClipInput[] {
+/** @internal Exported for the focused clip-audio policy test. */
+export function buildNativeClipList(axcutDocument: AxcutDocument): CompositorClipInput[] {
+ const recordingAudioMuted = getEditorSettings(axcutDocument).recordingAudioMuted;
const assetById = new Map(axcutDocument.assets.map((asset) => [asset.id, asset]));
return resolveVisibleClips(axcutDocument).flatMap((clip) => {
const asset = assetById.get(clip.assetId);
@@ -96,7 +98,7 @@ function buildNativeClipList(axcutDocument: AxcutDocument): CompositorClipInput[
sourceStartSec: clip.sourceStartSec,
sourceEndSec,
webcamOffsetSec: camera.offsetSec,
- hasAudio: true,
+ hasAudio: !recordingAudioMuted,
},
];
});
diff --git a/src/components/ai-edition/ExportDialog.params.test.tsx b/src/components/ai-edition/ExportDialog.params.test.tsx
index d78ad091c2..4e2de3e66f 100644
--- a/src/components/ai-edition/ExportDialog.params.test.tsx
+++ b/src/components/ai-edition/ExportDialog.params.test.tsx
@@ -142,6 +142,16 @@ describe("ExportDialog MP4 params", () => {
expect(at30).toMatchObject({ fps: 30, bitrate: expected(30) });
expect(at30?.bitrate).toBeLessThan(at60?.bitrate ?? 0);
});
+
+ it.each([false, true])("sets clip hasAudio when recording mute is %s", async (muted) => {
+ renderDialog({
+ ...DOC,
+ legacyEditor: { ...DOC.legacyEditor, recordingAudioMuted: muted },
+ });
+ await exportMp4();
+ const clips = vi.mocked(exportMultiNative).mock.calls.at(-1)?.[0];
+ expect(clips?.[0]?.hasAudio).toBe(!muted);
+ });
});
describe("ExportDialog format settings", () => {
diff --git a/src/components/ai-edition/ExportDialog.tsx b/src/components/ai-edition/ExportDialog.tsx
index a336901d85..0857319b86 100644
--- a/src/components/ai-edition/ExportDialog.tsx
+++ b/src/components/ai-edition/ExportDialog.tsx
@@ -111,6 +111,7 @@ function revealExportedFile(filePath: string): void {
* each with its asset's screen file + camera file (falls back to the screen when a clip has
* no camera — the no-webcam layout is a later step) and its source trim. */
function buildNativeClipList(document: AxcutDocument): CompositorClipInput[] {
+ const recordingAudioMuted = getEditorSettings(document).recordingAudioMuted;
const assetById = new Map(document.assets.map((a) => [a.id, a]));
return resolveVisibleClips(document).flatMap((clip) => {
const asset = assetById.get(clip.assetId);
@@ -122,9 +123,8 @@ function buildNativeClipList(document: AxcutDocument): CompositorClipInput[] {
// the single canonical precedence used by every consumer (clip.probe → asset.duration
// → timeline-length guess). See `resolveClipSourceEndSec` for the full order.
const sourceEndSec = resolveClipSourceEndSec(clip, asset);
- // ponytail: `hasAudio` stays optimistic for the same reason as in
- // `buildSceneDescription` — nothing populates `asset.audio` yet, and the
- // native side degrades cleanly on a stream-less file.
+ // `asset.audio` is not populated by the probe yet, so keep the current
+ // optimistic default unless the project explicitly mutes its recording audio.
return [
{
screenPath: asset.originalPath,
@@ -132,7 +132,7 @@ function buildNativeClipList(document: AxcutDocument): CompositorClipInput[] {
sourceStartSec: clip.sourceStartSec,
sourceEndSec,
webcamOffsetSec: camera.offsetSec,
- hasAudio: true,
+ hasAudio: !recordingAudioMuted,
},
];
});
diff --git a/src/components/ai-edition/RightPanes.audioPane.test.tsx b/src/components/ai-edition/RightPanes.audioPane.test.tsx
new file mode 100644
index 0000000000..e55206192e
--- /dev/null
+++ b/src/components/ai-edition/RightPanes.audioPane.test.tsx
@@ -0,0 +1,45 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom";
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { I18nProvider } from "@/contexts/I18nContext";
+import { LOCALE_STORAGE_KEY } from "@/i18n/config";
+
+const { setSettings } = vi.hoisted(() => ({ setSettings: vi.fn() }));
+
+vi.mock("@/lib/ai-edition/store/useEditorSettings", () => ({
+ useEditorSettings: () => ({
+ settings: { audioGainDb: -6, recordingAudioMuted: true },
+ set: setSettings,
+ setLive: vi.fn(),
+ commit: vi.fn(),
+ hasDocument: true,
+ }),
+}));
+
+import { AudioPane } from "./RightPanes";
+
+beforeEach(() => {
+ localStorage.clear();
+ localStorage.setItem(LOCALE_STORAGE_KEY, "en");
+ setSettings.mockClear();
+});
+
+afterEach(() => {
+ cleanup();
+ localStorage.clear();
+});
+
+describe("AudioPane reset button", () => {
+ it("resets output gain and unmutes recording audio", () => {
+ render(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Reset audio" }));
+
+ expect(setSettings).toHaveBeenCalledWith({ audioGainDb: 0, recordingAudioMuted: false });
+ });
+});
diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx
index 17f3350487..cff4294439 100644
--- a/src/components/ai-edition/RightPanes.tsx
+++ b/src/components/ai-edition/RightPanes.tsx
@@ -3462,11 +3462,29 @@ export function AudioPane() {
onCommit={() => void commit()}
/>
+
+ {ts("audio.muteRecording")}
+ void set({ recordingAudioMuted: checked })}
+ />
+
diff --git a/src/components/ai-edition/VirtualPreview.audio.test.ts b/src/components/ai-edition/VirtualPreview.audio.test.ts
index d87e65816a..f8fc87cb96 100644
--- a/src/components/ai-edition/VirtualPreview.audio.test.ts
+++ b/src/components/ai-edition/VirtualPreview.audio.test.ts
@@ -142,6 +142,38 @@ describe("applyPreviewAudioSettings", () => {
applyPreviewAudioSettings(null, [element], 0, 6.0206);
expect(element.volume).toBe(1);
});
+
+ it("mutes routed and direct-output recording elements without changing output gain", () => {
+ const graph = fakeGraph();
+ const primary = { volume: 0.2 } as HTMLAudioElement;
+ const supplemental = { volume: 0.2 } as HTMLAudioElement;
+ const directFallback = { volume: 0.2 } as HTMLAudioElement;
+ const fallbackGain = { gain: { value: 1 } } as GainNode;
+ applyPreviewAudioSettings(
+ graph,
+ [primary, supplemental, directFallback],
+ 6.0206,
+ 9.5424,
+ true,
+ {
+ routedElements: new Set([primary, supplemental]),
+ fallbackGainNodes: new Map([[supplemental, fallbackGain]]),
+ },
+ );
+
+ expect(graph.gain.gain.value).toBeCloseTo(2, 3);
+ expect(graph.voice.gain.value).toBe(0);
+ expect(primary.volume).toBe(1);
+ expect(supplemental.volume).toBe(1);
+ expect(fallbackGain.gain.value).toBe(0);
+ expect(directFallback.volume).toBe(0);
+ });
+
+ it("silences newly mounted direct-output sources before WebAudio is available", () => {
+ const element = { volume: 1 } as HTMLAudioElement;
+ applyPreviewAudioSettings(null, [element], 6.0206, 9.5424, true);
+ expect(element.volume).toBe(0);
+ });
});
describe("previewCeilingCurve", () => {
diff --git a/src/components/ai-edition/VirtualPreview.playback.test.tsx b/src/components/ai-edition/VirtualPreview.playback.test.tsx
index c2be743922..019b7a1b6d 100644
--- a/src/components/ai-edition/VirtualPreview.playback.test.tsx
+++ b/src/components/ai-edition/VirtualPreview.playback.test.tsx
@@ -2,7 +2,13 @@
import "@testing-library/jest-dom";
import { act, cleanup, fireEvent, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import type { AxcutClip, AxcutTrimRange } from "@/lib/ai-edition/schema";
+import type {
+ AxcutAudioTrack,
+ AxcutClip,
+ AxcutDocument,
+ AxcutTrimRange,
+} from "@/lib/ai-edition/schema";
+import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import type { SpeedRegion } from "@/lib/ai-edition/timeline/speed";
import { type VideoSource, VirtualPreview } from "./VirtualPreview";
@@ -13,6 +19,7 @@ let frameCallbacks: FrameRequestCallback[] = [];
beforeEach(() => {
frameCallbacks = [];
+ useProjectStore.setState({ document: null, projectId: null });
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
frameCallbacks.push(cb);
return frameCallbacks.length;
@@ -25,6 +32,7 @@ beforeEach(() => {
afterEach(() => {
cleanup();
+ useProjectStore.setState({ document: null, projectId: null });
vi.unstubAllGlobals();
});
@@ -307,7 +315,7 @@ describe("VirtualPreview imported audio tracks", () => {
// in the export. `element.volume` caps at 1, so the boost has to ride a WebAudio gain node.
// jsdom has no WebAudio, so install a minimal fake context and watch the nodes it mints.
class FakeAudioNode {
- connect = vi.fn();
+ connect = vi.fn((_destination?: unknown) => undefined);
disconnect = vi.fn();
}
class FakeGainNode extends FakeAudioNode {
@@ -315,21 +323,55 @@ class FakeGainNode extends FakeAudioNode {
}
let createdGains: FakeGainNode[] = [];
let createdShapers: Array = [];
+let voiceGainNodes = new Set();
+let failSourceCreationFor = new Set();
+let failVoiceConnectionFor = new Set();
+let failAnalyserConnectionFor = new Set();
+let sourceAttempts: Array<{ testId: string; src: string; volume: number }> = [];
+let createdSources: Array<{ testId: string; source: FakeAudioNode }> = [];
class FakeAudioContext {
state = "running";
destination = new FakeAudioNode();
+ private gainNodes: FakeGainNode[] = [];
+ private analyserNode: FakeAudioNode | null = null;
resume = vi.fn(() => Promise.resolve());
close = vi.fn(() => Promise.resolve());
- createMediaElementSource = vi.fn(() => new FakeAudioNode());
+ createMediaElementSource = vi.fn((element: HTMLAudioElement) => {
+ const testId = element.getAttribute("data-testid") ?? "";
+ sourceAttempts.push({ testId, src: element.src, volume: element.volume });
+ if (failSourceCreationFor.has(testId)) throw new Error("source creation failed");
+ const source = new FakeAudioNode();
+ createdSources.push({ testId, source });
+ if (failVoiceConnectionFor.has(testId) || failAnalyserConnectionFor.has(testId)) {
+ source.connect.mockImplementation((destination) => {
+ if (failVoiceConnectionFor.has(testId) && destination === this.gainNodes[2]) {
+ throw new Error("voice connection failed");
+ }
+ if (failAnalyserConnectionFor.has(testId) && destination instanceof FakeGainNode) {
+ destination.connect.mockImplementation((output) => {
+ if (output === this.analyserNode) throw new Error("analyser connection failed");
+ });
+ }
+ });
+ }
+ return source;
+ });
createGain = vi.fn(() => {
const node = new FakeGainNode();
+ this.gainNodes.push(node);
+ if (this.gainNodes.length === 3) voiceGainNodes.add(node);
createdGains.push(node);
return node;
});
// The music ducker listens to the voice; silence here, so nothing ducks.
- createAnalyser = vi.fn(() =>
- Object.assign(new FakeAudioNode(), { fftSize: 2048, getFloatTimeDomainData: vi.fn() }),
- );
+ createAnalyser = vi.fn(() => {
+ const node = Object.assign(new FakeAudioNode(), {
+ fftSize: 2048,
+ getFloatTimeDomainData: vi.fn(),
+ });
+ this.analyserNode = node;
+ return node;
+ });
createWaveShaper = vi.fn(() => {
const node = Object.assign(new FakeAudioNode(), { curve: null });
createdShapers.push(node);
@@ -341,6 +383,12 @@ describe("VirtualPreview imported audio track boost", () => {
beforeEach(() => {
createdGains = [];
createdShapers = [];
+ voiceGainNodes = new Set();
+ failSourceCreationFor = new Set();
+ failVoiceConnectionFor = new Set();
+ failAnalyserConnectionFor = new Set();
+ sourceAttempts = [];
+ createdSources = [];
vi.stubGlobal("AudioContext", FakeAudioContext);
});
@@ -416,6 +464,191 @@ describe("VirtualPreview imported audio track boost", () => {
expect(gain.connect).not.toHaveBeenCalledWith(speakers);
}
});
+
+ it("mutes only the recording when source creation and connection fail independently", async () => {
+ useProjectStore.setState({
+ document: { legacyEditor: { recordingAudioMuted: true } } as unknown as AxcutDocument,
+ });
+ failSourceCreationFor.add("preview-audio-primary");
+ failVoiceConnectionFor.add("preview-audio-supplemental");
+ const music: AxcutAudioTrack = {
+ id: "music-track",
+ assetId: "music",
+ kind: "music",
+ startMs: 0,
+ endMs: 4000,
+ durationSec: 10,
+ offsetMs: 0,
+ gainDb: 6.0206,
+ loop: false,
+ fadeInMs: 0,
+ fadeOutMs: 0,
+ muted: false,
+ label: "",
+ origin: "user",
+ };
+ const voiceover: AxcutAudioTrack = {
+ ...music,
+ id: "voiceover-track",
+ assetId: "voiceover",
+ kind: "voiceover",
+ gainDb: 3,
+ };
+ window.electronAPI = {
+ preparePreviewAudioTrack: vi.fn(async (path: string) => ({
+ success: true,
+ path: `${path}.mic.wav`,
+ })),
+ } as unknown as Window["electronAPI"];
+ const videoSources: VideoSource[] = [
+ {
+ id: "a1",
+ src: "file:///tmp/a1.mp4",
+ filePath: "/tmp/a1.mp4",
+ label: "recording",
+ },
+ ];
+ const audioSources: VideoSource[] = [
+ { id: "music", src: "file:///tmp/music.mp3", label: "music" },
+ { id: "voiceover", src: "file:///tmp/voiceover.mp3", label: "voiceover" },
+ ];
+ const { container } = render(
+ ,
+ );
+ const primary = container.querySelector(
+ "[data-testid='preview-audio-primary']",
+ );
+ if (!primary) throw new Error("no primary recording audio element");
+ const video = container.querySelector("video");
+ if (!video) throw new Error("no preview video");
+ driveVideo(video as HTMLVideoElement);
+ act(() => fireEvent.loadedMetadata(video));
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ const supplemental = container.querySelector(
+ "[data-testid='preview-audio-supplemental']",
+ );
+ if (!supplemental) throw new Error("no supplemental recording audio element");
+ for (const id of ["music-track", "voiceover-track"]) {
+ const audio = container.querySelector(
+ `[data-testid='preview-audio-track-${id}']`,
+ );
+ if (!audio) throw new Error(`no imported audio element for ${id}`);
+ driveAudioEl(audio);
+ }
+ tick();
+
+ expect(primary.volume).toBe(0);
+ expect(supplemental.volume).toBe(1);
+ expect([...voiceGainNodes][0]?.gain.value).toBe(0);
+ const supplementalSource = createdSources.find(
+ (entry) => entry.testId === "preview-audio-supplemental",
+ )?.source;
+ const fallbackGain = supplementalSource?.connect.mock.calls.at(-1)?.[0] as
+ | FakeGainNode
+ | undefined;
+ expect(fallbackGain?.gain.value).toBe(0);
+ for (const testId of [
+ "preview-audio-track-music-track",
+ "preview-audio-track-voiceover-track",
+ ]) {
+ const source = createdSources.find((entry) => entry.testId === testId)?.source;
+ const trackGain = source?.connect.mock.calls.at(-1)?.[0] as FakeGainNode | undefined;
+ expect(trackGain?.gain.value).toBeGreaterThan(0);
+ }
+ });
+
+ it("remounts a new recording source muted before its graph route is ready", async () => {
+ useProjectStore.setState({
+ document: { legacyEditor: { recordingAudioMuted: true } } as unknown as AxcutDocument,
+ });
+ window.electronAPI = {
+ preparePreviewAudioTrack: vi.fn(async (path: string) => ({
+ success: true,
+ path: `${path}.mic.wav`,
+ })),
+ } as unknown as Window["electronAPI"];
+ const videoSources: VideoSource[] = [
+ { id: "a1", src: "file:///tmp/a1.mp4", filePath: "/tmp/a1.mp4", label: "first" },
+ { id: "a2", src: "file:///tmp/a2.mp4", filePath: "/tmp/a2.mp4", label: "second" },
+ ];
+ const clips = [clip("c1", "a1", 0, 4, 0), clip("c2", "a2", 0, 4, 4)];
+ const view = render(
+ ,
+ );
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(sourceAttempts.find((attempt) => attempt.src.includes("a1.mp4"))?.volume).toBe(0);
+
+ failSourceCreationFor.add("preview-audio-primary");
+ await act(async () => {
+ view.rerender(
+ ,
+ );
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ const activePrimary = view.container.querySelector(
+ "[data-testid='preview-audio-primary']",
+ );
+ if (!activePrimary) throw new Error("no remounted primary recording audio element");
+ expect(activePrimary.src).toContain("a2.mp4");
+ expect(sourceAttempts.find((attempt) => attempt.src.includes("a2.mp4"))?.volume).toBe(0);
+ expect(activePrimary.volume).toBe(0);
+ });
+
+ it("disconnects a partial fallback when the analyser route fails after output connects", async () => {
+ useProjectStore.setState({
+ document: { legacyEditor: { recordingAudioMuted: true } } as unknown as AxcutDocument,
+ });
+ failVoiceConnectionFor.add("preview-audio-primary");
+ failAnalyserConnectionFor.add("preview-audio-primary");
+ const videoSources: VideoSource[] = [
+ { id: "a1", src: "file:///tmp/a1.mp4", filePath: "/tmp/a1.mp4", label: "recording" },
+ ];
+ const { container } = render(
+ ,
+ );
+ const video = container.querySelector("video");
+ if (!video) throw new Error("no preview video");
+ driveVideo(video as HTMLVideoElement);
+ act(() => fireEvent.loadedMetadata(video));
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ const source = createdSources.find((entry) => entry.testId === "preview-audio-primary")?.source;
+ if (!source) throw new Error("no primary recording source");
+ const fallbackGain = source.connect.mock.calls.at(-1)?.[0] as FakeGainNode | undefined;
+ if (!fallbackGain) throw new Error("no source-only fallback gain");
+ expect(fallbackGain.connect.mock.results.map((result) => result.type)).toEqual([
+ "return",
+ "throw",
+ ]);
+ expect(fallbackGain.disconnect).toHaveBeenCalled();
+ expect(source.disconnect.mock.calls.length).toBeGreaterThanOrEqual(3);
+ });
});
// The recording's sound is a second element over the same file: a muted