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
4 changes: 2 additions & 2 deletions .claude/skills/verify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,8 @@ its profile. It keeps the vault and `.obsidian-e2e-artifacts/`. Delete

- `.claude/skills/verify/net.mjs` runs as `watch`, `log` or `unwatch`. It logs
every `fetch` and `requestUrl` call PodNotes makes, without query strings,
and answers ElevenLabs, Deepgram and OpenAI with a fixture provider while it
watches.
and answers ElevenLabs, Deepgram, OpenRouter and OpenAI with a fixture
provider while it watches.

- `.claude/skills/verify/ui.mjs` sends trusted input through
`obsidian dev:cdp`. Its verbs are `click <css> [--text <s>] [--right]`,
Expand Down
17 changes: 11 additions & 6 deletions .claude/skills/verify/features/transcripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ from `Transcript template`, and opens.
- `transcript-settings` shows `Transcription provider`, the chosen provider's API key row alone, `Model`, `Speaker labels` and, while those are on, `Speaker label format`. `Speaker labels` is disabled for a model without them.
- `transcript-elevenlabs` writes timestamped paragraphs with numbered speakers from one `scribe_v3` request. ElevenLabs 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-deepgram` does the same with `nova-3`.
- `transcript-openrouter` offers `Grok STT 2.0` (recommended), `ElevenLabs Scribe v2` and `MAI-Transcribe 2`, 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.
- `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-existing` refuses a second run while the note exists.

Expand All @@ -32,13 +33,13 @@ Preconditions:

- `FEED=$(node .claude/skills/verify/fixtures.mjs transcripts)` prints `http://podnotes.test:<port>/transcripts.xml`. Add `PodNotes Transcripts` through Settings as [feeds.md](./feeds.md) describes. Its episodes are `VTT Episode` (an HTML link, then a relative VTT link), `SRT Episode`, `JSON Episode`, `Untranscribed Episode` and `Broken Transcript Episode` (its VTT returns 404).
- No `transcripts/PodNotes Transcripts/` notes exist yet.
- `NET=node .claude/skills/verify/net.mjs`. Run `$NET watch` before each command, and again after an `app:reload`, which drops it. While it watches, the fixture provider answers ElevenLabs, Deepgram and OpenAI, so nothing is charged even with a key set.
- A fake key is a precondition, not a user path. For ElevenLabs: `CLI eval code='app.secretStorage.setSecret("podnotes-elevenlabs-api-key-424242", "sk-verify-fixture-not-real"), app.plugins.plugins.podnotes.settings.elevenLabsSecretId = "podnotes-elevenlabs-api-key-424242", app.plugins.plugins.podnotes.saveSettings(), "key set"'`. Deepgram and OpenAI take `podnotes-deepgram-api-key-424242` in `deepgramSecretId` and `podnotes-openai-api-key-424242` in `openAISecretId`.
- `NET=node .claude/skills/verify/net.mjs`. Run `$NET watch` before each command, and again after an `app:reload`, which drops it. While it watches, the fixture provider answers ElevenLabs, Deepgram, OpenRouter (its model catalog too) and OpenAI, so nothing is charged even with a key set.
- A fake key is a precondition, not a user path. For ElevenLabs: `CLI eval code='app.secretStorage.setSecret("podnotes-elevenlabs-api-key-424242", "sk-verify-fixture-not-real"), app.plugins.plugins.podnotes.settings.elevenLabsSecretId = "podnotes-elevenlabs-api-key-424242", app.plugins.plugins.podnotes.saveSettings(), "key set"'`. Deepgram, OpenRouter and OpenAI take `podnotes-deepgram-api-key-424242` in `deepgramSecretId`, `podnotes-openrouter-api-key-424242` in `openRouterSecretId` and `podnotes-openai-api-key-424242` in `openAISecretId`.
- Choose a provider in Settings by focusing the `Transcription provider` dropdown with `UI click` and stepping with `UI key ArrowDown` or `UI key ArrowUp`, or as a precondition with `CLI eval code='app.plugins.plugins.podnotes.settings.transcript.provider = "openai", app.plugins.plugins.podnotes.settings.transcript.model = "", app.plugins.plugins.podnotes.saveSettings(), "chosen"'`.

