Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions docs/docs/commands.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
## Show player
Opens the PodNotes pane and brings it into focus.

If the pane already exists but is hidden for example in a collapsed sidebar or out of view because the right sidebar has too many icons this command reveals it. If it does not exist yet, the command creates it in the right sidebar. You can run it from the command palette or bind it to a hotkey.
If the pane already exists but is hidden - for example in a collapsed sidebar or out of view because the right sidebar has too many icons - this command reveals it. If it does not exist yet, the command creates it in the right sidebar. You can run it from the command palette or bind it to a hotkey.

PodNotes also adds a **podcast icon to the left ribbon** as a reliable way to reopen the pane. On mobile it appears in the ribbon menu, and you can hide it via Obsidian's *Manage ribbon actions* if you prefer to use the command instead.

Expand All @@ -25,7 +25,7 @@ This will skip the current episode forward by the amount of seconds specified in
## Download Playing Episode
This will download the currently playing episode.

Downloads are stored in the location specified by the **Episode download path** setting. This path is a template and **must include a per-episode token** such as `{{title}}` (the default is `PodNotes/{{podcast}}/{{title}}`). A path without `{{title}}` makes every episode resolve to the same file, so downloads overwrite each other or fail. The file extension is added automatically do not include one.
Downloads are stored in the location specified by the **Episode download path** setting. This path is a template and **must include a per-episode token** such as `{{title}}` (the default is `PodNotes/{{podcast}}/{{title}}`). A path without `{{title}}` makes every episode resolve to the same file, so downloads overwrite each other or fail. The file extension is added automatically - do not include one.

