Skip to content
Open
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
10 changes: 9 additions & 1 deletion .claude/skills/verify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,15 @@ 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`,
`private`, `library`, `retitle`, `requests`, `audio [vault path]` or `stop`.
`private`, `library`, `retitle`, `requests`, `server`, `transcriptions`,
`audio [vault path]` or `stop`. `server` prints the address of an
OpenAI-compatible transcription server on the same port, for the
"OpenAI-compatible server" provider's Server address. It answers
`POST /v1/audio/transcriptions` with verbose_json words, two sentences 12 s
apart, whatever the audio, and json's text when asked for json. The model
`json-only` refuses verbose_json with a 400, as OpenAI's gpt-transcribe does.
`transcriptions` prints each request it answered: its Authorization header,
model, response format, timestamp granularities, file type and size.
`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
Expand Down
9 changes: 7 additions & 2 deletions .claude/skills/verify/features/transcripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ from `Transcript template`, and opens.
- `transcript-deepgram` does the same with `nova-3`.
- `transcript-openrouter` offers `Grok STT 2.0` (recommended), `ElevenLabs Scribe v2`, `MAI-Transcribe 2`, `Voxtral Mini Transcribe 3` and `Qwen3 ASR 1.7B`, filtered by OpenRouter's public model catalog. Grok gets uploads as base64 JSON routed to xAI, Scribe v2 the address of a public episode, and an upload goes in parts of at most 18 MB and 1,800 s, or 12 MB and 900 s for Voxtral and Qwen3 ASR, which label no speakers.
- `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-openai-compatible` sends the audio to the address under `Server address`, with the model typed under `Model` and the key only when one is selected. It asks for verbose_json with word and segment times, and once the server refuses that, asks for json for that part and every later one. It writes timed paragraphs from words, punctuated from the answer's text when the words carry none, or from segments, and untimed ones from text. A server on this machine or a private network is reached.
- `transcript-existing` refuses a second run while the note exists.

