Skip to content

Commit 83c34e7

Browse files
authored
fix(transcription): resolve three deepsec transcription-pipeline bugs (#224)
* fix(transcription): don't save failed chunks as a completed transcript A chunk that exhausted its retries wrote an "[Error transcribing chunk N]" placeholder and was counted as completed, and buildTranscriptBody only rejected a fully-empty body. A transcript made of error placeholders was therefore saved as "completed", and the existence check then blocked any retry, leaving the user with a corrupt transcript and no easy way to re-run. transcribeChunks now reports how many chunks failed. buildTranscriptBody strips the error placeholders to decide whether any real speech was transcribed: if nothing real remains (every chunk failed, or the only successes were empty) it throws, so no file is written and the episode stays retryable - mirroring the diarization provider's all-chunks-failed contract. When some chunks fail but real speech remains, the transcript is kept and a warning notice reports the partial failure instead of a clean success. Fixes deepsec finding other-silent-failure. * fix(transcription): decode container/lossless audio to WAV before splitting createChunkFiles only routed m4a over the decode+WAV path; every other format over the 20 MB request limit fell through to raw byte-splitting. MP3 frames are self-synchronizing so that is fine, but container/header-dependent formats (ogg, webm, flac, wav, m4a) carry codec setup only at the stream start, so non-first byte slices are undecodable and the API rejects or garbles them. Restrict raw byte-splitting to MP3; route every other format through the existing decode+WAV re-encode path. When WAV conversion yields nothing (no Web Audio decoder, or an undecodable codec) throw a clear error rather than byte-splitting a container into corrupt chunks, keeping the run retryable. Fixes deepsec finding other-logic-bug (audioChunker). * fix(diarization): insert speaker labels verbatim in formatSpeakerLabel formatSpeakerLabel passed the speaker label as the replacement string to String.replace, which interprets special patterns ($$, $&, $`, $', $n). A label containing one of those would be rewritten with matched/surrounding text instead of inserted literally. Use a replacer function so the label is inserted verbatim. Fixes deepsec finding other-logic-bug (diarization segments).
1 parent 053d51f commit 83c34e7

6 files changed

Lines changed: 378 additions & 35 deletions

File tree

src/services/TranscriptionService.test.ts

Lines changed: 142 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,20 @@ describe("TranscriptionService", () => {
193193
});
194194

195195
describe("buildTranscriptBody (TR-01)", () => {
196-
const buildBody = (plugin: PodNotes) => {
196+
const buildBody = (
197+
plugin: PodNotes,
198+
audio: {
199+
buffer: ArrayBuffer;
200+
mimeType: string;
201+
extension: string;
202+
basename: string;
203+
} = {
204+
buffer: new ArrayBuffer(1024),
205+
mimeType: "audio/mpeg",
206+
extension: "mp3",
207+
basename: "episode",
208+
},
209+
) => {
197210
const service = new TranscriptionService(plugin);
198211
return (
199212
service as unknown as {
@@ -205,17 +218,9 @@ describe("TranscriptionService", () => {
205218
basename: string;
206219
},
207220
update: (message: string) => void,
208-
) => Promise<string>;
221+
) => Promise<{ body: string; warning?: string }>;
209222
}
210-
).buildTranscriptBody(
211-
{
212-
buffer: new ArrayBuffer(1024),
213-
mimeType: "audio/mpeg",
214-
extension: "mp3",
215-
basename: "episode",
216-
},
217-
() => {},
218-
);
223+
).buildTranscriptBody(audio, () => {});
219224
};
220225

221226
test("throws when the trimmed Whisper body is empty", async () => {
@@ -231,9 +236,133 @@ describe("TranscriptionService", () => {
231236
text: "One. Two.",
232237
});
233238

234-
await expect(buildBody(createMockPlugin())).resolves.toBe(
235-
"One.\n\nTwo.",
239+
await expect(buildBody(createMockPlugin())).resolves.toEqual({
240+
body: "One.\n\nTwo.",
241+
warning: undefined,
242+
});
243+
});
244+
});
245+
246+
describe("failed chunks are not saved as a completed transcript (other-silent-failure)", () => {
247+
const buildBodyDirect = (
248+
plugin: PodNotes,
249+
audio: {
250+
buffer: ArrayBuffer;
251+
mimeType: string;
252+
extension: string;
253+
basename: string;
254+
},
255+
) => {
256+
const service = new TranscriptionService(plugin);
257+
return (
258+
service as unknown as {
259+
buildTranscriptBody: (
260+
a: typeof audio,
261+
update: (message: string) => void,
262+
) => Promise<{ body: string; warning?: string }>;
263+
}
264+
).buildTranscriptBody(audio, () => {});
265+
};
266+
267+
const mp3Audio = (byteLength: number) => ({
268+
buffer: new ArrayBuffer(byteLength),
269+
mimeType: "audio/mp3",
270+
extension: "mp3",
271+
basename: "episode",
272+
});
273+
274+
test("throws (no file) when the single chunk fails every retry", async () => {
275+
transcriptionsCreateMock.mockRejectedValue(new Error("boom"));
276+
vi.useFakeTimers();
277+
try {
278+
const promise = buildBodyDirect(createMockPlugin(), mp3Audio(1024));
279+
const assertion = expect(promise).rejects.toThrow(
280+
"Transcription failed: all 1 audio chunk(s) failed or returned no text.",
281+
);
282+
await vi.runAllTimersAsync();
283+
await assertion;
284+
} finally {
285+
vi.useRealTimers();
286+
}
287+
});
288+
289+
test("throws when failed chunks plus empty successes leave no real text", async () => {
290+
// >20 MB mp3 → two chunks. chunk 0 fails every retry; chunk 1 "succeeds"
291+
// but returns empty text. The body is then only an error marker, which
292+
// must NOT be saved as a completed transcript.
293+
transcriptionsCreateMock.mockImplementation(
294+
async ({ file }: { file: File }) => {
295+
if (file.name.includes("part0")) {
296+
throw new Error("boom");
297+
}
298+
return { text: " " };
299+
},
236300
);
301+
302+
vi.useFakeTimers();
303+
try {
304+
const promise = buildBodyDirect(
305+
createMockPlugin(),
306+
mp3Audio(20 * 1024 * 1024 + 1024),
307+
);
308+
const assertion = expect(promise).rejects.toThrow(
309+
"Transcription failed: all 2 audio chunk(s) failed or returned no text.",
310+
);
311+
await vi.runAllTimersAsync();
312+
await assertion;
313+
} finally {
314+
vi.useRealTimers();
315+
}
316+
});
317+
318+
test("does not write a file when transcription fails completely", async () => {
319+
transcriptionsCreateMock.mockRejectedValue(new Error("boom"));
320+
const plugin = createMockPlugin();
321+
const service = new TranscriptionService(plugin);
322+
323+
vi.useFakeTimers();
324+
try {
325+
const promise = (
326+
service as unknown as {
327+
transcribeEpisode: (episode: Episode) => Promise<void>;
328+
}
329+
).transcribeEpisode(mockEpisode);
330+
// One chunk, MAX_RETRIES=3 → backoff 1000ms + 2000ms before it gives up.
331+
await vi.advanceTimersByTimeAsync(3500);
332+
await promise;
333+
} finally {
334+
vi.useRealTimers();
335+
}
336+
337+
expect(plugin.app.vault.create).not.toHaveBeenCalled();
338+
});
339+
340+
test("keeps an otherwise-good transcript but warns when only some chunks fail", async () => {
341+
// A >20 MB mp3 byte-splits into two chunks; fail the second one.
342+
transcriptionsCreateMock.mockImplementation(
343+
async ({ file }: { file: File }) => {
344+
if (file.name.includes("part1")) {
345+
throw new Error("boom");
346+
}
347+
return { text: "Good chunk." };
348+
},
349+
);
350+
351+
vi.useFakeTimers();
352+
try {
353+
const promise = buildBodyDirect(
354+
createMockPlugin(),
355+
mp3Audio(20 * 1024 * 1024 + 1024),
356+
);
357+
await vi.runAllTimersAsync();
358+
const result = await promise;
359+
360+
expect(result.body).toContain("Good chunk.");
361+
expect(result.body).toContain("[Error transcribing chunk 1]");
362+
expect(result.warning).toContain("1 of 2 chunk(s) failed");
363+
} finally {
364+
vi.useRealTimers();
365+
}
237366
});
238367
});
239368
});

src/services/TranscriptionService.ts

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,14 @@ function formatTime(ms: number): string {
5757
return `${hours.toString().padStart(2, "0")}:${(minutes % 60).toString().padStart(2, "0")}:${(seconds % 60).toString().padStart(2, "0")}`;
5858
}
5959

60+
// A chunk that exhausts its retries leaves this placeholder in its transcript
61+
// slot; the pattern matches it so buildTranscriptBody can tell real speech apart
62+
// from a body made only of error markers. Keep the builder and matcher in sync.
63+
function chunkErrorPlaceholder(index: number): string {
64+
return `[Error transcribing chunk ${index}]`;
65+
}
66+
const CHUNK_ERROR_PLACEHOLDER_PATTERN = /\[Error transcribing chunk \d+\]/g;
67+
6068
export class TranscriptionService {
6169
private plugin: PodNotes;
6270
private client: OpenAI | null = null;
@@ -168,7 +176,7 @@ export class TranscriptionService {
168176
} = await getEpisodeAudioBuffer(episode);
169177
const mimeType = getMimeType(fileExtension);
170178

171-
const transcriptBody = await this.buildTranscriptBody(
179+
const { body: transcriptBody, warning } = await this.buildTranscriptBody(
172180
{
173181
buffer: fileBuffer,
174182
mimeType,
@@ -182,7 +190,7 @@ export class TranscriptionService {
182190
await this.saveTranscription(episode, transcriptBody);
183191

184192
notice.stop();
185-
notice.update("Transcription completed and saved.");
193+
notice.update(warning ?? "Transcription completed and saved.");
186194
} catch (error) {
187195
console.error("Transcription error:", error);
188196
const message = error instanceof Error ? error.message : String(error);
@@ -201,11 +209,20 @@ export class TranscriptionService {
201209
* separated into paragraphs, so it is rendered as-is. Either path yielding no
202210
* speech is treated as a failure rather than writing an empty transcript (empty
203211
* Whisper chunks join to whitespace, so the body is trimmed before the check).
212+
*
213+
* A chunk that exhausts its retries leaves an `[Error transcribing chunk N]`
214+
* placeholder in its slot. If stripping those placeholders leaves no real text -
215+
* every chunk failed, or the only successes were empty - we throw, so no file is
216+
* written and the episode stays retryable instead of saving a "transcript" made
217+
* only of error markers that the existence check would then refuse to re-run
218+
* (mirrors the diarization provider's all-chunks-failed contract). When some
219+
* chunks fail but real speech remains we keep the otherwise-good transcript and
220+
* return a `warning` so the run is reported as partial, not a clean success.
204221
*/
205222
private async buildTranscriptBody(
206223
audio: DiarizationAudio,
207224
updateNotice: (message: string) => void,
208-
): Promise<string> {
225+
): Promise<{ body: string; warning?: string }> {
209226
const diarization = this.plugin.settings.transcript.diarization;
210227

211228
if (diarization?.enabled) {
@@ -217,19 +234,41 @@ export class TranscriptionService {
217234
if (segments.length === 0) {
218235
throw new Error("Diarization returned no speech segments.");
219236
}
220-
return renderDiarizedTranscript(segments, diarization.speakerTemplate);
237+
return {
238+
body: renderDiarizedTranscript(segments, diarization.speakerTemplate),
239+
};
221240
}
222241

223242
updateNotice("Creating audio chunks...");
224243
const files = await createChunkFiles(audio);
225244
updateNotice("Starting transcription...");
226-
const transcription = await this.transcribeChunks(files, updateNotice);
227-
// Empty chunks join to " " (not ""), so trim before deciding it is empty.
228-
const body = transcription.trim().replace(/\.\s+/g, ".\n\n");
229-
if (body.length === 0) {
230-
throw new Error("Transcription returned no text.");
245+
const { text, failedChunks } = await this.transcribeChunks(
246+
files,
247+
updateNotice,
248+
);
249+
250+
// Strip the error placeholders (and trim) to see whether ANY real speech was
251+
// transcribed. Nothing real means every chunk failed or the only successes
252+
// were empty - either way there is no usable transcript, so throw instead of
253+
// saving a body of pure error markers (empty chunks join to " ", not "").
254+
const realText = text.replace(CHUNK_ERROR_PLACEHOLDER_PATTERN, " ").trim();
255+
if (realText.length === 0) {
256+
throw new Error(
257+
failedChunks > 0
258+
? `Transcription failed: all ${files.length} audio chunk(s) failed or returned no text.`
259+
: "Transcription returned no text.",
260+
);
231261
}
232-
return body;
262+
263+
// Reflow the full body (placeholders kept inline so the user can see which
264+
// chunks failed) after sentence periods for readability.
265+
const body = text.trim().replace(/\.\s+/g, ".\n\n");
266+
267+
const warning =
268+
failedChunks > 0
269+
? `Transcription saved, but ${failedChunks} of ${files.length} chunk(s) failed - look for [Error transcribing chunk N] markers and re-run after deleting the note to retry.`
270+
: undefined;
271+
return { body, warning };
233272
}
234273

235274
/** Route the episode audio to the configured diarization provider (#168). */
@@ -269,10 +308,11 @@ export class TranscriptionService {
269308
private async transcribeChunks(
270309
files: File[],
271310
updateNotice: (message: string) => void,
272-
): Promise<string> {
311+
): Promise<{ text: string; failedChunks: number }> {
273312
const client = await this.getClient();
274313
const transcriptions: string[] = new Array(files.length);
275314
let completedChunks = 0;
315+
let failedChunks = 0;
276316
let nextIndex = 0;
277317

278318
const updateProgress = () => {
@@ -308,7 +348,8 @@ export class TranscriptionService {
308348
`Failed to transcribe chunk ${index} after ${this.MAX_RETRIES} attempts:`,
309349
error,
310350
);
311-
transcriptions[index] = `[Error transcribing chunk ${index}]`;
351+
transcriptions[index] = chunkErrorPlaceholder(index);
352+
failedChunks++;
312353
completedChunks++;
313354
updateProgress();
314355
} else {
@@ -329,7 +370,7 @@ export class TranscriptionService {
329370

330371
await Promise.all(workers);
331372

332-
return transcriptions.join(" ");
373+
return { text: transcriptions.join(" "), failedChunks };
333374
}
334375

335376
/**

0 commit comments

Comments
 (0)