Repository navigation
Conversation
requestWithTimeout and assertFetchableUrl take a privateHosts option. With it, a target on this device or a private network (localhost, 127.0.0.1, 192.168.x.x, .local and the other non-public ranges) passes the target policy. Every other rule still applies: http or https only, an explicit authority, no fragment, no surrounding whitespace. Without the option, which every existing caller omits, the policy is unchanged, so feeds, artwork, transcripts and the hosted providers stay blocked from private targets.
A sixth provider, "OpenAI-compatible server", sends audio to any server that speaks OpenAI's /v1/audio/transcriptions API: a local whisper.cpp, faster-whisper or speaches server, Groq, Together, or OpenAI itself. It is a credential row, one provider file and a registry line, plus two settings. Settings. transcript.serverUrl holds the address as typed, and transcript.model the model ID the user types, both additive with schemaVersion still 2. The key reference is the new root field openAICompatibleSecretId, stored in SecretStorage as podnotes-openai-compatible-api-key, and a settings export carries the key as secrets.openAICompatible. With the provider chosen, the Transcripts rows read Transcription provider, Server address, OpenAI-compatible server API key, Model as a text field, then the shared rows. An address that is not http or https says so under the field. Address. A base such as https://api.groq.com/openai/v1 and the full .../audio/transcriptions URL reach the same endpoint, after spaces and trailing slashes are trimmed. Only this provider's requests may reach this device or a private network, because the user typed the address. Gate. The key is optional: with none selected the request carries no Authorization header. A selected key that is not on this device still stops the job. An address that is not http or https, or an empty model, stops the job with a notice that says what to enter. Request. Multipart with model, file, response_format=verbose_json and word and segment timestamp granularities, in parts of 24 MB with no length cap. A 400 that names the response format or the timestamp option, as OpenAI's gpt-transcribe answers, is sent once more as json without timestamps. Words become timed paragraphs, segments when there are no words, and text untimed paragraphs. The provider labels no speakers, and audio is always uploaded, so no episode address, public or private, reaches the server. The player's Transcript panel reads "Transcribe with your server", without the Paid tag, because PodNotes cannot tell whether the user's server charges. The notice after a published transcript fails offers "your OpenAI-compatible server" without the API credit clause. Errors stay PodNotes' own sentences. A 401 says the server at <host> rejected the API key, a 404 that it has no such model at that address, and no answer that PodNotes could not reach the server at <host>.
…hine The journey fixture server also answers POST /v1/audio/transcriptions as a Whisper server does, with verbose_json words for two sentences 12 s apart, and records each request. J-TR-11 points the OpenAI-compatible provider at it with a trailing slash and no key, runs Transcribe current episode, and checks the timed note and the one request: no Authorization header, the model, verbose_json, word and segment granularities, and the episode's audio byte for byte. The request travels Obsidian's requestUrl to 127.0.0.1, so it also proves a server on this machine is reached.
… fixture server fixtures.mjs server prints http://podnotes.test:<port>/v1, a route on the fixture server that answers POST /v1/audio/transcriptions with verbose_json words, or json's text when asked for json, and refuses verbose_json for the model json-only with the 400 OpenAI's gpt-transcribe gives. fixtures.mjs transcriptions prints each request it answered. Runtime checks reach it over Obsidian's real network path with no paid key. The transcripts recipe covers the provider's settings, a local file, the json fallback and the refusals.
…ovider measures The branch this builds on leaves main.js at 327,446 raw and 106,873 gzip bytes, built with the same vite command from an export of its head. The OpenAI-compatible server provider, its settings rows, its gate and failure sentences and the panel's server wording add 3,293 raw and 895 gzip bytes, which takes main.js to 330,739 and 107,768, over the 330,000 and 106,970 budgets. Each budget rises by its delta, to 333,293 and 107,865, so the 2,554 raw and 97 gzip bytes of headroom stay as they were. This is the first rise of the raw budget. AGENTS.md now names both budgets.
Deploying podnotes with
|
| Latest commit: |
b961dd4
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://01866513.podnotes.pages.dev |
| Branch Preview URL: | https://feat-transcription-openai-co.podnotes.pages.dev |
…iption-openai-compatible
…nce per job
OpenRouter and OpenAI-compatible servers answer in OpenAI's verbose_json
shape, so both now read it through providers/verboseJson.ts: words when the
answer lists any, punctuated from its text when they carry none, else
segments as the { segments } PartTranscript that cues.ts times across parts.
The OpenAI-compatible provider no longer stores segments as words.
A server that refuses verbose_json, as OpenAI's API does for gpt-transcribe,
is asked for json for that part and every later part of the job, so each
later part is uploaded once instead of twice.
chhoumann
changed the base branch from
feat/transcription-speaker-merge
to
feat/transcription-openrouter-models
November 14, 2026 12:04
Whisper lists each word with its leading space and splits some words, so "sci-fi" arrives as " sci" and "-fi". Reading words as separate cues saved "sci -fi" in the note. A piece without a leading space now continues the word before it. Lists of bare words, as OpenRouter's other models send, are unchanged.
chhoumann
marked this pull request as ready for review
November 14, 2026 14:49
…erge from it Words that a server lists without punctuation take it from the answer's `text`. The matcher looked only three tokens past the last match and never moved on a miss, so a stretch of three or more `text` tokens the words leave out, such as filler, left every later word in the part unpunctuated. A word still matches a token within three of the last match. A token further on now counts when the next word follows it in `text` too, so the words find their place again after a stretch of any length. Each key's positions are consumed in order, which keeps the pass linear in the words and tokens. The gzip budget rises by the 125 bytes this measures.
…tions path The OpenAI-compatible provider appended `/audio/transcriptions` to the whole address string. An address that carries a query, such as `http://host/v1?api_key=...`, sent to `/v1?api_key=.../audio/transcriptions`, which corrupted the token and missed the route. A full endpoint with a query got the path a second time. The address is now parsed as a URL. Its path loses trailing slashes and gains `/audio/transcriptions` only when it lacks it, and the query stays as typed. The gzip budget rises by the 20 bytes this measures.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Freedom of choice among transcription providers, in a codebase where adding a provider stays cheap and each task has one way to do it. This PR and #544 were built in parallel on #542 and overlapped, so this branch now merges #544 and stacks on it, with one way to read an OpenAI-shaped answer and one json fallback per job.
Summary
Freedom of choice for transcription. A user can now transcribe with any server that speaks OpenAI's
/v1/audio/transcriptionsAPI: a local whisper.cpp, faster-whisper or speaches server, so the audio stays on their own machine, or a hosted one such as Groq or Together. They are no longer tied to the four built-in providers. Stacked on #544, which builds on #542.It follows the recipe that added ElevenLabs and OpenRouter: one
CREDENTIALSrow, one provider file (providers/openaiCompatible.ts), one registry line, plus the settings this provider needs.OpenAI-compatible servershowsTranscription provider,Server address(placeholderhttp://localhost:8000/v1),OpenAI-compatible server API key,Modelas a text field, then the shared rows. An address withouthttp://orhttps://says so under the field.Speaker labelsbehaves as for any model without them: it readsOpenAI-compatible server does not label speakers.and can only be turned off.https://api.groq.com/openai/v1and.../v1/audio/transcriptionsreach the same endpoint, after spaces and trailing slashes are trimmed. Only http and https.requestWithTimeoutandassertFetchableUrltake aprivateHostsoption, and only this provider's sender passes it, because the user typed the address. Feeds, artwork, transcripts, chapters and the hosted providers keep the old policy, which still blocks localhost and private networks.Authorizationheader. A selected key that is not on this device still stops the job. An empty model or a non-http(s) address stops it with a notice that says what to enter.model,file,response_format=verbose_json,timestamp_granularities[]=wordandsegment, in parts of 24 MB with no length cap. A 400 whose body namesresponse_format,verbose_jsonortimestamp_granularities, as OpenAI's gpt-transcribe answers, is sent once more withresponse_format=jsonand no timestamp option, and every later part of the job goes straight to json. Any other 400 is not resent.words[]become timed paragraphs, punctuated fromtextwhen the words carry none, elsesegments[]become timed paragraphs through feat(transcription): offer Voxtral Mini Transcribe 3 and Qwen3 ASR on OpenRouter #544's{ segments }transcript, elsetextbecomes untimed paragraphs.The server at <host> rejected the API key.,The server at <host> has no <model> model at that address.for a 404,PodNotes could not reach the server at <host>.for no answer. A refused json resend readsOpenAI-compatible server did not answer in a format PodNotes reads.url: false, so the audio is always uploaded and no episode address, public or private, reaches the server.Transcribe with your serverwithout thePaidtag, because PodNotes cannot tell whether the user's server charges, andTranscribe with OpenAI-compatible server Paidoverflowed a 300 px sidebar. The failed-published-transcript notice offersyour OpenAI-compatible serverwithout the API credit clause.Merge with #544
feat/transcription-openrouter-models(#544, 92b5a19) is merged into this branch, and the base is now #544's branch.providers/types.tskeeps feat(transcription): offer Voxtral Mini Transcribe 3 and Qwen3 ASR on OpenRouter #544'sSegmenttype and{ segments }PartTranscriptvariant. The verify skill's transcripts recipe keeps both providers' sections, with the OpenAI notice now namingGPT Transcribeas feat(transcription): offer Voxtral Mini Transcribe 3 and Qwen3 ASR on OpenRouter #544 changed it. The OpenAI-compatible e2e journey is renamed from J-TR-11 to J-TR-13, because feat(transcription): offer Voxtral Mini Transcribe 3 and Qwen3 ASR on OpenRouter #544's Voxtral and Qwen3 ASR journeys took J-TR-11 and J-TR-12.{ segments }for a segments-only answer, whichcues.tstimes across parts as it does for Voxtral. Its segments-as-words reader is deleted.providers/verboseJson.ts: words when the answer lists any, else segments, else nothing. feat(transcription): offer Voxtral Mini Transcribe 3 and Qwen3 ASR on OpenRouter #544'spunctuated(words, text)moved there fromopenrouter.ts, so an OpenAI-compatible server that lists bare words, as a local Qwen or Whisper server can, gets its punctuation fromtexttoo. OpenRouter keeps "no speech" for an answer with neither, and the OpenAI-compatible provider keepstextfor it.verbose_jsonfor gpt-transcribe with 400 "response_format 'verbose_json' is not compatible with model ...". The fallback used to live in the provider, per part, so every part was uploaded twice. It now lives in the job (transcribe.ts). After the first such 400 the job asks for json for that part and every later one, through atextOnlyfield onPartInput.Verification
After the merge
All with Node 22.23.2 through
fnm exec, on head 3fe2dab:npm run lint,format:check,typecheck,build,size,test(124 files, 2,932 tests),test:tooling(48 tests) anddocs:buildexit 0.npm run test:e2e -- -t "J-TR"passes 13 tests, J-TR-11 (Voxtral), J-TR-12 (Qwen3 ASR) and J-TR-13 (OpenAI-compatible) included.npm run test:e2e -- tests/e2e/settings-tab.test.tspasses 11.npm run size: the merge commit alone, built from agit archiveexport, measures 331,775 raw and 108,094 gzip bytes. With the shared reader and the job-level fallback the branch measures 331,542 and 108,060. The budgets are those plus the headroom the stack keeps, 2,554 raw and 97 gzip bytes: 334,096 and 108,157.scripts/size-baseline.mdhas an entry for the merge, and AGENTS.md names both budgets.Plants, each through
npm run plant. Every one failed its named test and passed once restored:textOnlyreset after each part)textwithheld from the reader)textOnlynever set)One branch went instead of getting a test. Planting "fall back twice" as the removal of a
textOnly ||guard was not caught, because that guard only stopped a resend when a later part's json request was refused too. That is implausible, and it costs one bounded duplicate send, so the guard is deleted.Runtime, Obsidian 1.14.4 on Linux, this worktree's isolated instance, fresh vault.
fixtures.mjs serverprintedhttp://podnotes.test:26951/v1. I wroteFixtures/Big Fixture.mp3withffmpeg -f lavfi -i sine=frequency=440:duration=1900 -ac 1 -b:a 128k, 1,900 s and 30,401,140 bytes, so it goes in two parts under the 24 MB cap. In Settings > PodNotes I choseOpenAI-compatible serverwith the keyboard, typed the address and the modeljson-only, which the fixture refuses verbose_json for with gpt-transcribe's 400.data.jsonreadprovider: "openai-compatible",model: "json-only",serverUrl: "http://podnotes.test:26951/v1". Then I pickedPlay with PodNotesfrom the file menu and ranTranscribe current episode from audiofrom the palette.fixtures.mjs transcriptionsshowed three requests:The second part went once, as json, and the two parts sum to the file's bytes.
transcripts/local file/Big Fixture.mdwas saved and opened with both parts' text, untimed. The notice read "Saved without speaker labels, because OpenAI-compatible server does not label speakers. These providers do: ElevenLabs, Deepgram, OpenRouter.", since speaker labels were on as on a new install.dev:errorsreported none. The instance and the fixture server are stopped.Before the merge
npm run testpasses 2,923 unit tests, 35 new.lint,format:check,typecheck,build,test:tooling(48 passed) anddocs:buildpass.npm run test:e2e -- tests/e2e/journey-resilience.test.ts -t "J-TR"passes 10 tests, the new J-TR-11 (now J-TR-13) included. The journey fixture server now also answersPOST /v1/audio/transcriptions. J-TR-11 seeds the provider with the server's address plus a trailing slash and no key, runsTranscribe current episode, and checks the timed note and the one request: noAuthorization, the model,verbose_json, both granularities and the episode's audio byte for byte. The request goes through Obsidian'srequestUrlto 127.0.0.1.npm run test:e2e -- tests/e2e/settings-tab.test.ts tests/e2e/journey-transcript.test.ts tests/e2e/focused-settings-save.test.tspasses 27 tests.npm run size: the base built from an export of feat(transcription): one speaker label per voice across the parts of an episode #542's head measures 327,446 raw and 106,873 gzip bytes, this branch 330,739 and 107,768. Both budgets rise by the measured delta, 3,293 raw and 895 gzip, to 333,293 and 107,865, which keeps the base's 2,554 and 97 bytes of headroom. This is the first rise of the raw budget. AGENTS.md now names both.npm run plant, each run against the test's own file (37 of 37):Authorization" https://api.groq.com/openai/v1/audio/transcriptions/ "sends to the server's transcriptions endpoint"https://api.groq.com/openai/v1//"..." https://.../audio/transcriptions/ "..."ftp://files.example.com/v1""localhost:8000/v1"http://192.168.1.20:9000/v1is reachedprivateHostsserverUrlObsidian runtime
Obsidian 1.14.4 on Linux, this worktree's isolated instance, fresh vault. The verify skill's fixture server gains a transcription route,
fixtures.mjs serverprintedhttp://podnotes.test:34900/v1, which the instance reaches at 127.0.0.1 over the real network (net.mjslogged the POST and did not answer it). The local file isFixtures/Interview.mp3, 30 s of a 440 Hz tone at 32 kbps, 120,608 bytes, made with ffmpeg.Transcription providerchosen with the keyboard. The rows came in the order above.localhost:34900/v1showed the error under the field. Thenhttp://podnotes.test:34900/v1/andwhisper-large-v3-turbo, typed into the fields.data.jsonreadprovider: "openai-compatible",model: "whisper-large-v3-turbo",serverUrl: "http://podnotes.test:34900/v1/",openAICompatibleSecretId: "".Play with PodNotesfrom the file menu, thenTranscribe current episode from audiofrom the palette, with no key.transcripts/local file/Interview.mdopened with[00:00:00]and[00:00:12]paragraphs. The server recorded one request:{"authorization":null,"model":"whisper-large-v3-turbo","responseFormat":"verbose_json","timestampGranularities":["word","segment"],"fileType":"audio/mp3","fileBytes":120608}. Speaker labels were on, as on a new install, so the notice said the server does not label speakers.json-only, which the fixture refuses verbose_json for with gpt-transcribe's 400: two requests,verbose_jsonthenjsonwith no granularities, and an untimed note.Enter a model for your OpenAI-compatible server in the transcript settings., no request.http://podnotes.test:1/v1: three POSTs, thenTranscription failed: PodNotes could not reach the server at podnotes.test:1. Check the address in the transcript settings.Transcribe with your server, and its button transcribed the episode.dev:errorsreported none. The instance and the fixture server are stopped.Not checked here: real servers. The lead verifies against OpenRouter's and OpenAI's own endpoints with real keys. Pre-existing and not changed: a settings row with an error message narrows its text field, as
Download pathdoes.Real servers after the merge (lead)
Obsidian 1.14.4, palette command, 60 s local clip of E231, a pass-through request log, build of the head after the fix below:
https://api.openai.com/v1,gpt-transcribe):verbose_jsonrefused with 400, resent once asjson, untimed 150-word note..../audio/transcriptions/URL (openai/whisper-large-v3-turbo): oneverbose_jsonrequest, timed note.The first rerun after the merge saved "only see sci -fi as cultural criticism" and "science -fictional": the merged reader prefers words, and Whisper lists each word with its leading space and splits "sci-fi" into " sci" and "-fi" (captured live). A piece without a leading space now continues the word before it; bare word lists (Grok, Scribe v2, MAI, Qwen) are unchanged. After the fix the real note reads "only see sci-fi as cultural criticism. They try to turn all these science-fictional stories". Plant: skip the join (caught by
joins a Whisper word list, whose words carry their own leading space, back into its words). Checks: lint, format, typecheck, size (108,114 of 108,157), 2,933 unit tests, 48 tooling tests.Release impact
Additive settings, schemaVersion stays 2:
transcript.serverUrl(the address as typed) and the rootopenAICompatibleSecretId(SecretStoragepodnotes-openai-compatible-api-key). A settings export carries the address and, when keys are included,secrets.openAICompatible. A device on 2.24.0 keeps both fields on its saves. An unknown provider string is still kept and refused as before. The provider is unreleased, so no released behavior changes. Docs follow after release, as for the other providers.Note
Add OpenAI-compatible server as a transcription provider
openai-compatibleprovider that sends multipart audio to a user-entered server endpoint (/audio/transcriptionsappended to the configured address), with an optional API key, a user-entered model, and averbose_jsontojsonfallback when the server rejects timed output (openaiCompatible.ts)assertFetchableUrlandrequestWithTimeoutgain aprivateHostsoption so local and private-network servers are allowed for this provider only (networkRequest.ts)punctuated word helperchanged for all verbose-JSON providers (including OpenRouter) — checkverboseJson.tsmatching behavior against existing OpenRouter transcript outputMacroscope summarized b961dd4.
Note
Medium Risk
Introduces user-controlled transcription endpoints and an opt-in network policy that allows localhost/private hosts for those requests, which expands attack surface if settings are misused but stays scoped to explicit server URLs and upload-only audio.
Overview
Adds
openai-compatibleas a fifth transcription provider so users can point PodNotes at any server that implements OpenAI’s/v1/audio/transcriptionsAPI (local Whisper stacks or hosted Groq/Together-style endpoints). Settings gaintranscript.serverUrl, an optionalopenAICompatibleSecretId, and UI for server address plus a free-text model; speaker labels stay off for this provider, and the transcript panel shows Transcribe with your server without the Paid tag.The new provider always uploads episode audio (no URL passthrough), normalizes the user’s base URL to the transcriptions path (keeping query params), and sends multipart
verbose_jsonwith word/segment granularities.transcribe.tsretries once per job with plainjsonwhen the server rejects timed formats; parsing is shared with OpenRouter viaverboseJson.ts(Whisper word joining and punctuation fromtext).requestWithTimeout/assertFetchableUrlgain an opt-inprivateHostsflag used only for this user-entered address.Persistence, export/import, migrations, fixture/e2e harnesses, and verify docs are extended accordingly; bundle size budgets rise to 334,096 raw and 108,302 gzip.
Reviewed by Cursor Bugbot for commit b961dd4. Bugbot is set up for automated code reviews on this repo. Configure here.