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
20 changes: 10 additions & 10 deletions .claude/skills/verify/net.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
// for the fixture server, OpenAI and Deepgram
// node .claude/skills/verify/net.mjs unwatch restore fetch and requestUrl
//
// PodNotes sends OpenAI uploads through window.fetch and everything else
// (feeds, audio, transcripts, Deepgram) through requestUrl, which Obsidian
// forwards to its main process on the `request-url` IPC channel.
// PodNotes sends its requests (feeds, audio, transcripts, OpenAI, Deepgram)
// through requestUrl, which Obsidian forwards to its main process on the
// `request-url` IPC channel. The fetch log catches anything that bypasses it.
import { fileURLToPath } from "node:url";
import {
cliSocketExists,
Expand Down Expand Up @@ -78,11 +78,6 @@ const WATCH = `(() => {
window.fetch = async function (input, init) {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
if (url.startsWith("https://api.openai.com/")) {
const file = init?.body instanceof FormData ? init.body.get("file") : null;
entries.push({ via: "fetch", method, url: logged(url), answeredBy: "fixture provider", uploadBytes: file instanceof Blob ? file.size : 0 });
return new Response(JSON.stringify({ text, segments: [{ speaker: "A", text, start: 0, end: 1 }] }), { status: 200, headers: { "content-type": "application/json" } });
}
entries.push({ via: "fetch", method, url: logged(url) });
return originalFetch.call(this, input, init);
};
Expand All @@ -91,9 +86,14 @@ const WATCH = `(() => {
ipc.send = function (channel, replyChannel, request, ...rest) {
if (channel === "request-url" && typeof request?.url === "string") {
const method = (request.method ?? "GET").toUpperCase();
if (request.url.startsWith("https://api.deepgram.com/")) {
const answer = request.url.startsWith("https://api.openai.com/")
? { text, segments: [{ speaker: "A", text, start: 0, end: 1 }] }
: request.url.startsWith("https://api.deepgram.com/")
? { results: { utterances: [{ speaker: 0, transcript: text, start: 0, end: 1 }] } }
: null;
if (answer) {
entries.push({ via: "requestUrl", method, url: logged(request.url), answeredBy: "fixture provider", uploadBytes: request.body?.byteLength ?? 0 });
const body = new TextEncoder().encode(JSON.stringify({ results: { utterances: [{ speaker: 0, transcript: text, start: 0, end: 1 }] } })).buffer;
const body = new TextEncoder().encode(JSON.stringify(answer)).buffer;
queueMicrotask(() => ipc.emit(replyChannel, {}, { status: 200, headers: { "content-type": "application/json" }, body }));
return;
}
Expand Down
16 changes: 8 additions & 8 deletions src/services/TranscriptionService.published.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,17 @@ import { deferred } from "../../tests/mocks/feeds";
import {
createMockPlugin,
diarizeWithDeepgramMock,
fetchMock,
getEpisodeAudioBufferMock,
installTranscriptionMocks,
mockEpisode,
noticeMessages,
} from "../../tests/mocks/transcriptionService";
import { openAIMock } from "../../tests/mocks/openaiRequestUrl";
import { TranscriptionService } from "./TranscriptionService";

vi.mock("obsidian", async (importOriginal) => {
const { recordingNotices } = await import("../../tests/mocks/transcriptionService");
return recordingNotices(await importOriginal<typeof import("obsidian")>());
const { mockedObsidian } = await import("../../tests/mocks/transcriptionService");
return mockedObsidian(await importOriginal<typeof import("obsidian")>());
});

const fetchTextMock = vi.hoisted(() => vi.fn());
Expand Down Expand Up @@ -116,7 +116,7 @@ describe("published transcripts", () => {
"Preparing to transcribe...",
]);
expect(getEpisodeAudioBufferMock).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
expect(openAIMock).not.toHaveBeenCalled();
expect(diarizeWithDeepgramMock).not.toHaveBeenCalled();
});

Expand All @@ -130,7 +130,7 @@ describe("published transcripts", () => {

expect(vi.mocked(plugin.app.vault.create).mock.calls[0][1]).toBe(publishedNote);
expect(getEpisodeAudioBufferMock).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
expect(openAIMock).not.toHaveBeenCalled();
expect(diarizeWithDeepgramMock).not.toHaveBeenCalled();
});

Expand Down Expand Up @@ -166,7 +166,7 @@ describe("published transcripts", () => {

expect(noticeMessages).toEqual(["No published transcript was found for this episode."]);
expect(getEpisodeAudioBufferMock).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
expect(openAIMock).not.toHaveBeenCalled();
});

test("links of unknown types keep today's key notice", async () => {
Expand Down Expand Up @@ -197,7 +197,7 @@ describe("published transcripts", () => {

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

test("the offer names Deepgram when diarization uses it", async () => {
Expand Down Expand Up @@ -235,7 +235,7 @@ describe("published transcripts", () => {
});

test("transcribing from audio never looks for a published transcript", async () => {
fetchMock.mockImplementation(async () => Response.json({ text: "Spoken words." }));
openAIMock.mockImplementation(async () => Response.json({ text: "Spoken words." }));
const plugin = createMockPlugin({ transcripts: published });
const service = new TranscriptionService(plugin);

Expand Down
73 changes: 36 additions & 37 deletions src/services/TranscriptionService.test.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
// @vitest-environment jsdom
import { describe, expect, test, vi } from "vitest";
import { Notice } from "obsidian";
import { Notice, type RequestUrlParam } from "obsidian";
import { deferred } from "../../tests/mocks/feeds";
import { OPENAI_FAILURES, rateLimited } from "../../tests/mocks/openaiFailures";
import {
createMockPlugin,
diarizeWithDeepgramMock,
fetchMock,
getEpisodeAudioBufferMock,
installTranscriptionMocks,
mockEpisode,
noticeMessages,
} from "../../tests/mocks/transcriptionService";
import { openAIMock, sentForm } from "../../tests/mocks/openaiRequestUrl";
import { TranscriptionService } from "./TranscriptionService";
import type PodNotes from "src/main";
import type { Episode } from "src/types/Episode";

vi.mock("obsidian", async (importOriginal) => {
const { recordingNotices } = await import("../../tests/mocks/transcriptionService");
return recordingNotices(await importOriginal<typeof import("obsidian")>());
const { mockedObsidian } = await import("../../tests/mocks/transcriptionService");
return mockedObsidian(await importOriginal<typeof import("obsidian")>());
});

async function settlesAfterMicrotasks(promise: Promise<unknown>): Promise<boolean> {
Expand Down Expand Up @@ -53,9 +53,10 @@ type AudioJob = { episode: Episode; source: { kind: "audio" } };
const audioJob: AudioJob = { episode: mockEpisode, source: { kind: "audio" } };

const whisperReturns = (text: string) =>
fetchMock.mockImplementation(async () => Response.json({ text }));
openAIMock.mockImplementation(async () => Response.json({ text }));

const sentFile = (init?: RequestInit) => (init?.body as FormData | undefined)?.get("file") as File;
// Vitest's jsdom Request names every multipart file "blob", so these tests tell chunks apart by size.
const sentFile = async (request: RequestUrlParam) => (await sentForm(request)).get("file") as File;

describe("TranscriptionService", () => {
describe("transcribeCurrentEpisode validation", () => {
Expand Down Expand Up @@ -142,7 +143,7 @@ describe("TranscriptionService", () => {
const plugin = createMockPlugin();
const service = new TranscriptionService(plugin);
let finishRequest!: (response: Response) => void;
fetchMock.mockImplementation(
openAIMock.mockImplementation(
() =>
new Promise((resolve) => {
finishRequest = resolve;
Expand All @@ -155,14 +156,13 @@ describe("TranscriptionService", () => {
transcribeEpisode: (job: AudioJob) => Promise<void>;
}
).transcribeEpisode(audioJob);
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce());
await vi.waitFor(() => expect(openAIMock).toHaveBeenCalledOnce());

const messagesBeforeDispose = setMessage.mock.calls.length;
service.dispose();
finishRequest(Response.json({ text: "This must not be saved." }));
await pending;

expect(fetchMock.mock.calls[0][1]?.signal?.aborted).toBe(true);
expect(plugin.app.vault.create).not.toHaveBeenCalled();
expect(setMessage).toHaveBeenCalledTimes(messagesBeforeDispose);
} finally {
Expand Down Expand Up @@ -382,12 +382,12 @@ describe("TranscriptionService", () => {
});

test("throws (no file) when the single chunk fails every retry", async () => {
fetchMock.mockImplementation(async () => new Response("boom", { status: 500 }));
openAIMock.mockImplementation(async () => new Response("boom", { status: 500 }));
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.",
"all 1 audio chunk(s) failed or returned no text. Last error: 500 boom",
);
await vi.runAllTimersAsync();
await assertion;
Expand All @@ -400,8 +400,8 @@ describe("TranscriptionService", () => {
// >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.
fetchMock.mockImplementation(async (_url, init) =>
sentFile(init).name.includes("part0")
openAIMock.mockImplementation(async (request) =>
(await sentFile(request)).size > 1024
? new Response("boom", { status: 500 })
: Response.json({ text: " " }),
);
Expand All @@ -413,7 +413,7 @@ describe("TranscriptionService", () => {
mp3Audio(20 * 1024 * 1024 + 1024),
);
const assertion = expect(promise).rejects.toThrow(
"Transcription failed: all 2 audio chunk(s) failed or returned no text.",
"all 2 audio chunk(s) failed or returned no text. Last error: 500 boom",
);
await vi.runAllTimersAsync();
await assertion;
Expand All @@ -423,7 +423,7 @@ describe("TranscriptionService", () => {
});

test("does not write a file when transcription fails completely", async () => {
fetchMock.mockImplementation(async () => new Response("boom", { status: 500 }));
openAIMock.mockImplementation(async () => new Response("boom", { status: 500 }));
const plugin = createMockPlugin();
const service = new TranscriptionService(plugin);

Expand All @@ -446,8 +446,8 @@ describe("TranscriptionService", () => {

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.
fetchMock.mockImplementation(async (_url, init) =>
sentFile(init).name.includes("part1")
openAIMock.mockImplementation(async (request) =>
(await sentFile(request)).size === 1024
? new Response("boom", { status: 500 })
: Response.json({ text: "Good chunk." }),
);
Expand Down Expand Up @@ -501,42 +501,41 @@ describe("TranscriptionService", () => {

await transcribe(createMockPlugin());

expect(fetchMock).toHaveBeenCalledOnce();
const form = fetchMock.mock.calls[0][1]?.body as FormData;
expect(openAIMock).toHaveBeenCalledOnce();
const form = await sentForm(openAIMock.mock.calls[0][0]);
expect([...form.keys()]).toEqual(["model", "file"]);
expect(form.get("model")).toBe("whisper-1");
const file = sentFile(fetchMock.mock.calls[0][1]);
expect(file.name).toBe("episode.mp3");
const file = form.get("file") as File;
expect(file.type).toBe("audio/mp3");
expect(file.size).toBe(1024);
});

test.each(OPENAI_FAILURES)(
"Whisper %s fails the run after three sends",
async (_name, respond) => {
fetchMock.mockImplementation(respond);
"Whisper %s shows the API error after three sends",
async (_name, respond, message) => {
openAIMock.mockImplementation(respond);

await expect(finalNotice(createMockPlugin())).resolves.toBe(
"Transcription failed: Transcription failed: all 1 audio chunk(s) failed or returned no text.",
`Transcription failed: all 1 audio chunk(s) failed or returned no text. Last error: ${message}`,
);
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(openAIMock).toHaveBeenCalledTimes(3);
},
);

test("Whisper answering without text fails the run after three sends", async () => {
fetchMock.mockImplementation(async () => Response.json({}));
openAIMock.mockImplementation(async () => Response.json({}));

await expect(finalNotice(createMockPlugin())).resolves.toBe(
"Transcription failed: Transcription failed: all 1 audio chunk(s) failed or returned no text.",
"Transcription failed: all 1 audio chunk(s) failed or returned no text. Last error: OpenAI returned a transcription without text.",
);
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(openAIMock).toHaveBeenCalledTimes(3);
});

test("Whisper waits out the server's Retry-After and recovers", async () => {
vi.useFakeTimers();
try {
const sentAt: number[] = [];
fetchMock.mockImplementation(async () => {
openAIMock.mockImplementation(async () => {
sentAt.push(Date.now());
return Date.now() - sentAt[0] < 4000
? rateLimited({ "retry-after": "4" })
Expand All @@ -560,7 +559,7 @@ describe("TranscriptionService", () => {
vi.useFakeTimers();
const setMessage = vi.spyOn(Notice.prototype, "setMessage");
try {
fetchMock.mockImplementation(async () => rateLimited({ "retry-after": "30" }));
openAIMock.mockImplementation(async () => rateLimited({ "retry-after": "30" }));
const plugin = createMockPlugin();
const service = new TranscriptionService(plugin);
let settled = false;
Expand All @@ -574,15 +573,15 @@ describe("TranscriptionService", () => {
settled = true;
});
await vi.advanceTimersByTimeAsync(29_000);
expect(fetchMock).toHaveBeenCalledOnce();
expect(openAIMock).toHaveBeenCalledOnce();

const messagesBeforeDispose = setMessage.mock.calls.length;
service.dispose();
await vi.advanceTimersByTimeAsync(0);

expect(settled).toBe(true);
await pending;
expect(fetchMock).toHaveBeenCalledOnce();
expect(openAIMock).toHaveBeenCalledOnce();
expect(plugin.app.vault.create).not.toHaveBeenCalled();
expect(setMessage).toHaveBeenCalledTimes(messagesBeforeDispose);
} finally {
Expand All @@ -605,16 +604,16 @@ describe("TranscriptionService", () => {
await service.transcribeEpisode(audioJob);

const authorization = (call: number) =>
new Headers(fetchMock.mock.calls[call][1]?.headers).get("authorization");
expect(fetchMock).toHaveBeenCalledTimes(2);
openAIMock.mock.calls[call][0].headers?.Authorization;
expect(openAIMock).toHaveBeenCalledTimes(2);
expect(authorization(0)).toBe("Bearer first-key");
expect(authorization(1)).toBe("Bearer second-key");
});

test.each(OPENAI_FAILURES)(
"OpenAI diarization %s shows the API error after three sends",
async (_name, respond, message) => {
fetchMock.mockImplementation(respond);
openAIMock.mockImplementation(respond);
const plugin = createMockPlugin();
plugin.settings.transcript.diarization = {
enabled: true,
Expand All @@ -625,7 +624,7 @@ describe("TranscriptionService", () => {
await expect(finalNotice(plugin)).resolves.toBe(
`Transcription failed: OpenAI diarization failed for every chunk: ${message}`,
);
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(openAIMock).toHaveBeenCalledTimes(3);
},
);
});
Expand Down
14 changes: 8 additions & 6 deletions src/services/TranscriptionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ export class TranscriptionService {
const files = await createChunkFiles(audio);
this.assertActive();
updateNotice("Starting transcription...");
const { text, failedChunks } = await this.transcribeChunks(files, updateNotice);
const { text, failedChunks, lastError } = await this.transcribeChunks(files, updateNotice);
this.assertActive();

// Strip the error placeholders (and trim) to see whether ANY real speech was
Expand All @@ -337,10 +337,10 @@ export class TranscriptionService {
// 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) {
if (failedChunks === 0) throw new Error("Transcription returned no text.");
const detail = lastError instanceof Error ? lastError.message : String(lastError);
throw new Error(
failedChunks > 0
? `Transcription failed: all ${files.length} audio chunk(s) failed or returned no text.`
: "Transcription returned no text.",
`all ${files.length} audio chunk(s) failed or returned no text. Last error: ${detail}`,
);
}

Expand Down Expand Up @@ -402,11 +402,12 @@ export class TranscriptionService {
private async transcribeChunks(
files: File[],
updateNotice: (message: string) => void,
): Promise<{ text: string; failedChunks: number }> {
): Promise<{ text: string; failedChunks: number; lastError: unknown }> {
const apiKey = this.getApiKey();
const transcriptions: string[] = Array.from({ length: files.length });
let completedChunks = 0;
let failedChunks = 0;
let lastError: unknown;
let nextIndex = 0;

const updateProgress = () => {
Expand Down Expand Up @@ -453,6 +454,7 @@ export class TranscriptionService {
);
transcriptions[index] = chunkErrorPlaceholder(index);
failedChunks++;
lastError = error;
completedChunks++;
updateProgress();
} else {
Expand All @@ -469,7 +471,7 @@ export class TranscriptionService {
await Promise.all(workers);
this.assertActive();

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

private waitForRetry(delayMs: number): Promise<void> {
Expand Down
Loading
Loading