Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
aca94c4
test(transcription): send OpenAI diarization no part over 1400 s
chhoumann Nov 13, 2026
217b040
fix(transcription): cap OpenAI diarization chunks at 1400 s
chhoumann Nov 13, 2026
61c8c52
test(transcription): send a rejected OpenAI request once
chhoumann Nov 13, 2026
44947b5
fix(transcription): send a rejected OpenAI request once
chhoumann Nov 13, 2026
a9a3b07
fix(transcription): keep OpenAI diarization requests inside the timeout
chhoumann Nov 13, 2026
f128d4c
refactor(transcription): keep the speaker label format in the renderer
chhoumann Nov 13, 2026
6887b5a
feat(transcription): add one engine for transcription providers
chhoumann Nov 13, 2026
42b55a3
feat(transcription): transcribe through the engine with current models
chhoumann Nov 13, 2026
2f9cd67
test(verify): answer OpenAI and Deepgram as the engine reads them
chhoumann Nov 13, 2026
989185f
test(e2e): read the abort rejection through waitForLifecycle
chhoumann Nov 13, 2026
7d46678
fix(transcription): stop sooner after an abort or a long Retry-After
chhoumann Nov 13, 2026
df5ea09
fix(transcription): cut MP3 parts without a one-frame tail or an unco…
chhoumann Nov 14, 2026
3a14c53
fix(transcription): drop the summed-seconds cap that split off one-fr…
chhoumann Nov 14, 2026
27d8958
fix(transcription): split MP3 parts equally even when the size cap binds
chhoumann Nov 14, 2026
da3baf9
fix(transcription): fill MP3 parts up to the size cap before adding one
chhoumann Nov 14, 2026
25470c3
fix(transcription): cut MP3 parts as few as fit, moving cuts either way
chhoumann Nov 14, 2026
520f2dd
fix(transcription): sum each MP3 frame's duration when cutting parts
chhoumann Nov 14, 2026
aea5d1a
fix(transcription): skip ID3v2 tags before, between and after MP3 frames
chhoumann Nov 14, 2026
7370a71
Merge remote-tracking branch 'origin/master' into feat/transcription-…
chhoumann Nov 14, 2026
d544390
fix(transcription): split long audio in other formats under the lengt…
chhoumann Nov 14, 2026
cc142ba
build(size): raise the gzip budget by the 118 bytes the length check …
chhoumann Nov 14, 2026
a8c8417
fix(transcription): upload episodes whose address is a signed URL
chhoumann Nov 14, 2026
7b64684
build(size): set the gzip budget to the 104,235 bytes the engine fixe…
chhoumann Nov 14, 2026
1f0b4c2
fix(transcription): upload episodes signed with an Akamai hdnea token
chhoumann Nov 14, 2026
37fdc08
build(size): set the gzip budget to the 104,238 bytes the engine fixe…
chhoumann Nov 14, 2026
79addc3
fix(transcription): stop waiting for audio metadata after 5 s or on u…
chhoumann Nov 14, 2026
3641fb8
build(size): set the gzip budget to the 104,290 bytes the engine fixe…
chhoumann Nov 14, 2026
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
4 changes: 3 additions & 1 deletion .claude/skills/verify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,9 @@ its profile. It keeps the vault and `.obsidian-e2e-artifacts/`. Delete
live in `screens/scenes.mjs`, the instance calls in `screens/instance.mjs`,
and the contact sheet in `screens/sheet.mjs`.
- `.claude/skills/verify/fixtures.mjs` runs as `feed`, `feed-b`, `transcripts`,
`library`, `retitle`, `requests`, `audio [vault path]` or `stop`. `library`
`private`, `library`, `retitle`, `requests`, `audio [vault path]` or `stop`.
`private` prints a feed at `priv3.supercast.com`, an address PodNotes keeps in
SecretStorage as it does a Supercast subscriber's. `library`
prints the URL of a manifest of eight podcasts, one whose feed answers 404, and
one more feed to add. Their episodes' media is silence as long as the feed
says. `#213` has chapters and a WebVTT transcript, and `Signal & Noise` has a 10 s video episode that
Expand Down
15 changes: 12 additions & 3 deletions .claude/skills/verify/features/transcripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ A user transcribes the current episode. When its feed publishes a transcript
(`<podcast:transcript>`), PodNotes writes it as timestamped paragraphs with no
API key and no call to OpenAI or Deepgram. Otherwise, and always through
`Transcribe current episode from audio`, it sends the audio to the configured
provider with the user's key. The note goes to `Transcript path`, filled
from `Transcript template`, and opens.
provider with the user's key. That is Deepgram when `Speaker diarization` is on
with Deepgram as the provider, and OpenAI otherwise. The note goes to
`Transcript path`, filled from `Transcript template`, and opens.

