From b939c4b1d2cab8e8ca4c32cc51dacaff4ae40f6d Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 2 Oct 2026 08:36:12 +0200 Subject: [PATCH 1/3] fix(stt): show a readable message when transcription cannot read the audio Strip the 'Error invoking remote method' IPC wrapper from transcription failures, and map an ffmpeg extraction timeout or a refused read (macOS TCC prompt pending or denied) to a translated hint asking the user to check that OpenScreen can access the media's folder. The raw rejection is logged to the console. A refused read is no longer persisted as a permanent no-audio verdict, and no longer fails the rest of the queue. Fixes #968 --- src/components/ai-edition/v4/MediaStage.tsx | 8 +- src/i18n/locales/ar/editor.json | 1 + src/i18n/locales/cs/editor.json | 1 + src/i18n/locales/de/editor.json | 1 + src/i18n/locales/en/editor.json | 1 + src/i18n/locales/es/editor.json | 1 + src/i18n/locales/fr/editor.json | 1 + src/i18n/locales/it/editor.json | 1 + src/i18n/locales/ja-JP/editor.json | 1 + src/i18n/locales/ko-KR/editor.json | 1 + src/i18n/locales/pt-BR/editor.json | 1 + src/i18n/locales/ru/editor.json | 1 + src/i18n/locales/tr/editor.json | 1 + src/i18n/locales/vi/editor.json | 1 + src/i18n/locales/zh-CN/editor.json | 1 + src/i18n/locales/zh-TW/editor.json | 1 + .../ai-edition/store/transcriptionStore.ts | 18 ++++- .../ai-edition/transcription/status.test.ts | 75 +++++++++++++++++++ src/lib/ai-edition/transcription/status.ts | 37 ++++++++- 19 files changed, 145 insertions(+), 8 deletions(-) diff --git a/src/components/ai-edition/v4/MediaStage.tsx b/src/components/ai-edition/v4/MediaStage.tsx index bb30e2115..7b0558084 100644 --- a/src/components/ai-edition/v4/MediaStage.tsx +++ b/src/components/ai-edition/v4/MediaStage.tsx @@ -19,6 +19,7 @@ import { type AssetTranscriptionView, isSilentFailure, transcriptHasSpeech, + transcriptionFailureHintKey, } from "@/lib/ai-edition/transcription/status"; import { formatBytes } from "@/utils/formatBytes"; import { @@ -105,6 +106,9 @@ export function MediaStage({ : { assetId: "", status: "idle" }; const selectedBusy = selectedTranscription.status === "running" || selectedTranscription.status === "queued"; + const failureHintKey = selectedTranscription.failure + ? transcriptionFailureHintKey(selectedTranscription.failure) + : null; const handleImport = async () => { if (!projectId) { @@ -380,9 +384,7 @@ export function MediaStage({ color: "var(--muted)", }} > - {selectedTranscription.failure.kind === "error" - ? selectedTranscription.failure.message - : t("mediaStage.noAudioTrackHint")} + {failureHintKey ? t(failureHintKey) : selectedTranscription.failure.message}

) : null} diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json index e93f6914b..0de8bfa88 100644 --- a/src/i18n/locales/ar/editor.json +++ b/src/i18n/locales/ar/editor.json @@ -172,6 +172,7 @@ "detectedLanguage": "اللغة المكتشفة: {{language}}", "noAudioTrack": "لا يوجد مسار صوتي", "noAudioTrackHint": "لا يحتوي هذا الملف على مسار صوتي — لا يوجد ما يمكن نسخه.", + "mediaUnreadableHint": "تعذّرت قراءة صوت هذا الملف. تأكد من أن OpenScreen يمكنه الوصول إلى المجلد الذي يوجد فيه، ثم أعد الإنشاء.", "noSpeechDetected": "لم يتم اكتشاف كلام" }, "exportDialog": { diff --git a/src/i18n/locales/cs/editor.json b/src/i18n/locales/cs/editor.json index ac242ff8d..78ed90f4f 100644 --- a/src/i18n/locales/cs/editor.json +++ b/src/i18n/locales/cs/editor.json @@ -172,6 +172,7 @@ "detectedLanguage": "Zjištěný jazyk: {{language}}", "noAudioTrack": "Žádná zvuková stopa", "noAudioTrackHint": "Toto médium nemá zvukovou stopu, takže není co přepisovat.", + "mediaUnreadableHint": "Zvuk tohoto média se nepodařilo přečíst. Ověřte, že OpenScreen má přístup ke složce, ve které je uloženo, a pak jej vygenerujte znovu.", "noSpeechDetected": "Žádná řeč nezjištěna" }, "exportDialog": { diff --git a/src/i18n/locales/de/editor.json b/src/i18n/locales/de/editor.json index a292f3270..c5bc7453f 100644 --- a/src/i18n/locales/de/editor.json +++ b/src/i18n/locales/de/editor.json @@ -172,6 +172,7 @@ "detectedLanguage": "Erkannte Sprache: {{language}}", "noAudioTrack": "Keine Audiospur", "noAudioTrackHint": "Dieses Medium hat keine Audiospur — es gibt nichts zu transkribieren.", + "mediaUnreadableHint": "Der Ton dieses Mediums konnte nicht gelesen werden. Stelle sicher, dass OpenScreen auf den Ordner zugreifen darf, in dem es liegt, und generiere dann neu.", "noSpeechDetected": "Keine Sprache erkannt" }, "exportDialog": { diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 52dbd2d35..8a2073355 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -172,6 +172,7 @@ "detectedLanguage": "Detected language: {{language}}", "noAudioTrack": "No audio track", "noAudioTrackHint": "This media has no audio track — there is nothing to transcribe.", + "mediaUnreadableHint": "Couldn't read this media's audio. Make sure OpenScreen can access the folder it's in, then regenerate.", "noSpeechDetected": "No speech detected" }, "exportDialog": { diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index d3e01791c..5640049e7 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -172,6 +172,7 @@ "detectedLanguage": "Idioma detectado: {{language}}", "noAudioTrack": "Sin pista de audio", "noAudioTrackHint": "Este medio no tiene pista de audio: no hay nada que transcribir.", + "mediaUnreadableHint": "No se pudo leer el audio de este medio. Asegúrate de que OpenScreen puede acceder a la carpeta que lo contiene y vuelve a generar.", "noSpeechDetected": "No se detectó voz" }, "exportDialog": { diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index 60bede225..e7e0c5bf2 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -172,6 +172,7 @@ "detectedLanguage": "Langue détectée : {{language}}", "noAudioTrack": "Aucune piste audio", "noAudioTrackHint": "Ce média n'a pas de piste audio — il n'y a rien à transcrire.", + "mediaUnreadableHint": "Impossible de lire l'audio de ce média. Vérifiez qu'OpenScreen a accès au dossier qui le contient, puis régénérez.", "noSpeechDetected": "Aucune parole détectée" }, "exportDialog": { diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json index bef5e48d6..cc2116ea3 100644 --- a/src/i18n/locales/it/editor.json +++ b/src/i18n/locales/it/editor.json @@ -172,6 +172,7 @@ "detectedLanguage": "Lingua rilevata: {{language}}", "noAudioTrack": "Nessuna traccia audio", "noAudioTrackHint": "Questo contenuto non ha una traccia audio: non c'è nulla da trascrivere.", + "mediaUnreadableHint": "Impossibile leggere l'audio di questo contenuto. Verifica che OpenScreen possa accedere alla cartella che lo contiene, poi rigenera.", "noSpeechDetected": "Nessun parlato rilevato" }, "exportDialog": { diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json index b8322677a..2313587b6 100644 --- a/src/i18n/locales/ja-JP/editor.json +++ b/src/i18n/locales/ja-JP/editor.json @@ -172,6 +172,7 @@ "detectedLanguage": "検出された言語: {{language}}", "noAudioTrack": "音声トラックがありません", "noAudioTrackHint": "このメディアには音声トラックがないため、文字起こしできません。", + "mediaUnreadableHint": "このメディアの音声を読み取れませんでした。OpenScreen が保存先のフォルダーにアクセスできることを確認してから、再生成してください。", "noSpeechDetected": "音声が検出されませんでした" }, "exportDialog": { diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json index 3dfd3a431..b75059aa3 100644 --- a/src/i18n/locales/ko-KR/editor.json +++ b/src/i18n/locales/ko-KR/editor.json @@ -172,6 +172,7 @@ "detectedLanguage": "감지된 언어: {{language}}", "noAudioTrack": "오디오 트랙 없음", "noAudioTrackHint": "이 미디어에는 오디오 트랙이 없어 받아쓸 내용이 없습니다.", + "mediaUnreadableHint": "이 미디어의 오디오를 읽을 수 없습니다. OpenScreen이 파일이 있는 폴더에 접근할 수 있는지 확인한 후 다시 생성하세요.", "noSpeechDetected": "음성이 감지되지 않음" }, "exportDialog": { diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json index d4c344ad9..003ea563a 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -172,6 +172,7 @@ "detectedLanguage": "Idioma detectado: {{language}}", "noAudioTrack": "Sem faixa de áudio", "noAudioTrackHint": "Esta mídia não tem faixa de áudio — não há nada para transcrever.", + "mediaUnreadableHint": "Não foi possível ler o áudio desta mídia. Verifique se o OpenScreen tem acesso à pasta onde ela está e gere novamente.", "noSpeechDetected": "Nenhuma fala detectada" }, "exportDialog": { diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index 35f0d0233..096daf5e7 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -172,6 +172,7 @@ "detectedLanguage": "Обнаруженный язык: {{language}}", "noAudioTrack": "Нет аудиодорожки", "noAudioTrackHint": "В этом медиафайле нет аудиодорожки — расшифровывать нечего.", + "mediaUnreadableHint": "Не удалось прочитать звук этого медиафайла. Убедитесь, что у OpenScreen есть доступ к папке, в которой он находится, и создайте расшифровку заново.", "noSpeechDetected": "Речь не обнаружена" }, "exportDialog": { diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json index df1eb203f..f4a76714e 100644 --- a/src/i18n/locales/tr/editor.json +++ b/src/i18n/locales/tr/editor.json @@ -172,6 +172,7 @@ "detectedLanguage": "Algılanan dil: {{language}}", "noAudioTrack": "Ses parçası yok", "noAudioTrackHint": "Bu medyada ses parçası yok — metne dökülecek bir şey bulunmuyor.", + "mediaUnreadableHint": "Bu medyanın sesi okunamadı. OpenScreen'in bulunduğu klasöre erişebildiğinden emin olun, ardından yeniden oluşturun.", "noSpeechDetected": "Konuşma algılanmadı" }, "exportDialog": { diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json index 97e000f27..1ab9da48c 100644 --- a/src/i18n/locales/vi/editor.json +++ b/src/i18n/locales/vi/editor.json @@ -172,6 +172,7 @@ "detectedLanguage": "Ngôn ngữ phát hiện: {{language}}", "noAudioTrack": "Không có bản âm thanh", "noAudioTrackHint": "Media này không có bản âm thanh — không có gì để phiên âm.", + "mediaUnreadableHint": "Không thể đọc âm thanh của media này. Hãy đảm bảo OpenScreen có quyền truy cập thư mục chứa nó, rồi tạo lại.", "noSpeechDetected": "Không phát hiện giọng nói" }, "exportDialog": { diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index e2be1ddc2..98a12b084 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -172,6 +172,7 @@ "detectedLanguage": "检测到的语言:{{language}}", "noAudioTrack": "无音频轨道", "noAudioTrackHint": "此媒体没有音频轨道,没有可转录的内容。", + "mediaUnreadableHint": "无法读取此媒体的音频。请确认 OpenScreen 可以访问它所在的文件夹,然后重新生成。", "noSpeechDetected": "未检测到语音" }, "exportDialog": { diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index 991a5d04f..8862c279a 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -172,6 +172,7 @@ "detectedLanguage": "偵測到的語言:{{language}}", "noAudioTrack": "無音訊軌道", "noAudioTrackHint": "此媒體沒有音訊軌道,沒有可轉錄的內容。", + "mediaUnreadableHint": "無法讀取此媒體的音訊。請確認 OpenScreen 可以存取它所在的資料夾,然後重新產生。", "noSpeechDetected": "未偵測到語音" }, "exportDialog": { diff --git a/src/lib/ai-edition/store/transcriptionStore.ts b/src/lib/ai-edition/store/transcriptionStore.ts index 0eb950a56..316f30791 100644 --- a/src/lib/ai-edition/store/transcriptionStore.ts +++ b/src/lib/ai-edition/store/transcriptionStore.ts @@ -42,6 +42,7 @@ import { type TranscriptionFailure, type TranscriptionPhase, type TranscriptionProgress, + transcriptionFailureHintKey, transcriptRelevantAssetIds, } from "../transcription/status"; import { useProjectStore } from "./projectStore"; @@ -451,6 +452,9 @@ async function runJob(assetId: string, job: TranscriptionJob): Promise { } if (!isCurrentRun(assetId, runId)) return; // superseded by a newer request const failure = classifyTranscriptionError(error); + // The UI shows a translated hint or a trimmed message; the full detail + // (channel, path, ffmpeg stderr) belongs here. + console.warn(`[transcription] asset ${assetId} failed:`, error); patchJob(assetId, runId, { status: "failed", phase: undefined, progress: undefined, failure }); flushSettleWaiters(assetId); await persistPermanentFailure(projectId, assetId, failure); @@ -459,8 +463,13 @@ async function runJob(assetId: string, job: TranscriptionJob): Promise { // queue into the same wall would spend a full retry budget per asset and // stack one identical toast per asset. Fail them with the same verdict // instead — the gate then reads "failed" (not "queued forever"), and one - // manual retry re-runs them all once the engine is back. - if (failure.kind === "error") failRemainingQueue(projectId, failure); + // manual retry re-runs them all once the engine is back. A file the OS would + // not let ffmpeg read is the exception: that verdict is about this file's + // folder, and stamping it on media elsewhere would send the user looking at + // the wrong permission. + if (failure.kind === "error" && !failure.unreadableMedia) { + failRemainingQueue(projectId, failure); + } // A silent recording is an expected outcome, not an incident: the media card // and every gated button already say so, so the background pass stays quiet. // A run the user asked for by hand still gets an answer — but an @@ -469,7 +478,10 @@ async function runJob(assetId: string, job: TranscriptionJob): Promise { if (isPermanentFailure(failure.kind)) { if (job.manual) toast.info(toastText("mediaStage.noAudioTrackHint")); } else { - toast.error(toastText("mediaStage.transcriptionFailed"), { description: failure.message }); + const hintKey = transcriptionFailureHintKey(failure); + toast.error(toastText("mediaStage.transcriptionFailed"), { + description: hintKey ? toastText(hintKey) : failure.message, + }); } } finally { if (activeRun?.controller === controller) activeRun = null; diff --git a/src/lib/ai-edition/transcription/status.test.ts b/src/lib/ai-edition/transcription/status.test.ts index 442322d8a..8ff90ded9 100644 --- a/src/lib/ai-edition/transcription/status.test.ts +++ b/src/lib/ai-edition/transcription/status.test.ts @@ -14,7 +14,9 @@ import { progressFraction, realtimeSpeed, resolveTranscriptGate, + stripIpcErrorWrapper, transcriptHasSpeech, + transcriptionFailureHintKey, transcriptRelevantAssetIds, } from "./status"; @@ -100,7 +102,80 @@ describe("classifyTranscriptionError", () => { const failure = classifyTranscriptionError(new Error("whisper-server exited")); expect(failure.kind).toBe("error"); expect(failure.message).toBe("whisper-server exited"); + expect(failure.unreadableMedia).toBeUndefined(); expect(isPermanentFailure(failure.kind)).toBe(false); + expect(transcriptionFailureHintKey(failure)).toBeNull(); + }); + + // Issue #968: macOS held ffmpeg's read of a file in ~/Downloads behind a + // pending TCC prompt until the extraction timeout fired, and the media card + // printed the whole IPC rejection. + it("explains an extraction timeout as unreadable media, without the IPC wrapper", () => { + const failure = classifyTranscriptionError( + new Error( + "Error invoking remote method 'stt:transcribe': Error: ffmpeg timed out after 60000ms on /Users/me/Downloads/clip.mp4", + ), + ); + expect(failure.kind).toBe("error"); + expect(failure.unreadableMedia).toBe(true); + expect(failure.message).toBe("ffmpeg timed out after 60000ms on /Users/me/Downloads/clip.mp4"); + // Transient: once the prompt is answered, the next run must try again. + expect(isPermanentFailure(failure.kind)).toBe(false); + expect(isSilentFailure(view("a", "failed", failure.kind))).toBe(false); + expect(transcriptionFailureHintKey(failure)).toBe("mediaStage.mediaUnreadableHint"); + }); + + // A refused read exits ffmpeg non-zero with nothing decoded, which the native + // extractor words as "No decodable audio". It must not be remembered as a + // silent file, or granting access later would never get it transcribed. + it("does not persist a refused read as no-audio", () => { + const failure = classifyTranscriptionError( + new Error( + "Error invoking remote method 'stt:transcribe': Error: No decodable audio in /Users/me/Downloads/clip.mp4: /Users/me/Downloads/clip.mp4: Operation not permitted", + ), + ); + expect(failure.kind).toBe("error"); + expect(failure.unreadableMedia).toBe(true); + expect(transcriptionFailureHintKey(failure)).toBe("mediaStage.mediaUnreadableHint"); + }); + + it("strips the IPC wrapper from an engine message it has no hint for", () => { + const failure = classifyTranscriptionError( + new Error("Error invoking remote method 'stt:transcribe': Error: whisper-server exited"), + ); + expect(failure.kind).toBe("error"); + expect(failure.message).toBe("whisper-server exited"); + expect(transcriptionFailureHintKey(failure)).toBeNull(); + }); + + it("gives the media verdicts the no-audio hint", () => { + expect(transcriptionFailureHintKey({ kind: "no-audio", message: "x" })).toBe( + "mediaStage.noAudioTrackHint", + ); + expect(transcriptionFailureHintKey({ kind: "unsupported-audio", message: "x" })).toBe( + "mediaStage.noAudioTrackHint", + ); + }); +}); + +describe("stripIpcErrorWrapper", () => { + it("removes the channel prefix and the rebuilt error name", () => { + expect(stripIpcErrorWrapper("Error invoking remote method 'stt:transcribe': Error: boom")).toBe( + "boom", + ); + expect( + stripIpcErrorWrapper("Error invoking remote method 'stt:transcribe': TypeError: bad arg"), + ).toBe("bad arg"); + }); + + it("keeps the message when the rejection carries no error name", () => { + expect(stripIpcErrorWrapper("Error invoking remote method 'x:y': plain reason")).toBe( + "plain reason", + ); + }); + + it("leaves a message that never crossed IPC untouched", () => { + expect(stripIpcErrorWrapper("Error: not from IPC")).toBe("Error: not from IPC"); }); }); diff --git a/src/lib/ai-edition/transcription/status.ts b/src/lib/ai-edition/transcription/status.ts index 825d4e771..9e4178d83 100644 --- a/src/lib/ai-edition/transcription/status.ts +++ b/src/lib/ai-edition/transcription/status.ts @@ -14,8 +14,16 @@ export type TranscriptionFailureKind = "no-audio" | "unsupported-audio" | "error export interface TranscriptionFailure { kind: TranscriptionFailureKind; - /** Raw engine/exception message — surfaced as a tooltip / toast description. */ + /** Raw engine/exception message, IPC wrapper stripped. Surfaced as a tooltip. */ message: string; + /** + * The media file itself could not be read: the OS refused access, or ffmpeg hung + * opening it (macOS parks the read behind a pending "access files in your + * Downloads folder" prompt until the extraction timeout fires, issue #968). + * Transient, so `kind` stays `"error"`, but the user can fix it: the UI shows a + * hint pointing at file access instead of the raw `message`. + */ + unreadableMedia?: boolean; } /** Which part of the pipeline a running job is in (mirrors `TranscribeAssetOptions.onStatus`). */ @@ -129,7 +137,14 @@ export type PersistableFailureKind = Exclude; * open, and pops a toast carrying raw ffmpeg stderr (issue #628). */ export function classifyTranscriptionError(error: unknown): TranscriptionFailure { - const message = error instanceof Error ? error.message : String(error); + const message = stripIpcErrorWrapper(error instanceof Error ? error.message : String(error)); + // Checked before the no-audio verdict: a read the OS refused makes ffmpeg exit + // non-zero with nothing decoded, which `extractAudio.ts` reports as "No + // decodable audio in ...". Persisting that as a permanent no-audio would never + // retry the file once access is granted. + if (/ffmpeg timed out|operation not permitted|permission denied/i.test(message)) { + return { kind: "error", message, unreadableMedia: true }; + } if (/no audio track|zero audio frames|no decodable audio/i.test(message)) { return { kind: "no-audio", message }; } @@ -139,6 +154,24 @@ export function classifyTranscriptionError(error: unknown): TranscriptionFailure return { kind: "error", message }; } +/** + * `ipcRenderer.invoke` rejects with `Error invoking remote method '': + * : `. Only `` means anything to the user. + */ +export function stripIpcErrorWrapper(message: string): string { + return message.replace(/^Error invoking remote method '[^']*': (?:[A-Za-z]*Error: )?/, ""); +} + +/** + * The `editor` i18n key of the sentence that explains this failure to the user, + * or null when the engine message is the best explanation there is. + */ +export function transcriptionFailureHintKey(failure: TranscriptionFailure): string | null { + if (failure.kind !== "error") return "mediaStage.noAudioTrackHint"; + if (failure.unreadableMedia) return "mediaStage.mediaUnreadableHint"; + return null; +} + /** * True when this asset's job ended on a verdict about the MEDIA rather than on * something that went wrong: no audio track, or an audio codec nothing here can From c244d1c92d72ef6bfd11e84ded7e44d1c5ef1437 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 2 Oct 2026 08:43:08 +0200 Subject: [PATCH 2/3] fix(stt): key unreadable media on an extraction marker, not on wording Only extractAudio.ts now flags a read failure, through a new STT_MEDIA_UNREADABLE marker (MediaUnreadableError) set on the ffmpeg timeout and on a read the OS refused. The renderer classifies on that marker instead of matching 'permission denied' or 'ffmpeg timed out' anywhere in the message, so a permission error from model loading stays on the engine-failure path. --- electron/stt/extractAudio.test.ts | 36 +++++++++++++++++-- electron/stt/extractAudio.ts | 30 ++++++++++++++-- electron/stt/transcriptionContract.ts | 11 ++++++ .../ai-edition/transcription/status.test.ts | 30 +++++++++++----- src/lib/ai-edition/transcription/status.ts | 24 ++++++++----- 5 files changed, 107 insertions(+), 24 deletions(-) diff --git a/electron/stt/extractAudio.test.ts b/electron/stt/extractAudio.test.ts index 24e9694c8..5ed99608c 100644 --- a/electron/stt/extractAudio.test.ts +++ b/electron/stt/extractAudio.test.ts @@ -8,10 +8,11 @@ const resolveFfmpegMock = vi.fn<() => string | null>(); vi.mock("node:child_process", () => ({ spawn: (...args: unknown[]) => spawnMock(...args) })); vi.mock("../media/audioPeaks", () => ({ resolveFfmpeg: () => resolveFfmpegMock() })); -const { extractMono16kPcm, FfmpegUnavailableError, NoAudioTrackError } = await import( - "./extractAudio" +const { extractMono16kPcm, FfmpegUnavailableError, MediaUnreadableError, NoAudioTrackError } = + await import("./extractAudio"); +const { STT_MEDIA_UNREADABLE, STT_NATIVE_EXTRACTION_UNAVAILABLE } = await import( + "./transcriptionContract" ); -const { STT_NATIVE_EXTRACTION_UNAVAILABLE } = await import("./transcriptionContract"); /** A stand-in for the ffmpeg child: two pipes and a close event, nothing more. */ function fakeChild() { @@ -95,6 +96,35 @@ describe("extractMono16kPcm", () => { await expect(promise).rejects.toBeInstanceOf(NoAudioTrackError); }); + // Issue #968: the renderer explains these as "check folder access" and retries + // them, so both must carry the marker, which is all that survives the IPC. + it("reports a read the OS refused as unreadable media, not as no audio", async () => { + const child = fakeChild(); + spawnMock.mockReturnValue(child); + const promise = extractMono16kPcm("/Users/me/Downloads/a.mp4"); + child.stderr.end("/Users/me/Downloads/a.mp4: Operation not permitted"); + child.stdout.end(); + child.emit("close", 1); + + await expect(promise).rejects.toBeInstanceOf(MediaUnreadableError); + await expect(promise).rejects.toThrow(STT_MEDIA_UNREADABLE); + }); + + it("reports an ffmpeg that hangs opening the file as unreadable media", async () => { + vi.useFakeTimers(); + try { + const child = fakeChild(); + spawnMock.mockReturnValue(child); + const promise = extractMono16kPcm("/Users/me/Downloads/a.mp4"); + const settled = expect(promise).rejects.toThrow(STT_MEDIA_UNREADABLE); + await vi.advanceTimersByTimeAsync(60_000); + await settled; + expect(child.kill).toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + it("keeps the samples when ffmpeg exits non-zero AFTER writing audio", async () => { // A truncated file still yields usable audio; throwing it away would lose a // transcript over a trailing byte. diff --git a/electron/stt/extractAudio.ts b/electron/stt/extractAudio.ts index 08168d21e..bf86f4cb6 100644 --- a/electron/stt/extractAudio.ts +++ b/electron/stt/extractAudio.ts @@ -21,7 +21,7 @@ import { spawn } from "node:child_process"; import { resolveFfmpeg } from "../media/audioPeaks"; -import { STT_NATIVE_EXTRACTION_UNAVAILABLE } from "./transcriptionContract"; +import { STT_MEDIA_UNREADABLE, STT_NATIVE_EXTRACTION_UNAVAILABLE } from "./transcriptionContract"; /** What whisper.cpp wants, and what `decodePeaks` already asks ffmpeg for. */ const SAMPLE_RATE = 16_000; @@ -50,6 +50,22 @@ export class NoAudioTrackError extends Error { } } +/** + * The media file could not be read at all: the OS refused it, or ffmpeg hung + * opening it. Unlike `NoAudioTrackError` this is NOT permanent: it clears once + * the user grants access, so it carries `STT_MEDIA_UNREADABLE` for the renderer + * to explain rather than remember. + */ +export class MediaUnreadableError extends Error { + constructor(filePath: string, detail: string) { + super(`${STT_MEDIA_UNREADABLE}: cannot read ${filePath}: ${detail}`); + this.name = "MediaUnreadableError"; + } +} + +/** What ffmpeg's stderr says when the OS refused to open the input. */ +const ACCESS_DENIED = /operation not permitted|permission denied/i; + /** * Decode `filePath` to mono 16 kHz float samples. * @@ -106,7 +122,9 @@ export async function extractMono16kPcm( const timer = setTimeout(() => { child.kill("SIGKILL"); finish(() => - reject(new Error(`ffmpeg timed out after ${EXTRACT_TIMEOUT_MS}ms on ${filePath}`)), + reject( + new MediaUnreadableError(filePath, `ffmpeg timed out after ${EXTRACT_TIMEOUT_MS}ms`), + ), ); }, EXTRACT_TIMEOUT_MS); @@ -137,7 +155,13 @@ export async function extractMono16kPcm( // caller treats both the same way — this asset will not transcribe — so they // share an error type; `stderr` carries which it was. if (code !== 0 && total === 0) { - finish(() => reject(new NoAudioTrackError(filePath, stderr.trim()))); + // Except a read the OS refused: that also exits non-zero with nothing + // decoded, but remembering it as "no audio" would never retry the file + // once access is granted. + const error = ACCESS_DENIED.test(stderr) + ? new MediaUnreadableError(filePath, stderr.trim()) + : new NoAudioTrackError(filePath, stderr.trim()); + finish(() => reject(error)); return; } const out = new Float32Array(total); diff --git a/electron/stt/transcriptionContract.ts b/electron/stt/transcriptionContract.ts index 5433698ac..4a2563a78 100644 --- a/electron/stt/transcriptionContract.ts +++ b/electron/stt/transcriptionContract.ts @@ -142,6 +142,17 @@ export interface SttTranscribeRequest { */ export const STT_NATIVE_EXTRACTION_UNAVAILABLE = "stt:native-extraction-unavailable"; +/** + * Marker carried in the error message when audio extraction could not read the + * `sourcePath` itself: the OS refused the read, or ffmpeg hung opening the file + * (macOS holds the read behind a pending folder-access prompt, issue #968). + * + * Only `extractAudio.ts` sets it, so the renderer can tell "this file's folder is + * not readable" from an engine failure that happens to mention a permission (model + * loading, the whisper helper). Same IPC reason as above for being a string. + */ +export const STT_MEDIA_UNREADABLE = "stt:media-unreadable"; + /** IPC response: main → renderer. */ export interface SttTranscribeResponse { segments: SttPhraseSegment[]; diff --git a/src/lib/ai-edition/transcription/status.test.ts b/src/lib/ai-edition/transcription/status.test.ts index 8ff90ded9..c94cdf705 100644 --- a/src/lib/ai-edition/transcription/status.test.ts +++ b/src/lib/ai-edition/transcription/status.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { STT_MEDIA_UNREADABLE } from "../../../../electron/stt/transcriptionContract"; import type { AxcutDocument, AxcutTranscript } from "../schema"; import { type AssetTranscriptionView, @@ -110,33 +111,44 @@ describe("classifyTranscriptionError", () => { // Issue #968: macOS held ffmpeg's read of a file in ~/Downloads behind a // pending TCC prompt until the extraction timeout fired, and the media card // printed the whole IPC rejection. + // The shape `MediaUnreadableError` takes once `ipcRenderer.invoke` has wrapped it. it("explains an extraction timeout as unreadable media, without the IPC wrapper", () => { const failure = classifyTranscriptionError( new Error( - "Error invoking remote method 'stt:transcribe': Error: ffmpeg timed out after 60000ms on /Users/me/Downloads/clip.mp4", + `Error invoking remote method 'stt:transcribe': Error: ${STT_MEDIA_UNREADABLE}: cannot read /Users/me/Downloads/clip.mp4: ffmpeg timed out after 60000ms`, ), ); expect(failure.kind).toBe("error"); expect(failure.unreadableMedia).toBe(true); - expect(failure.message).toBe("ffmpeg timed out after 60000ms on /Users/me/Downloads/clip.mp4"); + expect(failure.message).toBe( + "cannot read /Users/me/Downloads/clip.mp4: ffmpeg timed out after 60000ms", + ); // Transient: once the prompt is answered, the next run must try again. expect(isPermanentFailure(failure.kind)).toBe(false); expect(isSilentFailure(view("a", "failed", failure.kind))).toBe(false); expect(transcriptionFailureHintKey(failure)).toBe("mediaStage.mediaUnreadableHint"); }); - // A refused read exits ffmpeg non-zero with nothing decoded, which the native - // extractor words as "No decodable audio". It must not be remembered as a - // silent file, or granting access later would never get it transcribed. - it("does not persist a refused read as no-audio", () => { + // Wording is not evidence: only the extraction step sets the marker, and a + // permission error out of model loading is an engine failure, which must keep + // the engine path (raw message, rest of the queue failed with it). + it("leaves a permission error from model loading on the engine-failure path", () => { const failure = classifyTranscriptionError( new Error( - "Error invoking remote method 'stt:transcribe': Error: No decodable audio in /Users/me/Downloads/clip.mp4: /Users/me/Downloads/clip.mp4: Operation not permitted", + "Error invoking remote method 'stt:transcribe': Error: EACCES: permission denied, open '/Users/me/Library/Application Support/openscreen/models/ggml-small.bin'", ), ); expect(failure.kind).toBe("error"); - expect(failure.unreadableMedia).toBe(true); - expect(transcriptionFailureHintKey(failure)).toBe("mediaStage.mediaUnreadableHint"); + expect(failure.unreadableMedia).toBeUndefined(); + expect(transcriptionFailureHintKey(failure)).toBeNull(); + }); + + it("does not take an ffmpeg timeout without the marker for unreadable media", () => { + // e.g. the peaks decoder's own timeout, which says the same words. + const failure = classifyTranscriptionError( + new Error("ffmpeg timed out after 60000ms on /Users/me/Downloads/clip.mp4"), + ); + expect(failure.unreadableMedia).toBeUndefined(); }); it("strips the IPC wrapper from an engine message it has no hint for", () => { diff --git a/src/lib/ai-edition/transcription/status.ts b/src/lib/ai-edition/transcription/status.ts index 9e4178d83..fe28e88ed 100644 --- a/src/lib/ai-edition/transcription/status.ts +++ b/src/lib/ai-edition/transcription/status.ts @@ -6,6 +6,7 @@ // plain data — `store/transcriptionStore.ts` owns the queue and the side // effects, this module owns the vocabulary. +import { STT_MEDIA_UNREADABLE } from "../../../../electron/stt/transcriptionContract"; import type { AxcutDocument, AxcutTranscript } from "../schema"; import { voiceoverPlacements } from "../timeline/aggregated-transcript"; @@ -17,9 +18,11 @@ export interface TranscriptionFailure { /** Raw engine/exception message, IPC wrapper stripped. Surfaced as a tooltip. */ message: string; /** - * The media file itself could not be read: the OS refused access, or ffmpeg hung - * opening it (macOS parks the read behind a pending "access files in your - * Downloads folder" prompt until the extraction timeout fires, issue #968). + * Audio extraction could not read the media file itself: the OS refused access, + * or ffmpeg hung opening it (macOS parks the read behind a pending "access files + * in your Downloads folder" prompt until the extraction timeout fires, issue + * #968). Set only from the `STT_MEDIA_UNREADABLE` marker `extractAudio.ts` puts + * on its rejection, never inferred from wording. * Transient, so `kind` stays `"error"`, but the user can fix it: the UI shows a * hint pointing at file access instead of the raw `message`. */ @@ -138,12 +141,15 @@ export type PersistableFailureKind = Exclude; */ export function classifyTranscriptionError(error: unknown): TranscriptionFailure { const message = stripIpcErrorWrapper(error instanceof Error ? error.message : String(error)); - // Checked before the no-audio verdict: a read the OS refused makes ffmpeg exit - // non-zero with nothing decoded, which `extractAudio.ts` reports as "No - // decodable audio in ...". Persisting that as a permanent no-audio would never - // retry the file once access is granted. - if (/ffmpeg timed out|operation not permitted|permission denied/i.test(message)) { - return { kind: "error", message, unreadableMedia: true }; + // Keyed on the marker only the audio-extraction step sets, not on wording: a + // "permission denied" out of model loading is an engine failure, and must keep + // the engine-failure handling (raw message, fail the rest of the queue). + if (message.includes(STT_MEDIA_UNREADABLE)) { + return { + kind: "error", + message: message.replace(`${STT_MEDIA_UNREADABLE}: `, ""), + unreadableMedia: true, + }; } if (/no audio track|zero audio frames|no decodable audio/i.test(message)) { return { kind: "no-audio", message }; From 66d550b3e3087cb46967b875f1ab00f8e5780d27 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 2 Oct 2026 08:49:33 +0200 Subject: [PATCH 3/3] fix(stt): only mark an extraction timeout unreadable when nothing was read A timeout after ffmpeg had already written audio means the file was readable and ffmpeg stalled, so it rejects with the plain 'ffmpeg timed out' error again and stays on the engine-failure path. MediaUnreadableError is kept for a hang before the first byte, which is what a pending macOS folder-access prompt produces. --- electron/stt/extractAudio.test.ts | 25 +++++++++++++++++++++++++ electron/stt/extractAudio.ts | 8 +++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/electron/stt/extractAudio.test.ts b/electron/stt/extractAudio.test.ts index 5ed99608c..c437b1ae9 100644 --- a/electron/stt/extractAudio.test.ts +++ b/electron/stt/extractAudio.test.ts @@ -125,6 +125,31 @@ describe("extractMono16kPcm", () => { } }); + it("reports a timeout AFTER audio came out as a plain stall, not unreadable media", async () => { + // The file was readable; pointing the user at folder access would be wrong. + vi.useFakeTimers(); + try { + const child = fakeChild(); + spawnMock.mockReturnValue(child); + const promise = extractMono16kPcm("/Users/me/Downloads/a.mp4"); + const settled = promise.then( + () => { + throw new Error("expected a rejection"); + }, + (error: Error) => error, + ); + child.stdout.write(f32le([0.5])); + await vi.advanceTimersByTimeAsync(60_000); + const error = await settled; + expect(error).not.toBeInstanceOf(MediaUnreadableError); + expect(error.message).not.toContain(STT_MEDIA_UNREADABLE); + expect(error.message).toBe("ffmpeg timed out after 60000ms on /Users/me/Downloads/a.mp4"); + expect(child.kill).toHaveBeenCalledWith("SIGKILL"); + } finally { + vi.useRealTimers(); + } + }); + it("keeps the samples when ffmpeg exits non-zero AFTER writing audio", async () => { // A truncated file still yields usable audio; throwing it away would lose a // transcript over a trailing byte. diff --git a/electron/stt/extractAudio.ts b/electron/stt/extractAudio.ts index bf86f4cb6..28dd9f177 100644 --- a/electron/stt/extractAudio.ts +++ b/electron/stt/extractAudio.ts @@ -121,9 +121,15 @@ export async function extractMono16kPcm( const timer = setTimeout(() => { child.kill("SIGKILL"); + // Only a hang before the first byte says the file could not be opened (a + // pending macOS folder-access prompt produces exactly that, issue #968). Once + // audio has come out the media was readable, and the stall is ffmpeg's. + const nothingRead = total === 0 && carry === null; finish(() => reject( - new MediaUnreadableError(filePath, `ffmpeg timed out after ${EXTRACT_TIMEOUT_MS}ms`), + nothingRead + ? new MediaUnreadableError(filePath, `ffmpeg timed out after ${EXTRACT_TIMEOUT_MS}ms`) + : new Error(`ffmpeg timed out after ${EXTRACT_TIMEOUT_MS}ms on ${filePath}`), ), ); }, EXTRACT_TIMEOUT_MS);