Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 142 additions & 13 deletions src/services/TranscriptionService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,20 @@ describe("TranscriptionService", () => {
});

describe("buildTranscriptBody (TR-01)", () => {
const buildBody = (plugin: PodNotes) => {
const buildBody = (
plugin: PodNotes,
audio: {
buffer: ArrayBuffer;
mimeType: string;
extension: string;
basename: string;
} = {
buffer: new ArrayBuffer(1024),
mimeType: "audio/mpeg",
extension: "mp3",
basename: "episode",
},
) => {
const service = new TranscriptionService(plugin);
return (
service as unknown as {
Expand All @@ -205,17 +218,9 @@ describe("TranscriptionService", () => {
basename: string;
},
update: (message: string) => void,
) => Promise<string>;
) => Promise<{ body: string; warning?: string }>;
}
).buildTranscriptBody(
{
buffer: new ArrayBuffer(1024),
mimeType: "audio/mpeg",
extension: "mp3",
basename: "episode",
},
() => {},
);
).buildTranscriptBody(audio, () => {});
};

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

await expect(buildBody(createMockPlugin())).resolves.toBe(
"One.\n\nTwo.",
await expect(buildBody(createMockPlugin())).resolves.toEqual({
body: "One.\n\nTwo.",
warning: undefined,
});
});
});

describe("failed chunks are not saved as a completed transcript (other-silent-failure)", () => {
const buildBodyDirect = (
plugin: PodNotes,
audio: {
buffer: ArrayBuffer;
mimeType: string;
extension: string;
basename: string;
},
) => {
const service = new TranscriptionService(plugin);
return (
service as unknown as {
buildTranscriptBody: (
a: typeof audio,
update: (message: string) => void,
) => Promise<{ body: string; warning?: string }>;
}
).buildTranscriptBody(audio, () => {});
};

const mp3Audio = (byteLength: number) => ({
buffer: new ArrayBuffer(byteLength),
mimeType: "audio/mp3",
extension: "mp3",
basename: "episode",
});

test("throws (no file) when the single chunk fails every retry", async () => {
transcriptionsCreateMock.mockRejectedValue(new Error("boom"));
vi.useFakeTimers();
try {
const promise = buildBodyDirect(createMockPlugin(), mp3Audio(1024));
const assertion = expect(promise).rejects.toThrow(
"Transcription failed: all 1 audio chunk(s) failed or returned no text.",
);
await vi.runAllTimersAsync();
await assertion;
} finally {
vi.useRealTimers();
}
});

test("throws when failed chunks plus empty successes leave no real text", async () => {
// >20 MB mp3 → two chunks. chunk 0 fails every retry; chunk 1 "succeeds"
// but returns empty text. The body is then only an error marker, which
// must NOT be saved as a completed transcript.
transcriptionsCreateMock.mockImplementation(
async ({ file }: { file: File }) => {
if (file.name.includes("part0")) {
throw new Error("boom");
}
return { text: " " };
},
);

vi.useFakeTimers();
try {
const promise = buildBodyDirect(
createMockPlugin(),
mp3Audio(20 * 1024 * 1024 + 1024),
);
const assertion = expect(promise).rejects.toThrow(
"Transcription failed: all 2 audio chunk(s) failed or returned no text.",
);
await vi.runAllTimersAsync();
await assertion;
} finally {
vi.useRealTimers();
}
});

test("does not write a file when transcription fails completely", async () => {
transcriptionsCreateMock.mockRejectedValue(new Error("boom"));
const plugin = createMockPlugin();
const service = new TranscriptionService(plugin);

vi.useFakeTimers();
try {
const promise = (
service as unknown as {
transcribeEpisode: (episode: Episode) => Promise<void>;
}
).transcribeEpisode(mockEpisode);
// One chunk, MAX_RETRIES=3 → backoff 1000ms + 2000ms before it gives up.
await vi.advanceTimersByTimeAsync(3500);
await promise;
} finally {
vi.useRealTimers();
}

expect(plugin.app.vault.create).not.toHaveBeenCalled();
});

test("keeps an otherwise-good transcript but warns when only some chunks fail", async () => {
// A >20 MB mp3 byte-splits into two chunks; fail the second one.
transcriptionsCreateMock.mockImplementation(
async ({ file }: { file: File }) => {
if (file.name.includes("part1")) {
throw new Error("boom");
}
return { text: "Good chunk." };
},
);

vi.useFakeTimers();
try {
const promise = buildBodyDirect(
createMockPlugin(),
mp3Audio(20 * 1024 * 1024 + 1024),
);
await vi.runAllTimersAsync();
const result = await promise;

expect(result.body).toContain("Good chunk.");
expect(result.body).toContain("[Error transcribing chunk 1]");
expect(result.warning).toContain("1 of 2 chunk(s) failed");
} finally {
vi.useRealTimers();
}
});
});
});
67 changes: 54 additions & 13 deletions src/services/TranscriptionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ function formatTime(ms: number): string {
return `${hours.toString().padStart(2, "0")}:${(minutes % 60).toString().padStart(2, "0")}:${(seconds % 60).toString().padStart(2, "0")}`;
}