## Sub-features

Expand All @@ -14,6 +15,8 @@ from `Transcript template`, and opens.
- `transcript-paid` keeps the audio path for an episode without a published transcript: the key notice without a key, the provider with one.
- `transcript-failed` shows the fetch failure when a published transcript cannot be loaded, names the audio command when a key is set, and sends nothing to a provider.
- `transcript-audio` transcribes the audio with the provider through `Transcribe current episode from audio`, even when a published transcript exists.
- `transcript-openai` sends OpenAI the audio as `gpt-transcribe` uploads of at most 24 MB and 2,070 s, one at a time, and writes paragraphs without timestamps or speaker labels.
- `transcript-deepgram` writes timestamped paragraphs with numbered speakers. Deepgram gets the address of an episode a saved public feed lists, and the audio itself for a private feed's episode, a local file, and an address that carries a credential or is not public.
- `transcript-existing` refuses a second run while the note exists.

## How to get to it (user POV)
Expand All @@ -35,6 +38,12 @@ Preconditions:
- **Key.** A fake key is a precondition, not a user path: `CLI eval code='app.secretStorage.setSecret("podnotes-openai-api-key-424242", "sk-verify-fixture-not-real"), app.plugins.plugins.podnotes.settings.openAISecretId = "podnotes-openai-api-key-424242", app.plugins.plugins.podnotes.saveSettings(), "key set"'`. With it, `Untranscribed Episode` downloads its WAV and `$NET log` shows a `POST https://api.openai.com/v1/audio/transcriptions` answered by the fixture provider.
- **Failed.** Play `Broken Transcript Episode` and run the command. The progress notice ends with `The published transcript could not be fetched: Network response returned HTTP 404.`, followed with a key by `Run "Transcribe current episode from audio" to transcribe it with OpenAI instead, which uses your API credit.` `$NET log` has the transcript GET (it records no status) and no provider call or `.wav`.
- **Audio command.** On the same episode, run `UI key Ctrl+p`, `UI type .prompt-input 'Transcribe current episode from audio'` and `UI click .suggestion-item --text 'from audio'`. With a key, the WAV is fetched and OpenAI (the fixture provider) answers.
- **OpenAI in parts.** Write an MP3 longer than 2,070 s into the vault, for example 70 min in 4 MB: `ffmpeg -f lavfi -i sine=frequency=440:duration=4200 -ac 1 -ar 8000 -b:a 8k "$VAULT/Fixtures/Long Fixture.mp3"`. Play it ([local-files.md](./local-files.md)) and run the audio command. `$NET log` shows three `POST https://api.openai.com/v1/audio/transcriptions` entries of about 1.4 MB each, one after the other, and the note holds the fixture text three times with no timestamps.
- **Deepgram.** Set a fake Deepgram key as above, with the id `podnotes-deepgram-api-key-424242` and the setting `deepgramSecretId`. In Settings turn on `Speaker diarization` and pick `Deepgram` under `Diarization provider`. The fixture provider answers with two speakers, the second 12 s in, so the note reads `[00:00:00](...) **1:** This text came from the fixture provider.` and `[00:00:12](...) **2:** A second speaker answers.`
- Public. Play `Fixture Episode 1` of `PodNotes Fixture` and run the audio command. `$NET log` shows one `POST https://api.deepgram.com/v1/listen` with `contentType` `application/json` and under 100 `uploadBytes`, and no request for `/episode-1.wav` after the command.
- Private. `FEED=$(node .claude/skills/verify/fixtures.mjs private)` prints a feed at `priv3.supercast.com`. Add `PodNotes Private` and check that `data.json` saved it with a `urlSecretId`. Play its `Fixture Episode 1` and run the audio command. `$NET log` shows a `GET` for `/p/episode-1.wav`, then one `POST https://api.deepgram.com/v1/listen` with `contentType` `audio/wav` and the WAV's 960,044 `uploadBytes`.
- Local. Play `Fixtures/Local Fixture.wav` and run the audio command. The `POST` carries `audio/wav`, with no `GET` before it.
- **Speaker labels with OpenAI.** With `Speaker diarization` on and `OpenAI (no speaker labels)` as the provider, the progress notice ends with `Saved without speaker labels, because OpenAI does not label speakers. These providers do: Deepgram.` and stays until clicked.
- **Proof.** Screenshot the opened note (`npm run capture:e2e-obsidian -- screenshot .obsidian-e2e-artifacts/transcripts/note.png`), keep the note text, `$NET log` and `fixtures.mjs requests` for every run, and read `data.json` to show no new field.