## How to get to it (user POV)
Expand Down Expand Up @@ -59,7 +60,11 @@ Preconditions:
- **OpenAI.** Choose `OpenAI` with its fake key. `Untranscribed Episode` downloads its WAV and `$NET log` shows a `POST https://api.openai.com/v1/audio/transcriptions` answered by the fixture provider. The note holds the fixture text with no timestamps.
- 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.
- Speaker labels. With `Speaker labels` on, as in a library that labelled speakers with OpenAI on 2.24.0, the progress notice ends with `Saved without speaker labels, because GPT Transcribe does not label speakers. These providers do: ElevenLabs, Deepgram, OpenRouter.` and stays until clicked.
- **A 2.24.0 library.** Write a 2.24.0 `data.json` state into the vault (`transcript.diarization` with `enabled` and `provider`, no `transcript.provider`), run `CLI --reload`, and read `app.plugins.plugins.podnotes.settings.transcript.provider`: `deepgram` for speaker labels with Deepgram, `openai` for speaker labels with OpenAI or a linked OpenAI key, `elevenlabs` otherwise. `data.json` is unchanged until the next save, which adds `transcript.provider`, `transcript.model`, `elevenLabsSecretId` and `openRouterSecretId` and leaves `transcript.diarization.provider` as it was.
- **OpenAI-compatible server.** `SERVER=$(node .claude/skills/verify/fixtures.mjs server)` prints `http://podnotes.test:<port>/v1`, a transcription route on the fixture server, which the instance reaches at 127.0.0.1 over the real network. `net.mjs` does not answer it. Choose `OpenAI-compatible server`: the rows read `Transcription provider`, `Server address`, `OpenAI-compatible server API key`, `Model` as a text field, then the shared rows, and `Speaker labels` reads `OpenAI-compatible server does not label speakers.` Fill `input[name="Server address"]` with `$SERVER` and `input[name="Model"]` with any model ID. An address without `http://` or `https://` shows `The address must start with http:// or https://.` under the field.
- Local file. Play an MP3 in the vault and run the audio command with no key. The note has `[00:00:00]` `This text came from the fixture server.` and `[00:00:12]` `A second sentence starts twelve seconds in.`, and `fixtures.mjs transcriptions` shows one request with `authorization` null, the model, `verbose_json`, `["word","segment"]` and the file's type and size.
- json only. With the model `json-only`, `fixtures.mjs transcriptions` shows a `verbose_json` request, then a `json` one with no granularities, and the note holds the text without timestamps. For an MP3 over 24 MB, which goes in two parts, such as `ffmpeg -f lavfi -i sine=frequency=440:duration=1900 -ac 1 -b:a 128k "$VAULT/Fixtures/Big Fixture.mp3"`, the requests read `verbose_json`, `json`, `json`, and the second `json` carries the other part's bytes.
- Refusals. An empty `Model` gives `Enter a model for your OpenAI-compatible server in the transcript settings.` and an empty or non-http(s) address `Enter the address of your OpenAI-compatible server, starting with http:// or https://, in the transcript settings.`, with no request. A port nothing listens on gives `Transcription failed: PodNotes could not reach the server at podnotes.test:<port>. Check the address in the transcript settings.` after three sends.
- **A 2.24.0 library.** Write a 2.24.0 `data.json` state into the vault (`transcript.diarization` with `enabled` and `provider`, no `transcript.provider`), run `CLI --reload`, and read `app.plugins.plugins.podnotes.settings.transcript.provider`: `deepgram` for speaker labels with Deepgram, `openai` for speaker labels with OpenAI or a linked OpenAI key, `elevenlabs` otherwise. `data.json` is unchanged until the next save, which adds `transcript.provider`, `transcript.model`, `transcript.serverUrl`, `elevenLabsSecretId`, `openRouterSecretId` and `openAICompatibleSecretId` and leaves `transcript.diarization.provider` as it was.
- **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 @@ -68,7 +73,7 @@ Preconditions:
- To test the feed cache across a restart, reload inside Obsidian (`CLI --reload`, or `app:reload`). `npm run stop:e2e-obsidian` deletes the profile, and the cache with it.

- `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-<provider>-api-key` or `podnotes-<provider>-api-key-<n>`, where the provider is `elevenlabs`, `deepgram`, `openrouter` or `openai`. Any other id reads as missing on this device.
- A secret id must be `podnotes-<provider>-api-key` or `podnotes-<provider>-api-key-<n>`, where the provider is `elevenlabs`, `deepgram`, `openrouter`, `openai` or `openai-compatible`. Any other id reads as missing on this device.
- 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.
105 changes: 104 additions & 1 deletion .claude/skills/verify/fixtures.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@
// episode whose media answers 404
// node .claude/skills/verify/fixtures.mjs retitle rename the first feed's episodes, keeping their GUIDs, until the server stops
// node .claude/skills/verify/fixtures.mjs requests print the paths the server has answered, oldest first
// node .claude/skills/verify/fixtures.mjs server same server, print the address of its OpenAI-compatible
// transcription route, which answers verbose_json. The model
// "json-only" refuses verbose_json with a 400, as
// gpt-transcribe does, and answers json
// node .claude/skills/verify/fixtures.mjs transcriptions print each request the transcription route answered, as JSON
// node .claude/skills/verify/fixtures.mjs audio [path] write a 60 s WAV into this worktree's vault
// (default path: "Fixtures/Local Fixture.wav")
// node .claude/skills/verify/fixtures.mjs stop stop the feed server
Expand All @@ -41,6 +46,13 @@ 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 serverUrl = `${base}/v1`;
const TRANSCRIPTIONS_ROUTE = "/v1/audio/transcriptions";
const JSON_ONLY_MODEL = "json-only";
const SPOKEN = [
"This text came from the fixture server.",
"A second sentence starts twelve seconds in.",
];
const local = `http://127.0.0.1:${port}`;
const FEED_TITLE = "PodNotes Fixture";
const FEED_B_TITLE = "PodNotes Fixture B";
Expand Down Expand Up @@ -538,6 +550,75 @@ function libraryManifest() {
};
}