// A chunk that exhausts its retries leaves this placeholder in its transcript
// slot; the pattern matches it so buildTranscriptBody can tell real speech apart
// from a body made only of error markers. Keep the builder and matcher in sync.
function chunkErrorPlaceholder(index: number): string {
return `[Error transcribing chunk ${index}]`;
}
const CHUNK_ERROR_PLACEHOLDER_PATTERN = /\[Error transcribing chunk \d+\]/g;

export class TranscriptionService {
private plugin: PodNotes;
private client: OpenAI | null = null;
Expand Down Expand Up @@ -168,7 +176,7 @@ export class TranscriptionService {
} = await getEpisodeAudioBuffer(episode);
const mimeType = getMimeType(fileExtension);

const transcriptBody = await this.buildTranscriptBody(
const { body: transcriptBody, warning } = await this.buildTranscriptBody(
{
buffer: fileBuffer,
mimeType,
Expand All @@ -182,7 +190,7 @@ export class TranscriptionService {
await this.saveTranscription(episode, transcriptBody);

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

if (diarization?.enabled) {
Expand All @@ -217,19 +234,41 @@ export class TranscriptionService {
if (segments.length === 0) {
throw new Error("Diarization returned no speech segments.");
}
return renderDiarizedTranscript(segments, diarization.speakerTemplate);
return {
body: renderDiarizedTranscript(segments, diarization.speakerTemplate),
};
}

updateNotice("Creating audio chunks...");
const files = await createChunkFiles(audio);
updateNotice("Starting transcription...");
const transcription = await this.transcribeChunks(files, updateNotice);
// Empty chunks join to " " (not ""), so trim before deciding it is empty.
const body = transcription.trim().replace(/\.\s+/g, ".\n\n");
if (body.length === 0) {
throw new Error("Transcription returned no text.");
const { text, failedChunks } = await this.transcribeChunks(
files,
updateNotice,
);

// Strip the error placeholders (and trim) to see whether ANY real speech was
// transcribed. Nothing real means every chunk failed or the only successes
// were empty - either way there is no usable transcript, so throw instead of
// saving a body of pure error markers (empty chunks join to " ", not "").
const realText = text.replace(CHUNK_ERROR_PLACEHOLDER_PATTERN, " ").trim();
if (realText.length === 0) {
throw new Error(
failedChunks > 0
? `Transcription failed: all ${files.length} audio chunk(s) failed or returned no text.`
: "Transcription returned no text.",
);
}
return body;

// Reflow the full body (placeholders kept inline so the user can see which
// chunks failed) after sentence periods for readability.
const body = text.trim().replace(/\.\s+/g, ".\n\n");

const warning =
failedChunks > 0
? `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.`
: undefined;
return { body, warning };
}

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

const updateProgress = () => {
Expand Down Expand Up @@ -308,7 +348,8 @@ export class TranscriptionService {
`Failed to transcribe chunk ${index} after ${this.MAX_RETRIES} attempts:`,
error,
);
transcriptions[index] = `[Error transcribing chunk ${index}]`;
transcriptions[index] = chunkErrorPlaceholder(index);
failedChunks++;
completedChunks++;
updateProgress();
} else {
Expand All @@ -329,7 +370,7 @@ export class TranscriptionService {

await Promise.all(workers);

return transcriptions.join(" ");
return { text: transcriptions.join(" "), failedChunks };
}

/**
Expand Down
Loading
Loading