## Gotchas
Expand All @@ -44,6 +53,6 @@ Preconditions:

- `net.mjs` sees `window.fetch` and `requestUrl`. The player streams audio through the media element, which only `fixtures.mjs requests` shows.
- A secret id must be `podnotes-openai-api-key` or `podnotes-openai-api-key-<n>`. Any other id reads as missing on this device.
- Notices hide after about 5 s. Read them right after the command. They also cover the PodNotes view's head and the full player's collapse button, and `ui.mjs` refuses a covered click, so dismiss them first.
- The progress notice hides about 5 s after a transcript is saved. A failure, or a transcript saved with a warning, stays until clicked. Notices also cover the PodNotes view's head and the full player's collapse button, and `ui.mjs` refuses a covered click, so dismiss them first.
- After editing `fixtures.mjs`, run `fixtures.mjs stop` before `fixtures.mjs transcripts`. A running server keeps serving the old routes.
- Every fixture transcript cue falls inside the 60 s WAV, so its links seek within the audio.
17 changes: 15 additions & 2 deletions .claude/skills/verify/fixtures.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
// node .claude/skills/verify/fixtures.mjs feed start or reuse the feed server, print the feed URL
// node .claude/skills/verify/fixtures.mjs feed-b same server, print a second feed whose episode titles repeat the first's
// node .claude/skills/verify/fixtures.mjs transcripts same server, print a feed whose episodes publish transcripts
// node .claude/skills/verify/fixtures.mjs private same server, print a feed at an address PodNotes keeps
// private, as a Supercast subscriber's feed is
// node .claude/skills/verify/fixtures.mjs library same server, print the URL of a JSON manifest of eight
// podcasts as PodNotes saves them (Night Owl Science's feed
// answers 404) and one more feed to add. Each episode's
Expand All @@ -26,7 +28,7 @@ import { Readable, pipeline } from "node:stream";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { loadRunnerConfig, resolveProvisionOptions } from "obsidian-e2e/runner";
import { FIXTURE_HOST } from "../../../scripts/e2e-instance.mjs";
import { FIXTURE_HOST, SUPERCAST_FIXTURE_HOST } from "../../../scripts/e2e-instance.mjs";

