Skip to content

Commit 64d5669

Browse files
asanmateuclaude
andcommitted
fix: surface audio save errors and handle fallback TTS in [w] save
Audio save (`w`) failed silently with "(audio failed)" because the catch block swallowed all errors and the fallback TTS path (macOS `say`) never produced an MP3 file. Now errors are surfaced in the audio panel, the fallback state is tracked via `audioIsFallback`, and `[w]` is hidden when no MP3 exists. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b714e58 commit 64d5669

File tree

4 files changed

+135
-4
lines changed

4 files changed

+135
-4
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Fixed
1111

1212
- App exits directly when pressing Esc during extraction/summarization if launched with a positional argument (`tldr <url>`), instead of returning to the interactive prompt
13+
- Audio save with `[w]` after fallback TTS (macOS `say`) no longer silently retries broken edge-tts; shows clear error instead
14+
- Audio save errors are now surfaced in the audio panel instead of being silently swallowed
15+
- `[w] save + audio` hidden when system TTS fallback was used (no MP3 file to save)
1316

1417
## [2.0.0] - 2026-02-19
1518

src/App.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export function App({ initialInput, showConfig, editProfile, overrides }: AppPro
8383
const [discardPending, setDiscardPending] = useState(false);
8484
const [toast, setToast] = useState<string | undefined>(undefined);
8585
const [tempAudioPath, setTempAudioPath] = useState<string | undefined>(undefined);
86+
const [audioIsFallback, setAudioIsFallback] = useState(false);
8687
const [cachedSpeechText, setCachedSpeechText] = useState<string | undefined>(undefined);
8788
const [isSavingAudio, setIsSavingAudio] = useState(false);
8889
const [profiles, setProfiles] = useState<{ name: string; active: boolean }[]>([]);
@@ -397,6 +398,7 @@ export function App({ initialInput, showConfig, editProfile, overrides }: AppPro
397398
config.ttsModel,
398399
);
399400
setTempAudioPath(path);
401+
setAudioIsFallback(false);
400402
setIsGeneratingAudio(false);
401403
const proc = playAudio(path);
402404
setAudioProcess(proc);
@@ -408,7 +410,8 @@ export function App({ initialInput, showConfig, editProfile, overrides }: AppPro
408410
const msg = ttsErr instanceof Error ? ttsErr.message : "OpenAI TTS failed";
409411
setAudioError(msg);
410412
} else {
411-
// edge-tts failed — fall back to system TTS
413+
// edge-tts failed — fall back to system TTS (no MP3 file produced)
414+
setAudioIsFallback(true);
412415
const proc = speakFallback(speechText);
413416
if (proc) {
414417
setAudioProcess(proc);
@@ -438,6 +441,7 @@ export function App({ initialInput, showConfig, editProfile, overrides }: AppPro
438441
if (ch === "r") {
439442
if (config) {
440443
setTempAudioPath(undefined);
444+
setAudioIsFallback(false);
441445
setCachedSpeechText(undefined);
442446
processInput(input, config);
443447
}
@@ -473,6 +477,8 @@ export function App({ initialInput, showConfig, editProfile, overrides }: AppPro
473477
if (tempAudioPath) {
474478
await saveAudioFile(saved, tempAudioPath);
475479
audioSaved = true;
480+
} else if (audioIsFallback) {
481+
setAudioError("System TTS has no MP3 file to save");
476482
} else if (config) {
477483
const speechText =
478484
cachedSpeechText ?? (await rewriteForSpeech(summary, config));
@@ -489,8 +495,8 @@ export function App({ initialInput, showConfig, editProfile, overrides }: AppPro
489495
await saveAudioFile(saved, audioPath);
490496
audioSaved = true;
491497
}
492-
} catch {
493-
// Audio failure is non-fatal — summary still saved
498+
} catch (err) {
499+
setAudioError(err instanceof Error ? err.message : "Audio save failed");
494500
}
495501
setIsSavingAudio(false);
496502
}
@@ -551,6 +557,7 @@ export function App({ initialInput, showConfig, editProfile, overrides }: AppPro
551557
setInput("");
552558
setCurrentSession(undefined);
553559
setTempAudioPath(undefined);
560+
setAudioIsFallback(false);
554561
setCachedSpeechText(undefined);
555562
return;
556563
}
@@ -577,6 +584,7 @@ export function App({ initialInput, showConfig, editProfile, overrides }: AppPro
577584
setInput("");
578585
setCurrentSession(undefined);
579586
setTempAudioPath(undefined);
587+
setAudioIsFallback(false);
580588
setCachedSpeechText(undefined);
581589
} else {
582590
// First press — start discard timer
@@ -680,6 +688,7 @@ export function App({ initialInput, showConfig, editProfile, overrides }: AppPro
680688
isSavingAudio={isSavingAudio}
681689
toast={toast}
682690
isSaved={!pendingResult}
691+
audioIsFallback={audioIsFallback}
683692
summaryPinned={summaryPinned}
684693
/>
685694
)}

src/__tests__/app.test.tsx

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -916,6 +916,123 @@ describe("App", () => {
916916
instance.unmount();
917917
});
918918

919+
it("'w' after successful 'a' saves with audio using temp path", async () => {
920+
mocks.rewriteForSpeech.mockResolvedValue("speech text");
921+
mocks.generateAudio.mockResolvedValue("/tmp/audio.mp3");
922+
mocks.saveAudioFile.mockResolvedValue(undefined);
923+
const fakeProc = { on: vi.fn((_e: string, cb: () => void) => cb()), kill: vi.fn() };
924+
mocks.playAudio.mockReturnValue(fakeProc);
925+
926+
const instance = render(<App initialInput="https://example.com/article" />);
927+
928+
await vi.waitFor(
929+
() => {
930+
expect(instance.lastFrame()).toContain("TL;DR");
931+
},
932+
{ timeout: 2000 },
933+
);
934+
935+
// Press 'a' to generate and play audio
936+
instance.stdin.write("a");
937+
938+
await vi.waitFor(
939+
() => {
940+
expect(mocks.generateAudio).toHaveBeenCalled();
941+
},
942+
{ timeout: 2000 },
943+
);
944+
945+
// Now press 'w' to save with audio — should use tempAudioPath
946+
instance.stdin.write("w");
947+
948+
await vi.waitFor(
949+
() => {
950+
expect(instance.lastFrame()).toContain("Saved with audio");
951+
},
952+
{ timeout: 3000 },
953+
);
954+
955+
expect(mocks.saveAudioFile).toHaveBeenCalledWith(TEST_SESSION, "/tmp/audio.mp3");
956+
957+
instance.unmount();
958+
});
959+
960+
it("'w' after fallback TTS shows audio failed", async () => {
961+
mocks.rewriteForSpeech.mockResolvedValue("speech text");
962+
mocks.generateAudio.mockRejectedValue(new Error("edge-tts failed"));
963+
const fakeProc = { on: vi.fn((_e: string, cb: () => void) => cb()), kill: vi.fn() };
964+
mocks.speakFallback.mockReturnValue(fakeProc);
965+
966+
const instance = render(<App initialInput="https://example.com/article" />);
967+
968+
await vi.waitFor(
969+
() => {
970+
expect(instance.lastFrame()).toContain("TL;DR");
971+
},
972+
{ timeout: 2000 },
973+
);
974+
975+
// Press 'a' — edge-tts fails, falls back to system TTS
976+
instance.stdin.write("a");
977+
978+
await vi.waitFor(
979+
() => {
980+
expect(mocks.speakFallback).toHaveBeenCalled();
981+
},
982+
{ timeout: 2000 },
983+
);
984+
985+
// [w] should be hidden when audioIsFallback is true
986+
await vi.waitFor(
987+
() => {
988+
expect(instance.lastFrame()).not.toContain("[w]");
989+
},
990+
{ timeout: 2000 },
991+
);
992+
993+
instance.unmount();
994+
});
995+
996+
it("'w' surfaces saveAudioFile errors", async () => {
997+
mocks.rewriteForSpeech.mockResolvedValue("speech text");
998+
mocks.generateAudio.mockResolvedValue("/tmp/audio.mp3");
999+
mocks.saveAudioFile.mockRejectedValue(new Error("ENOSPC: disk full"));
1000+
const fakeProc = { on: vi.fn((_e: string, cb: () => void) => cb()), kill: vi.fn() };
1001+
mocks.playAudio.mockReturnValue(fakeProc);
1002+
1003+
const instance = render(<App initialInput="https://example.com/article" />);
1004+
1005+
await vi.waitFor(
1006+
() => {
1007+
expect(instance.lastFrame()).toContain("TL;DR");
1008+
},
1009+
{ timeout: 2000 },
1010+
);
1011+
1012+
// Press 'a' first so tempAudioPath is set
1013+
instance.stdin.write("a");
1014+
1015+
await vi.waitFor(
1016+
() => {
1017+
expect(mocks.generateAudio).toHaveBeenCalled();
1018+
},
1019+
{ timeout: 2000 },
1020+
);
1021+
1022+
// Press 'w' — saveAudioFile will reject
1023+
instance.stdin.write("w");
1024+
1025+
await vi.waitFor(
1026+
() => {
1027+
const frame = instance.lastFrame();
1028+
expect(frame).toContain("ENOSPC: disk full");
1029+
},
1030+
{ timeout: 3000 },
1031+
);
1032+
1033+
instance.unmount();
1034+
});
1035+
9191036
it("HelpView shows 'w' shortcut and 'Save with audio'", async () => {
9201037
const instance = render(<App />);
9211038

src/components/SummaryView.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ interface SummaryViewProps {
1818
isSavingAudio: boolean;
1919
toast?: string | undefined;
2020
isSaved?: boolean | undefined;
21+
audioIsFallback?: boolean | undefined;
2122
summaryPinned?: boolean | undefined;
2223
}
2324

@@ -35,6 +36,7 @@ export function SummaryView({
3536
isSavingAudio,
3637
toast,
3738
isSaved,
39+
audioIsFallback,
3840
summaryPinned,
3941
}: SummaryViewProps) {
4042
const theme = useTheme();
@@ -94,7 +96,7 @@ export function SummaryView({
9496
<Text>
9597
<Text color={theme.accent}>[a]</Text>
9698
<Text dimColor> {audioError ? "retry" : "listen"}</Text>
97-
{!isSaved && (
99+
{!isSaved && !audioIsFallback && (
98100
<>
99101
<Text dimColor> · </Text>
100102
<Text color={theme.accent}>[w]</Text>

0 commit comments

Comments
 (0)