Community-review remediation (no behavior change):
- Remove eslint-disable in realtime/pcm.ts; reach the deprecated ScriptProcessor
  API through local type-aliases so no-deprecated never fires.
- Raise minAppVersion to 1.6.6 for FileManager.trashFile (@since 1.6.6).
- Enable the reviewer's type-checked lint rules locally for src/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
WiseGuru 2026-07-08 22:35:06 -07:00
parent 8657bc53b3
commit f94856eae3
10 changed files with 60 additions and 25 deletions

View file

@ -201,7 +201,7 @@ Saving flow strips secrets out of `data.json` and writes them to `secrets.json.n
The two stores **coexist**: the on-disk envelope retains passphrase `kdf`/`verifier`/`keys` even while `secretStorage` is active (a preserved-at-rest snapshot), so `mode` is just the active-store flag. Switching the active method and transferring keys are **decoupled** into three separate operations (a switch never moves or drops keys): `setEncryptionMode` (switch only), `copyKeys` (copy inactive→active, source kept), `clearKeys(mode)` (wipe one method). The settings tab exposes the mode dropdown (pure switch) plus explicit **Copy** and **Clear** buttons (with `ConfirmModal` confirmations + a copied/cleared count Notice). The transfer button is labelled **Copy**, not "Migrate", because the source copy is kept; `unlockPassphraseStore` lets a secret-storage-active user unlock the passphrase snapshot to copy it.
`ReWritePlugin.encryptionStatus` (`{ mode, locked, configured, secretStorageAvailable, passphraseConfigured }`, refreshed via `refreshEncryptionStatus()`) is read synchronously by UI; pipeline entry points gate on `locked` and call `promptUnlock()`. `passphraseConfigured` is mode-independent (kdf+verifier on disk), used by the UI to distinguish switch-to vs create-passphrase and to know whether a snapshot is copyable. `minAppVersion` is `1.4.4` (driven by `processFrontMatter`, not `secretStorage`, which is feature-detected).
`ReWritePlugin.encryptionStatus` (`{ mode, locked, configured, secretStorageAvailable, passphraseConfigured }`, refreshed via `refreshEncryptionStatus()`) is read synchronously by UI; pipeline entry points gate on `locked` and call `promptUnlock()`. `passphraseConfigured` is mode-independent (kdf+verifier on disk), used by the UI to distinguish switch-to vs create-passphrase and to know whether a snapshot is copyable. `minAppVersion` is `1.6.6` (raised from 1.4.4 in 1.2.1; driven by `FileManager.trashFile` `@since 1.6.6`, used to delete a disabled built-in template. `processFrontMatter` is `@since 1.4.4`; `secretStorage` is feature-detected so it does not drive the floor).
A `secrets.json.nosync` that fails to parse (bad JSON, or JSON that isn't an object) is treated as **corrupt**, not merely absent: the raw bytes are preserved as `secrets.json.nosync.corrupt` before falling back to a fresh envelope, and a sticky `Notice` warns the user before any new save can overwrite the salvageable ciphertext. Writes go through a temp-file + remove + rename sequence rather than a single direct write, so a crash mid-write can't leave a half-written file. A keyring (`secretStorage`) call that fails mid-session (as opposed to being unavailable from the start) is also surfaced once per session via `Notice`, rather than degrading silently to an empty key. See docs/SECRETS.md's Gotchas for both.
@ -304,7 +304,7 @@ Provider support is gated by `transcriptionProviderSupportsRealtime(id)` ([src/r
**Realtime is configured ENTIRELY independently of batch transcription** — its own provider, key, and model — so a profile can run e.g. Voxtral for batch, AssemblyAI for realtime, and Anthropic for cleanup. `EnvironmentProfile.realtimeProvider` (a `TranscriptionProviderID`; `'none'` = off, else a realtime-capable id) plus `realtimeConfig` (a `TranscriptionConfig` shape; only `apiKey`/`model` used). Reason: streaming providers/models/keys differ from batch. The key is persisted under secret id `profile-{kind}-realtime` (see Provider system / docs/SECRETS.md), hydrated/stripped/merged alongside the transcription and LLM slots; `realtimeProvider` is validated by `pickEnum` in `mergeProfile`. The settings tab renders an independent "Real-time transcription" section — a **Real-time provider** dropdown (None + realtime-capable only) that on change re-renders, plus key + model when a provider is picked (`renderRealtimeSection` in [src/settings/tab.ts](src/settings/tab.ts)). The model field is the same adaptive control as the batch fields (`populateModelField(..., 'realtime')`: dropdown + Refresh where the provider supports `listModels`, e.g. Deepgram; a plain text field otherwise, e.g. AssemblyAI), reading `profile.realtimeProvider` / `realtimeConfig` and sharing the transcription model cache. `startRealtimeTranscription` and `RealtimeController.begin()` gate on `realtimeProvider` (not `transcriptionProvider`) and `realtimeConfig.apiKey`.
- **Capture** ([src/realtime/pcm.ts](src/realtime/pcm.ts)): `PcmCapture` taps the mic with a `ScriptProcessorNode` (deprecated but universally available; `AudioWorklet` would need a separate module file a single-bundle plugin can't ship under the app CSP — the deprecation is `eslint-disable`d with that rationale inline), downsamples the context rate to 16 kHz via the pure `downsampleBuffer` (linear interpolation), quantizes to signed-16 PCM via the pure `floatTo16BitPcm`, and emits `ArrayBuffer` chunks. Both helpers and `REALTIME_SAMPLE_RATE` are exported and unit-tested. The node is routed through a zero-gain node to `destination` (a ScriptProcessor only runs while connected) so nothing is audible.
- **Capture** ([src/realtime/pcm.ts](src/realtime/pcm.ts)): `PcmCapture` taps the mic with a `ScriptProcessorNode` (deprecated but universally available; `AudioWorklet` would need a separate module file a single-bundle plugin can't ship under the app CSP; rather than `eslint-disable` the deprecation rule, which the review bot forbids, the deprecated members are reached through local `*Like` type-aliases that carry no `@deprecated` marker), downsamples the context rate to 16 kHz via the pure `downsampleBuffer` (linear interpolation), quantizes to signed-16 PCM via the pure `floatTo16BitPcm`, and emits `ArrayBuffer` chunks. Both helpers and `REALTIME_SAMPLE_RATE` are exported and unit-tested. The node is routed through a zero-gain node to `destination` (a ScriptProcessor only runs while connected) so nothing is audible.
- **Session interface** ([src/realtime/index.ts](src/realtime/index.ts)): `RealtimeProvider.start(config, sampleRate, callbacks) -> RealtimeSession`, where `RealtimeSession` is `{ sendAudio(chunk), stop() }` and callbacks are `onFinal` / `onInterim` / `onError` / `onUnexpectedClose`. `waitForOpen` / `waitForClose` are shared WS plumbing.
- **Deepgram** ([src/realtime/deepgram.ts](src/realtime/deepgram.ts)): auth rides the `['token', <key>]` WebSocket subprotocol (browser WebSockets can't set headers), so the key never touches the URL. `is_final` marks a finalized segment; the profile's batch model (nova family) generally works live, empty falls to Deepgram's default. `stop()` sends `{ type: 'CloseStream' }` to flush.
- **AssemblyAI** ([src/realtime/assemblyai.ts](src/realtime/assemblyai.ts)): browser WS can't send the API key as a header and AssemblyAI has no subprotocol auth, so the real key first buys a short-lived (60 s) single-use token over a normal header-authed `jsonGet`, and only that ephemeral token rides the WS URL query. **This is the one sanctioned exception to the "auth never goes in the query" rule** (documented in the HTTP gotcha too): the token expires fast, can't mint more, and no error path echoes the URL. `format_turns: true` re-sends each turn once formatted; only the formatted end-of-turn is treated as final so a turn isn't inserted twice. `stop()` sends `{ type: 'Terminate' }`.
@ -400,7 +400,7 @@ Per [.editorconfig](.editorconfig): tabs (width 4), LF, UTF-8, final newline. Ma
### Real-time transcription
- **Realtime capture uses `ScriptProcessorNode`, not `AudioWorklet`.** The deprecation is deliberate and `eslint-disable`d with an inline rationale ([src/realtime/pcm.ts](src/realtime/pcm.ts)): an `AudioWorklet` processor must be loaded from a separate module file via `addModule(url)`, which a single-file bundled Obsidian plugin cannot ship and the app's CSP complicates via blob URLs. ScriptProcessor is supported in every WebView Obsidian runs in. If it is ever actually removed, that is the point to revisit do not preemptively swap in a worklet.
- **Realtime capture uses `ScriptProcessorNode`, not `AudioWorklet`.** The deprecation is deliberate ([src/realtime/pcm.ts](src/realtime/pcm.ts)): an `AudioWorklet` processor must be loaded from a separate module file via `addModule(url)`, which a single-file bundled Obsidian plugin cannot ship and the app's CSP complicates via blob URLs. ScriptProcessor is supported in every WebView Obsidian runs in. The deprecated API is reached through local structural type-aliases (`ScriptProcessorNodeLike` / `AudioProcessingEventLike` / `ScriptProcessorFactory`) instead of an `eslint-disable` comment (the community-review bot rejects disabling its rules); the aliases carry no `@deprecated` marker so `@typescript-eslint/no-deprecated` never fires. If ScriptProcessor is ever actually removed, that is the point to revisit; do not preemptively swap in a worklet.
- **Realtime auth cannot use a header (browser WebSocket limitation).** Deepgram takes the key via the `['token', <key>]` subprotocol (the key stays off the URL). AssemblyAI has no subprotocol auth, so it mints a short-lived single-use token over a normal header-authed request and puts only that token in the WS query — see the HTTP query-auth gotcha. Do not "simplify" either adapter to put the real key in the URL.
- **Realtime is one session at a time, plugin-owned.** `activeRealtime` + `realtimeStarting` mirror `activeQuickRecord` / `quickRecordStarting`; `onunload` calls `activeRealtime?.cancel()`. A realtime session and a Quick Record session are independent slots — they can technically both run — but each is individually single-instance. If you ever need them mutually exclusive, gate at `toggleRealtime` / `launchQuickRecord`.
- **Interim vs final segments.** Only `onFinal` text is inserted into the note; `onInterim` is display-only on the floating bar (superseded by later events). Treating an interim as final would double-insert or insert unpunctuated text. Both adapters map their provider's "formatted end of turn" / `is_final` to `onFinal`.

View file

@ -32,7 +32,7 @@ The tag name must equal `manifest.json`'s `version` exactly. `.npmrc` already pi
- **A new release needs a new version number.** Obsidian's automated review only registers a change when the version increments. Re-pushing the same version does not count as a new submission. Bump the patch/minor/major rather than overwriting a published version. (To address review feedback, update the repo and publish a new GitHub release with an incremented version.)
- **Version format is `x.y.z` only.** Semantic Versioning, no pre-release suffixes (no `1.0.0-beta`, no build metadata). The initial release is `1.0.0`.
- **The directory reads `manifest.json` at the HEAD of your default branch** (`master`), not just the release asset. Keep master's `manifest.json` correct and in sync with the released version.
- **`minAppVersion` must be >= the highest `@since` of every Obsidian API you call directly** (anything not behind a runtime feature-detect). Check `node_modules/obsidian/obsidian.d.ts` for the `@since` of new APIs. Example from this project: `FileManager.processFrontMatter` is `@since 1.4.4`, which is why `minAppVersion` is `1.4.4`. Feature-detected APIs (like `app.secretStorage`) do not raise the floor.
- **`minAppVersion` must be >= the highest `@since` of every Obsidian API you call directly** (anything not behind a runtime feature-detect). Check `node_modules/obsidian/obsidian.d.ts` for the `@since` of new APIs. Example from this project: `FileManager.trashFile` is `@since 1.6.6`, which is why `minAppVersion` is `1.6.6` (raised from 1.4.4, which `FileManager.processFrontMatter` `@since 1.4.4` had driven). The `obsidianmd/no-unsupported-api` lint rule flags a direct call newer than the declared floor. Feature-detected APIs (like `app.secretStorage`) do not raise the floor.
- **`versions.json` maps plugin version -> minAppVersion.** Our [version-bump.mjs](../version-bump.mjs) only adds a new line when the `minAppVersion` value is not already present (i.e. when the floor actually changes). That is valid: Obsidian reads the latest version straight from the release `manifest.json`, and consults `versions.json` only to find the newest plugin version compatible with an older app. If you raise `minAppVersion`, confirm a new `versions.json` entry was written; if you keep it, no new line is expected.
- **Public repo + LICENSE.** The repo must be public to be listed, with a real LICENSE whose copyright holder is correct (this plugin is 0BSD). The README must disclose network use, and `manifest.json` carries `author`, `authorUrl`, and (if you take donations) `fundingUrl`.

View file

@ -38,6 +38,15 @@ Merged to `master`, not yet tagged. These become the next version's release note
> Per-version attribution starts with the first release cut after this roadmap was adopted. The archive below collects work that shipped across **1.0.0 through 1.1.0** before per-version tracking began; it is grouped as one historical block rather than retro-fitted to individual tags. New releases get their own `### <version> — <date>` heading above this archive.
### 1.2.1 — 2026-07-08
Community-review remediation for the 1.2.0 submission (no user-facing behavior change). Two blocking review-bot errors resolved:
- **Removed the `eslint-disable` directives in [src/realtime/pcm.ts](../src/realtime/pcm.ts).** The review bot forbids disabling its rules, and the file disabled `@typescript-eslint/no-deprecated` five times for the intentional `ScriptProcessorNode` usage (AudioWorklet can't ship in a single-file CSP-constrained plugin). The deprecated members (`createScriptProcessor`, `onaudioprocess`, `AudioProcessingEvent`, the node type) are now reached through local structural type-aliases (`ScriptProcessorNodeLike` / `AudioProcessingEventLike` / `ScriptProcessorFactory`) that carry no `@deprecated` marker, so the rule never fires and no directive is needed. Same pattern as `WakeLockLike` / `hotkeyManager`.
- **Raised `minAppVersion` from 1.4.4 to 1.6.6.** `FileManager.trashFile` (used to delete a disabled built-in template, respecting the user's deletion preference) is `@since 1.6.6`, which tripped `obsidianmd/no-unsupported-api`. 1.6.6 is a negligible floor bump; `versions.json` gained the `1.2.1 -> 1.6.6` entry.
Also enabled the reviewer's type-checked lint rules locally (`@typescript-eslint/no-deprecated` + the `no-unsafe-*` / `no-unnecessary-type-assertion` family) in [eslint.config.mts](../eslint.config.mts) for `src/`, so this class of finding surfaces in `npm run lint` instead of only at submission. The bot's remaining `no-unsafe-*` warnings are false-positives from its weaker type resolution (it types Obsidian's `moment` re-export and `parseYaml` result as `any` where our installed `obsidian` types resolve them); our code already guards those boundaries with `: unknown` annotations + explicit casts, and satisfying the "unnecessary assertion" warnings would break the correctly-typed `tsc` build.
### 1.2.0 — 2026-07-08
### Real-time transcription mode (live dictation at the cursor), configured independently of batch

View file

@ -9,7 +9,7 @@
History note: an earlier hand-rolled `safeStorage` mode (direct Electron keychain) was REMOVED and superseded by `secretStorage`, which uses the same OS backend but is Obsidian-managed and cross-platform. No users had it (unpublished), so it was dropped outright with no migration; a stale `safeStorage` envelope parses as invalid and resets to default.
`minAppVersion` is `1.4.4`, raised from `1.4.0` because `FileManager.processFrontMatter` (`@since 1.4.4`, used directly in [src/insert.ts](../src/insert.ts) for note-property frontmatter) is NOT feature-detected. `secretStorage` (the other newer API) IS feature-detected at runtime, so it does not drive the minimum; older-than-secretStorage Obsidian still loads the plugin and simply lands on passphrase.
`minAppVersion` is `1.6.6` (raised in 1.2.1 because `FileManager.trashFile`, `@since 1.6.6`, is used directly to delete a disabled built-in template). It had been `1.4.4` for `FileManager.processFrontMatter` (`@since 1.4.4`, used directly in [src/insert.ts](../src/insert.ts) for note-property frontmatter); neither is feature-detected. `secretStorage` IS feature-detected at runtime, so it does not drive the minimum; older-than-secretStorage Obsidian still loads the plugin and simply lands on passphrase.
File envelope (`SECRETS_VERSION = 2`). The `kdf.algo` discriminant distinguishes PBKDF2 from Argon2id; a legacy envelope with no `algo` but an `iterations` field is read as `pbkdf2`. `parseEnvelope` reads `kdf`/`verifier`/`keys` whenever a complete pair is present **regardless of `mode`**, so a secret-storage-active envelope can still carry a passphrase snapshot (the version stays `2`; the shape is backward-tolerant because an older plugin ignored `kdf`/`verifier` it saw under secret-storage mode):
```json

View file

@ -84,6 +84,23 @@ export default tseslint.config(
'obsidianmd/ui/sentence-case-locale-module': ['error', sentenceCaseOptions],
},
},
{
// Type-checked rules the Obsidian community-review bot runs but the local
// obsidianmd recommended config does not turn on. Enabling them here gives
// local parity with the reviewer so these never surface only at submission
// time again. Scoped to source (test/ keeps its own relaxations below).
files: ['src/**/*.ts'],
plugins: { '@typescript-eslint': tseslint.plugin },
rules: {
'@typescript-eslint/no-deprecated': 'error',
'@typescript-eslint/no-unsafe-assignment': 'error',
'@typescript-eslint/no-unsafe-call': 'error',
'@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-unsafe-argument': 'error',
'@typescript-eslint/no-unsafe-return': 'error',
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
},
},
{
// test/ runs under Node via Vitest, not inside Obsidian, so the Node-builtin ban that
// keeps src/ portable to the Electron/mobile plugin runtime doesn't apply here.

View file

@ -1,8 +1,8 @@
{
"id": "rewrite-voice-notes",
"name": "ReWrite (Voice Notes)",
"version": "1.2.0",
"minAppVersion": "1.4.4",
"version": "1.2.1",
"minAppVersion": "1.6.6",
"description": "Record or paste speech and have it transcribed and structured by AI.",
"author": "WiseGuru",
"authorUrl": "https://github.com/WiseGuru",

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "rewrite-voice-notes",
"version": "1.2.0",
"version": "1.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rewrite-voice-notes",
"version": "1.2.0",
"version": "1.2.1",
"license": "0BSD",
"dependencies": {
"@zxcvbn-ts/core": "^3.0.4",

View file

@ -1,6 +1,6 @@
{
"name": "rewrite-voice-notes",
"version": "1.2.0",
"version": "1.2.1",
"description": "Record or paste speech and have it transcribed and structured by AI.",
"main": "main.js",
"type": "module",

View file

@ -45,17 +45,29 @@ export function isPcmCaptureAvailable(): boolean {
return hasCtx && typeof navigator !== 'undefined' && !!navigator.mediaDevices;
}
// ScriptProcessorNode is deprecated in favor of AudioWorklet, but a worklet must be
// loaded as a separate module (addModule(url)), which a single-file bundled Obsidian
// plugin cannot ship and the app CSP complicates via blob URLs. ScriptProcessor remains
// supported in every WebView Obsidian runs in; revisit if it is ever actually removed.
// Rather than disable the deprecation lint rule (which the Obsidian community-review bot
// forbids), the deprecated members are reached through these local structural aliases,
// which carry no `@deprecated` marker, mirroring the `WakeLockLike` / `hotkeyManager`
// pattern used elsewhere for APIs outside the typed surface.
interface AudioProcessingEventLike {
inputBuffer: AudioBuffer;
}
interface ScriptProcessorNodeLike extends AudioNode {
onaudioprocess: ((ev: AudioProcessingEventLike) => void) | null;
}
interface ScriptProcessorFactory {
createScriptProcessor(bufferSize: number, inputChannels: number, outputChannels: number): ScriptProcessorNodeLike;
}
export class PcmCapture {
private stream: MediaStream | null = null;
private context: AudioContext | null = null;
private source: MediaStreamAudioSourceNode | null = null;
// ScriptProcessorNode is deprecated in favor of AudioWorklet, but a worklet
// must be loaded as a separate module (addModule(url)), which a single-file
// bundled Obsidian plugin cannot ship and the app CSP complicates via blob
// URLs. ScriptProcessor remains supported in every WebView Obsidian runs in;
// revisit if it is ever actually removed.
// eslint-disable-next-line @typescript-eslint/no-deprecated
private processor: ScriptProcessorNode | null = null;
private processor: ScriptProcessorNodeLike | null = null;
private silentGain: GainNode | null = null;
// Opens the mic and begins emitting 16 kHz mono Int16 PCM chunks. The chunk
@ -83,13 +95,10 @@ export class PcmCapture {
// start never leaks an AudioContext or a live mic track.
try {
const source = context.createMediaStreamSource(stream);
// See the deprecation note on the `processor` field for why ScriptProcessor
// is used instead of AudioWorklet.
// eslint-disable-next-line @typescript-eslint/no-deprecated
const processor = context.createScriptProcessor(4096, 1, 1);
// eslint-disable-next-line @typescript-eslint/no-deprecated
// See the note on ScriptProcessorNodeLike for why ScriptProcessor is used
// instead of AudioWorklet, and why it is reached through a local alias.
const processor = (context as unknown as ScriptProcessorFactory).createScriptProcessor(4096, 1, 1);
processor.onaudioprocess = (ev) => {
// eslint-disable-next-line @typescript-eslint/no-deprecated
const data = ev.inputBuffer.getChannelData(0);
const down = downsampleBuffer(data, context.sampleRate, REALTIME_SAMPLE_RATE);
const pcm = floatTo16BitPcm(down);
@ -117,7 +126,6 @@ export class PcmCapture {
}
stop(): void {
// eslint-disable-next-line @typescript-eslint/no-deprecated
if (this.processor) this.processor.onaudioprocess = null;
try { this.source?.disconnect(); } catch { /* best effort */ }
try { this.processor?.disconnect(); } catch { /* best effort */ }

View file

@ -2,5 +2,6 @@
"1.0.0": "1.4.4",
"1.1.0": "1.4.4",
"1.1.1": "1.4.4",
"1.2.0": "1.4.4"
"1.2.0": "1.4.4",
"1.2.1": "1.6.6"
}