diff --git a/.claude/skills/verify/net.mjs b/.claude/skills/verify/net.mjs index 21cc9ee4..25abb0e1 100644 --- a/.claude/skills/verify/net.mjs +++ b/.claude/skills/verify/net.mjs @@ -74,12 +74,10 @@ async function evaluate(code) { return output.slice(3); } -const WATCH = `(() => { +/** @param {[number, number, string][]} turns */ +const watchSource = (turns) => `(() => { const previous = window.__podnotesVerifyNet; - if (previous) { - previous.entries.length = 0; - return "reset"; - } + if (previous) previous.restore(); const entries = []; // Other hosts log only their origin, because a private feed's path can hold its token. const pathHosts = new Set(${jsLiteral(PATH_HOSTS)}); @@ -91,8 +89,10 @@ const WATCH = `(() => { return "(unparsable URL)"; } }; - const text = ${jsLiteral(FIXTURE_TRANSCRIPTION)}; - // Deepgram's words: one every 0.4 s, the reply 12 s in from a second speaker. + const turns = ${jsLiteral(turns)}; + const text = turns[0][2]; + const fullText = turns.map(([, , line]) => line).join(" "); + // Deepgram's words: one every 0.4 s for each turn. const words = (sentence, speaker, from) => sentence.split(" ").map((word, index) => ({ word: word.toLowerCase().replace(/[^a-z]/g, ""), punctuated_word: word, @@ -100,23 +100,28 @@ const WATCH = `(() => { end: from + index * 0.4 + 0.3, speaker, })); - const reply = ${jsLiteral(FIXTURE_REPLY)}; - const deepgram = { results: { channels: [{ alternatives: [{ words: [...words(text, 0, 0), ...words(reply, 1, 12)] }] }] } }; + const deepgram = { results: { channels: [{ alternatives: [{ words: turns.flatMap(([from, speaker, line]) => words(line, speaker, from)) }] }] } }; // ElevenLabs' words, at the same times, with a spacing item between each two. const scribeWords = (sentence, speaker, from) => words(sentence, speaker, from).flatMap(({ punctuated_word, start, end }, index) => { const word = { text: punctuated_word, type: "word", start, end, speaker_id: "speaker_" + speaker }; 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)] }; + const elevenlabs = { language_code: "eng", text: fullText, words: turns.flatMap(([from, speaker, line]) => scribeWords(line, speaker, from)) }; // 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 openrouter = { text: fullText, words: turns.flatMap(([from, speaker, line]) => openRouterWords(line, speaker, from)) }; // Voxtral times segments and refuses word times; Qwen3 ASR times words and labels no speaker. const plainOpenRouter = { "mistralai/voxtral-mini-transcribe-3-0": (form) => form.has("timestamp_granularities[]") ? Promise.reject({ status: 400, json: { error: { message: "Provider returned 400", code: 400 } } }) - : { text: openrouter.text, segments: [{ id: 0, start: 0, end: 2.7, text: " " + text }, { id: 1, start: 12, end: 13.5, text: " " + reply }] }, + : { + text: openrouter.text, + segments: turns.map(([from, , line], id) => { + const turnWords = words(line, 0, from); + return { id, start: from, end: Math.round(turnWords[turnWords.length - 1].end * 10) / 10, text: " " + line }; + }), + }, "qwen/qwen3-asr-1.7b": () => ({ text: openrouter.text, words: openrouter.words.map(({ word, start, end }) => ({ word, start, end })) }), }; const openrouterCatalog = { data: ${jsLiteral(OPENROUTER_MODELS)}.map((id) => ({ id, expiration_date: null })) }; @@ -209,7 +214,7 @@ const WATCH = `(() => { return originalSend.call(ipc, channel, replyChannel, request, ...rest); }; window.__podnotesVerifyNet = { entries, restore: () => { window.fetch = originalFetch; ipc.send = originalSend; } }; - return "watching"; + return previous ? "reset" : "watching"; })()`; const LOG = `JSON.stringify(window.__podnotesVerifyNet?.entries ?? null)`; @@ -220,15 +225,23 @@ const UNWATCH = `(() => { return "restored"; })()`; -const [verb] = process.argv.slice(2); +/** @type {[number, number, string][]} */ +const DEFAULT_TURNS = [ + [0, 0, FIXTURE_TRANSCRIPTION], + [12, 1, FIXTURE_REPLY], +]; + +const [verb, flag, value] = process.argv.slice(2); try { - if (verb === "watch") console.log(await evaluate(WATCH)); - else if (verb === "log") { + if (verb === "watch") { + const turns = flag === "--turns" && value ? JSON.parse(value) : DEFAULT_TURNS; + console.log(await evaluate(watchSource(turns))); + } else if (verb === "log") { const entries = JSON.parse(await evaluate(LOG)); if (entries === null) throw new Error("Not watching: run net.mjs watch first."); for (const entry of entries) console.log(JSON.stringify(entry)); } else if (verb === "unwatch") console.log(await evaluate(UNWATCH)); - else throw new Error("Usage: net.mjs watch | log | unwatch"); + else throw new Error("Usage: net.mjs watch [--turns ] | log | unwatch"); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; diff --git a/docs/public/screenshots/openai-compatible-settings-dark.webp b/docs/public/screenshots/openai-compatible-settings-dark.webp new file mode 100644 index 00000000..c5f35360 Binary files /dev/null and b/docs/public/screenshots/openai-compatible-settings-dark.webp differ diff --git a/docs/public/screenshots/openai-compatible-settings-phone-dark.webp b/docs/public/screenshots/openai-compatible-settings-phone-dark.webp new file mode 100644 index 00000000..b740938a Binary files /dev/null and b/docs/public/screenshots/openai-compatible-settings-phone-dark.webp differ diff --git a/docs/public/screenshots/openai-compatible-settings-phone.webp b/docs/public/screenshots/openai-compatible-settings-phone.webp new file mode 100644 index 00000000..b466ffd3 Binary files /dev/null and b/docs/public/screenshots/openai-compatible-settings-phone.webp differ diff --git a/docs/public/screenshots/openai-compatible-settings.webp b/docs/public/screenshots/openai-compatible-settings.webp new file mode 100644 index 00000000..b9ef0776 Binary files /dev/null and b/docs/public/screenshots/openai-compatible-settings.webp differ diff --git a/docs/public/screenshots/openrouter-models-dark.webp b/docs/public/screenshots/openrouter-models-dark.webp new file mode 100644 index 00000000..210407ed Binary files /dev/null and b/docs/public/screenshots/openrouter-models-dark.webp differ diff --git a/docs/public/screenshots/openrouter-models-phone-dark.webp b/docs/public/screenshots/openrouter-models-phone-dark.webp new file mode 100644 index 00000000..2de54aa1 Binary files /dev/null and b/docs/public/screenshots/openrouter-models-phone-dark.webp differ diff --git a/docs/public/screenshots/openrouter-models-phone.webp b/docs/public/screenshots/openrouter-models-phone.webp new file mode 100644 index 00000000..10f6c958 Binary files /dev/null and b/docs/public/screenshots/openrouter-models-phone.webp differ diff --git a/docs/public/screenshots/openrouter-models.webp b/docs/public/screenshots/openrouter-models.webp new file mode 100644 index 00000000..c8c450ee Binary files /dev/null and b/docs/public/screenshots/openrouter-models.webp differ diff --git a/docs/public/screenshots/transcript-note-dark.webp b/docs/public/screenshots/transcript-note-dark.webp new file mode 100644 index 00000000..0883f0ab Binary files /dev/null and b/docs/public/screenshots/transcript-note-dark.webp differ diff --git a/docs/public/screenshots/transcript-note-phone-dark.webp b/docs/public/screenshots/transcript-note-phone-dark.webp new file mode 100644 index 00000000..fcfda4fb Binary files /dev/null and b/docs/public/screenshots/transcript-note-phone-dark.webp differ diff --git a/docs/public/screenshots/transcript-note-phone.webp b/docs/public/screenshots/transcript-note-phone.webp new file mode 100644 index 00000000..cbbd3d33 Binary files /dev/null and b/docs/public/screenshots/transcript-note-phone.webp differ diff --git a/docs/public/screenshots/transcript-note.webp b/docs/public/screenshots/transcript-note.webp new file mode 100644 index 00000000..8fbff38c Binary files /dev/null and b/docs/public/screenshots/transcript-note.webp differ diff --git a/docs/public/screenshots/transcript-settings-dark.webp b/docs/public/screenshots/transcript-settings-dark.webp new file mode 100644 index 00000000..f6830cc6 Binary files /dev/null and b/docs/public/screenshots/transcript-settings-dark.webp differ diff --git a/docs/public/screenshots/transcript-settings-phone-dark.webp b/docs/public/screenshots/transcript-settings-phone-dark.webp new file mode 100644 index 00000000..da3f9ebe Binary files /dev/null and b/docs/public/screenshots/transcript-settings-phone-dark.webp differ diff --git a/docs/public/screenshots/transcript-settings-phone.webp b/docs/public/screenshots/transcript-settings-phone.webp new file mode 100644 index 00000000..ff0ba121 Binary files /dev/null and b/docs/public/screenshots/transcript-settings-phone.webp differ diff --git a/docs/public/screenshots/transcript-settings.webp b/docs/public/screenshots/transcript-settings.webp new file mode 100644 index 00000000..eb0678da Binary files /dev/null and b/docs/public/screenshots/transcript-settings.webp differ diff --git a/docs/screenshots/seed.json b/docs/screenshots/seed.json index 8ae33153..bee1b0ac 100644 --- a/docs/screenshots/seed.json +++ b/docs/screenshots/seed.json @@ -45,5 +45,23 @@ "demo": { "at": 2287, "note": "Opportunity cost is a man at the door with two keys. Use it in the talk." + }, + "transcript": { + "podcast": "Slow Thinking", + "title": "Walking as thinking", + "turns": [ + [ + 0, + 0, + "Why do so many writers say the sentence only came together once they stood up from the desk?" + ], + [ + 8, + 1, + "At a desk your eyes stay fixed on the page, and the argument narrows to whatever is already in front of you." + ], + [19, 0, "And ten minutes into a walk, the knot comes undone on its own."], + [25, 1, "Your pace gives the thought a rhythm, without asking you to finish it yet."] + ] } } diff --git a/docs/scripts/screenshots.mjs b/docs/scripts/screenshots.mjs index 4d98d268..1b6d39bd 100644 --- a/docs/scripts/screenshots.mjs +++ b/docs/scripts/screenshots.mjs @@ -2,7 +2,7 @@ // be regenerated after any UI change. Run from the repo root: // // npm run build && npm run start:e2e-obsidian -// node docs/scripts/screenshots.mjs +// node docs/scripts/screenshots.mjs [shot ...] // // It drives the instance with the verify skill's screens tooling // (.claude/skills/verify/screens): the fixture library seeds the podcasts, and @@ -15,7 +15,13 @@ import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { connectCapture, videoToGif, withRecording, typeText } from "obsidian-e2e/capture"; +import { + connectCapture, + LINUX_SECRET_WARNING_CSS, + typeText, + videoToGif, + withRecording, +} from "obsidian-e2e/capture"; import sharp from "sharp"; import { evaluate, @@ -36,9 +42,11 @@ import { jsLiteral } from "../../scripts/js-literal.mjs"; const outDir = fileURLToPath(new URL("../public/screenshots", import.meta.url)); const work = path.join(root, ".obsidian-e2e-artifacts", "docs-screenshots"); +const NET = fileURLToPath(new URL("../../.claude/skills/verify/net.mjs", import.meta.url)); const THEMES = /** @type {const} */ (["light", "dark"]); const HERO = { width: 1440, height: 900, sidebar: 360 }; const DEMO = { width: 1200, height: 900, sidebar: 360 }; +const SETTINGS_WINDOW = { width: 1120, height: 1080, sidebar: 360 }; // Linux draws scrollbars that macOS hides until you scroll, and window buttons // that belong to the operating system, not to Obsidian. const CAPTURE_CSS = "::-webkit-scrollbar, .titlebar-button-container { display: none; }"; @@ -110,6 +118,9 @@ async function openFile(/** @type {string} */ file) { ); } +const only = new Set(process.argv.slice(2)); +const wanted = (/** @type {string} */ name) => only.size === 0 || only.has(name); + /** * Writes `.webp` in light and `-dark.webp` in dark. `rect` is a page * function, as source, that returns the crop in CSS px. `focus` keeps the editor @@ -119,6 +130,7 @@ async function openFile(/** @type {string} */ file) { * @param {{ css?: string, focus?: boolean }} [options] */ async function capture(name, rect, { css = "", focus = false } = {}) { + if (!wanted(name)) return; fs.mkdirSync(work, { recursive: true }); for (const name_ of THEMES) { await theme(name_); @@ -170,6 +182,7 @@ async function webp(png, name, themeName) { * @param {string} [crop] */ async function viewShot(name, text, crop) { + if (!wanted(name)) return; fs.mkdirSync(work, { recursive: true }); for (const name_ of THEMES) { await theme(name_); @@ -239,11 +252,46 @@ const noteCrop = (/** @type {string} */ from, /** @type {string} */ to) => `() = return { x: left, y: top, width: right - left, height: bottom - top }; }`; const TITLE = `(blocks) => blocks.find((el) => el.matches(".inline-title"))`; +const FIRST_LINE = `(blocks) => blocks.find((el) => el.matches(".cm-line") && el.textContent.trim())`; const CALLOUT = `(blocks) => blocks.find((el) => el.matches(".callout"))`; const NOTES_HEADING = `(blocks) => blocks.find((el) => el.textContent.trim() === "Notes")`; const LAST_LINE = `(blocks) => blocks.filter((el) => el.matches(".cm-line") && el.textContent.trim()).at(-1)`; const LAST_TIMESTAMP = `(blocks) => blocks.filter((el) => /\\d\\d:\\d\\d:\\d\\d/.test(el.textContent)).at(-1)`; const VIEWPORT = `() => ({ x: 0, y: 0, width: innerWidth, height: innerHeight })`; +const TRANSCRIPT_GROUP = `[...app.setting.activeTab.containerEl.querySelectorAll(".setting-group")] + .find((el) => el.querySelector(".setting-item-heading")?.innerText.includes("Transcripts"))`; +const TRANSCRIPT_SETTINGS = `() => { + const group = ${TRANSCRIPT_GROUP}; + const box = group.getBoundingClientRect(); + const pad = app.isMobile ? 12 : 16; + const x = Math.max(0, box.x - pad); + const right = Math.min(innerWidth, box.right + pad); + return { x, y: box.y - pad, width: right - x, height: box.height + pad * 2 }; +}`; +const OPENROUTER_MODELS = `() => { + const group = ${TRANSCRIPT_GROUP}; + const box = group.getBoundingClientRect(); + const pad = app.isMobile ? 12 : 16; + const x = Math.max(0, box.x - pad); + const right = Math.min(innerWidth, box.right + pad); + const y = Math.max(0, box.y - pad); + const pathRow = [...group.querySelectorAll(".setting-item")] + .find((el) => el.querySelector(".setting-item-name")?.innerText === "Transcript path") + .getBoundingClientRect(); + return { x, y, width: right - x, height: pathRow.bottom - y }; +}`; +const OPENROUTER_MODELS_PHONE = `() => { + const group = ${TRANSCRIPT_GROUP}; + const box = group.getBoundingClientRect(); + const pad = 12; + const x = Math.max(0, box.x - pad); + const right = Math.min(innerWidth, box.right + pad); + const y = Math.max(0, box.y - pad); + const formatRow = [...group.querySelectorAll(".setting-item")] + .find((el) => el.querySelector(".setting-item-name")?.innerText === "Speaker label format") + .getBoundingClientRect(); + return { x, y, width: right - x, height: formatRow.bottom + pad - y }; +}`; const BASES_TABLE = `() => { const box = document.querySelector('.workspace-leaf-content[data-type="bases"]').getBoundingClientRect(); const rows = [...document.querySelectorAll('.workspace-leaf-content[data-type="bases"] .bases-tr')]; @@ -264,6 +312,226 @@ const DRAWER_PLAYER = `() => { // to a reader, so the note's shots hide them. const HIDE_ID_PROPERTIES = '.metadata-property:is([data-property-key="guid" i], [data-property-key="feedurl" i]) { display: none; }'; +const SETTINGS_CAPTURE_CSS = `${LINUX_SECRET_WARNING_CSS} body:not(.is-mobile) .modal.mod-settings { height: 92vh !important; max-height: 92vh !important; } .modal-close-button, .modal-header-button, .modal.mod-settings .modal-header { visibility: hidden !important; }`; +/** The example address and model the setup guide shows. The settings shots never contact them. */ +const EXAMPLE_SERVER_URL = "http://localhost:8000/v1"; +const EXAMPLE_SERVER_MODEL = "whisper-large-v3-turbo"; + +/** + * Opens Settings -> PodNotes and scrolls the Transcripts group to the top. + * @param {"elevenlabs" | "openrouter" | "openai-compatible"} provider + * @param {string} [serverUrl] + */ +async function openTranscriptSettings(provider, serverUrl = "") { + const onServer = provider === "openai-compatible"; + await evaluate(` + const plugin = app.plugins.plugins.podnotes; + app.secretStorage.setSecret("podnotes-elevenlabs-api-key", "sk-verify-fixture-not-real"); + app.secretStorage.setSecret("podnotes-openrouter-api-key", "sk-verify-fixture-not-real"); + plugin.settings.transcript.provider = ${jsLiteral(provider)}; + plugin.settings.transcript.serverUrl = ${jsLiteral(onServer ? serverUrl : "")}; + plugin.settings.transcript.model = ${jsLiteral(onServer ? EXAMPLE_SERVER_MODEL : "")}; + plugin.settings.transcript.diarization.enabled = ${!onServer}; + plugin.settings.elevenLabsSecretId = "podnotes-elevenlabs-api-key"; + plugin.settings.openRouterSecretId = "podnotes-openrouter-api-key"; + plugin.settings.openAICompatibleSecretId = ""; + await plugin.saveSettings(); + app.setting.close(); + // Defer opening the tab past obsidian eval's synchronous console capture, then waitFor below checks the rendered DOM. + setTimeout(() => { + app.setting.open(); + app.setting.openTabById("podnotes"); + }, 0); + return true; + `); + await waitFor( + onServer + ? `const group = ${TRANSCRIPT_GROUP}; + if (!group) return false; + const items = [...group.querySelectorAll(".setting-item")]; + const address = items + .find((el) => el.querySelector(".setting-item-name")?.innerText === "Server address") + ?.querySelector("input"); + const model = items + .find((el) => el.querySelector(".setting-item-name")?.innerText === "Model" && el.offsetParent !== null) + ?.querySelector("input"); + return address?.value === ${jsLiteral(serverUrl)} && model?.value === ${jsLiteral(EXAMPLE_SERVER_MODEL)};` + : `const group = ${TRANSCRIPT_GROUP}; + if (!group) return false; + const model = [...group.querySelectorAll(".setting-item")] + .find((el) => el.querySelector(".setting-item-name")?.innerText === "Model" && el.offsetParent !== null) + ?.querySelector("select.dropdown"); + return Boolean(model) && (${jsLiteral(provider)} !== "openrouter" || model.options.length >= 6);`, + `The Transcripts settings for ${provider} did not render`, + 10000, + ); + await evaluate(` + const group = ${TRANSCRIPT_GROUP}; + group.scrollIntoView({ block: "start" }); + return true; + `); + await sleep(300); +} + +/** + * Captures an open native `` popup in its own window outside `Page.captureScreenshot`, so this + * renders at 2x with Electron's zoom and grabs the window from X11 or macOS. + * @param {string} name @param {string} rowName @param {string} rect + * @param {{ width: number, height: number }} [windowSize] + */ +async function captureOpenSelect(name, rowName, rect, windowSize = SETTINGS_WINDOW) { + if (!wanted(name)) return; + fs.mkdirSync(work, { recursive: true }); + const { width, height } = windowSize; + await cdp("Emulation.clearDeviceMetricsOverride"); + await evaluate(` + const { remote, webFrame } = require("electron"); + const win = remote.getCurrentWindow(); + win.setPosition(80, 80); + win.setContentSize(${width * 2 + 2}, ${height * 2 + 2}); + await new Promise((resolve) => setTimeout(resolve, 250)); + win.setContentSize(${width * 2}, ${height * 2}); + webFrame.setZoomFactor(2); + return true; + `); + await waitFor( + `return devicePixelRatio === 2 && innerWidth === ${width} && innerHeight === ${height};`, + "The window did not zoom to 2x for the select capture", + 5000, + ); + try { + for (const themeName of THEMES) { + await theme(themeName); + await openTranscriptSettings("openrouter"); + const target = await evaluate(` + const { remote } = require("electron"); + const win = remote.getCurrentWindow(); + win.focus(); + document.getElementById("docs-capture")?.remove(); + document.head.createEl("style", { attr: { id: "docs-capture" }, + text: ${jsLiteral(`${CAPTURE_CSS} .status-bar { display: none; } ${SETTINGS_CAPTURE_CSS}`)} }); + document.querySelectorAll(".tooltip, .notice").forEach((el) => el.remove()); + const group = ${TRANSCRIPT_GROUP}; + const select = [...group.querySelectorAll(".setting-item")] + .find((el) => el.querySelector(".setting-item-name")?.innerText === ${jsLiteral(rowName)}) + .querySelector("select.dropdown") + .getBoundingClientRect() + .toJSON(); + return { + platform: process.platform, + display: process.env.DISPLAY ?? "", + xauthority: process.env.XAUTHORITY ?? "", + bounds: win.getContentBounds(), + select, + clip: (${rect})(), + }; + `); + await sleep(400); + const clickX = Math.round(target.select.x + 24); + const clickY = Math.round(target.select.y + target.select.height / 2); + await cdp("Input.dispatchMouseEvent", { + type: "mousePressed", + x: clickX, + y: clickY, + button: "left", + buttons: 1, + clickCount: 1, + }); + await cdp("Input.dispatchMouseEvent", { + type: "mouseReleased", + x: clickX, + y: clickY, + button: "left", + buttons: 0, + clickCount: 1, + }); + await sleep(500); + const fullPng = path.join(work, `${name}-${themeName}-window.png`); + if (target.platform === "darwin") { + const { x, y, width: w, height: h } = target.bounds; + execFileSync("screencapture", ["-x", "-R", `${x},${y},${w},${h}`, fullPng]); + } else { + const { x, y, width: w, height: h } = target.bounds; + execFileSync( + "ffmpeg", + [ + "-v", + "error", + "-y", + "-f", + "x11grab", + "-draw_mouse", + "0", + "-video_size", + `${w}x${h}`, + "-i", + `${target.display}+${x},${y}`, + "-frames:v", + "1", + fullPng, + ], + { + env: { + ...process.env, + DISPLAY: target.display, + ...(target.xauthority && { XAUTHORITY: target.xauthority }), + }, + }, + ); + } + await cdp("Input.dispatchKeyEvent", { + type: "keyDown", + key: "Escape", + code: "Escape", + windowsVirtualKeyCode: 27, + }); + await cdp("Input.dispatchKeyEvent", { + type: "keyUp", + key: "Escape", + code: "Escape", + windowsVirtualKeyCode: 27, + }); + const [left, top, cropW, cropH] = ["x", "y", "width", "height"].map( + (key) => Math.round(target.clip[key]) * 2, + ); + const png = path.join(work, `${name}-${themeName}.png`); + await sharp(fullPng).extract({ left, top, width: cropW, height: cropH }).toFile(png); + await webp(png, name, themeName); + } + } finally { + await evaluate(` + require("electron").webFrame.setZoomFactor(1); + document.getElementById("docs-capture")?.remove(); + return true; + `); + } +} + +/** Transcribes `item` through the fixture provider and returns the opened note's path. */ +async function transcribeEpisode(/** @type {{ title: string, podcastName: string }} */ item) { + await evaluate(` + const plugin = app.plugins.plugins.podnotes; + app.secretStorage.setSecret("podnotes-elevenlabs-api-key", "sk-verify-fixture-not-real"); + plugin.settings.transcript.provider = "elevenlabs"; + plugin.settings.transcript.model = ""; + plugin.settings.transcript.diarization.enabled = true; + plugin.settings.elevenLabsSecretId = "podnotes-elevenlabs-api-key"; + await plugin.saveSettings(); + const existing = app.vault.getAbstractFileByPath("transcripts"); + if (existing) await app.vault.delete(existing, true); + return true; + `); + await load(item); + const before = await evaluate(`return app.workspace.getActiveFile()?.path ?? null;`); + await evaluate(`app.commands.executeCommandById("podnotes:podnotes-transcribe"); return true;`); + await waitFor( + `const file = app.workspace.getActiveFile()?.path; + return Boolean(file) && file !== ${jsLiteral(before)} && file.startsWith("transcripts/");`, + `The transcript note for ${item.title} did not open`, + ); + return evaluate(`return app.workspace.getActiveFile().path;`); +} /** Replaces the instance vault's files with docs/screenshots/vault and sets up Obsidian for it. */ async function prepareVault() { @@ -277,6 +545,7 @@ async function prepareVault() { ); fs.cpSync(path.join(seedDir, "vault"), provision.vaultPath, { recursive: true }); await evaluate(` + app.setting.close(); for (const id of ["file-explorer", "command-palette", "properties", "bases", "page-preview", "outline", "word-count"]) { const plugin = app.internalPlugins.getPluginById(id); if (plugin && !plugin.enabled) await plugin.enable(true); @@ -502,10 +771,12 @@ async function main() { await capture("hero", VIEWPORT, { css: ".workspace-sidedock-vault-profile { visibility: hidden; }", }); - await sharp(path.join(work, "hero-light.png")) - .resize(1200, 630, { fit: "cover", position: "top" }) - .png({ compressionLevel: 9, palette: true }) - .toFile(path.join(outDir, "og.png")); + if (wanted("hero")) { + await sharp(path.join(work, "hero-light.png")) + .resize(1200, 630, { fit: "cover", position: "top" }) + .png({ compressionLevel: 9, palette: true }) + .toFile(path.join(outDir, "og.png")); + } await scrollNote("## Notes"); await capture("timestamps", noteCrop(NOTES_HEADING, LAST_LINE)); await scrollNote(""); @@ -516,13 +787,72 @@ async function main() { await capture("bases", BASES_TABLE); await evaluate("app.workspace.rightSplit.expand(); return true;"); - await desktop(DEMO); - await show({ kind: "library" }, true); - await openFile(heroNote); - for (const name of THEMES) await recordDemo(heroNote, name); + if (wanted("demo")) { + await desktop(DEMO); + await show({ kind: "library" }, true); + await openFile(heroNote); + for (const name of THEMES) await recordDemo(heroNote, name); + await playback(hero.playAt); + } + + // Transcript settings, OpenRouter's model menu, OpenAI-compatible server, and a saved transcript note. + const watchNet = () => + execFileSync( + process.execPath, + [NET, "watch", "--turns", JSON.stringify(plan.transcript.turns)], + { stdio: "inherit" }, + ); + watchNet(); + await desktop(SETTINGS_WINDOW); + await openTranscriptSettings("elevenlabs"); + await capture("transcript-settings", TRANSCRIPT_SETTINGS, { css: SETTINGS_CAPTURE_CSS }); + await openTranscriptSettings("openai-compatible", EXAMPLE_SERVER_URL); + await capture("openai-compatible-settings", TRANSCRIPT_SETTINGS, { css: SETTINGS_CAPTURE_CSS }); + await captureOpenSelect("openrouter-models", "Model", OPENROUTER_MODELS); + await evaluate(`app.setting.close(); return true;`); + await evaluate("app.plugins.plugins.podnotes.setAutoQueue(false); return true;"); + const transcriptNote = await transcribeEpisode( + episode(lib, plan.transcript.podcast, plan.transcript.title), + ); + await desktop(HERO); + await openFile(transcriptNote); + await scrollNote(""); + await capture("transcript-note", noteCrop(FIRST_LINE, LAST_LINE), { + css: ".inline-title { display: none; }", + }); + await load(episode(lib, hero.podcast, hero.title)); await playback(hero.playAt); + await evaluate("app.plugins.plugins.podnotes.setAutoQueue(true); return true;"); + + // Obsidian's phone layout. `place("phone")` reloads the window, so re-install the network mock. + await place("phone"); + watchNet(); + await cdp("Emulation.setDeviceMetricsOverride", { + width: 390, + height: 1200, + deviceScaleFactor: 2, + mobile: true, + screenWidth: 390, + screenHeight: 1200, + }); + await waitFor( + `return innerHeight === 1200 && devicePixelRatio === 2;`, + "The phone window did not grow for transcript settings", + 5000, + ); + await openTranscriptSettings("elevenlabs"); + await capture("transcript-settings-phone", TRANSCRIPT_SETTINGS, { css: SETTINGS_CAPTURE_CSS }); + await openTranscriptSettings("openrouter"); + await capture("openrouter-models-phone", OPENROUTER_MODELS_PHONE, { + css: SETTINGS_CAPTURE_CSS, + }); + await openTranscriptSettings("openai-compatible", EXAMPLE_SERVER_URL); + await capture("openai-compatible-settings-phone", TRANSCRIPT_SETTINGS, { + css: SETTINGS_CAPTURE_CSS, + }); + await evaluate(`app.setting.close(); return true;`); + execFileSync(process.execPath, [NET, "unwatch"], { stdio: "inherit" }); - // Obsidian's phone layout. await place("phone"); await hiDpi(); await openFile(heroNote); @@ -549,6 +879,16 @@ async function main() { focus: true, }, ); + await openFile(transcriptNote); + await scrollNote(""); + await capture("transcript-note-phone", noteCrop(FIRST_LINE, LAST_LINE), { + css: ".inline-title { display: none; } .is-mobile .view-header { visibility: hidden !important; }", + }); + await evaluate(` + const folder = app.vault.getAbstractFileByPath("transcripts"); + if (folder) await app.vault.delete(folder, true); + return true; + `); await openFile("PodNotes/Episodes.base"); await sleep(1500); await capture("bases-phone", BASES_TABLE); @@ -567,6 +907,11 @@ async function main() { await capture("transcript-phone", DRAWER_PLAYER); // A new install: the empty library. + await evaluate(` + app.secretStorage.setSecret("podnotes-elevenlabs-api-key", ""); + app.secretStorage.setSecret("podnotes-openrouter-api-key", ""); + return true; + `); await seed("empty"); await place("sidebar"); await hiDpi(); diff --git a/docs/src/content/docs/commands.md b/docs/src/content/docs/commands.md index 05f0dcef..29349bf0 100644 --- a/docs/src/content/docs/commands.md +++ b/docs/src/content/docs/commands.md @@ -108,8 +108,8 @@ PodNotes finds the show in the Apple Podcasts directory and the episode on pod.l Saves a transcript of the loaded episode at **Transcript path**. For a podcast you added, when the episode's feed lists a transcript in a format that PodNotes reads, PodNotes saves that one, with no API key. Otherwise, it transcribes the audio with the selected provider. It works for podcast episodes and local audio, but not for video. If the transcript file already exists, PodNotes keeps it and does not transcribe again. -If PodNotes needs to transcribe the audio but this device has no API key for the selected provider, the command transcribes nothing and shows a notice that names the key to add. See [Transcribe episodes](/transcripts/). +If PodNotes needs to transcribe the audio before the selected provider is set up on this device, the command transcribes nothing and shows a notice naming the missing API key, **Server address**, or **Model**. See [Transcribe episodes](/transcripts/). ## Transcribe current episode from audio -Transcribes the audio of the loaded episode with the selected provider and your API key, even when the feed publishes a transcript. Your provider bills your account for it. Otherwise it works like [Transcribe current episode](/commands/#transcribe-current-episode). +Transcribes the audio of the loaded episode with the selected provider, even when the feed publishes a transcript. Cloud providers bill your own account for it, and **OpenAI-compatible server** sends the audio to **Server address**. Otherwise it works like [Transcribe current episode](/commands/#transcribe-current-episode). diff --git a/docs/src/content/docs/faq.md b/docs/src/content/docs/faq.md index d667a3aa..bc7802cb 100644 --- a/docs/src/content/docs/faq.md +++ b/docs/src/content/docs/faq.md @@ -21,6 +21,10 @@ Most commands appear only while an episode is loaded. A paused episode counts, a **PodNotes: Create episode note** needs **Episode note path** and **Episode note template**. With both set, it appears when an episode is loaded or you have added a podcast. The transcribe commands need an audio episode and a connection. **PodNotes: Show player**, **PodNotes: Reorder Queue**, and the capture commands are always available. [Commands](/commands/) lists what each command needs. +### What changes when I update to 2.25.0? + +PodNotes transcribes audio with **ElevenLabs**, **Deepgram**, **OpenRouter**, **OpenAI**, and **OpenAI-compatible server**, adds timestamps to audio transcripts, and moves OpenAI from `whisper-1` and `gpt-4o-transcribe-diarize` to **GPT Transcribe**. OpenAI no longer labels speakers, so switch to **ElevenLabs**, **Deepgram**, or a speaker-labeled **OpenRouter** model if you want speaker labels. [Update to 2.25.0](/update-2-25/) lists what your existing setup becomes. + ### What changes when I update to 2.22.0? The PodNotes view, the player, and the settings are redesigned, and your devices merge their changes into one library. Update PodNotes on every device before you use it there. [Update to 2.22.0](/upgrade/) lists what to do. @@ -102,5 +106,46 @@ API keys and the private feed addresses that PodNotes recognizes stay on each de ### Does transcription cost money? Not for a podcast you added, when its feed lists a transcript in a format that PodNotes reads. PodNotes saves that transcript for free, with no API key. -To transcribe the audio, PodNotes uses your own OpenAI or Deepgram account, and the provider bills you for API use. Plain transcripts use OpenAI Whisper. **Speaker diarization** uses OpenAI or Deepgram. -PodNotes does not import transcripts from Apple Podcasts. See [What transcription costs](/transcripts/#what-transcription-costs). +To transcribe the audio, PodNotes uses your own **ElevenLabs**, **Deepgram**, **OpenRouter**, or **OpenAI** account (which bills your account for API use), or an **OpenAI-compatible server** (which runs at no API cost on your own hardware, or uses your server host's rates). +PodNotes does not import transcripts from Apple Podcasts. See [What transcription costs](/transcripts/#what-transcription-costs) and [Choose a transcription provider](/transcription-providers/). + +### Which provider should I use? + +Start with **ElevenLabs**. It is the default on new installs, labels speakers, adds timestamps, and fetches public episodes up to 15 hours itself so your device uploads nothing. +Use **OpenRouter** to run speech transcription on the same OpenRouter key and balance as your other AI tools: **Grok STT 2.0** (recommended), **ElevenLabs Scribe v2**, and **MAI-Transcribe 2** label speakers, and **Voxtral Mini Transcribe 3** and **Qwen3 ASR 1.7B** write plain transcripts without speaker labels. +Use **Deepgram** if you already have a Deepgram key, **OpenAI-compatible server** to keep audio on your machine with a local server or connect a hosted `/v1/audio/transcriptions` endpoint, or **OpenAI** if you already have an OpenAI key and do not need timestamps or speaker labels. See [Choose a transcription provider](/transcription-providers/). + +### Why does my transcript have no speaker labels? + +Check these four causes: + +- **The feed published the transcript.** **PodNotes: Transcribe current episode** saves the feed's own transcript when one exists, and keeps speaker names only if the publisher wrote them into that file. Run **PodNotes: Transcribe current episode from audio** to transcribe with your provider instead. +- **Speaker labels are turned off.** In **Settings → PodNotes**, under **Transcripts**, turn on **Speaker labels**, delete the old transcript note, and run the command again. +- **Your selected model or provider does not label speakers.** OpenRouter's **Voxtral Mini Transcribe 3** and **Qwen3 ASR 1.7B**, **OpenAI** (**GPT Transcribe**), and **OpenAI-compatible server** never label speakers, even when **Speaker labels** was left on. Switch to **ElevenLabs**, **Deepgram**, or an **OpenRouter** model that labels speakers (**Grok STT 2.0**, **ElevenLabs Scribe v2**, or **MAI-Transcribe 2**). +- **The model returned no speaker labels.** A recording with one voice, or a response with no speaker tags, saves with the notice `"Saved without speaker labels, because returned none."` Try another model or provider if the episode has multiple speakers. + +See [Speaker labels](/transcripts/#speaker-labels). + +### Why did my transcription fail? + +Match the notice PodNotes showed to find what happened and what to do. In the notices below, `` is **ElevenLabs**, **Deepgram**, **OpenRouter**, **OpenAI**, or **OpenAI-compatible server**, `` is the host and port from **Server address**, and `` is the model ID sent to that provider. + +- **`"Enter the address of your OpenAI-compatible server, starting with http:// or https://, in the transcript settings."`** **Server address** is empty or invalid. Under **Transcripts**, enter a valid `http://` or `https://` URL, such as `http://localhost:8000/v1`. +- **`"Enter a model for your OpenAI-compatible server in the transcript settings."`** **Model** is empty while **OpenAI-compatible server** is selected. Enter the model ID your server expects under **Transcripts**. +- **`"Select or create an API key in the transcript settings on this device."`** or **`"Select or create a Deepgram API key in the transcript settings on this device."`** No key is chosen for the active cloud provider on this device. Open **Settings → PodNotes**, scroll to **Transcripts**, and link or create the key. See [Choose a transcription provider](/transcription-providers/). +- **`"The selected API key is not available on this device. Select or create it in the transcript settings."`** Your synced settings name a secret that has not been added on this device yet. Under **Transcripts**, link or create the key on this device. +- **`"This version of PodNotes cannot transcribe with . Update PodNotes, or choose another provider in the transcript settings."`** Another device chose a provider that this older PodNotes version does not recognize. Update PodNotes on this device, or pick another **Transcription provider**. +- **`"The published transcript could not be fetched: ..."`** or **`"The published transcript could not be read."`** The feed linked to a broken or unreadable transcript file. Check your connection, or run **PodNotes: Transcribe current episode from audio** to transcribe the audio instead. +- **`"Transcription failed: rejected the API key. Check it in the transcript settings."`** or **`"Transcription failed: The server at rejected the API key. Check it in the transcript settings."`** The key was revoked, mistyped, missing, or lacks permission. Create or check the key (on ElevenLabs, enable Speech to Text if you restrict permissions), then update the key row under **Transcripts**. +- **`"Transcription failed: Your account is out of credit. Add credit there, then try again."`** Your balance or monthly limit ran out on the provider's site. Add credit in your provider's billing settings, then run the command again. +- **`"Transcription failed: does not offer the model to this API key."`** Your account or key tier cannot access the selected model. Under **Transcripts**, set **Model** to the recommended model, or check model access on the provider's site. +- **`"Transcription failed: The server at has no model at that address. Check the address and the model in the transcript settings."`** The server returned HTTP 404 for the endpoint or model. Check **Model**, and check that **Server address** includes `/v1` if your server serves `/v1/audio/transcriptions`. +- **`"Transcription failed: does not offer the model under your privacy settings. Change them there, or choose another model in the transcript settings."`** On OpenRouter, your [OpenRouter privacy settings](https://openrouter.ai/settings/privacy) block every upstream provider for that model. Relax that setting on OpenRouter, or choose another **Model** under **Transcripts**. +- **`"Transcription failed: could not transcribe this audio."`** The provider or server rejected the audio data or request. Check that the episode plays in PodNotes, or try another provider or model. +- **`"Transcription failed: OpenAI-compatible server did not answer in a format PodNotes reads."`** The server rejected both `verbose_json` and `json` for `response_format` with HTTP 400. Check that **Server address** and **Model** point to an OpenAI-compatible `/v1/audio/transcriptions` endpoint. +- **`"Transcription failed: could not fetch the episode."`** When a provider cannot fetch a public episode URL itself, PodNotes automatically downloads the episode on your device and uploads it instead. If a request still ends with this notice, check that the episode plays in PodNotes and try again. +- **`"Transcription failed: is limiting how fast your key may be used. Try again in a few minutes."`** You hit the provider's rate limit. PodNotes retries up to three times when the server asks to wait a minute or less. Wait a few minutes, then run the command again. +- **`"Transcription failed: failed (HTTP ). Try again later."`** The provider returned a server error after up to three attempts, or answered HTTP 2xx with non-JSON text. Wait and try again, check **Server address** when using **OpenAI-compatible server**, or switch **Transcription provider** if an outage continues. +- **`"Transcription failed: Could not reach . Check your connection."`** or **`"Transcription failed: PodNotes could not reach the server at . Check the address in the transcript settings."`** PodNotes could not connect after up to three attempts. Check your internet connection, check that your server is running, and on a phone use your computer's local network or VPN address instead of `localhost`. +- **`"Transcription failed: did not answer within 10 minutes."`** (or **`"30 minutes."`** for **Deepgram**). The request timed out. PodNotes never retries a timed-out request automatically so you are not billed twice if the provider is still finishing it. Wait a few minutes, then try again. +- **`"Transcription failed: found no speech in this episode."`** The provider returned no words for the audio, for example on an instrumental or silent file. diff --git a/docs/src/content/docs/import_export.md b/docs/src/content/docs/import_export.md index 82677bd8..face1e62 100644 --- a/docs/src/content/docs/import_export.md +++ b/docs/src/content/docs/import_export.md @@ -31,7 +31,7 @@ A settings file copies your PodNotes setup to another vault, or keeps a backup o ### Export your settings 1. In **Settings → PodNotes**, under **Import and export**, enter a file name next to **Export settings**, or keep `PodNotes_Settings.json`. -2. To include your OpenAI and Deepgram API keys, turn on **Include API keys**. Read [API keys in a settings file](/import_export/#api-keys-in-a-settings-file) first. +2. To include your transcription API keys, turn on **Include API keys**. Read [API keys in a settings file](/import_export/#api-keys-in-a-settings-file) first. 3. Next to **Export settings**, select **Export**. PodNotes saves the file in your vault and never overwrites an existing file. If the name is taken, choose another name. @@ -52,10 +52,10 @@ PodNotes also imports settings files from older versions and a copy of `data.jso ### API keys in a settings file -With **Include API keys** on, the file contains your OpenAI and Deepgram API keys in plain text. The file sits in your vault, so it can sync to other devices, and other plugins can read it. Keep the file private, and delete it when you no longer need it. +With **Include API keys** on, the file contains your ElevenLabs, Deepgram, OpenRouter, OpenAI, and OpenAI-compatible server API keys in plain text, for every provider whose key is linked on this device. **Server address** and **Model** are always part of the exported settings. The file sits in your vault, so it can sync to other devices, and other plugins can read it. Keep the file private, and delete it when you no longer need it. - **Include API keys** turns off again each time you open the settings. - If a selected key is missing on this device, the export stops with a notice. Select or create the key under **Transcripts**, then export again. - An import saves the file's keys, including plain-text keys from older files, to secret storage on this device only. It never overwrites an existing secret, and an import without keys keeps the keys you selected. -See [API keys and private feeds on each device](/sync/#api-keys-and-private-feeds-on-each-device). +See [API keys across devices and backups](/transcription-providers/#api-keys-across-devices-and-backups) and [API keys and private feeds on each device](/sync/#api-keys-and-private-feeds-on-each-device). diff --git a/docs/src/content/docs/mobile.md b/docs/src/content/docs/mobile.md index 183b150b..50a60e7f 100644 --- a/docs/src/content/docs/mobile.md +++ b/docs/src/content/docs/mobile.md @@ -76,10 +76,19 @@ If the episode is playing when its download finishes, it keeps playing without a Long-press a media file in the file explorer, where a computer uses a right-click. See [Play your own audio and video](/local_files/#play-your-own-audio-and-video). +## Transcribe on your phone + +**PodNotes: Transcribe current episode**, **PodNotes: Transcribe current episode from audio**, and the **Transcript** panel work on a phone as on a computer. See [Transcribe episodes](/transcripts/). + +- Add your provider's API key on your phone once under **Transcripts** in **Settings → PodNotes** (when your provider or server uses a key), because Obsidian stores API keys per device. See [Choose a transcription provider](/transcription-providers/). +- For a public episode from a podcast you added, **ElevenLabs**, **Deepgram**, and OpenRouter's **ElevenLabs Scribe v2** send only the episode's public address, so your phone does not download or upload the audio file over mobile data unless the provider cannot fetch that address. +- OpenRouter's **Grok STT 2.0**, **MAI-Transcribe 2**, **Voxtral Mini Transcribe 3**, and **Qwen3 ASR 1.7B**, **OpenAI**, **OpenAI-compatible server**, private feeds, and local audio files download or read the audio on your phone and upload it, splitting long files into parts in memory. +- **Server address** syncs between devices, and on a phone `localhost` or `127.0.0.1` points to the phone itself. To reach an **OpenAI-compatible server** running on your computer, enter your computer's local network or VPN address (for example `http://192.168.1.20:8000/v1`) instead of `localhost`. + ## Limits on phones - A file that plays on a computer can fail on a phone that lacks its codec. - On Android, headset buttons and lock screen controls do not reach PodNotes. -- API keys and the private feed addresses that PodNotes recognizes stay on each device, so add them on your phone too. See [Use a private feed on another device](/private-feeds/#use-a-private-feed-on-another-device). +- API keys and the private feed addresses that PodNotes recognizes stay on each device, so add them on your phone too. See [API keys across devices and backups](/transcription-providers/#api-keys-across-devices-and-backups) and [Use a private feed on another device](/private-feeds/#use-a-private-feed-on-another-device). - A download made on another device plays only after its media file syncs to this one. See [Downloads on another device](/sync/#downloads-on-another-device). - Before you switch to your phone, pause on your other device and let sync finish. A paused player on the phone then moves to the synced position, unless you moved its position and PodNotes has not saved the move yet. An episode that ended on the phone plays from the synced position, if it is before the end, when you play it. See [Use PodNotes on two devices](/sync/#use-podnotes-on-two-devices). diff --git a/docs/src/content/docs/player.md b/docs/src/content/docs/player.md index 3939678e..c7e4147c 100644 --- a/docs/src/content/docs/player.md +++ b/docs/src/content/docs/player.md @@ -88,13 +88,13 @@ This clip opens **Transcript**, selects a line to play from it, and finds the wo ![The full player's Transcript panel: a selected line plays from its time, and a search for walk shows only the lines that contain it](/videos/transcript.mp4) -When the feed publishes no transcript, the panel says "No transcript in this feed". For an audio episode, it offers **Transcribe with OpenAI**, or **Transcribe with Deepgram** when your speaker labels come from Deepgram, with a **Paid** tag: +When the feed publishes no transcript, the panel says "No transcript in this feed". For an audio episode, it offers **Transcribe with ElevenLabs**, or **Transcribe with** whichever cloud **Transcription provider** you chose, with a **Paid** tag (or **Transcribe with your server** without a **Paid** tag when **OpenAI-compatible server** is chosen): -- The button transcribes the audio with your own API key, and your provider bills you for it. See [What transcription costs](/transcripts/#what-transcription-costs). -- Without the key on this device, a notice asks you to add it. See [Set up an OpenAI key](/transcripts/#set-up-an-openai-key). +- The button transcribes the audio with your chosen provider. Cloud providers bill your own account for it, and **OpenAI-compatible server** sends the audio to **Server address**. See [What transcription costs](/transcripts/#what-transcription-costs). +- Before the provider is set up on this device, a notice asks for the missing key, **Server address**, or **Model**. See [Choose a transcription provider](/transcription-providers/). - The result is a transcript note, which PodNotes opens. The panel shows only transcripts that feeds publish, so it does not show the note's lines. -- Once the episode's transcript note exists, the panel offers **Open note** in place of the paid button, and also next to **Retry** when the transcript did not load. -- Offline, the paid button and **Save as note** are turned off, and **Open note** still works. +- Once the episode's transcript note exists, the panel offers **Open note** in place of the transcribe button, and also next to **Retry** when the transcript did not load. +- Offline, the transcribe button and **Save as note** are turned off, and **Open note** still works. ### Up next diff --git a/docs/src/content/docs/settings.md b/docs/src/content/docs/settings.md index 9ff23df7..c32d0e60 100644 --- a/docs/src/content/docs/settings.md +++ b/docs/src/content/docs/settings.md @@ -32,7 +32,7 @@ These settings decide what the previous-track and next-track buttons of a headse | Setting | Default | What it does | | --- | --- | --- | -| **Timestamp template** | `- {{linktime}}` with a space at the end | Sets the text each capture inserts. While an episode is loaded, the setting shows the result for it. Its time tag also sets the times in transcripts that feeds publish. See [Capture timestamps](/timestamps/). | +| **Timestamp template** | `- {{linktime}}` with a space at the end | Sets the text each capture inserts. While an episode is loaded, the setting shows the result for it. Its time tag also sets the times in audio transcripts from ElevenLabs, Deepgram, OpenRouter, and an OpenAI-compatible server that returns times, and in timed transcripts that feeds publish. See [Capture timestamps](/timestamps/). | | **Timestamp offset (s)** | 0 | Subtracts this many seconds from each capture, to make up for reaction time. | | **QuickAdd choice** | None | Runs this QuickAdd choice for each capture, instead of inserting the timestamp. **Choose...** picks one of your QuickAdd choices, and **Create** adds a ready-made one. See [Capture with QuickAdd](/QuickAdd/#capture-with-quickadd). | | **Episode note path** | `PodNotes/{{podcast}}/{{title}}.md` | Sets where PodNotes creates episode notes, and shows the path for one of your episodes. See [Create episode notes](/notes/). | @@ -42,17 +42,18 @@ These settings decide what the previous-track and next-track buttons of a headse ## Transcripts -**Diarization provider**, **Deepgram API key**, and **Speaker label format** are turned off until **Speaker diarization** is on. **Deepgram API key** also needs **Diarization provider** set to **Deepgram**. +Only the API key row for the selected **Transcription provider** is shown. **Server address** appears while **OpenAI-compatible server** is selected, and **Speaker label format** appears while **Speaker labels** is on. | Setting | Default | What it does | | --- | --- | --- | -| **OpenAI API key** | None | Selects the Obsidian secret that holds your OpenAI API key, or creates one. See [Set up an OpenAI key](/transcripts/#set-up-an-openai-key). | -| **Transcript path** | `transcripts/{{podcast}}/{{title}}.md` | Sets where PodNotes saves transcripts. Keep `.md` at the end, because PodNotes does not add it. | +| **Transcription provider** | **ElevenLabs** on new installs ([migrated on update](/update-2-25/)) | Chooses **ElevenLabs**, **Deepgram**, **OpenRouter**, **OpenAI**, or **OpenAI-compatible server** for audio transcription, and resets **Model** to the provider's recommended model (or clears it for **OpenAI-compatible server**) when changed. See [Choose a transcription provider](/transcription-providers/). | +| **Server address** | Empty | Shown when **Transcription provider** is **OpenAI-compatible server**. Sets the server's `http://` or `https://` URL (placeholder `http://localhost:8000/v1`). PodNotes trims trailing slashes and appends `/audio/transcriptions` unless the address already ends with `/audio/transcriptions`. | +| **ElevenLabs API key**, **Deepgram API key**, **OpenRouter API key**, **OpenAI API key**, or **OpenAI-compatible server API key** | None | Selects the Obsidian secret on this device that holds your API key for the selected provider, or creates one. Optional when **Transcription provider** is **OpenAI-compatible server**. | +| **Model** | Provider's recommended model (empty on **OpenAI-compatible server**) | Chooses the speech-to-text model for the selected provider: **Scribe v3** on **ElevenLabs**, **Nova-3** on **Deepgram**, **Grok STT 2.0** (recommended), **ElevenLabs Scribe v2**, **MAI-Transcribe 2**, **Voxtral Mini Transcribe 3**, or **Qwen3 ASR 1.7B** on **OpenRouter**, and **GPT Transcribe** on **OpenAI**. On **OpenAI-compatible server**, becomes a text field where you enter the model ID your server expects. | +| **Speaker labels** | On (Off if off before updating) | Numbers speakers `1`, `2`, `3` in audio transcripts. Disabled while off when the selected model or provider does not label speakers (**Voxtral Mini Transcribe 3**, **Qwen3 ASR 1.7B**, **GPT Transcribe**, or **OpenAI-compatible server**). See [Speaker labels](/transcripts/#speaker-labels). | +| **Speaker label format** | `**{{speaker}}:**` with a space at the end | Sets the text before each speaker's turn. `{{speaker}}` becomes the speaker number or feed speaker name. Transcripts that feeds publish use it too, even with **Speaker labels** off. See [Change the speaker label format](/transcripts/#change-the-speaker-label-format). | +| **Transcript path** | `transcripts/{{podcast}}/{{title}}.md` | Sets where PodNotes saves transcripts, and shows the path for one of your episodes. Keep `.md` at the end, because PodNotes does not add it. | | **Transcript template** | [The default transcript template](/templates/#transcript-and-timestamp-tags) | Sets the content of a new transcript note. | -| **Speaker diarization** | Off | Labels transcript turns by speaker. See [Add speaker labels](/transcripts/#add-speaker-labels). | -| **Diarization provider** | **OpenAI (gpt-4o-transcribe-diarize)** | Chooses OpenAI, which uses your OpenAI API key, or **Deepgram**, which needs its own key and keeps speaker labels consistent across long episodes. | -| **Deepgram API key** | None | Selects the Obsidian secret that holds your Deepgram API key, or creates one. | -| **Speaker label format** | `**{{speaker}}:**` with a space at the end | Sets the text before each speaker's turn. `{{speaker}}` becomes the speaker label. Transcripts that feeds publish use it too, even with **Speaker diarization** off. See [Change the speaker label format](/transcripts/#change-the-speaker-label-format). | ## Downloads @@ -73,14 +74,14 @@ These settings decide what the previous-track and next-track buttons of a headse | Setting | Default | What it does | | --- | --- | --- | | **Import settings** | None | **Import** opens a file picker. After you confirm, PodNotes replaces your preferences, templates, feeds, playlists, queue, and favorites with the ones in the file, and adds its playback progress for episodes this vault has not played. Your own progress, downloads, and local files stay. See [Import your settings](/import_export/#import-your-settings). | -| **Include API keys** | Off | Adds the OpenAI and Deepgram keys on this device to the next export, in plain text. The export file sits in your vault, where sync and other plugins can read it. The toggle turns off again each time you open the tab. | +| **Include API keys** | Off | Adds the ElevenLabs, Deepgram, OpenRouter, OpenAI, and OpenAI-compatible server API keys stored on this device to the next export, in plain text. The export file sits in your vault, where sync and other plugins can read it. The toggle turns off again each time you open the tab. | | **Export settings** | `PodNotes_Settings.json` | **Export** saves your preferences, templates, feeds, playlists, queue, favorites, and playback progress to a file with this name in your vault. Private feeds that PodNotes recognizes keep their placeholder and reference, never their address, and other feed addresses stay as they are. It does not overwrite an existing file. See [Export your settings](/import_export/#export-your-settings). | ## Settings that moved or were removed -PodNotes 2.22.0 moved these out of the settings tab. [Update to 2.22.0](/upgrade/) lists what else changes when you update. +PodNotes 2.22.0 moved these out of the settings tab. [Update to 2.22.0](/upgrade/) lists what else changes when you update from 2.21.1 or earlier, and [Update to 2.25.0](/update-2-25/) covers the 2.25.0 transcription update. -| Before 2.22.0 | Now | +| Earlier name | Now | | --- | --- | | **Search Podcasts** | **Add podcast** in the PodNotes view. Remove a podcast from its page's **Podcast options** menu. See [Add and play podcasts](/podcasts/). | | **Playlists** | The Playlists page in the PodNotes view, with **New playlist**. A new playlist gets the default icon. See [Use the queue and playlists](/playlists/). | @@ -89,7 +90,7 @@ PodNotes 2.22.0 moved these out of the settings tab. [Update to 2.22.0](/upgrade These settings have new names: -| Before 2.22.0 | Now | +| Earlier name | Now | | --- | --- | | **Skip backward length (s)**, **Skip forward length (s)** | **Skip back (s)**, **Skip forward (s)** | | **Default Playback Rate** | **Default speed** | @@ -98,4 +99,6 @@ These settings have new names: | **Note creation file path**, **Note creation template** | **Episode note path**, **Episode note template** | | **Feed note file path**, **Feed note template** | **Podcast note path**, **Podcast note template** | | **Transcript file path** | **Transcript path** | +| **Speaker diarization** | **Speaker labels** | +| **Diarization provider** | **Transcription provider** and **Model** | | **Episode download path** | **Download path** | diff --git a/docs/src/content/docs/sync.md b/docs/src/content/docs/sync.md index 9e8c7ef8..bb7a494a 100644 --- a/docs/src/content/docs/sync.md +++ b/docs/src/content/docs/sync.md @@ -101,9 +101,9 @@ Another device resumes from the saved position, but it cannot follow or control ## API keys and private feeds on each device -PodNotes stores your OpenAI and Deepgram API keys in Obsidian's secret storage on each device. -Only a reference to the key syncs in `data.json`. -Add the key on every device where you transcribe audio. See [Transcribe episodes](/transcripts/). +PodNotes stores your ElevenLabs, Deepgram, OpenRouter, OpenAI, and optional OpenAI-compatible server API keys in Obsidian's secret storage on each device. +**Transcription provider**, **Server address**, and **Model** sync in `data.json`, while only a reference to a selected API key syncs. +Add the key on every device where you transcribe audio with it, and note that `localhost` in **Server address** refers to each device itself. See [API keys across devices and backups](/transcription-providers/#api-keys-across-devices-and-backups). PodNotes also keeps each private feed address that it [recognizes](/private-feeds/#which-addresses-podnotes-keeps-private) in secret storage on each device. The podcast and a reference to its address sync, but the address does not. On each other device, enter the address once. See [Use a private feed on another device](/private-feeds/#use-a-private-feed-on-another-device). diff --git a/docs/src/content/docs/templates.md b/docs/src/content/docs/templates.md index fdfed2ae..77cdc5a6 100644 --- a/docs/src/content/docs/templates.md +++ b/docs/src/content/docs/templates.md @@ -181,7 +181,7 @@ tags: ## Transcript and timestamp tags -**Transcript template** accepts `{{title}}`, `{{podcast}}`, `{{date}}`, `{{currentDate}}`, `{{episodeNumber}}`, `{{duration}}`, `{{transcript}}`, `{{description}}`, `{{url}}`, and `{{artwork}}`. `{{transcript}}` is the transcript text. A transcript that the feed publishes starts each paragraph with its time when the feed gives times. A transcript of the audio has no times. `{{title}}` and `{{podcast}}` give names cleaned for a file name, as in paths, and accept a whitespace replacement. The other tags follow the episode note rules. `{{stream}}`, `{{chapters}}`, and `{{episodelink}}` are not available in transcripts. +**Transcript template** accepts `{{title}}`, `{{podcast}}`, `{{date}}`, `{{currentDate}}`, `{{episodeNumber}}`, `{{duration}}`, `{{transcript}}`, `{{description}}`, `{{url}}`, and `{{artwork}}`. `{{transcript}}` is the transcript text. A transcript that the feed publishes starts each paragraph with its time when the feed gives times. An audio transcript from **ElevenLabs**, **OpenRouter**, **Deepgram**, or an **OpenAI-compatible server** that returns times starts each speaker turn or paragraph with its time. An audio transcript from **OpenAI** (or an **OpenAI-compatible server** that returns plain text only) has no times. `{{title}}` and `{{podcast}}` give names cleaned for a file name, as in paths, and accept a whitespace replacement. The other tags follow the episode note rules. `{{stream}}`, `{{chapters}}`, and `{{episodelink}}` are not available in transcripts. **Transcript template** starts as this template: diff --git a/docs/src/content/docs/transcription-providers.md b/docs/src/content/docs/transcription-providers.md new file mode 100644 index 00000000..cecbc0b3 --- /dev/null +++ b/docs/src/content/docs/transcription-providers.md @@ -0,0 +1,94 @@ +--- +title: Choose a transcription provider +slug: transcription-providers +description: Compare ElevenLabs, Deepgram, OpenRouter, OpenAI, and OpenAI-compatible servers for podcast transcription, and set one up in PodNotes. +--- + +Pick a speech-to-text provider or connect an OpenAI-compatible server so PodNotes can transcribe episodes that publish no transcript in their feed: + +- **ElevenLabs** (default on new installs): speaker labels and clickable timestamps, fetched directly by ElevenLabs for public episodes up to 15 hours. +- **Deepgram**: fast speaker labels and clickable timestamps, fetched directly for public episodes up to 2 GB. +- **OpenRouter**: five models on one key and balance, with speaker-labeled **Grok STT 2.0**, **ElevenLabs Scribe v2**, and **MAI-Transcribe 2**, plus low-error **Voxtral Mini Transcribe 3** and low-cost **Qwen3 ASR 1.7B**. +- **OpenAI**: plain-text transcripts without timestamps or speaker labels on an existing OpenAI key. +- **OpenAI-compatible server**: any `/v1/audio/transcriptions` server, such as local `whisper.cpp`, `faster-whisper`, or `speaches` on your machine, or a hosted endpoint such as Groq. + +PodNotes charges nothing for transcription. Compare limits, timestamps, and model details in [Provider and model reference](/transcription-providers/#provider-and-model-reference). + +## Set up ElevenLabs, Deepgram, or OpenAI + +1. On your provider's site ([ElevenLabs](https://elevenlabs.io/app/developers/api-keys), [Deepgram](https://console.deepgram.com/), or [OpenAI](https://platform.openai.com/api-keys)), create an API key. +2. In Obsidian, open **Settings → PodNotes**. +3. Under **Transcripts**, in **Transcription provider**, select **ElevenLabs**, **Deepgram**, or **OpenAI**. +4. Next to **ElevenLabs API key**, **Deepgram API key**, or **OpenAI API key**, select **Link...**, or **Change** when a key is already set. +5. In the secret picker, create a secret that holds your key, or select one you saved before. +6. In **Model**, keep **Recommended (Scribe v3)**, **Recommended (Nova-3)**, or **Recommended (GPT Transcribe)**. + +If you restrict key permissions on ElevenLabs, enable Speech to Text. On OpenAI, **GPT Transcribe** writes plain paragraphs without timestamps or speaker labels (see [Update to 2.25.0](/update-2-25/) if you used OpenAI in 2.24.0). + +![The Transcripts group in PodNotes settings with ElevenLabs selected](/screenshots/transcript-settings.webp) + +## Set up OpenRouter + +1. On the [OpenRouter keys page](https://openrouter.ai/settings/keys), create an API key. +2. In Obsidian, open **Settings → PodNotes**. +3. Under **Transcripts**, in **Transcription provider**, select **OpenRouter**. +4. Next to **OpenRouter API key**, select **Link...**, or **Change** when a key is already set. +5. In the secret picker, create a secret that holds your key, or select one you saved before. +6. In **Model**, select **Recommended (Grok STT 2.0)**, **ElevenLabs Scribe v2**, **MAI-Transcribe 2**, **Voxtral Mini Transcribe 3**, or **Qwen3 ASR 1.7B**. + +The first three models add speaker labels and timestamps, while **Voxtral Mini Transcribe 3** and **Qwen3 ASR 1.7B** add timestamps without speaker labels. Compare all five in [OpenRouter models](/transcription-providers/#openrouter-models). + +![Transcripts settings with OpenRouter selected and the Model dropdown](/screenshots/openrouter-models.webp) + +## Set up an OpenAI-compatible server + +1. On your computer or server host, start your `/v1/audio/transcriptions` server or copy its base URL. +2. In Obsidian, open **Settings → PodNotes**. +3. Under **Transcripts**, in **Transcription provider**, select **OpenAI-compatible server**. +4. In **Server address**, enter the server's `http://` or `https://` URL, for example `http://localhost:8000/v1`. +5. If the server requires an API key, next to **OpenAI-compatible server API key**, select **Link...**, or **Change** when a key is already set. Leave it unlinked if your server needs no key. +6. When the secret picker opens, create a secret that holds your key, or select one you saved before. +7. In **Model**, enter the model ID your server expects, for example `Systran/faster-whisper-large-v3` on an example `speaches` server or `whisper-large-v3-turbo` on an example Groq server. + +![The Transcripts group in PodNotes settings with OpenAI-compatible server selected](/screenshots/openai-compatible-settings.webp) + +- **Accepted addresses:** Enter `http://` or `https://` with a valid host, an optional port (`1` to `65535`), and no `#` fragment or backslash (otherwise the row shows `"The address must start with http:// or https://."`). PodNotes trims trailing slashes and appends `/audio/transcriptions` unless already present, so include `/v1` when your server serves `/v1/audio/transcriptions`, as in the example addresses `http://localhost:8000/v1`, `http://192.168.1.20:8000/v1`, `https://api.groq.com/openai/v1`, or `http://localhost:8000/v1/audio/transcriptions`. +- **Uploads and privacy:** PodNotes downloads or reads the episode on your device and uploads it in parts up to 24 MB. With a server on the same computer, audio stays on your machine. On a phone, `localhost` points to the phone itself, so enter your computer's local network or VPN address instead. +- **Timestamps and speaker labels:** PodNotes requests `verbose_json` word and segment times, joins Whisper word pieces, and restores punctuation onto unpunctuated words from the full text. You get clickable paragraph timestamps when the server returns times, or plain text without timestamps when it returns text only or refuses timestamp options (`response_format`, `verbose_json`, or `timestamp_granularities`) with HTTP 400, in which case PodNotes retries for `json`. **OpenAI-compatible server** does not label speakers. + +## Provider and model reference + +| Provider and models | Labels and times | Public podcast feeds | Long episodes | Key and pricing | +| --- | --- | --- | --- | --- | +| **ElevenLabs**
**Scribe v3** (recommended) | Speakers (`1`, `2`, `3`) and timestamps | Fetched by provider, so your device uploads nothing | One request up to 5 GB and 15 hours | [API keys](https://elevenlabs.io/app/developers/api-keys), [pricing](https://elevenlabs.io/pricing/api) | +| **Deepgram**
**Nova-3** (recommended) | Speakers (`1`, `2`, `3`) and timestamps | Fetched by provider, so your device uploads nothing | One request up to 2 GB | [Console](https://console.deepgram.com/), [pricing](https://deepgram.com/pricing) | +| **OpenRouter**
5 models (see table below) | Timestamps on all 5. Speakers (`1`, `2`, `3`) on **Grok STT 2.0**, **ElevenLabs Scribe v2**, and **MAI-Transcribe 2**. | **ElevenLabs Scribe v2** fetches public URLs. The other four upload from your device. | **ElevenLabs Scribe v2** takes a public URL in one request. Uploads split past 18 MB / 30 min MP3 (or 12 MB / 15 min MP3 on **Voxtral Mini Transcribe 3** and **Qwen3 ASR 1.7B**). | [Keys](https://openrouter.ai/settings/keys), [model rates](https://openrouter.ai/models?output_modalities=transcription) | +| **OpenAI**
**GPT Transcribe** (recommended) | Plain text only (no speakers or timestamps) | Uploaded from your device | Split past 24 MB (or 34.5 min for MP3) | [API keys](https://platform.openai.com/api-keys), [pricing](https://openai.com/api/pricing/) | +| **OpenAI-compatible server**
Any model ID your server offers | Timestamps when the server returns word or segment times, otherwise plain text. No speakers. | Uploaded from your device (stays on your machine with a local server) | Split past 24 MB | Optional bearer key, your own server or host's rates | + +A provider fetches an episode URL directly only when you added the podcast to PodNotes with a public feed address and that feed still lists the episode at a public `http` or `https` address without credentials. For [private feeds](/private-feeds/), [downloads and local files](/local_files/), or an episode URL the provider cannot fetch, PodNotes downloads the audio on your device and uploads it instead. + +### OpenRouter models + +| Model | Speaker labels | Timestamps | Long episodes | Notes | +| --- | --- | --- | --- | --- | +| **Grok STT 2.0** (recommended) | Yes (`1`, `2`, `3`) | Word and segment | Splits uploads past 18 MB (or 30 min for MP3), and matches MP3 voices across parts | Default OpenRouter model | +| **ElevenLabs Scribe v2** | Yes (`1`, `2`, `3`) | Word | Fetches public episode URLs in one request. Uploads split past 18 MB (or 30 min for MP3), and speaker numbers restart per part. | No device upload on public feeds | +| **MAI-Transcribe 2** | Yes (`1`, `2`, `3`) | Word | Splits uploads past 18 MB (or 30 min for MP3), and speaker numbers restart per part | Speaker-labeled alternative | +| **Voxtral Mini Transcribe 3** | No | Segment | Splits uploads past 12 MB (or 15 min for MP3) | Plain transcript, timed by passage | +| **Qwen3 ASR 1.7B** | No | Word (PodNotes restores punctuation from the response text) | Splits uploads past 12 MB (or 15 min for MP3) | Plain transcript at a low rate, see [model rates](https://openrouter.ai/models?output_modalities=transcription) | + +PodNotes checks OpenRouter's public transcription catalog at most once a day when you open settings or start a transcription, without sending your key: + +- A model with a scheduled retirement date in the future shows that date in **Model**, such as `, ends 2027-06-01`. +- If OpenRouter withdraws a model you selected earlier, **Model** marks it `, no longer offered`, and PodNotes falls back to **Grok STT 2.0** with a notice when you transcribe. +- If your [OpenRouter privacy settings](https://openrouter.ai/settings/privacy) block every route for a model, transcription stops with a notice asking you to change them or choose another model. + +### API keys across devices and backups + +PodNotes stores your **ElevenLabs**, **Deepgram**, **OpenRouter**, **OpenAI**, and optional **OpenAI-compatible server** API keys in Obsidian's secret storage on each device: + +- Only the selected provider's key row appears under **Transcripts**, and keys you saved for other providers stay stored so you can switch **Transcription provider** without entering them again. +- **Server address** and **Model** sync in `data.json`, while vault sync copies only a secret reference (such as `podnotes-elevenlabs-api-key` or `podnotes-openai-compatible-api-key`), never the key itself. On another device where a selected secret has not been added yet, the key row reads "The selected secret is not available on this device. Select an existing secret or create one." Add or select the key on each device where you transcribe audio. See [API keys and private feeds on each device](/sync/#api-keys-and-private-feeds-on-each-device). +- **Export settings** leaves API keys out unless you turn on **Include API keys** first. An export that includes API keys writes them in plain text, so treat the file like a password. See [API keys in a settings file](/import_export/#api-keys-in-a-settings-file). +- When you update from a version before 2.19.0 that stored plain-text keys in `data.json`, PodNotes moves them into secret storage on first start before finishing load. Old backups or exports keep any plain-text keys they held, so replace a key on the provider's site if it may have leaked. diff --git a/docs/src/content/docs/transcripts.md b/docs/src/content/docs/transcripts.md index 3ae30524..5c7c3e5b 100644 --- a/docs/src/content/docs/transcripts.md +++ b/docs/src/content/docs/transcripts.md @@ -2,133 +2,114 @@ title: Transcribe episodes slug: transcripts legacyTitleId: transcripts -description: Save the transcript an episode's feed publishes, or transcribe the audio with your own API key, add speaker labels, and see what it costs. +description: Save the transcript a podcast feed publishes for free, or transcribe the audio with a cloud provider or an OpenAI-compatible server. --- -Turn an episode into a transcript note. For a podcast you added, when its feed lists a transcript in a format that PodNotes reads, PodNotes saves that one, with no API key and at no cost. Otherwise, PodNotes transcribes the audio with your own OpenAI or Deepgram account. +Turn an episode into a transcript note with clickable timestamps and speaker labels. When a podcast you added publishes a transcript in its feed, PodNotes saves that transcript at no cost and without an API key. Otherwise, PodNotes transcribes the audio through **ElevenLabs**, **Deepgram**, **OpenRouter**, **OpenAI**, or an **OpenAI-compatible server**. ## Transcribe an episode -**PodNotes: Transcribe current episode** first uses the transcript the episode's feed publishes, at no cost, for a podcast you added to PodNotes. For any other episode, and when the feed publishes none in a format that PodNotes reads, it transcribes the audio with your provider and API key, and your provider bills you for it. Without your provider's key on the device, the command never bills you. It saves a transcript that the feed publishes. For an episode without one, it shows a notice that asks for the key. +**PodNotes: Transcribe current episode** uses the transcript the episode's feed publishes whenever one is available for a podcast in your library. When the feed has none in a format PodNotes reads, or for any other audio episode, it sends the audio to the provider selected in **Settings → PodNotes** under **Transcripts**. Before you set up a provider on this device, the command still saves feed transcripts and never bills you. For an episode that needs audio transcription, it shows a notice asking for your provider's API key, or for your **Server address** and **Model** when **OpenAI-compatible server** is selected. -1. Play an audio episode in PodNotes. You can pause it. -2. Run **PodNotes: Transcribe current episode** from the command palette (`Ctrl+P`, or `Command+P` on macOS). +1. In PodNotes, play an audio episode. +2. From the command palette (`Ctrl+P`, or `Command+P` on macOS), run **PodNotes: Transcribe current episode**. -The transcribe commands need a connection, so they are missing from the command palette while the device is offline. +A paused episode, or the episode PodNotes restores on startup, works too. -To transcribe audio, [set up an OpenAI key](/transcripts/#set-up-an-openai-key). For [speaker labels](/transcripts/#add-speaker-labels) from Deepgram, set up a Deepgram key instead. +To transcribe audio, [choose a transcription provider or connect an OpenAI-compatible server](/transcription-providers/). That guide also covers provider pricing and how PodNotes stores keys on each device. -A notice shows the progress, and you can keep listening. When the transcript is ready, PodNotes saves it as a note and opens it, as [Where a note opens](/notes/#where-a-note-opens) describes. With the default settings, the transcript of the Acquired episode "The Home Depot" is `transcripts/Acquired/The Home Depot.md`. +A notice tracks progress while you keep listening. When the transcript finishes, PodNotes saves the note and opens it, as [Where a note opens](/notes/#where-a-note-opens) describes. With the default path, the transcript of the Slow Thinking episode "Walking as thinking" saves to `transcripts/Slow Thinking/Walking as thinking.md`. + +![A saved transcript note with clickable timestamps and numbered speaker labels](/screenshots/transcript-note.webp) ### Transcribe the audio instead -To transcribe the audio even when the feed publishes a transcript: +After you [set up a transcription provider](/transcription-providers/) on this device, you can transcribe the audio even when the feed publishes a transcript: -1. If this device has no key for your provider, set one up. See [Set up an OpenAI key](/transcripts/#set-up-an-openai-key), or [Add speaker labels](/transcripts/#add-speaker-labels) for Deepgram. -2. Rename, move, or delete any transcript note you saved for the episode. PodNotes does not replace an existing transcript. -3. Run **PodNotes: Transcribe current episode from audio**. +1. In your vault, rename, move, or delete any transcript note you already saved for the episode, because PodNotes keeps an existing note. +2. In PodNotes, play the audio episode. +3. From the command palette (`Ctrl+P`, or `Command+P` on macOS), run **PodNotes: Transcribe current episode from audio**. -The command always uses your provider and your API key. A transcript of the audio has paragraphs without times, so its lines do not link to their moment in the episode. With speaker labels, each turn starts with a label such as **A:** with OpenAI or **1:** with Deepgram. +This command always sends the episode to your chosen provider. Cloud providers use credit on your account there, and **OpenAI-compatible server** sends the audio to the server at **Server address**. ## Read the transcript in the player -The full player's **Transcript** panel shows the transcript that the episode's feed publishes, for free, without saving a note. It reads **Feed transcript** and **Free** at the top. +The full player's **Transcript** panel displays the transcript that an episode's feed publishes, without saving a note. It shows **Feed transcript** and **Free** at the top. 1. In the full player, select **Transcript**. -2. Select a line with a time to play the episode from that moment. A transcript without times can only be read. +2. In the transcript list, select a line with a time to play the episode from that moment. A transcript without times can only be read. -The panel highlights the line being spoken and follows it. To search the transcript, select **Find in transcript**. To keep the transcript, select **Save as note**. It saves the same note as **PodNotes: Transcribe current episode**, but never transcribes the audio. Where the panel is narrow, such as in the sidebar or on a phone, the button shows only its icon, a page with a plus sign. Once the note exists, the button reads **Open note** and opens it. The note opens as [Where a note opens](/notes/#where-a-note-opens) describes. See [Read the transcript](/player/#read-the-transcript) for every control. +The panel highlights the line being spoken and scrolls with playback. Select **Find in transcript** to filter lines by word, or select **Save as note** to save the feed transcript without calling a provider. Where the panel is narrower than 340 px, such as in a narrow sidebar, **Save as note** shows only its page-with-plus icon. Once the note exists, the button reads **Open note**. See [Read the transcript](/player/#read-the-transcript) for every control. -This clip opens **Transcript**, selects a line to play from it, and finds the word "walk": +This clip opens **Transcript**, selects a line to jump to its time, and searches for "walk": ![The full player's Transcript panel: a selected line plays from its time, and a search for walk shows only the lines that contain it](/videos/transcript.mp4) -When the feed publishes no transcript, the panel says "No transcript in this feed". For an audio episode, it offers **Transcribe with OpenAI**, or **Transcribe with Deepgram** when your speaker labels come from Deepgram, with a **Paid** tag. It works like **PodNotes: Transcribe current episode from audio**, with your provider and your API key. When the episode's transcript note already exists, the panel offers **Open note** in place of that button. Offline, the panel's transcribe button and **Save as note** are turned off, and **Open note** still works. +When an episode has no feed transcript, the panel says "No transcript in this feed", or "No transcript yet" for a local file. For an audio episode, it offers **Transcribe with ElevenLabs** (or your chosen cloud **Transcription provider**) with a **Paid** tag, or **Transcribe with your server** without a **Paid** tag when **OpenAI-compatible server** is selected. Selecting the button runs the same audio transcription as **PodNotes: Transcribe current episode from audio**. Once the transcript note exists, the panel shows **Open note** instead. While your device is offline, the transcribe button and **Save as note** are disabled, and **Open note** remains available. -## Transcripts that feeds publish +## Timestamps in transcripts -Some feeds list a transcript for each episode with the Podcasting 2.0 `` tag. PodNotes uses it for a podcast you added, while the feed still lists the episode. +Audio transcripts from **ElevenLabs**, **Deepgram**, all five **OpenRouter** models, and an **OpenAI-compatible server** that returns word or segment times include paragraph timestamps, matching timed transcripts from podcast feeds: -- When an episode lists several transcripts, PodNotes picks WebVTT, then SRT, then JSON, then HTML, then plain text. -- In a transcript with times, each paragraph starts with the time it begins. The time uses the time format in **Timestamp template**. With the default format, `- {{linktime}}` with a space at the end, the time is a link that plays the episode from that moment. With `{{time}}`, it is plain text. PodNotes ignores the rest of the format, such as the `-` list marker. -- When the transcript names its speakers, each speaker's turn starts with **Speaker label format**, even with **Speaker diarization** off. -- A plain text transcript, or an HTML transcript without times, has paragraphs but no times. +- Each paragraph starts with its start time, formatted from **Timestamp template** under **Notes** in **Settings → PodNotes**. +- With the default **Timestamp template**, `- {{linktime}}` followed by a space, each time is a link that plays the episode from that moment. With `{{time}}`, PodNotes writes plain text such as `00:00:08`. PodNotes ignores surrounding text in **Timestamp template**, such as the `-` list marker. +- **OpenAI** (**GPT Transcribe**), an **OpenAI-compatible server** that refuses timestamp options with HTTP 400 or returns text without word or segment times, plain-text feed transcripts, and HTML feed transcripts without cue times produce paragraphs without timestamps. +- When a podcast feed lists several `` tags for one episode, PodNotes picks WebVTT first, then SRT, JSON, HTML, and plain text. -With the default settings, the Markdown of a transcript with times and speakers looks like this: +With the default settings, the Markdown of an audio transcript with timestamps and speaker labels looks like this: ```md wrap -[00:00:00](obsidian://podnotes?...) **Ben:** Welcome back to the show. Today we look at how the company started. +[00:00:00](obsidian://podnotes?...) **1:** Why do so many writers say the sentence only came together once they stood up from the desk? -[00:00:41](obsidian://podnotes?...) **David:** It started with two founders and one store. +[00:00:08](obsidian://podnotes?...) **2:** At a desk your eyes stay fixed on the page, and the argument narrows to whatever is already in front of you. ``` -## Set up an OpenAI key +## Speaker labels + +In an audio transcript with **Speaker labels** turned on, PodNotes numbers speakers `1`, `2`, `3` in the order they first speak. With the default **Speaker label format**, `**{{speaker}}:**` followed by a space, each turn starts with **1:** or **2:** right after the timestamp. -To transcribe the audio without speaker labels, PodNotes sends it to OpenAI with your API key. A transcript that the feed publishes needs no key. +In a transcript that a feed publishes, PodNotes uses the speaker names from the feed, such as **Maya Lind:**, whether **Speaker labels** is on or off. -1. Create a key on the [OpenAI API keys page](https://platform.openai.com/api-keys). -2. In Obsidian, open **Settings → PodNotes**. -3. Under **Transcripts**, select the secret picker next to **OpenAI API key**. -4. Create a secret that holds your key, or select one that you saved before. +**Speaker labels** is on by default in new installs. To turn it on or off: -On each other device, select or create the secret again. See [API keys and private feeds on each device](/sync/#api-keys-and-private-feeds-on-each-device). +1. Open **Settings → PodNotes**. +2. Under **Transcripts**, toggle **Speaker labels**. -## Add speaker labels +### Models and speaker labels -To label who speaks in a transcript of the audio: +- **ElevenLabs** (**Scribe v3**), **Deepgram** (**Nova-3**), and three **OpenRouter** models (**Grok STT 2.0**, **ElevenLabs Scribe v2**, and **MAI-Transcribe 2**) label speakers. +- OpenRouter's **Voxtral Mini Transcribe 3** and **Qwen3 ASR 1.7B**, **OpenAI** (**GPT Transcribe**), and **OpenAI-compatible server** do not label speakers. When one of them is selected, the **Speaker labels** row reads `"\ does not label speakers."` (for example `"Voxtral Mini Transcribe 3 does not label speakers."` or `"OpenAI-compatible server does not label speakers."`). If **Speaker labels** is off, the toggle is disabled. If **Speaker labels** was already on, the toggle stays active so you can turn it off. Transcribing with one of them while **Speaker labels** is on saves the note without speaker labels and shows a notice: `"Saved without speaker labels, because \ does not label speakers. These providers do: ElevenLabs, Deepgram, OpenRouter."` +- If a model that supports speaker labels returns no speaker tags for an episode, PodNotes saves the note without labels and shows `"Saved without speaker labels, because \ returned none."` -1. Open **Settings → PodNotes**. -2. Under **Transcripts**, turn on **Speaker diarization**. -3. In **Diarization provider**, select **OpenAI (gpt-4o-transcribe-diarize)** or **Deepgram**. -4. If you selected **Deepgram**, create a key in the [Deepgram console](https://console.deepgram.com/). -5. If you selected **Deepgram**, under **Deepgram API key**, select or create a secret that holds the Deepgram key. With OpenAI, PodNotes uses your OpenAI key. +### Long episodes on OpenRouter -The providers differ in these ways: +**ElevenLabs** and **Deepgram** transcribe a full episode in one request, so each speaker keeps the same number from start to finish. **OpenRouter** also transcribes a full public podcast episode in one request when you select **ElevenLabs Scribe v2** and OpenRouter can fetch the episode URL directly. -| Provider | Key | Labels | Long episodes | -| --- | --- | --- | --- | -| OpenAI | Your OpenAI key | `A`, `B`, `C` | Sent in parts of about 20 MB. `A` in one part may be `B` in the next. | -| Deepgram | A Deepgram key. No OpenAI key needed. | `1`, `2`, `3` | Sent in one request. Labels stay the same for the whole episode. | +When **OpenRouter** uploads audio from your device, PodNotes splits the episode into parts past 18 MB (or 30 minutes for MP3) on **Grok STT 2.0**, **ElevenLabs Scribe v2**, and **MAI-Transcribe 2**, and past 12 MB (or 15 minutes for MP3) on **Voxtral Mini Transcribe 3** and **Qwen3 ASR 1.7B**: -A transcript that the feed publishes uses the speaker names in the feed, with or without **Speaker diarization**. +- **Grok STT 2.0** (the recommended OpenRouter model): for MP3 episodes, PodNotes cuts a short clip reel of voices that speak for at least 2.6 seconds and sends one extra request to match them across parts. Matched voices keep one speaker number for the whole episode. +- **ElevenLabs Scribe v2** (on private feeds, local files, or other uploads) and **MAI-Transcribe 2**: speaker numbers restart in each part, so speaker `1` in part two may be a different person from speaker `1` in part one. The same happens on **Grok STT 2.0** when a split upload is not MP3, has fewer than two voice turns of at least 2.6 seconds across parts, or cannot complete the matching request. PodNotes saves the note and shows a warning: `"OpenRouter transcribed this episode in \ parts, and speaker labels restart in each, so one person can have several labels."` +- **Voxtral Mini Transcribe 3** and **Qwen3 ASR 1.7B**: neither model labels speakers, and timestamps continue from the start of the episode across parts. ### Change the speaker label format -1. In **Settings → PodNotes**, under **Transcripts**, turn on **Speaker diarization**. **Speaker label format**, below it, becomes available. -2. Set **Speaker label format**, for example to `**Speaker {{speaker}}:**` with a space at the end. +If the selected model or provider does not label speakers and **Speaker labels** is disabled, switch **Transcription provider** or **Model** to one that labels speakers first. -The format sets the text before each turn. The default, `**{{speaker}}:**` with a space at the end, starts a turn with **A:** in bold. With `**Speaker {{speaker}}:**`, a turn starts with **Speaker A:**. With Deepgram, the labels are numbers, as in **Speaker 1:**. The labeled text fills `{{transcript}}`, so **Transcript template** needs no change. +1. In **Settings → PodNotes**, under **Transcripts**, turn on **Speaker labels** so **Speaker label format** appears below it. +2. In **Speaker label format**, enter the text to place before each speaker's turn, such as `**Speaker {{speaker}}:**` followed by a space. -Transcripts that feeds publish use the format too. If you turn **Speaker diarization** off again afterwards, they keep using it. +`{{speaker}}` becomes the speaker number or feed speaker name, and the formatted text fills `{{transcript}}` in **Transcript template**. Transcripts that feeds publish keep using **Speaker label format** even if you turn **Speaker labels** off afterwards. ## Change where transcripts go and what they contain -**Transcript path** under **Transcripts** sets where the note goes, `transcripts/{{podcast}}/{{title}}.md` by default. Keep `.md` at the end, because PodNotes does not add it. **Transcript template** sets the content, and `{{transcript}}` is the transcript text. [Transcript and timestamp tags](/templates/#transcript-and-timestamp-tags) lists every tag and the default template. - -## What transcription costs - -A transcript that the feed publishes costs nothing. To transcribe the audio, PodNotes charges nothing, and your provider bills your account per minute of audio: - -- [OpenAI API pricing](https://openai.com/api/pricing/) lists the rate for plain transcription (`whisper-1`) and for speaker labels (`gpt-4o-transcribe-diarize`). -- [Deepgram pricing](https://deepgram.com/pricing) lists the rate for pre-recorded audio with speaker labels. - -## Limits and error messages - -These cases stop a transcription or change its result: - -- PodNotes transcribes audio episodes only. For a video episode, both commands are missing. The **Transcript** panel can still show and save a video's transcript that the feed publishes. -- Both commands need a connection, and they are missing while the device is offline. -- PodNotes transcribes the audio instead of using the feed's transcript in these cases: - - The episode is a local file. - - You have not added the podcast to PodNotes. - - The feed no longer lists the episode, or lists no transcript in a format PodNotes reads. - - PodNotes cannot fetch the feed, and no copy of the feed on this device lists the episode. -- When PodNotes would transcribe the audio but the provider's key is not on this device, it transcribes nothing, and a notice asks you to select or create the key in the transcript settings. -- When PodNotes cannot fetch or read the feed's transcript, a notice starts with "The published transcript could not be fetched" or "The published transcript could not be read." PodNotes does not transcribe the audio on its own. If your provider's key is on this device, the notice names **Transcribe current episode from audio**, which uses your API credit. To try the feed again, run **PodNotes: Transcribe current episode** again. -- PodNotes waits 30 seconds for a feed's transcript and reads at most about 16 MB of it. -- When a transcript note already exists at the path, a notice gives its path and PodNotes does nothing else. To transcribe again, rename, move, or delete the note first. -- PodNotes sends long audio to OpenAI in parts of about 20 MB. In a transcript without speaker labels, a part that fails three times leaves the marker `[Error transcribing chunk N]`. With OpenAI speaker labels, the marker is `[Error diarizing chunk N]`. To retry, delete the note and run the command again. When every part fails, PodNotes saves no note. +Under **Transcripts** in **Settings → PodNotes**, **Transcript path** sets where each transcript note is saved (`transcripts/{{podcast}}/{{title}}.md` by default). Keep `.md` at the end of **Transcript path**, because PodNotes does not add it. **Transcript template** sets the note's contents, where `{{transcript}}` inserts the transcript body. See [Transcript and timestamp tags](/templates/#transcript-and-timestamp-tags) for every available tag. -## API key storage +## Limits -PodNotes keeps your API keys in Obsidian's secret storage on each device, and only the name of each key syncs. On the first start after an upgrade from a version that kept plain-text keys in `data.json`, PodNotes moves them to secret storage and does not finish loading until the move succeeds. If it fails, restart PodNotes. The move cannot erase old copies in backups or settings exports, so replace a key at the provider if it may have leaked. See [API keys and private feeds on each device](/sync/#api-keys-and-private-feeds-on-each-device). +- PodNotes transcribes audio episodes only. For video episodes, both transcribe commands are hidden, though the **Transcript** panel can still read and save a video's published feed transcript. +- Both commands check that your device is online and are hidden while Obsidian reports your device as offline, even when **OpenAI-compatible server** points to a local server. +- PodNotes transcribes the audio instead of using a feed transcript when the episode is a local file, when you have not added the podcast to PodNotes, when the feed no longer lists the episode or lists no supported transcript format, or when PodNotes cannot reach the feed and has no cached copy listing the episode on this device. +- When a published transcript fails to download or parse, PodNotes shows a notice starting with "The published transcript could not be fetched" or "The published transcript could not be read." PodNotes never falls back to audio transcription on its own. When your chosen provider is ready on this device, the notice names **Transcribe current episode from audio** (noting API credit use for cloud providers, or naming your **OpenAI-compatible server**) so you can choose to run it. PodNotes waits 30 seconds for a feed transcript and reads up to 16 MB. +- When a note already exists at **Transcript path**, PodNotes leaves it untouched and shows its path. Rename, move, or delete the note to transcribe the episode again. +- When **OpenRouter** (past 18 MB or 30 minutes for MP3, or past 12 MB or 15 minutes for MP3 on **Voxtral Mini Transcribe 3** and **Qwen3 ASR 1.7B**), **OpenAI** (past 24 MB, or 34.5 minutes for MP3), or **OpenAI-compatible server** (past 24 MB) uploads an episode in parts, a part that fails leaves `[Error transcribing chunk N]` in that spot of the note, and a notice reports how many parts failed. Delete the note and run the command again to retry. If every part fails, PodNotes saves no note. +- For provider error notices and how to fix each one, see [Why did my transcription fail?](/faq/#why-did-my-transcription-fail). diff --git a/docs/src/content/docs/update-2-25.md b/docs/src/content/docs/update-2-25.md new file mode 100644 index 00000000..cc18cb16 --- /dev/null +++ b/docs/src/content/docs/update-2-25.md @@ -0,0 +1,43 @@ +--- +title: Update to 2.25.0 +slug: update-2-25 +description: What changes in audio transcription when you update from PodNotes 2.24.0 to 2.25.0, including the OpenAI model change and how to keep speaker labels. +--- + +Update from PodNotes 2.24.0 to 2.25.0 to transcribe audio with **ElevenLabs**, **Deepgram**, **OpenRouter**, **OpenAI**, or an **OpenAI-compatible server**, and get clickable timestamps in audio transcripts. If you are updating from 2.21.1 or earlier, read [Update to 2.22.0](/upgrade/) first. + +## Why OpenAI transcription changed + +OpenAI shuts down `whisper-1` and `gpt-4o-transcribe-diarize` on `2027-02-26`. PodNotes 2.25.0 moves OpenAI transcription to **GPT Transcribe** (`gpt-transcribe`). + +**GPT Transcribe** returns plain text only, without timestamps and without speaker labels. **ElevenLabs**, **Deepgram**, and three **OpenRouter** models (**Grok STT 2.0**, **ElevenLabs Scribe v2**, and **MAI-Transcribe 2**) return both clickable timestamps and speaker labels. OpenRouter's **Voxtral Mini Transcribe 3** and **Qwen3 ASR 1.7B**, and an **OpenAI-compatible server** that returns word or segment times, add clickable timestamps without speaker labels. + +## How your 2.24.0 settings migrate + +PodNotes maps your 2.24.0 transcript settings automatically on first load: + +| Your 2.24.0 setup | Transcription provider in 2.25.0 | Model | Speaker labels | What changes | +| --- | --- | --- | --- | --- | +| **Speaker diarization** on, **Deepgram** | **Deepgram** | **Recommended (Nova-3)** | On | Keeps using your Deepgram key. Audio transcripts now include clickable timestamps, and speakers are numbered `1`, `2`, `3` in the order they first speak. | +| **Speaker diarization** on, **OpenAI (gpt-4o-transcribe-diarize)** | **OpenAI** | **Recommended (GPT Transcribe)** | On | Keeps using your OpenAI key, but OpenAI no longer labels speakers. Each transcript saves as plain paragraphs and shows a notice naming the providers that label speakers. | +| **Speaker diarization** off, OpenAI key linked | **OpenAI** | **Recommended (GPT Transcribe)** | Off | Keeps using your OpenAI key with **GPT Transcribe** in place of `whisper-1`. Transcripts remain plain paragraphs without timestamps or speaker labels. | +| **Speaker diarization** off, no OpenAI key linked | **ElevenLabs** | **Recommended (Scribe v3)** | Off | Ready for ElevenLabs if you decide to add a key later. Turn on **Speaker labels** under **Transcripts** when you want speaker numbers. Feed transcripts still save for free without a key. | + +**Transcript path**, **Transcript template**, and **Speaker label format** keep the values you set in 2.24.0. In settings under **Transcripts**, **Speaker diarization** is renamed to **Speaker labels**, and **Diarization provider** is replaced by **Transcription provider** and **Model** (plus **Server address** when you select **OpenAI-compatible server**). + +## Get speaker labels and timestamps after using OpenAI + +If you used OpenAI for speaker labels in 2.24.0, set up **ElevenLabs**, **Deepgram**, or a speaker-labeled **OpenRouter** model in [Choose a transcription provider](/transcription-providers/), or connect a local or hosted **OpenAI-compatible server** if you want timestamps without speaker labels. For speaker labels, check that **Speaker labels** is on: + +1. In Obsidian, open **Settings → PodNotes**. +2. Under **Transcripts**, turn on **Speaker labels** if it is off. + +If you prefer to stay on **OpenAI** (or use **OpenAI-compatible server**, **Voxtral Mini Transcribe 3**, or **Qwen3 ASR 1.7B**) without speaker labels, turn off **Speaker labels** under **Transcripts** so PodNotes stops showing the missing-labels notice after each transcription. + +## Devices still on 2.24.0 + +PodNotes 2.25.0 keeps the same `data.json` storage format as 2.24.0 and preserves the 2.24.0 diarization fields alongside the new provider settings: + +- A phone or second computer still running 2.24.0 keeps syncing your library and keeps using its 2.24.0 transcription setup without overwriting your 2.25.0 provider choice. +- Importing a 2.24.0 settings file carries over **Deepgram** when that file had **Speaker diarization** on with **Deepgram**, and otherwise keeps your library's current **Transcription provider**. +- Update any 2.24.0 device that uses OpenAI before `2027-02-26`, when OpenAI turns off `whisper-1` and `gpt-4o-transcribe-diarize`. diff --git a/docs/src/content/docs/upgrade.md b/docs/src/content/docs/upgrade.md index bf1af1d1..1149b466 100644 --- a/docs/src/content/docs/upgrade.md +++ b/docs/src/content/docs/upgrade.md @@ -4,7 +4,7 @@ slug: upgrade description: What changes when you update from PodNotes 2.21.1 to 2.22.0, and what to do on each device. --- -Update from PodNotes 2.21.1 or earlier to 2.22.0 without losing your library. PodNotes 2.22.0 redesigns the PodNotes view, the player, and the settings, and changes how your devices share one library. Go through the sections below in order. +Update from PodNotes 2.21.1 or earlier to 2.22.0 without losing your library. PodNotes 2.22.0 redesigns the PodNotes view, the player, and the settings, and changes how your devices share one library. Go through the sections below in order, then see [Update to 2.25.0](/update-2-25/) for the 2.25.0 transcription update. ## Update every device first diff --git a/docs/src/site.mjs b/docs/src/site.mjs index 404d0ad6..12f47e91 100644 --- a/docs/src/site.mjs +++ b/docs/src/site.mjs @@ -16,6 +16,7 @@ export const sidebar = [ items: [ { label: "Overview", slug: "index" }, { label: "Getting started", slug: "getting-started" }, + { label: "Update to 2.25.0", slug: "update-2-25" }, { label: "Update to 2.22.0", slug: "upgrade" }, { label: "Use PodNotes on your phone", slug: "mobile" }, ], @@ -29,6 +30,7 @@ export const sidebar = [ { label: "Use the queue and playlists", slug: "playlists" }, { label: "Downloads and local files", slug: "local_files" }, { label: "Transcribe episodes", slug: "transcripts" }, + { label: "Choose a transcription provider", slug: "transcription-providers" }, ], }, {