- **Published.** Play `VTT Episode` ([player.md](./player.md)), then run `UI key Ctrl+p`, `UI type .prompt-input 'Transcribe current episode'` and `UI key Enter`. The exact command is the first suggestion; `--text 'Transcribe current episode'` matches both transcribe commands. The notices read `Queued "VTT Episode" for transcription. It will start automatically.` and, in the progress notice headed `Transcription: VTT Episode (00:00:00):`, `Saved the transcript the feed publishes.` `transcripts/PodNotes Transcripts/VTT Episode.md` opens. Its body starts `[00:00:00](obsidian://podnotes?episodeName=VTT%20Episode&url=...&time=0.48) From Potsdam in Brandenburg, Germany,`. `$NET log` lists `GET .../t/podnews.vtt` and nothing at `api.elevenlabs.io`, `api.deepgram.com` or `api.openai.com`. `node .claude/skills/verify/fixtures.mjs requests` has `/t/podnews.vtt`, not `/t/buzzcast.html`. Repeat with `SRT Episode` and `JSON Episode`.
- **Seek.** Switch to Reading view and click a timestamp. `api.currentTime` lands on its time.
- **Settings.** Open Settings > PodNotes. Under `Transcripts` a new vault shows `Transcription provider` on `ElevenLabs`, `ElevenLabs API key`, `Model` on `Recommended (Scribe v3)`, `Speaker labels` on, `Speaker label format`, then the path and template. Choosing `Deepgram` or `OpenAI` swaps the key row for that provider's and the model list for its models. On `OpenAI` the `Speaker labels` row is disabled and reads `GPT Transcribe does not label speakers.`
- **Settings.** Open Settings > PodNotes. Under `Transcripts` a new vault shows `Transcription provider` on `ElevenLabs`, `ElevenLabs API key`, `Model` on `Recommended (Scribe v3)`, `Speaker labels` on, `Speaker label format`, then the path and template. Choosing `Deepgram`, `OpenRouter` or `OpenAI` swaps the key row for that provider's and the model list for its models. On `OpenRouter` the `Model` row offers `Recommended (Grok STT 2.0)`, `Grok STT 2.0`, `ElevenLabs Scribe v2` and `MAI-Transcribe 2`, and `$NET log` shows one `GET https://openrouter.ai/api/v1/models` with no `Authorization`. A model the catalog dates reads `<label>, ends <date>`, and a chosen model it no longer lists reads `<label>, no longer offered`. On `OpenAI` the `Speaker labels` row is disabled and reads `GPT Transcribe does not label speakers.`
- **No published transcript.** Play `Untranscribed Episode` and run the command with no key. The notice is `Select or create an ElevenLabs API key in the transcript settings on this device.` and no note appears.
- **ElevenLabs.** With the fake ElevenLabs key, 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.elevenlabs.io/v1/speech-to-text` with a `multipart/form-data` `contentType`, under 1,000 `uploadBytes` and the episode's address as `sourceUrl`, and no request for `/episode-1.wav` after the command.
Expand All @@ -50,10 +51,14 @@ Preconditions:
- **Deepgram.** Choose `Deepgram` with its fake key. The notes read as for ElevenLabs.
- Public. `$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. `$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`.
- **OpenRouter.** Choose `OpenRouter` with its fake key. The notes read as for ElevenLabs.
- Scribe v2, public. Choose `ElevenLabs Scribe v2`, play `Fixture Episode 1` of `PodNotes Fixture` and run the audio command. `$NET log` shows the catalog `GET`, then one `POST https://openrouter.ai/api/v1/audio/transcriptions` with `contentType` `application/json`, under 1,000 `uploadBytes` and the episode's address as `sourceUrl`, and no request for `/episode-1.wav` after the command.
- Grok STT 2.0. On `Recommended (Grok STT 2.0)`, play `Fixtures/Local Fixture.wav` and run the audio command. The `POST` is `application/json` with about 4/3 of the file's bytes, its audio in base64, and no `sourceUrl`.
- In parts. Write the 70 min MP3 from the OpenAI steps below, play it and run the audio command on Grok. `$NET log` shows three `POST https://openrouter.ai/api/v1/audio/transcriptions` entries of about 1.9 MB each, one after the other. The fixture answers each with two speakers, so the note numbers six, and the progress notice ends with `OpenRouter transcribed this episode in 3 parts, and speaker labels restart in each, so one person can have several labels.`
- **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 OpenAI does not label speakers. These providers do: ElevenLabs, Deepgram.` 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` and `elevenLabsSecretId` and leaves `transcript.diarization.provider` as it was.
- 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 OpenAI 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.
- **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 @@ -62,7 +67,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` 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` or `openai`. 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.
36 changes: 30 additions & 6 deletions .claude/skills/verify/net.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
// Network log for this worktree's Obsidian instance, with a fixture provider in
// place of the paid transcription APIs.
//
// node .claude/skills/verify/net.mjs watch start a fresh log; answer ElevenLabs, Deepgram and OpenAI locally
// node .claude/skills/verify/net.mjs watch start a fresh log; answer ElevenLabs, Deepgram, OpenRouter
// and OpenAI locally, OpenRouter's model catalog included
// node .claude/skills/verify/net.mjs log print each request as JSON, oldest first; a path only
// for the fixture server and the three providers
// for the fixture server and the four providers
// node .claude/skills/verify/net.mjs unwatch restore fetch and requestUrl
//
// PodNotes sends its requests (feeds, audio, transcripts, the providers)
Expand Down Expand Up @@ -33,7 +34,20 @@ 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.elevenlabs.io", "api.deepgram.com", "api.openai.com"];
const PATH_HOSTS = [
FIXTURE_HOST,
"api.elevenlabs.io",
"api.deepgram.com",
"openrouter.ai",
"api.openai.com",
];
const OPENROUTER_TRANSCRIPTIONS = "https://openrouter.ai/api/v1/audio/transcriptions";
const OPENROUTER_CATALOG = "https://openrouter.ai/api/v1/models?";
const OPENROUTER_MODELS = [
"x-ai/grok-stt-2.0",
"elevenlabs/scribe-v2",
"microsoft/mai-transcribe-2",
];