## Capture Timestamp
This will capture the current timestamp of the currently playing episode.
Expand Down Expand Up @@ -85,8 +85,16 @@ Episode links look like this: [https://pod.link/1138055739/episode/1732808e781cc
They can be used to share the episode with others, no matter what podcast app they use.

## Transcribe current episode
This command will transcribe the currently playing episode using OpenAI's Whisper model.

The transcription will be saved in the location specified in the transcript settings.
This command transcribes the currently playing audio episode and saves the
result at the path configured in **Transcript settings**.

Note: This feature requires an OpenAI API key to be set in the settings.
Plain Whisper transcription and OpenAI speaker diarization require an available
OpenAI secret on the current device. Deepgram speaker diarization requires an
available Deepgram secret instead. In **Settings -> PodNotes -> Transcript
settings**, select an existing Obsidian secret or create one for the provider
you use.

The command remains available when a required secret is missing so PodNotes can
tell you which provider needs to be configured. If a selected secret is not
available on this device, open PodNotes settings and select or create it again.
66 changes: 56 additions & 10 deletions docs/docs/import_export.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,33 +19,79 @@ First designate a file path to save to (or use the default), and click _Export_.

## Settings & templates

Under the **Settings & templates** heading you can move your PodNotes
Under the **Settings & templates** heading, you can move your PodNotes
configuration between vaults or back it up. This covers your preferences,
note/timestamp/transcript templates, file paths, saved feeds, and playlists.

![Settings & templates import/export](resources/settings_import_export.png)

Playback progress, downloaded-episode bookkeeping, the currently-playing
Playback progress, downloaded-episode bookkeeping, the currently playing
episode, and the episode-to-note mapping are **not** included, because they are
specific to a single vault.

PodNotes settings exports use format v2. API key values and the names of the
Obsidian secrets selected in PodNotes are omitted by default. This keeps a
destination vault's existing OpenAI and Deepgram selections unchanged when you
import the file.

### Exporting settings

1. (Optional) Enable **Include OpenAI API key** if you also want your key in the
file. The key is written in plaintext, so only do this for files you keep
private — the file lives in your vault and may sync to other devices or be
read by other plugins.
1. Leave **Include API keys** off for a normal settings export. To transfer the
OpenAI and Deepgram keys that are available on this device, enable it
explicitly.
2. Set a file name (or keep the default `PodNotes_Settings.json`).
3. Click **Export**. The settings file is written to your vault.

When **Include API keys** is enabled, available values are added in plaintext
under a separate top-level `secrets` payload:

```json
{
"type": "podnotes-settings",
"version": 2,
"settings": {},
"secrets": {
"openAI": "your OpenAI API key",
"deepgram": "your Deepgram API key"
}
}
```

Only configured keys are included. If a selected secret name exists in PodNotes
settings but its value is unavailable on this device, the export stops instead
of silently creating an incomplete credential backup. Open **Transcript
settings**, select or create the missing secret on this device, and export
again.

An export containing `secrets` is sensitive. The values are plaintext in a file
inside your vault, where they may sync to other devices or be read by other
plugins. Keep the file private and delete it when it is no longer needed.

### Importing settings

1. Click **Import** next to _Import settings_ and choose a settings file. Both a
PodNotes export file and a raw `data.json` are accepted.
1. Click **Import** next to **Import settings** and choose a settings file.
PodNotes accepts current v2 exports, legacy v1 exports, and a raw PodNotes
`data.json`.
2. Confirm the import. Your current preferences, templates, feeds, and playlists
are replaced with the imported values; playback progress and downloads are
kept.

An import without API key values preserves the OpenAI and Deepgram secret names
already selected in the destination vault. For a legacy v1 export or raw
`data.json`, PodNotes recognizes the old plaintext `openAIApiKey` and
`diarizationApiKey` fields and imports them as secrets instead of putting them
back into plugin settings.

When an import contains API keys, the confirmation names the affected
providers. PodNotes stores each value under a PodNotes-owned name in Obsidian's
vault-local secret storage, then updates PodNotes to reference it. Existing
Obsidian secrets are never overwritten. If a PodNotes-owned name already holds
a different value, the imported key is stored under a new suffixed name.

Imported secret values are available only in the current vault on the current
device. Repeat the import or select/create the appropriate secrets separately
on another device.

A file exported by a newer version of PodNotes is rejected until you update the
plugin. If an episode is already open when you import, a changed default playback
rate applies to the next episode you open.
plugin. If an episode is already open when you import, a changed default
playback rate applies to the next episode you open.
Binary file modified docs/docs/resources/diarization_settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/docs/resources/settings_import_export.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
97 changes: 80 additions & 17 deletions docs/docs/transcripts.md
Original file line number Diff line number Diff line change
@@ -1,37 +1,90 @@
# Transcripts

PodNotes allows you to create transcripts of podcast episodes using OpenAI's Whisper model.
PodNotes can create transcript notes from podcast episodes. Plain transcription
uses OpenAI's Whisper model, while optional speaker diarization can use OpenAI
or Deepgram.

## Setting Up

Before you can use the transcription feature, you need to set up a few things:

1. **OpenAI API Key**: You need to have an OpenAI API key. You can get one by signing up at [OpenAI's website](https://openai.com/). Once you have the key, enter it in the PodNotes settings under the "Transcript settings" section.

2. **Transcript File Path**: In the settings, you can specify where you want the transcript files to be saved. You can use placeholders like `{{podcast}}` and `{{title}}` in the path.

3. **Transcript Template**: You can also customize how the transcript content is formatted using a template.
Before you can use transcription, set up the following:

1. **OpenAI API key**: Create a key at [OpenAI's website](https://openai.com/).
In **Settings -> PodNotes -> Transcript settings**, use **OpenAI API key** to
select an existing Obsidian secret or create one. Plain Whisper transcription
and OpenAI diarization both use this secret.

2. **Transcript file path**: Choose where transcript files are saved. You can
use placeholders such as `{{podcast}}` and `{{title}}` in the path.

3. **Transcript template**: Customize how the transcript note is formatted.

### How API keys are stored

PodNotes uses Obsidian's native secret picker. The API key value is kept in
Obsidian's vault-local secret storage; PodNotes stores only a provider-specific,
PodNotes-owned secret name in its `data.json`, through the `openAISecretId` and
`deepgramSecretId` references. If you explicitly select a shared Obsidian
secret, PodNotes copies its value into a collision-safe name owned by the chosen
provider. Persisted OpenAI references therefore cannot read Deepgram or foreign
secrets, and persisted Deepgram references cannot read OpenAI or foreign
secrets.

PodNotes requires Obsidian 1.11.5 or newer because that release encrypts secret
storage on disk using encryption provided by the operating system. Secret
storage keeps keys out of PodNotes files and normal settings exports, but it is
shared infrastructure rather than plugin isolation: other installed plugins can
access the same vault-local secret collection.

Secret values are local to the current vault on the current device. A selected
secret name can sync with the rest of the vault configuration while its value
does not. If PodNotes says that a selected secret is unavailable on this device,
open **Transcript settings** on that device and select or create the secret
again.

When upgrading from a version that stored API keys directly in `data.json`,
PodNotes moves existing OpenAI and Deepgram keys into Obsidian's secret storage
before rewriting the settings file. The plaintext fields are then removed. If
that migration cannot be completed and verified, PodNotes leaves the existing
settings file unchanged and shows a persistent notice so you can restart the
plugin and retry.

The migration cannot erase copies that already exist in backups, Obsidian Sync
version history, filesystem snapshots, or old plaintext settings exports. If a
key may have reached an untrusted device or backup, rotate it with the provider
and remove any old export files you no longer need.

## Creating a Transcript

To create a transcript:

1. Start playing the podcast episode you want to transcribe.
2. Use the "Transcribe current episode" command in Obsidian.
3. PodNotes will fetch the audio for the episode you are playing (reusing an already-downloaded copy when one exists), split it into chunks, and send these chunks to OpenAI for transcription. The transcription always uses the currently playing episode's own audio, regardless of your download path settings.
4. Once the transcription is complete, a new file will be created at the specified location with the transcribed content.
1. Start playing the audio episode you want to transcribe.
2. Run **PodNotes: Transcribe current episode** from the command palette.
3. PodNotes fetches the currently playing episode's audio, reusing an existing
download when available. Plain Whisper and OpenAI diarization split large
audio into chunks; Deepgram diarization sends the episode in one request.
4. When transcription finishes, PodNotes creates a note at the configured
transcript path.

The command always uses the currently playing episode's own audio, regardless
of your episode download path setting. It remains available when a required
secret is missing so it can tell you which provider must be configured on the
current device.

Generated transcript notes are also available to workflow plugins through the
[PodNotes API](./api.md#transcript), so tools such as QuickAdd or Templater can
read the text and send it to the AI provider configured in your own macro.

## Transcript Template

The transcript template works similarly to the [note template](./templates.md#note-template), but with the added `{{transcript}}` placeholder.
The transcript template works similarly to the
[note template](./templates.md#note-template), with the additional
`{{transcript}}` placeholder.

## Speaker Diarization

By default the transcription uses OpenAI's Whisper model, which produces plain text with **no speaker labels**. Speaker diarization is an opt-in setting that instead labels each segment of the transcript by speaker, turning a wall of text into a readable, speaker-by-speaker conversation:
By default, transcription uses OpenAI's Whisper model, which produces plain
text with **no speaker labels**. Speaker diarization is an opt-in setting that
labels each transcript segment by speaker:

![Transcript with speaker labels](resources/transcript_diarization.png)

Expand All @@ -41,8 +94,16 @@ In the **Transcript settings** section, turn on **Speaker diarization** and choo

![Speaker diarization settings](resources/diarization_settings.png)

- **OpenAI** (`gpt-4o-transcribe-diarize`): reuses the OpenAI API key you already entered above, so there is nothing else to configure. Because each request is capped at ~20 MB (a conservative margin under OpenAI's 25 MB request cap), a long episode is split into chunks that are diarized independently — so on long episodes the speaker labels can change across chunk boundaries (the same person may be labelled `A` in one chunk and `B` in the next). A typical-length episode fits in a single request and is fully consistent.
- **Deepgram**: sends the whole episode in one request, so speaker labels stay consistent across the entire episode. This requires a separate **Deepgram API key**, which you can create at [deepgram.com](https://deepgram.com) (new accounts include free credit). Your Deepgram key is stored separately from your OpenAI key and is only used for diarization.
- **OpenAI** (`gpt-4o-transcribe-diarize`) reuses the OpenAI secret selected
above. Because each request is capped at about 20 MB, a conservative margin
under OpenAI's 25 MB request cap, a long episode is diarized in independent
chunks. Speaker labels can therefore reset across chunk boundaries. A typical
episode fits in one request and keeps consistent labels.
- **Deepgram** sends the whole episode in one request, so speaker labels remain
consistent throughout the episode. Create a separate key at
[deepgram.com](https://deepgram.com), then select an existing Obsidian secret
or create one under **Deepgram API key**. The Deepgram secret is used only for
Deepgram diarization and is local to the current vault and device.

Diarization is off by default, so existing transcripts and the plain-Whisper workflow are unchanged unless you enable it.

Expand All @@ -53,7 +114,9 @@ The **Speaker label format** setting controls the prefix added before each speak
- OpenAI labels speakers `A`, `B`, `C`, …
- Deepgram labels speakers `1`, `2`, `3`, …

The default is `**{{speaker}}:** `, which renders as `**A:**`-style bold prefixes. To spell out the word "Speaker", set it to `**Speaker {{speaker}}:** ` (rendering `**Speaker A:**`); `> {{speaker}}: ` would instead put each turn in a blockquote.
The default is `**{{speaker}}:** `, which renders as an `**A:**`-style bold
prefix. To spell out the word "Speaker", use `**Speaker {{speaker}}:** `. To put
each turn in a blockquote, use `> {{speaker}}: `.

The labelled transcript replaces the usual `{{transcript}}` value in your [transcript template](#transcript-template), so you don't need to change your template to use diarization.

Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"id": "podnotes",
"name": "PodNotes",
"version": "2.18.4",
"minAppVersion": "0.15.9",
"minAppVersion": "1.11.5",
"description": "Helps you write notes on podcasts.",
"author": "Christian B. B. Houmann",
"authorUrl": "https://bagerbach.com",
Expand Down
6 changes: 3 additions & 3 deletions scripts/provision-obsidian-e2e-vault.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const PODNOTES_READY_EVAL = `Boolean(app.plugins.plugins[${JSON.stringify
// constants.ts. (currentEpisode is intentionally omitted - DEFAULT_SETTINGS sets it to
// undefined, which JSON cannot represent and PodNotes treats as absent.)
export const DEFAULT_PODNOTES_DATA = {
schemaVersion: 1,
schemaVersion: 2,
savedFeeds: {},
podNotes: {},
defaultPlaybackRate: 1,
Expand Down Expand Up @@ -104,8 +104,8 @@ export const DEFAULT_PODNOTES_DATA = {
shouldRepeat: false,
episodes: [],
},
openAIApiKey: "",
diarizationApiKey: "",
openAISecretId: "",
deepgramSecretId: "",
transcript: {
path: "transcripts/{{podcast}}/{{title}}.md",
template: "# {{title}}\n\nPodcast: {{podcast}}\nDate: {{date}}\n\n{{transcript}}",
Expand Down
4 changes: 2 additions & 2 deletions scripts/provision-obsidian-e2e-vault.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,13 @@ describe("provision-obsidian-e2e-vault", () => {
expect(options.vaultPath).toBe("/tmp/podnotes-repo/vaults/podnotes-a");
});

it("seeds schema v1 data that mirrors the real DEFAULT_SETTINGS", () => {
it("seeds schema v2 data that mirrors the real DEFAULT_SETTINGS", () => {
// JSON cannot represent currentEpisode: undefined, so compare the
// serialized forms - this is exactly what lands in the vault's data.json
// and it fails if a new setting is added to src/constants.ts without
// updating DEFAULT_PODNOTES_DATA.
expect(JSON.parse(JSON.stringify(DEFAULT_PODNOTES_DATA))).toEqual({
schemaVersion: 1,
schemaVersion: 2,
...JSON.parse(JSON.stringify(DEFAULT_SETTINGS)),
});
});
Expand Down
4 changes: 2 additions & 2 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,8 @@ export const DEFAULT_SETTINGS: IPodNotesSettings = {
...LOCAL_FILES_SETTINGS,
episodes: [],
},
openAIApiKey: "",
diarizationApiKey: "",
openAISecretId: "",
deepgramSecretId: "",
transcript: {
path: "transcripts/{{podcast}}/{{title}}.md",
template: "# {{title}}\n\nPodcast: {{podcast}}\nDate: {{date}}\n\n{{transcript}}",
Expand Down
7 changes: 6 additions & 1 deletion src/main.activateView.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,11 @@ describe("PodNotes onload wiring (#55)", () => {
storeUnsubscribers: [],
views: new Set(),
app: {
secretStorage: {
getSecret: vi.fn(() => null),
setSecret: vi.fn(),
listSecrets: vi.fn(() => []),
},
workspace: {
onLayoutReady: vi.fn(),
on: vi.fn(() => ({})),
Expand Down Expand Up @@ -398,7 +403,7 @@ describe("PodNotes onload wiring (#55)", () => {
mediaType: "video",
};
const { commands } = await loadPlugin({
openAIApiKey: "sk-test",
openAISecretId: "podnotes-openai-api-key",
currentEpisode: videoEpisode,
});

Expand Down
Loading