From 020f3d7ecb0caaa840d161747a82b259c10da440 Mon Sep 17 00:00:00 2001 From: CyberSparkx Date: Thu, 10 Sep 2026 18:31:13 +0530 Subject: [PATCH] fix: treat recordings without audio as a non-error transcription state (#628) A screen-only recording with no system audio and no microphone was being reported as a failed transcription job (NoAudioTrackError) instead of a clean informational empty state. Changes: - classifyTranscriptionError() now matches the native NoAudioTrackError by error .name AND by the IPC-wrapped message format ("Error invoking remote method 'stt:transcribe': NoAudioTrackError: ..."), mapping both to the "no-audio" kind rather than "error" - TranscriptionStatusDot renders an amber dot (not red) for no-audio / unsupported-audio failures; tooltip shows only the human label, not the raw engine message - MediaStage detail panel uses amber (--warn) pill colour and noAudioTrackHint copy for silence failures, keeping red (--danger) only for genuinely transient errors - Tests added for native NoAudioTrackError, IPC-wrapped variant, no error-toast behaviour in the store, and amber-vs-red dot rendering Closes #628 --- .../ai-edition/TranscriptionStatus.test.tsx | 40 +++++++++++++++++++ .../ai-edition/TranscriptionStatus.tsx | 11 ++++- src/components/ai-edition/v4/MediaStage.tsx | 15 +++++-- .../store/transcriptionStore.test.ts | 23 +++++++++++ .../ai-edition/transcription/status.test.ts | 22 ++++++++++ src/lib/ai-edition/transcription/status.ts | 13 ++++-- 6 files changed, 116 insertions(+), 8 deletions(-) diff --git a/src/components/ai-edition/TranscriptionStatus.test.tsx b/src/components/ai-edition/TranscriptionStatus.test.tsx index 4bbd09b49..6b9163eb1 100644 --- a/src/components/ai-edition/TranscriptionStatus.test.tsx +++ b/src/components/ai-edition/TranscriptionStatus.test.tsx @@ -114,4 +114,44 @@ describe("TranscriptionStatusDot", () => { expect(container.querySelector("svg")).toBeNull(); expect(container.querySelector("span")).toHaveAttribute("title", "mediaStage.transcriptReady"); }); + + it("renders amber dot and clean title for silent media (no-audio)", () => { + const { container } = render( + , + ); + const span = container.querySelector("span"); + expect(span).toHaveStyle({ background: "#f59e0b" }); + expect(span).toHaveAttribute("title", "mediaStage.noAudioTrack"); + }); + + it("renders danger dot and detail title for actual error failure", () => { + const { container } = render( + , + ); + const span = container.querySelector("span"); + expect(span).toHaveStyle({ background: "var(--danger)" }); + expect(span).toHaveAttribute( + "title", + "mediaStage.transcriptionFailed — whisper-server exited unexpectedly", + ); + }); }); diff --git a/src/components/ai-edition/TranscriptionStatus.tsx b/src/components/ai-edition/TranscriptionStatus.tsx index 2b405441f..cf59cb412 100644 --- a/src/components/ai-edition/TranscriptionStatus.tsx +++ b/src/components/ai-edition/TranscriptionStatus.tsx @@ -105,7 +105,10 @@ export function TranscriptionStatusDot({ ); } - const { fill, halo } = DOT_COLOR[view.status]; + const isSilence = view.failure?.kind === "no-audio" || view.failure?.kind === "unsupported-audio"; + const { fill, halo } = isSilence + ? { fill: "#f59e0b", halo: "0 0 0 3px rgba(245, 158, 11, 0.2)" } + : DOT_COLOR[view.status]; return ( ); } diff --git a/src/components/ai-edition/v4/MediaStage.tsx b/src/components/ai-edition/v4/MediaStage.tsx index a69c615fe..07faa241a 100644 --- a/src/components/ai-edition/v4/MediaStage.tsx +++ b/src/components/ai-edition/v4/MediaStage.tsx @@ -94,6 +94,9 @@ export function MediaStage({ : { assetId: "", status: "idle" }; const selectedBusy = selectedTranscription.status === "running" || selectedTranscription.status === "queued"; + const selectedSilence = + selectedTranscription.failure?.kind === "no-audio" || + selectedTranscription.failure?.kind === "unsupported-audio"; const handleImport = async () => { if (!projectId) { @@ -308,13 +311,17 @@ export function MediaStage({ borderRadius: 9999, background: selectedTranscription.status === "failed" - ? "var(--danger-soft)" + ? selectedSilence + ? "var(--warn-soft)" + : "var(--danger-soft)" : selectedTranscription.status === "ready" ? "var(--success-soft)" : "var(--accent-soft)", color: selectedTranscription.status === "failed" - ? "var(--danger)" + ? selectedSilence + ? "var(--warn)" + : "var(--danger)" : selectedTranscription.status === "ready" ? "var(--success)" : "var(--accent)", @@ -452,7 +459,9 @@ export function MediaStage({ {selectedBusy ? transcriptionLabel(selectedTranscription) : selectedTranscription.status === "failed" - ? t("mediaStage.generationFailedHint") + ? selectedSilence + ? t("mediaStage.noAudioTrackHint") + : t("mediaStage.generationFailedHint") : t("mediaStage.notGeneratedHint")} )} diff --git a/src/lib/ai-edition/store/transcriptionStore.test.ts b/src/lib/ai-edition/store/transcriptionStore.test.ts index 03276bd25..dd33ddf98 100644 --- a/src/lib/ai-edition/store/transcriptionStore.test.ts +++ b/src/lib/ai-edition/store/transcriptionStore.test.ts @@ -195,6 +195,29 @@ describe("useTranscriptionStore", () => { expect(transcribeMocks.transcribeAsset).toHaveBeenCalledTimes(1); }); + it("remembers NoAudioTrackError as a no-audio verdict and does not toast an error", async () => { + const err = Object.assign( + new Error( + "Error invoking remote method 'stt:transcribe': NoAudioTrackError: No decodable audio in /path/to/rec.mp4: Output file #0 does not contain any stream", + ), + { name: "NoAudioTrackError" }, + ); + transcribeMocks.transcribeAsset.mockRejectedValue(err); + loadDocument(makeDoc(["asset_1"])); + + const { sync } = useTranscriptionStore.getState(); + sync(useProjectStore.getState().document); + await whenTranscriptionIdle(); + + const job = useTranscriptionStore.getState().jobs.asset_1; + expect(job?.status).toBe("failed"); + expect(job?.failure?.kind).toBe("no-audio"); + expect(useProjectStore.getState().document?.assets[0].transcriptionFailure?.kind).toBe( + "no-audio", + ); + expect(toastMocks.error).not.toHaveBeenCalled(); + }); + it("skips an asset that already carries a persisted failure on a fresh load", async () => { const doc = makeDoc(["asset_1"]); loadDocument({ diff --git a/src/lib/ai-edition/transcription/status.test.ts b/src/lib/ai-edition/transcription/status.test.ts index d453936bf..88d289e44 100644 --- a/src/lib/ai-edition/transcription/status.test.ts +++ b/src/lib/ai-edition/transcription/status.test.ts @@ -64,6 +64,28 @@ describe("classifyTranscriptionError", () => { ).toBe("no-audio"); }); + it("recognises native extraction NoAudioTrackError", () => { + const err = Object.assign( + new Error("No decodable audio in /tmp/rec.mp4: Output file #0 does not contain any stream"), + { + name: "NoAudioTrackError", + }, + ); + const failure = classifyTranscriptionError(err); + expect(failure.kind).toBe("no-audio"); + expect(isPermanentFailure(failure.kind)).toBe(true); + }); + + it("recognises remote IPC wrapped NoAudioTrackError", () => { + const failure = classifyTranscriptionError( + new Error( + "Error invoking remote method 'stt:transcribe': NoAudioTrackError: No decodable audio in C:\\test\\rec.mp4: Output file #0 does not contain any stream", + ), + ); + expect(failure.kind).toBe("no-audio"); + expect(isPermanentFailure(failure.kind)).toBe(true); + }); + it("recognises an audio codec the caption path cannot read", () => { const failure = classifyTranscriptionError( new Error("Audio codec not supported for captions: ac-3"), diff --git a/src/lib/ai-edition/transcription/status.ts b/src/lib/ai-edition/transcription/status.ts index f9df6a89f..07f0217d4 100644 --- a/src/lib/ai-edition/transcription/status.ts +++ b/src/lib/ai-edition/transcription/status.ts @@ -117,12 +117,19 @@ export type PersistableFailureKind = Exclude; /** * Map an exception out of `transcribeAsset` onto a failure the UI can explain. - * The two deterministic cases come from `extractMono16kWebDemuxer` — it is the - * only layer that knows whether the container actually holds audio. + * The deterministic silence cases come from `electron/stt/extractAudio` (`NoAudioTrackError`, + * "No decodable audio") or renderer extraction (`extractMono16kWebDemuxer`). */ export function classifyTranscriptionError(error: unknown): TranscriptionFailure { const message = error instanceof Error ? error.message : String(error); - if (/no audio track/i.test(message) || /zero audio frames/i.test(message)) { + const name = (error as { name?: string })?.name ?? ""; + if ( + name === "NoAudioTrackError" || + /noaudiotrackerror/i.test(message) || + /no decodable audio/i.test(message) || + /no audio track/i.test(message) || + /zero audio frames/i.test(message) + ) { return { kind: "no-audio", message }; } if (/audio codec not supported/i.test(message)) {