const root = fileURLToPath(new URL("../../..", import.meta.url));
// Stable per worktree, so a feed saved in the vault keeps working across restarts.
Expand All @@ -36,11 +38,14 @@ const base = `http://${host}:${port}`;
const feedUrl = `${base}/feed.xml`;
const feedBUrl = `${base}/feed-b.xml`;
const transcriptsUrl = `${base}/transcripts.xml`;
const PRIVATE_ROUTE = "/feeds/verify-subscriber-token/feed.xml";
const privateUrl = `http://${SUPERCAST_FIXTURE_HOST}:${port}${PRIVATE_ROUTE}`;
const libraryUrl = `${base}/library.json`;
const local = `http://127.0.0.1:${port}`;
const FEED_TITLE = "PodNotes Fixture";
const FEED_B_TITLE = "PodNotes Fixture B";
const TRANSCRIPTS_TITLE = "PodNotes Transcripts";
const PRIVATE_TITLE = "PodNotes Private";
const transcriptFixtures = path.join(root, "tests/fixtures/transcripts");
// Media made here outlives the server; node_modules/.cache is where tools keep caches.
const CACHE = path.join(root, "node_modules/.cache/podnotes-fixtures");
Expand Down Expand Up @@ -548,6 +553,13 @@ function serve() {
"/b/artwork.svg": ["image/svg+xml", Buffer.from(artwork(FEED_B_TITLE, "#0f766e"))],
"/b/episode-1.wav": ["audio/wav", audio],
"/b/episode-2.wav": ["audio/wav", audio],
[PRIVATE_ROUTE]: [
"application/rss+xml",
Buffer.from(feedXml(audio.length, PRIVATE_TITLE, "/p")),
],
"/p/artwork.svg": ["image/svg+xml", Buffer.from(artwork(PRIVATE_TITLE, "#be123c"))],
"/p/episode-1.wav": ["audio/wav", audio],
"/p/episode-2.wav": ["audio/wav", audio],
"/transcripts.xml": ["application/rss+xml", Buffer.from(transcriptsFeedXml(audio.length))],
"/t/artwork.svg": ["image/svg+xml", Buffer.from(artwork("Transcripts", "#b45309"))],
"/t/buzzcast.html": ["text/html; charset=utf-8", transcript("buzzcast.html")],
Expand Down Expand Up @@ -748,14 +760,15 @@ try {
else if (verb === "feed") await startFeed(feedUrl);
else if (verb === "feed-b") await startFeed(feedBUrl);
else if (verb === "transcripts") await startFeed(transcriptsUrl);
else if (verb === "private") await startFeed(privateUrl);
else if (verb === "library") await startFeed(libraryUrl);
else if (verb === "retitle") await retitleFeed();
else if (verb === "requests") await printRequests();
else if (verb === "audio") await writeAudio(arg);
else if (verb === "stop") await stopFeed();
else
throw new Error(
"Usage: fixtures.mjs feed | feed-b | transcripts | library | retitle | requests | audio [vault-relative path] | stop",
"Usage: fixtures.mjs feed | feed-b | transcripts | private | library | retitle | requests | audio [vault-relative path] | stop",
);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
Expand Down
16 changes: 13 additions & 3 deletions .claude/skills/verify/net.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const instance = resolveInstanceOptions(
);
const CALL_TIMEOUT_MS = 15000;
const FIXTURE_TRANSCRIPTION = "This text came from the fixture provider.";
const FIXTURE_REPLY = "A second speaker answers.";
const PATH_HOSTS = [FIXTURE_HOST, "api.openai.com", "api.deepgram.com"];

/** @param {string} code */
Expand Down Expand Up @@ -75,6 +76,15 @@ const WATCH = `(() => {
}
};
const text = ${jsLiteral(FIXTURE_TRANSCRIPTION)};
// Deepgram's words: one every 0.4 s, the reply 12 s in from a second speaker.
const words = (sentence, speaker, from) => sentence.split(" ").map((word, index) => ({
word: word.toLowerCase().replace(/[^a-z]/g, ""),
punctuated_word: word,
start: from + index * 0.4,
end: from + index * 0.4 + 0.3,
speaker,
}));
const deepgram = { results: { channels: [{ alternatives: [{ words: [...words(text, 0, 0), ...words(${jsLiteral(FIXTURE_REPLY)}, 1, 12)] }] }] } };
const originalFetch = window.fetch;
window.fetch = async function (input, init) {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
Expand All @@ -88,12 +98,12 @@ const WATCH = `(() => {
if (channel === "request-url" && typeof request?.url === "string") {
const method = (request.method ?? "GET").toUpperCase();
const answer = request.url.startsWith("https://api.openai.com/")
? { text, segments: [{ speaker: "A", text, start: 0, end: 1 }] }
? { text }
: request.url.startsWith("https://api.deepgram.com/")
? { results: { utterances: [{ speaker: 0, transcript: text, start: 0, end: 1 }] } }
? deepgram
: null;
if (answer) {
entries.push({ via: "requestUrl", method, url: logged(request.url), answeredBy: "fixture provider", uploadBytes: request.body?.byteLength ?? 0 });
entries.push({ via: "requestUrl", method, url: logged(request.url), answeredBy: "fixture provider", contentType: request.contentType, uploadBytes: request.body?.byteLength ?? 0 });
const body = new TextEncoder().encode(JSON.stringify(answer)).buffer;
queueMicrotask(() => ipc.emit(replyChannel, {}, { status: 200, headers: { "content-type": "application/json" }, body }));
return;
Expand Down
17 changes: 17 additions & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,23 @@
]
},
"overrides": [
{
"files": ["src/**/*.ts", "tests/**/*.ts"],
"excludeFiles": ["src/services/transcription/**"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["**/services/transcription/**", "./transcription/**"],
"message": "Import from src/services/transcription, the folder's public surface."
}
]
}
]
}
},
{
"files": ["src/**/*.ts"],
"excludeFiles": ["src/**/*.test.ts", "src/**/*.spec.ts"],
Expand Down
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ in `src/main.ts`; public API code is under `src/API/`; parsing code is under
are under `src/utility/`; Svelte UI lives under `src/ui/`; shared types live in
`src/types/`.

Audio transcription lives in `src/services/transcription/`. A provider is one
file under `providers/` and one line in `registry.ts`. Code outside the folder
imports only its `index.ts`, which `npm run lint` enforces.

Tests are colocated with source files as `*.test.ts` where practical. Shared
test mocks live in `tests/mocks/`. User-facing documentation lives in `docs/`
and is built with Astro Starlight.
Expand Down Expand Up @@ -58,7 +62,7 @@ svelte-check's native API path passes when a tsconfig matches no files, which
`check:a11y` in temporary workspaces and requires it to fail on that case and
when the native compiler cannot start.

After `npm run build`, `npm run size` enforces 330,000 raw bytes and 104,145 gzip bytes at level 9 for root `main.js` in the Test workflow's Build and sizes worker. `npm run size:data` reports a synthetic v2 stress library with 50 feeds, 2,000 history entries, and 200 saved episodes from frozen NPR metadata, including duplicated snapshots. Its 1.5 MB document goal is advisory. Pass an additive candidate JSON path to report optional-field overhead without changing existing data. In the isolated Obsidian instance, `npm run count:writes -- start`, `report`, and `stop` record writes to PodNotes' exact `data.json` path. Stop the recorder before stopping the instance, because recorder commands start a stopped instance; plugin reloads invalidate its counts.
After `npm run build`, `npm run size` enforces 330,000 raw bytes and 104,290 gzip bytes at level 9 for root `main.js` in the Test workflow's Build and sizes worker. `npm run size:data` reports a synthetic v2 stress library with 50 feeds, 2,000 history entries, and 200 saved episodes from frozen NPR metadata, including duplicated snapshots. Its 1.5 MB document goal is advisory. Pass an additive candidate JSON path to report optional-field overhead without changing existing data. In the isolated Obsidian instance, `npm run count:writes -- start`, `report`, and `stop` record writes to PodNotes' exact `data.json` path. Stop the recorder before stopping the instance, because recorder commands start a stopped instance; plugin reloads invalidate its counts.

Before opening a PR or cutting a release, run the CI-equivalent checks locally:

Expand Down
13 changes: 12 additions & 1 deletion scripts/size-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ main.js raw: 307131 bytes; budget 330000 bytes; 22869 bytes remaining
main.js gzip (level 9): 97161 bytes; budget 100000 bytes; 2839 bytes remaining
```

The gzip budget is now 104,145 bytes. On 2026-10-14 it rose from 100,000 for
The gzip budget is now 104,290 bytes. On 2026-10-14 it rose from 100,000 for
the full player's Transcript panel, which the owner approved at about 1.9 KB
and which measured 2,576 bytes. The coordinator set 103,000 to keep the
panel's Find and Follow chip and to leave room for fixes in flight. The
Expand Down Expand Up @@ -43,6 +43,17 @@ main.js raw: 320267 bytes; budget 330000 bytes; 9733 bytes remaining
main.js gzip (level 9): 104145 bytes; budget 104145 bytes; 0 bytes remaining
```

On 2026-11-14 it rose from 104,145 to 104,290 for the transcription engine,
which measured 104,060 on its own. On top of it, 170 bytes measure audio in other
formats against each model's length cap, for at most 5 s and only while the job
runs, so a long `.m4a` under the size cap is split instead of sent whole, and 60
keep a signed enclosure URL from going to a provider. With them:

```text
main.js raw: 319405 bytes; budget 330000 bytes; 10595 bytes remaining
main.js gzip (level 9): 104290 bytes; budget 104290 bytes; 0 bytes remaining
```

Gzip uses Node's `gzipSync` at level 9, without a filename or timestamp.
Budgets use decimal bytes. The brief's 96,533-byte reference is unexplained.
This report fixes the compressor instead of comparing compressed sizes across
Expand Down
2 changes: 1 addition & 1 deletion scripts/size.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ try {
if (!bundle.length) throw new Error(`${path} is empty. Run npm run build first.`);
const measurements = [
{ name: "raw", bytes: bundle.length, budget: 330_000 },
{ name: "gzip (level 9)", bytes: gzipSync(bundle, { level: 9 }).length, budget: 104_145 },
{ name: "gzip (level 9)", bytes: gzipSync(bundle, { level: 9 }).length, budget: 104_290 },
];
const rows = measurements.map(({ name, bytes, budget }) => {
const overage = Math.max(0, bytes - budget);
Expand Down
2 changes: 1 addition & 1 deletion scripts/size.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ describe("bundle budget CLI", () => {
const result = check("gzip.js", randomBytes(110_000));
expect(result.status).toBe(1);
expect(result.stdout).toMatch(
/gzip \(level 9\): \d+ bytes; budget 104145 bytes; OVER by \d+ bytes/,
/gzip \(level 9\): \d+ bytes; budget 104290 bytes; OVER by \d+ bytes/,
);
expect(result.stdout).toContain("budget 330000 bytes; 220000 bytes remaining");
});
Expand Down
5 changes: 1 addition & 4 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { IPodNotesSettings } from "src/types/IPodNotesSettings";
import type { Playlist } from "./types/Playlist";
import { DEFAULT_SPEAKER_TEMPLATE } from "./services/diarization/segments";
import { ownRecord } from "./utility/ownRecord";

export const VIEW_TYPE = "podcast_player_view";
Expand Down Expand Up @@ -162,12 +161,10 @@ export const DEFAULT_SETTINGS: IPodNotesSettings = {
transcript: {
path: "transcripts/{{podcast}}/{{title}}.md",
template: "# {{title}}\n\nPodcast: {{podcast}}\nDate: {{date}}\n\n{{transcript}}",
// Diarization is off by default so existing behaviour (plain Whisper) is
// unchanged; enabling it routes audio to the chosen provider (#168).
diarization: {
enabled: false,
provider: "openai",
speakerTemplate: DEFAULT_SPEAKER_TEMPLATE,
speakerTemplate: "**{{speaker}}:** ",
},
},
};
Loading
Loading