From 3decaddec8a510876a5711fc61e0886912249f04 Mon Sep 17 00:00:00 2001 From: WiseGuru <42100212+WiseGuru@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:54:21 -0700 Subject: [PATCH] Warning if no audio + audio processing testing --- CLAUDE.md | 4 +- README.md | 27 +++++++++ docs/FEATURES.md | 4 ++ src/pipeline.ts | 2 +- src/recorder.ts | 100 ++++++++++++++++++++++++++++++++ src/transcription/assemblyai.ts | 10 ++-- src/transcription/index.ts | 4 ++ src/transcription/limits.ts | 15 +++++ src/transcription/revai.ts | 10 ++-- src/ui/modal.ts | 21 ++++++- src/ui/quick-record.ts | 37 +++++++++--- styles.css | 29 ++++++++- 12 files changed, 241 insertions(+), 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 241b996..aae9b46 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ src/ ├── passphrase-strength.ts # zxcvbn-ts wrapper (lazy dynamic import, async API): evaluatePassphrase + MIN_PASSPHRASE_SCORE gate + warmPassphraseStrength ├── diceware.ts # generateDicewarePassphrase (EFF wordlist, crypto rejection sampling) ├── eff-large-wordlist.ts # EFF large diceware wordlist (7776 words) -├── recorder.ts # MediaRecorder state machine + getBestMimeType (no size cap; per-provider limits live in transcription/limits.ts) +├── recorder.ts # MediaRecorder state machine + getBestMimeType + live input-level monitor (no size cap; per-provider limits live in transcription/limits.ts) ├── audio-transcode.ts # WebAudio decode + resample to 16 kHz mono PCM WAV (shared by whisper-local and mistral-voxtral) ├── pipeline.ts # transcribe → cleanup → insert orchestrator ├── insert.ts # cursor/newFile/append + {{date}}/{{time}} expansion @@ -279,6 +279,7 @@ Per [.editorconfig](.editorconfig): tabs (width 4), LF, UTF-8, final newline. Ma - **Quick Record uses a custom floating div, not a `Notice`.** Obsidian `Notice` does not support real interactive buttons. The floater is a `position: fixed` div on `document.body`, owned by `QuickRecordController`, with `cancel()` wired into `onunload`. - **`secrets.json.nosync` uses the `.nosync` suffix on purpose**: iCloud Drive natively skips any file or folder whose name ends in `.nosync`. The README documents per-tool sync exclusion for other tools. - **Per-provider recording limits live in [src/transcription/limits.ts](src/transcription/limits.ts), not the recorder.** [src/recorder.ts](src/recorder.ts) does not cap recordings at any size; `validateRecording(blobSize, durationMs, providerId)` runs in [src/pipeline.ts](src/pipeline.ts) between the `persist-audio` and `transcribe` stages, throwing a friendly provider-attributed error if the recording exceeds the documented byte or duration ceiling. Both modal and Quick Record thread the recorder's `durationMs` onto the `audio` pipeline source so the duration check has data; the reprocess flow ([src/ui/audio-source.ts](src/ui/audio-source.ts)) omits `durationMs` (no cheap way to measure an arbitrary vault file), so reprocess only triggers the byte check. Limits source: `openai`/`groq` 25 MB, `assemblyai` 5 GB/10 h, `deepgram` 2 GB, `revai` 2 GB/17 h, `mistral-voxtral` 1 GB/30 min, `openai-compatible`/`whisper-local`/`webspeech` no client-side cap. +- **Async poll timeout is duration-aware**, also in [src/transcription/limits.ts](src/transcription/limits.ts) via `pollTimeoutMs(durationMs?)`. The two polling adapters ([src/transcription/assemblyai.ts](src/transcription/assemblyai.ts), [src/transcription/revai.ts](src/transcription/revai.ts)) used to share a flat `POLL_TIMEOUT_MS = 60_000`, which made any recording the server took longer than a minute to process fail before it finished. They now derive the timeout from the recorded length: `min(60s + durationMs * 2, 2 h)`, so a short clip with a problem fails in ~1 min while a long job has room. `durationMs` is threaded as the optional 4th arg of `TranscriptionProvider.transcribe(audio, config, signal?, durationMs?)` from [src/pipeline.ts](src/pipeline.ts) (`source.durationMs`); the non-polling adapters ignore it (their impls simply omit the param). It is `undefined` for the reprocess flow ([src/ui/audio-source.ts](src/ui/audio-source.ts), no cheap way to measure a vault file), which then falls back to the 2 h ceiling. - **WhisperHost lazy-requires Node modules** the same way [src/secrets.ts](src/secrets.ts) does for `safeStorage`. Importing `child_process` / `net` / `fs` at module top would crash on mobile load. The cached `nodeApiCache` is `null` on mobile, so any host method that needs it bails with a clear "desktop only" error. - **`splitArgs` is a naive whitespace split.** The local-whisper `extraArgs` value is tokenized on whitespace only, so a single argument containing spaces (e.g. a quoted path) is not supported. Not a security issue (argv array, no shell). The Extra args settings field description states the limitation; add quote-aware parsing only if users actually hit it. The tokenized list is also scanned by `getHostArg` so a non-loopback `--host` is rejected before spawn (see Local whisper.cpp host section). - **Async settings-tab buttons are concurrency-guarded.** Handlers that run an async op then `this.display()` (Populate buttons, whisper Start/Stop/probe, Lock) are wrapped in `runGuardedButton(b, fn)` in [src/settings/tab.ts](src/settings/tab.ts), which disables the button for the duration so a rapid double-click cannot launch the work (and its full-container re-render) twice. The encryption-mode dropdown uses a `modeChangeInFlight` flag for the same reason. Wrap new async-then-display buttons the same way. Each handler keeps its own try/catch; observability `console.error('ReWrite: ', e)` sits alongside the user-facing Notice in those catches (and in the swallowed catches in [src/main.ts](src/main.ts)). @@ -288,6 +289,7 @@ Per [.editorconfig](.editorconfig): tabs (width 4), LF, UTF-8, final newline. Ma - **Stop semantics depend on ownership.** Spawned: `child.kill()` via the live ChildProcess handle. Adopted: `process.kill(pid, signal)` since we only have the PID, then clear the sidecar. External: `stop()` throws "not started by ReWrite — stop it via OS tools." (the settings-tab button is disabled with a tooltip, the status-bar click shows a Notice, the `stop-whisper-host` command is hidden via `checkCallback`). This preserves the "never kill a process we didn't start" invariant — the sidecar is proof we started it. - **`onunload` stops the whisper-host fire-and-forget.** `void this.whisperHost?.stop()` — Obsidian's `Plugin.onunload` signature is `() => void`, so we can't await. Stop() sends SIGTERM with a 3 s SIGKILL fallback, but the unload sequence may complete before that. Child processes do NOT auto-die with the parent on Linux (they reparent to init), on Windows (orphaned but still running), or reliably on macOS (SIGTERM may not land before Obsidian exits). The probe/adopt flow above is what closes this hole on next launch; don't try to make unload async. - **Audio persistence runs before transcription**, not after, so the user keeps the recording even if transcription fails. [src/audio-persist.ts](src/audio-persist.ts) catches its own errors and emits a `Notice`; the pipeline always continues to the transcribe stage even when persistence throws. Cancel paths in [src/ui/modal.ts](src/ui/modal.ts) and [src/ui/quick-record.ts](src/ui/quick-record.ts) call `recorder.cancel()` before `runPipeline()`, so no orphan file is written on cancel. The `![[]]` embed is prepended to the cleaned output unconditionally when an audio file was saved, regardless of insert mode. The reprocess flow ([src/ui/audio-source.ts](src/ui/audio-source.ts)) skips persistence by passing `sourcePath` on the `audio` source variant; the embed prepend still runs, reusing the existing vault path so reprocessed output links back to the original file. +- **Live silence detection is a Web Audio tap on the recording stream.** [src/recorder.ts](src/recorder.ts) builds an `AnalyserNode` off the mic `MediaStream` in `start()` (no connection to `destination`, so no monitoring feedback) and samples peak amplitude every 100 ms via a `window.setInterval`. It exposes `getInputLevel()` (0..1 peak), `hasDetectedSound()`, and `getSilentMs()` (ms of continuous silence; returns 0 while paused / stopped or when the analyser could not be created, so the UI never warns when there is nothing to listen to). `resume()` resets the silence baseline so the paused gap is not counted. Both recording UIs poll `getSilentMs()` in their existing 250 ms timer loop and show a "No audio detected" warning past `SILENCE_WARNING_MS` (3 s): the modal's Record tab (`.rewrite-silence-warning`, [src/ui/modal.ts](src/ui/modal.ts)) and the Quick Record floater (`.rewrite-quick-silence-warning` below the controls row, [src/ui/quick-record.ts](src/ui/quick-record.ts) `setSilenceWarning`, hidden while busy). The monitor is torn down in `releaseStream()` (called by `stop()`/`cancel()`); any setup failure (no `AudioContext`, e.g. a stripped environment) degrades to "monitoring unavailable" and the recording proceeds without a warning. The threshold `SILENCE_LEVEL_THRESHOLD = 0.015` clears even quiet speech but flags a muted or dead mic. - **Wake-name extraction is regex-only, off by default.** [src/wake-name.ts](src/wake-name.ts) requires `,` (vocative comma) to fire, captures up to the next sentence terminator or next name occurrence, and drops filler matches ("never mind", "scratch that", short tokens). It runs on ALL pipeline sources, including `paste` and `text`. The extracted instructions are appended to the LLM system prompt as a numbered `## Ad-hoc instructions` block, prefaced by `plugin.assistantPrompt` (loaded from `GlobalSettings.assistantPromptPath`); when the file is missing or empty, `DEFAULT_ASSISTANT_PROMPT` from [src/assistant-prompt.ts](src/assistant-prompt.ts) is used as the fallback so behavior is identical to the previous hardcoded textarea. Both the OpenAI and Anthropic adapters route this into the API's system slot. Whisper transcription homophones ("Scribner", "Scrivner") are not fuzzy-matched in v1; document the limitation if a user reports misses. - **Known nouns frontmatter is NOT sent to the LLM.** The vault file at `GlobalSettings.knownNounsPath` uses YAML frontmatter purely for human-readable guidance (token-cost warning, format hint). Only the body lines are parsed via `loadKnownNounsFromFile` and injected by `buildKnownNounsSystemPromptSection`. If a future change opts frontmatter in, it should be a per-vault opt-in setting, not the default. The body parser treats `#` lines and blank lines as ignored; an entry can be either bare canonical (`Anthropic`) or canonical + misheard alternates (`Hoxhunt: hawks hunt, hocks hunt`). - **PipelineHost decouples the pipeline from `ReWritePlugin`.** [src/pipeline.ts](src/pipeline.ts) reads `params.host.assistantPrompt` and `params.host.knownNouns` through the narrow `PipelineHost` interface in [src/types.ts](src/types.ts). The plugin class `implements PipelineHost`, but the pipeline never imports `ReWritePlugin` directly, which would create a cycle through the UI layer. New cross-cutting cleanup-stage inputs should extend `PipelineHost` rather than reach for the plugin object. diff --git a/README.md b/README.md index 719d755..e2ea438 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,33 @@ You bring your own provider keys. Nothing is sent to a ReWrite server; the plugi - **Known nouns**: a vault Markdown file of proper nouns (with optional misheard variants) that the LLM preserves verbatim, fixing names the transcriber tends to mangle. - **API key encryption**: keys are stored per device in the verified OS keychain (desktop) or with a strength-checked passphrase using Argon2id/PBKDF2 AES-GCM (cross-platform). There is no unencrypted option. +## Tested providers + +ReWrite ships adapters for every provider listed below, but only some have been exercised end to end so far. "Tested" means a maintainer has run the full record/transcribe/cleanup/insert flow against that service. "Untested" means the adapter is implemented to the provider's documented API shape but has not yet been verified against a live account. Untested does not mean broken; it means unverified, so treat reports of issues there as expected and welcome. + +### Transcription + +| Provider | Status | +| --- | --- | +| Local whisper.cpp (plugin-managed) | ✅ Tested | +| Mistral Voxtral | ✅ Tested | +| AssemblyAI | ✅ Tested | +| OpenAI Whisper | Untested | +| OpenAI-compatible (whisper.cpp, faster-whisper-server) | Untested | +| Groq | Untested | +| Deepgram | Untested | +| Rev.ai | Untested | + +### LLM (cleanup) + +| Provider | Status | +| --- | --- | +| Anthropic Claude | ✅ Tested | +| Mistral | ✅ Tested | +| OpenAI-compatible (Ollama, LM Studio) | ✅ Tested (local Ollama) | +| OpenAI GPT | Untested | +| Google Gemini | Untested | + ## Install ### Manual install (current method) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index bc827c0..42e9b5e 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -25,6 +25,10 @@ Voxtral exposes a real-time STT model that doesn't accept whole-file uploads, an ## Done +### Live "no audio detected" warning during recording + +Warns the user about a muted or dead mic *while* recording instead of only surfacing an empty result afterward. [src/recorder.ts](../src/recorder.ts) taps the mic `MediaStream` with a Web Audio `AnalyserNode` in `start()` (not connected to `destination`, so no monitoring feedback) and samples peak amplitude every 100 ms via a `window.setInterval`. New methods `getInputLevel()` (0..1 peak), `hasDetectedSound()`, and `getSilentMs()` (continuous-silence ms; returns 0 while paused / stopped or when the analyser could not be built, so the UI never warns when there is nothing to listen to). `resume()` resets the silence baseline so the paused gap is not counted, and `releaseStream()` tears the monitor down on `stop()`/`cancel()`. Threshold `SILENCE_LEVEL_THRESHOLD = 0.015` clears even quiet speech but flags silence. Both recording UIs poll `getSilentMs()` in their existing 250 ms timer loop and show a "No audio detected" banner past `SILENCE_WARNING_MS` (3 s): the main modal's Record tab (`.rewrite-silence-warning`, [src/ui/modal.ts](../src/ui/modal.ts)) and the Quick Record floater, which gained a `.rewrite-quick-row` wrapper so the warning (`.rewrite-quick-silence-warning`) can sit below the controls (`setSilenceWarning`, hidden while busy, [src/ui/quick-record.ts](../src/ui/quick-record.ts)). Any Web Audio setup failure degrades to "monitoring unavailable" and the recording proceeds normally. CSS added in [styles.css](../styles.css). + ### Per-template diarization override + Meeting transcript default Added `NoteTemplate.diarize?: boolean` (frontmatter `diarize: true`), a positive opt-in that forces speaker diarization on for a template's transcription regardless of the per-profile "Identify speakers" toggle. [src/pipeline.ts](../src/pipeline.ts) `collectTranscript` merges `{ ...profile.transcriptionConfig, diarize: true }` for the transcribe call only when the template flag is set AND `transcriptionProviderSupportsDiarization` is true for the active provider (assemblyai/deepgram/revai); a documented no-op on the rest. The profile config is never mutated, and a template can only raise the effective setting, never turn diarization off. Parse/render in [src/templates-folder.ts](../src/templates-folder.ts) follow the same always-emitted-key, boolean-or-`"true"` convention as `disableSharedCore` / `enableContextHint`. New eighth default template **Meeting transcript** ([src/settings/default-templates.ts](../src/settings/default-templates.ts)): the Meeting notes prompt adapted for `Speaker X:` input, shipped with `diarize: true` and `enableContextHint: true`. The vault-seeded template guide ([src/template-guide.ts](../src/template-guide.ts)) gained frontmatter-table rows and dedicated sections for both `enableContextHint` and `diarize`, plus an updated assembly-order list and template count. CLAUDE.md now records (in both the Documentation maintenance and Templates sections) that any template-structure change must update the template guide in the same commit. diff --git a/src/pipeline.ts b/src/pipeline.ts index 74925e9..2ff851b 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -104,7 +104,7 @@ async function collectTranscript(params: PipelineParams): Promise { && transcriptionProviderSupportsDiarization(params.profile.transcriptionProvider) ? { ...params.profile.transcriptionConfig, diarize: true } : params.profile.transcriptionConfig; - return provider.transcribe(source.audio, config, params.signal); + return provider.transcribe(source.audio, config, params.signal, source.durationMs); } } } diff --git a/src/recorder.ts b/src/recorder.ts index cb578d7..0f1fae2 100644 --- a/src/recorder.ts +++ b/src/recorder.ts @@ -22,6 +22,12 @@ const MP4_FIRST: string[] = [ 'audio/ogg;codecs=opus', ]; +// Peak amplitude (0..1, deviation from silence) below which a sample counts as silence. +// Mute / a dead mic sits near 0; even quiet speech clears this comfortably. +const SILENCE_LEVEL_THRESHOLD = 0.015; +// How often the level monitor samples the analyser while recording. +const LEVEL_SAMPLE_INTERVAL_MS = 100; + export function getBestMimeType(preference: RecordingFormatPreference): string { if (typeof MediaRecorder === 'undefined') return ''; const candidates = preference === 'mp4' ? MP4_FIRST : WEBM_FIRST; @@ -40,6 +46,16 @@ export class Recorder { private accumulatedMs = 0; private mimeType = ''; + // Live input-level monitoring (so the UI can warn about a muted / dead mic). + private audioContext: AudioContext | null = null; + private analyser: AnalyserNode | null = null; + private sourceNode: MediaStreamAudioSourceNode | null = null; + private levelData: Uint8Array | null = null; + private levelTimer: number | null = null; + private currentLevel = 0; + private lastSoundAt = 0; + private soundDetected = false; + getState(): RecorderState { return this.state; } @@ -51,6 +67,26 @@ export class Recorder { return this.accumulatedMs; } + /** Most recent input level, 0..1 (peak deviation from silence). */ + getInputLevel(): number { + return this.currentLevel; + } + + /** Whether any audio above the silence threshold has been heard since recording began. */ + hasDetectedSound(): boolean { + return this.soundDetected; + } + + /** + * Milliseconds of continuous silence up to now. Returns 0 while paused / not + * recording (so the UI does not warn when there is nothing to listen to) and + * when the level monitor could not be created. + */ + getSilentMs(): number { + if (this.state !== 'recording' || !this.analyser) return 0; + return Date.now() - this.lastSoundAt; + } + async start(preference: RecordingFormatPreference): Promise { if (this.state !== 'idle') { throw new Error(`Recorder.start: invalid transition from ${this.state}`); @@ -81,6 +117,7 @@ export class Recorder { this.startedAt = Date.now(); this.accumulatedMs = 0; this.state = 'recording'; + this.startLevelMonitor(stream); recorder.start(); } @@ -101,6 +138,8 @@ export class Recorder { if (!this.mediaRecorder) throw new Error('Recorder.resume: missing MediaRecorder'); this.mediaRecorder.resume(); this.startedAt = Date.now(); + // Don't count the paused gap as silence. + this.lastSoundAt = Date.now(); this.state = 'recording'; } @@ -143,9 +182,70 @@ export class Recorder { } private releaseStream(): void { + this.stopLevelMonitor(); if (this.stream) { for (const track of this.stream.getTracks()) track.stop(); this.stream = null; } } + + private startLevelMonitor(stream: MediaStream): void { + this.currentLevel = 0; + this.soundDetected = false; + this.lastSoundAt = Date.now(); + const Ctx = window.AudioContext + ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + if (!Ctx) return; // No Web Audio: silence detection is simply unavailable. + try { + const ctx = new Ctx(); + const source = ctx.createMediaStreamSource(stream); + const analyser = ctx.createAnalyser(); + analyser.fftSize = 1024; + source.connect(analyser); // Not connected to destination, so no monitoring feedback. + this.audioContext = ctx; + this.sourceNode = source; + this.analyser = analyser; + this.levelData = new Uint8Array(analyser.fftSize); + this.levelTimer = window.setInterval(() => this.sampleLevel(), LEVEL_SAMPLE_INTERVAL_MS); + } catch { + // Treat any setup failure as "monitoring unavailable" rather than failing the recording. + this.teardownLevelNodes(); + } + } + + private sampleLevel(): void { + if (this.state !== 'recording' || !this.analyser || !this.levelData) return; + this.analyser.getByteTimeDomainData(this.levelData); + let peak = 0; + for (let i = 0; i < this.levelData.length; i++) { + const deviation = Math.abs((this.levelData[i] ?? 128) - 128); + if (deviation > peak) peak = deviation; + } + this.currentLevel = peak / 128; + if (this.currentLevel >= SILENCE_LEVEL_THRESHOLD) { + this.lastSoundAt = Date.now(); + this.soundDetected = true; + } + } + + private stopLevelMonitor(): void { + if (this.levelTimer !== null) { + window.clearInterval(this.levelTimer); + this.levelTimer = null; + } + this.teardownLevelNodes(); + this.currentLevel = 0; + } + + private teardownLevelNodes(): void { + try { this.sourceNode?.disconnect(); } catch { /* best effort */ } + try { this.analyser?.disconnect(); } catch { /* best effort */ } + if (this.audioContext) { + void this.audioContext.close().catch(() => { /* best effort */ }); + } + this.sourceNode = null; + this.analyser = null; + this.audioContext = null; + this.levelData = null; + } } diff --git a/src/transcription/assemblyai.ts b/src/transcription/assemblyai.ts index a353aeb..2b6c63d 100644 --- a/src/transcription/assemblyai.ts +++ b/src/transcription/assemblyai.ts @@ -1,8 +1,8 @@ import { TranscriptionConfig } from '../types'; import { jsonGet, jsonPost, providerRequest, sleep } from '../http'; import { TranscriptionProvider } from './index'; +import { pollTimeoutMs } from './limits'; -const POLL_TIMEOUT_MS = 60_000; const INITIAL_DELAY_MS = 1000; const MAX_DELAY_MS = 8000; @@ -34,6 +34,7 @@ export function createAssemblyAITranscription(): TranscriptionProvider { audio: Blob, config: TranscriptionConfig, signal?: AbortSignal, + durationMs?: number, ): Promise { if (!config.apiKey) throw new Error('assemblyai: API key is not configured'); const authHeaders = { Authorization: config.apiKey }; @@ -67,7 +68,7 @@ export function createAssemblyAITranscription(): TranscriptionProvider { throw new Error('assemblyai: transcript request missing id'); } - return pollAssemblyAI(created.id, authHeaders, !!config.diarize, signal); + return pollAssemblyAI(created.id, authHeaders, !!config.diarize, pollTimeoutMs(durationMs), signal); }, }; } @@ -83,14 +84,15 @@ async function pollAssemblyAI( id: string, headers: Record, diarize: boolean, + timeoutMs: number, signal: AbortSignal | undefined, ): Promise { const start = Date.now(); let delay = INITIAL_DELAY_MS; for (;;) { const elapsed = Date.now() - start; - if (elapsed > POLL_TIMEOUT_MS) { - throw new Error(`assemblyai: poll timeout after ${POLL_TIMEOUT_MS / 1000}s`); + if (elapsed > timeoutMs) { + throw new Error(`assemblyai: poll timeout after ${Math.round(timeoutMs / 1000)}s`); } const status = await jsonGet( 'assemblyai', diff --git a/src/transcription/index.ts b/src/transcription/index.ts index 6000b26..39d9bf4 100644 --- a/src/transcription/index.ts +++ b/src/transcription/index.ts @@ -13,6 +13,10 @@ export interface TranscriptionProvider { audio: Blob, config: TranscriptionConfig, signal?: AbortSignal, + // Recorded audio length, when known. Used only by the async polling + // adapters (AssemblyAI, Rev.ai) to size their poll timeout; ignored by the + // rest. Undefined for reprocess flows that don't measure the file. + durationMs?: number, ): Promise; listModels?(config: TranscriptionConfig, signal?: AbortSignal): Promise; } diff --git a/src/transcription/limits.ts b/src/transcription/limits.ts index 48f31bc..2a4e942 100644 --- a/src/transcription/limits.ts +++ b/src/transcription/limits.ts @@ -54,6 +54,21 @@ export function transcriptionProviderLabel(id: TranscriptionProviderID): string } } +// Poll budget for async transcription providers (AssemblyAI, Rev.ai). A fixed +// short timeout makes long recordings fail before the server finishes; a fixed +// long one makes a stuck short clip hang for minutes. Scale with the audio +// duration instead: ~2x real-time over a 1 min base, clamped so a short clip +// with a problem fails fast and the longest advertised jobs still have room. +// Unknown duration (reprocess of an arbitrary vault file, where we don't measure +// length) falls back to the ceiling. +const POLL_FLOOR_MS = 60 * 1000; // 1 min: short clips + network slop +const POLL_CEILING_MS = 2 * HOUR; // covers the longest realistic provider jobs + +export function pollTimeoutMs(durationMs?: number): number { + if (!durationMs || durationMs <= 0) return POLL_CEILING_MS; + return Math.min(POLL_FLOOR_MS + durationMs * 2, POLL_CEILING_MS); +} + export function validateRecording( blobSize: number, durationMs: number | undefined, diff --git a/src/transcription/revai.ts b/src/transcription/revai.ts index a47576d..67bf348 100644 --- a/src/transcription/revai.ts +++ b/src/transcription/revai.ts @@ -1,8 +1,8 @@ import { TranscriptionConfig } from '../types'; import { jsonGet, MultipartPart, multipartPost, providerRequest, sleep } from '../http'; import { audioFilename, TranscriptionProvider } from './index'; +import { pollTimeoutMs } from './limits'; -const POLL_TIMEOUT_MS = 60_000; const INITIAL_DELAY_MS = 1000; const MAX_DELAY_MS = 8000; @@ -37,6 +37,7 @@ export function createRevAITranscription(): TranscriptionProvider { audio: Blob, config: TranscriptionConfig, signal?: AbortSignal, + durationMs?: number, ): Promise { if (!config.apiKey) throw new Error('revai: API key is not configured'); const authHeaders = { Authorization: `Bearer ${config.apiKey}` }; @@ -69,7 +70,7 @@ export function createRevAITranscription(): TranscriptionProvider { throw new Error('revai: submit response missing id'); } - await pollRevAI(created.id, authHeaders, signal); + await pollRevAI(created.id, authHeaders, pollTimeoutMs(durationMs), signal); // Rev.ai diarizes by default. The plain-text transcript flattens that // away, so when diarization is requested we fetch the JSON transcript @@ -115,14 +116,15 @@ function formatMonologues(json: RevAiTranscriptJson): string { async function pollRevAI( id: string, headers: Record, + timeoutMs: number, signal: AbortSignal | undefined, ): Promise { const start = Date.now(); let delay = INITIAL_DELAY_MS; for (;;) { const elapsed = Date.now() - start; - if (elapsed > POLL_TIMEOUT_MS) { - throw new Error(`revai: poll timeout after ${POLL_TIMEOUT_MS / 1000}s`); + if (elapsed > timeoutMs) { + throw new Error(`revai: poll timeout after ${Math.round(timeoutMs / 1000)}s`); } const status = await jsonGet( 'revai', diff --git a/src/ui/modal.ts b/src/ui/modal.ts index 3f6831f..a9c33a5 100644 --- a/src/ui/modal.ts +++ b/src/ui/modal.ts @@ -7,6 +7,10 @@ import { DestinationOverride, EnvironmentProfile, InsertMode, NoteTemplate } fro import { isProfileConfigured, isProfileConfiguredForText, renderSetupCard } from './setup-card'; import { resolveActiveTextSource } from './text-source'; +// Continuous silence (ms) before the Record UI warns about a muted / dead mic. +const SILENCE_WARNING_MS = 3000; +const SILENCE_WARNING_TEXT = 'No audio detected. Check that your microphone is on and not muted.'; + export class ReWriteModal extends Modal { private templateId: string; private activeTab: 'record' | 'paste' | 'fromNote' = 'record'; @@ -323,6 +327,11 @@ export class ReWriteModal extends Modal { const dot = indicator.createSpan({ cls: 'rewrite-pulse-dot' }); dot.hide(); const timer = indicator.createSpan({ cls: 'rewrite-timer', text: '0:00' }); + const warning = parent.createDiv({ + cls: 'rewrite-silence-warning', + text: SILENCE_WARNING_TEXT, + }); + warning.hide(); let isRecording = false; const handleClick = async (): Promise => { @@ -337,7 +346,7 @@ export class ReWriteModal extends Modal { isRecording = true; button.setText('Stop'); dot.show(); - this.startTimerLoop(timer); + this.startTimerLoop(timer, warning); } else { button.disabled = true; try { @@ -345,6 +354,7 @@ export class ReWriteModal extends Modal { isRecording = false; button.setText('Record'); dot.hide(); + warning.hide(); this.stopTimerLoop(); await this.execute(source); } catch (e) { @@ -352,6 +362,7 @@ export class ReWriteModal extends Modal { isRecording = false; button.setText('Record'); dot.hide(); + warning.hide(); this.stopTimerLoop(); } finally { button.disabled = false; @@ -419,10 +430,14 @@ export class ReWriteModal extends Modal { return { kind: 'audio', audio: result.blob, durationMs: result.durationMs }; } - private startTimerLoop(timerEl: HTMLElement): void { + private startTimerLoop(timerEl: HTMLElement, warningEl: HTMLElement): void { this.timerHandle = window.setInterval(() => { - const ms = this.recorder?.getElapsedMs() ?? 0; + const recorder = this.recorder; + const ms = recorder?.getElapsedMs() ?? 0; timerEl.setText(formatDuration(ms)); + const silent = (recorder?.getSilentMs() ?? 0) > SILENCE_WARNING_MS; + if (silent) warningEl.show(); + else warningEl.hide(); }, 250); } diff --git a/src/ui/quick-record.ts b/src/ui/quick-record.ts index 239f3f7..6b22d15 100644 --- a/src/ui/quick-record.ts +++ b/src/ui/quick-record.ts @@ -7,6 +7,10 @@ import { NoteTemplate } from '../types'; import { isProfileConfigured } from './setup-card'; import { ReWriteModal } from './modal'; +// Continuous silence (ms) before the Quick Record floater warns about a muted / dead mic. +const SILENCE_WARNING_MS = 3000; +const SILENCE_WARNING_TEXT = 'No audio detected. Check that your microphone is on and not muted.'; + export class QuickRecordController { private recorder: Recorder | null = null; private timerHandle: number | null = null; @@ -43,6 +47,8 @@ export class QuickRecordController { this.timerHandle = window.setInterval(() => { const ms = this.recorder?.getElapsedMs() ?? 0; this.floater?.setTime(formatDuration(ms)); + const silent = (this.recorder?.getSilentMs() ?? 0) > SILENCE_WARNING_MS; + this.floater?.setSilenceWarning(silent); }, 250); } @@ -211,6 +217,7 @@ interface QuickRecordFloaterOptions { class QuickRecordFloater { private readonly el: HTMLElement; private readonly timerEl: HTMLElement; + private readonly warningEl: HTMLElement; private readonly templateBtn: HTMLButtonElement; private readonly templateLabel: HTMLElement; private popover: HTMLElement | null = null; @@ -220,11 +227,12 @@ class QuickRecordFloater { constructor(private readonly options: QuickRecordFloaterOptions) { this.el = document.body.createDiv({ cls: 'rewrite-quick-floater' }); - this.el.createSpan({ cls: 'rewrite-quick-dot' }); - this.el.createSpan({ cls: 'rewrite-quick-label', text: 'Recording' }); - this.timerEl = this.el.createSpan({ cls: 'rewrite-quick-timer', text: '0:00' }); + const row = this.el.createDiv({ cls: 'rewrite-quick-row' }); + row.createSpan({ cls: 'rewrite-quick-dot' }); + row.createSpan({ cls: 'rewrite-quick-label', text: 'Recording' }); + this.timerEl = row.createSpan({ cls: 'rewrite-quick-timer', text: '0:00' }); - this.templateBtn = this.el.createEl('button', { + this.templateBtn = row.createEl('button', { cls: 'rewrite-quick-template', }); this.templateLabel = this.templateBtn.createSpan({ @@ -239,13 +247,13 @@ class QuickRecordFloater { }); if (options.stopHotkey) { - this.el.createSpan({ + row.createSpan({ cls: 'rewrite-quick-stop-hint', text: `Press ${options.stopHotkey} or`, }); } - const stopBtn = this.el.createEl('button', { + const stopBtn = row.createEl('button', { text: 'Stop', cls: 'mod-cta rewrite-quick-stop', }); @@ -253,7 +261,7 @@ class QuickRecordFloater { if (this.busy) return; options.onStop(); }); - const cancelBtn = this.el.createEl('button', { + const cancelBtn = row.createEl('button', { text: 'Cancel', cls: 'rewrite-quick-cancel', }); @@ -261,6 +269,20 @@ class QuickRecordFloater { if (this.busy) return; options.onCancel(); }); + + this.warningEl = this.el.createDiv({ + cls: 'rewrite-quick-silence-warning', + text: SILENCE_WARNING_TEXT, + }); + this.warningEl.hide(); + } + + setSilenceWarning(show: boolean): void { + if (this.busy || show === false) { + this.warningEl.hide(); + return; + } + this.warningEl.show(); } setTime(label: string): void { @@ -272,6 +294,7 @@ class QuickRecordFloater { this.el.addClass('is-busy'); this.timerEl.setText(label); this.templateBtn.disabled = true; + this.warningEl.hide(); this.closePopover(); } diff --git a/styles.css b/styles.css index f6b5398..207483c 100644 --- a/styles.css +++ b/styles.css @@ -117,6 +117,15 @@ 50% { opacity: 0.3; } } +.rewrite-modal .rewrite-silence-warning { + margin-top: 8px; + padding: 6px 10px; + border-radius: 4px; + background-color: var(--background-modifier-error); + color: var(--text-on-accent); + font-size: var(--font-ui-small); +} + .rewrite-modal .rewrite-live-transcript { min-height: 60px; padding: 8px; @@ -262,8 +271,9 @@ right: 24px; z-index: 9999; display: flex; - align-items: center; - gap: 8px; + flex-direction: column; + align-items: stretch; + gap: 6px; padding: 8px 12px; background-color: var(--background-secondary); color: var(--text-normal); @@ -273,6 +283,21 @@ font-size: var(--font-ui-small); } +.rewrite-quick-floater .rewrite-quick-row { + display: flex; + align-items: center; + gap: 8px; +} + +.rewrite-quick-floater .rewrite-quick-silence-warning { + padding: 4px 8px; + border-radius: 4px; + background-color: var(--background-modifier-error); + color: var(--text-on-accent); + font-size: var(--font-ui-smaller); + max-width: 28ch; +} + .rewrite-quick-floater .rewrite-quick-dot { width: 10px; height: 10px;