/** @param {string} code */
async function evaluate(code) {
Expand Down Expand Up @@ -92,10 +106,16 @@ const WATCH = `(() => {
return index === 0 ? [word] : [{ text: " ", type: "spacing", start, end: start, speaker_id: word.speaker_id }, word];
});
const elevenlabs = { language_code: "eng", text: text + " " + reply, words: [...scribeWords(text, 0, 0), ...scribeWords(reply, 1, 12)] };
// A multipart request that names an address in place of a file is a few hundred bytes.
// OpenRouter's verbose_json words, at the same times, with numbered speakers.
const openRouterWords = (sentence, speaker, from) => words(sentence, speaker, from).map(({ punctuated_word, start, end }) => ({ word: punctuated_word, start, end, speaker, speaker_label: "speaker_" + speaker, confidence: 0.99 }));
const openrouter = { text: text + " " + reply, words: [...openRouterWords(text, 0, 0), ...openRouterWords(reply, 1, 12)] };
const openrouterCatalog = { data: ${jsLiteral(OPENROUTER_MODELS)}.map((id) => ({ id, expiration_date: null })) };
// A request that names an address in place of a file is a few hundred bytes: multipart
// with source_url for ElevenLabs, JSON with input_audio.url for OpenRouter.
const namedAddress = (request) => {
if (!(request.body?.byteLength < 4096)) return undefined;
const match = /name="source_url"\\r\\n\\r\\n([^\\r]+)/.exec(new TextDecoder().decode(request.body));
const body = new TextDecoder().decode(request.body);
const match = /name="source_url"\\r\\n\\r\\n([^\\r]+)/.exec(body) ?? /"input_audio":\\{"url":"([^"]+)"/.exec(body);
return match ? logged(match[1]) : undefined;
};
const originalFetch = window.fetch;
Expand All @@ -116,7 +136,11 @@ const WATCH = `(() => {
? deepgram
: request.url.startsWith("https://api.elevenlabs.io/")
? elevenlabs
: null;
: request.url === ${jsLiteral(OPENROUTER_TRANSCRIPTIONS)}
? openrouter
: request.url.startsWith(${jsLiteral(OPENROUTER_CATALOG)})
? openrouterCatalog
: null;
if (answer) {
entries.push({ via: "requestUrl", method, url: logged(request.url), answeredBy: "fixture provider", contentType: request.contentType, uploadBytes: request.body?.byteLength ?? 0, sourceUrl: namedAddress(request) });
const body = new TextEncoder().encode(JSON.stringify(answer)).buffer;
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 104,866 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 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.

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

Expand Down
1 change: 1 addition & 0 deletions obsidian-e2e.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export default {
openAISecretId: "",
deepgramSecretId: "",
elevenLabsSecretId: "",
openRouterSecretId: "",
transcript: {
path: "transcripts/{{podcast}}/{{title}}.md",
template: "# {{title}}\n\nPodcast: {{podcast}}\nDate: {{date}}\n\n{{transcript}}",
Expand Down
Loading
Loading