/**
* Answers the OpenAI-compatible transcription route as a Whisper server does: verbose_json
* with a word every 0.4 s and the second sentence 12 s in, or json's text.
* @param {http.IncomingMessage} request @param {http.ServerResponse} response
* @param {object[]} transcriptions
*/
async function answerTranscription(request, response, transcriptions) {
/** @type {Buffer[]} */
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
const form = await new Request(local, {
method: "POST",
headers: { "content-type": request.headers["content-type"] ?? "" },
body: Buffer.concat(chunks),
}).formData();
const file = form.get("file");
const model = form.get("model");
const format = form.get("response_format");
transcriptions.push({
authorization: request.headers.authorization ?? null,
model,
responseFormat: format,
timestampGranularities: form.getAll("timestamp_granularities[]"),
fileType: file instanceof Blob ? file.type : null,
fileBytes: file instanceof Blob ? file.size : 0,
});
/** @param {number} status @param {unknown} body */
const answer = (status, body) =>
response
.writeHead(status, { "Content-Type": "application/json" })
.end(JSON.stringify(body));
if (model === JSON_ONLY_MODEL && format === "verbose_json") {
answer(400, {
error: {
message: `response_format 'verbose_json' is not compatible with model '${JSON_ONLY_MODEL}'. Use 'json' or 'text' instead.`,
type: "invalid_request_error",
param: "response_format",
code: "unsupported_value",
},
});
return;
}
const text = SPOKEN.join(" ");
if (format !== "verbose_json") {
answer(200, { text });
return;
}
const timed = SPOKEN.map((sentence, index) =>
sentence.split(" ").map((word, at) => ({
word,
start: index * 12 + at * 0.4,
end: index * 12 + at * 0.4 + 0.3,
})),
);
answer(200, {
task: "transcribe",
language: "english",
duration: 15,
text,
words: timed.flat(),
segments: timed.map((words, id) => ({
id,
start: words[0].start,
end: words[words.length - 1].end,
text: ` ${SPOKEN[id]}`,
})),
});
}

function serve() {
const audio = wav();
/** @type {Record<string, [string, Buffer | Stream]>} */
Expand Down Expand Up @@ -614,6 +695,8 @@ function serve() {
}
/** @type {string[]} */
const requests = [];
/** @type {object[]} */
const transcriptions = [];
const server = http.createServer(async (request, response) => {
if (request.method === "POST" && request.url === "/__retitle") {
routes["/feed.xml"][1] = Buffer.from(feedXml(audio.length, FEED_TITLE, "", true));
Expand All @@ -630,8 +713,17 @@ function serve() {
response.end(JSON.stringify(requests));
return;
}
if (request.url === "/__transcriptions") {
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify(transcriptions));
return;
}
const fromPodNotes = request.headers.host?.startsWith(host);
if (fromPodNotes) requests.push(`${request.method} ${request.url}`);
if (request.method === "POST" && request.url === TRANSCRIPTIONS_ROUTE) {
await answerTranscription(request, response, transcriptions);
return;
}
const url = request.url ?? "";
let route = routes[url];
if (!route && videos[url]) {
Expand Down Expand Up @@ -737,6 +829,15 @@ async function printRequests() {
for (const line of /** @type {string[]} */ (await response.json())) console.log(line);
}

async function printTranscriptions() {
const response = await fetch(`${local}/__transcriptions`, {
signal: AbortSignal.timeout(1000),
});
for (const entry of /** @type {object[]} */ (await response.json())) {
console.log(JSON.stringify(entry));
}
}

async function retitleFeed() {
const response = await fetch(`${local}/__retitle`, {
method: "POST",
Expand Down Expand Up @@ -764,11 +865,13 @@ try {
else if (verb === "library") await startFeed(libraryUrl);
else if (verb === "retitle") await retitleFeed();
else if (verb === "requests") await printRequests();
else if (verb === "server") await startFeed(serverUrl);
else if (verb === "transcriptions") await printTranscriptions();
else if (verb === "audio") await writeAudio(arg);
else if (verb === "stop") await stopFeed();
else
throw new Error(
"Usage: fixtures.mjs feed | feed-b | transcripts | private | library | retitle | requests | audio [vault-relative path] | stop",
"Usage: fixtures.mjs feed | feed-b | transcripts | private | library | retitle | requests | server | transcriptions | audio [vault-relative path] | stop",
);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,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 106,162 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 334,096 raw bytes and 108,302 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
2 changes: 2 additions & 0 deletions obsidian-e2e.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,13 @@ export default {
deepgramSecretId: "",
elevenLabsSecretId: "",
openRouterSecretId: "",
openAICompatibleSecretId: "",
transcript: {
path: "transcripts/{{podcast}}/{{title}}.md",
template: "# {{title}}\n\nPodcast: {{podcast}}\nDate: {{date}}\n\n{{transcript}}",
provider: "elevenlabs",
model: "",
serverUrl: "",
diarization: {
enabled: true,
speakerTemplate: "**{{speaker}}:** ",
Expand Down
45 changes: 44 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 107,181 bytes. On 2026-10-14 it rose from 100,000 for
The gzip budget is now 108,302 bytes and the raw budget 334,096. 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 @@ -82,6 +82,49 @@ main.js raw: 328482 bytes; budget 330000 bytes; 1518 bytes remaining
main.js gzip (level 9): 107184 bytes; budget 107281 bytes; 97 bytes remaining
```

Later on 2026-11-14 both budgets rose for the OpenAI-compatible server
provider, the first rise of the raw budget. The branch it builds on measured
327,446 raw and 106,873 gzip bytes. The provider, its settings rows, gate and
failure sentences measured 3,293 raw and 895 gzip bytes more. Each budget rose
by its delta, to 333,293 and 107,865, so the headroom stays as it was. With
them:

```text
main.js raw: 330739 bytes; budget 333293 bytes; 2554 bytes remaining
main.js gzip (level 9): 107768 bytes; budget 107865 bytes; 97 bytes remaining
```

Both budgets then rose when the OpenAI-compatible branch merged the OpenRouter
models' branch and was stacked on it. Apart, the two measured 107,768 and
107,184 gzip bytes. Merged, they measured 331,775 raw and 108,094 gzip bytes.
One verbose_json reader shared by both providers and a json fallback asked for
once per job brought that to 331,542 and 108,060. Each budget is that plus the
headroom kept above, 2,554 raw and 97 gzip bytes. With them:

```text
main.js raw: 331542 bytes; budget 334096 bytes; 2554 bytes remaining
main.js gzip (level 9): 108060 bytes; budget 108157 bytes; 97 bytes remaining
```

Joining Whisper's word pieces then brought gzip to 108,114 bytes, 43 under the
budget. Words that find their punctuation in the answer's text again after a
stretch the words leave out measured 298 raw and 125 gzip bytes more. The gzip
budget rose by those 125 bytes, so its 43 bytes of headroom stay. With them:

```text
main.js raw: 332075 bytes; budget 334096 bytes; 2021 bytes remaining
main.js gzip (level 9): 108239 bytes; budget 108282 bytes; 43 bytes remaining
```

A server address that carries a query, such as a token, keeps the query after
the transcriptions path. That measured 36 raw and 20 gzip bytes more, and the
gzip budget rose by those 20 bytes. With it:

```text
main.js raw: 332111 bytes; budget 334096 bytes; 1985 bytes remaining
main.js gzip (level 9): 108259 bytes; budget 108302 bytes; 43 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
Loading
Loading