wiseguru_ReWrite-Voice-Notes/src/ui/quick-record.ts
WiseGuru 8657bc53b3 1.2.0
Real-time dictation (AssemblyAI/Deepgram, own provider/key/model), auto-ingest
folders, record-in-background, per-invocation diarization, manage built-in
templates (enable/track), local whisper.cpp auto-start/idle-stop, and dev tooling
(local review + release-checklist skill).

Voxtral realtime was reverse-engineered but pulled (not WebView-reachable); its
adapter is kept on disk, unwired.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 21:38:30 -07:00

456 lines
15 KiB
TypeScript

import { App, Notice, Platform } from 'obsidian';
import type ReWritePlugin from '../main';
import { PipelineSource, runPipeline } from '../pipeline';
import { isMediaRecorderAvailable, resolveActiveProfile } from '../platform';
import { Recorder } from '../recorder';
import { DestinationOverride, NoteTemplate } from '../types';
import { isProfileConfigured } from './setup-card';
import { ReWriteModal } from './modal';
import { formatDuration, stageLabel } from './pipeline-progress';
// 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.';
// Mobile-only caution shown while recording: backgrounding the app suspends the Capacitor
// WebView and stops the capture (the wake lock prevents screen-sleep, not an app switch).
const MOBILE_RECORD_WARNING_TEXT = 'Keep Obsidian in the foreground. Switching to another app stops the recording.';
export class QuickRecordController {
private recorder: Recorder | null = null;
private timerHandle: number | null = null;
private floater: QuickRecordFloater | null = null;
private settled = false;
private template: NoteTemplate;
// Wired into runPipeline's signal during finish() so the floater's Cancel button keeps
// working after recording stops, instead of becoming a no-op once processing starts (a
// hung transcription/LLM call previously had no way to be stopped short of reloading).
private controller: AbortController | null = null;
// Per-invocation params handed off by the main modal's "Record in background"
// path. Both belong to the template they were set up against, so switching the
// template via the floater's popover clears them.
private destinationOverride: DestinationOverride | undefined;
private contextHint: string | undefined;
private diarize: boolean | undefined;
constructor(
private readonly plugin: ReWritePlugin,
template: NoteTemplate,
private readonly onDispose: () => void,
private readonly stopHotkey: string | null = null,
extras?: { destinationOverride?: DestinationOverride; contextHint?: string; diarize?: boolean },
) {
this.template = template;
this.destinationOverride = extras?.destinationOverride;
this.contextHint = extras?.contextHint;
this.diarize = extras?.diarize;
}
async begin(): Promise<void> {
this.recorder = new Recorder();
await this.recorder.start(this.plugin.settings.recordingFormat);
this.floater = new QuickRecordFloater({
onStop: () => {
void this.finish();
},
onCancel: () => this.cancel(),
onAbortProcessing: () => this.controller?.abort(),
getTemplates: () => this.plugin.templates,
getActiveTemplateId: () => this.template.id,
onPickTemplate: (t) => {
if (t.id !== this.template.id) {
// The handed-off destination override / context hint / diarize choice
// were set up against the original template; a different pick invalidates
// them (the new template's own `diarize` flag then governs).
this.destinationOverride = undefined;
this.contextHint = undefined;
this.diarize = undefined;
}
this.template = t;
this.floater?.setTemplateName(t.name);
},
initialTemplateName: this.template.name,
stopHotkey: this.stopHotkey,
});
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);
}
cancel(): void {
if (this.settled) return;
this.settled = true;
this.stopTimer();
this.recorder?.cancel();
this.recorder = null;
this.floater?.dispose();
this.floater = null;
this.onDispose();
}
async finish(): Promise<void> {
if (this.settled) return;
this.settled = true;
this.stopTimer();
this.floater?.setBusy('Processing...');
this.controller = new AbortController();
try {
const source = await this.endCapture();
const { profile } = resolveActiveProfile(this.plugin.settings);
await runPipeline({
app: this.plugin.app,
settings: this.plugin.settings,
host: this.plugin,
profile,
template: this.template,
source,
destinationOverride: this.destinationOverride,
contextHint: this.contextHint,
diarize: this.diarize,
signal: this.controller.signal,
onStage: (stage) => this.floater?.setBusy(stageLabel(stage)),
});
this.plugin.settings.lastUsedTemplateId = this.template.id;
await this.plugin.saveSettings();
new Notice('ReWrite complete.');
this.floater?.dispose();
this.floater = null;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
new Notice(`ReWrite quick record failed: ${msg}`);
this.floater?.dispose();
this.floater = null;
new ReWriteModal(this.plugin.app, this.plugin).open();
} finally {
this.onDispose();
}
}
private async endCapture(): Promise<PipelineSource> {
if (!this.recorder) throw new Error('No active recording.');
const result = await this.recorder.stop();
this.recorder = null;
return { kind: 'audio', audio: result.blob, durationMs: result.durationMs };
}
private stopTimer(): void {
if (this.timerHandle !== null) {
window.clearInterval(this.timerHandle);
this.timerHandle = null;
}
}
}
export async function startQuickRecord(
plugin: ReWritePlugin,
onDispose: () => void,
opts?: {
template?: NoteTemplate;
commandId?: string;
// Set by the main modal's "Record in background" handoff; carried into the
// pipeline run exactly like the modal's own detached path would.
destinationOverride?: DestinationOverride;
contextHint?: string;
diarize?: boolean;
},
): Promise<QuickRecordController | null> {
const settings = plugin.settings;
const { profile } = resolveActiveProfile(settings);
if (plugin.encryptionStatus.locked) {
new Notice('ReWrite: API keys are locked. Unlock to record.');
plugin.promptUnlock();
return null;
}
if (profile.transcriptionProvider === 'none') {
new Notice('ReWrite: transcription is disabled for this profile. Opening the modal so you can paste text instead.');
new ReWriteModal(plugin.app, plugin).open();
return null;
}
if (!isProfileConfigured(profile)) {
new Notice('ReWrite: profile is not configured. Finish setup to use quick record.');
new ReWriteModal(plugin.app, plugin).open();
return null;
}
const template = opts?.template ?? pickQuickRecordTemplate(plugin);
if (!template) {
new Notice('ReWrite: add a template before using quick record.');
new ReWriteModal(plugin.app, plugin).open();
return null;
}
if (!isMediaRecorderAvailable()) {
new Notice('Audio recording is not supported in this environment. Opening the modal instead.');
new ReWriteModal(plugin.app, plugin).open();
return null;
}
const stopHotkey = opts?.commandId ? formatCommandHotkey(plugin.app, opts.commandId) : null;
const controller = new QuickRecordController(plugin, template, onDispose, stopHotkey, {
destinationOverride: opts?.destinationOverride,
contextHint: opts?.contextHint,
diarize: opts?.diarize,
});
try {
await controller.begin();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
new Notice(`ReWrite quick record could not start: ${msg}`);
onDispose();
return null;
}
return controller;
}
function pickQuickRecordTemplate(plugin: ReWritePlugin): NoteTemplate | undefined {
const s = plugin.settings;
const templates = plugin.templates;
return (
templates.find((t) => t.id === s.lastUsedTemplateId)
?? templates.find((t) => t.id === s.defaultTemplateId)
?? templates[0]
);
}
interface Hotkey {
modifiers: string[];
key: string;
}
interface HotkeyManager {
getHotkeys(id: string): Hotkey[] | undefined;
getDefaultHotkeys(id: string): Hotkey[] | undefined;
}
// Obsidian's hotkey manager is internal and not in the public typings; read through a
// narrow cast. Returns null when the command has no binding (custom or default).
function formatCommandHotkey(app: App, commandId: string): string | null {
const manager = (app as unknown as { hotkeyManager?: HotkeyManager }).hotkeyManager;
if (!manager) return null;
const hotkeys = manager.getHotkeys(commandId) ?? manager.getDefaultHotkeys(commandId);
const hotkey = hotkeys?.[0];
if (!hotkey) return null;
const mac = Platform.isMacOS;
const symbols: Record<string, string> = {
Mod: mac ? '⌘' : 'Ctrl',
Meta: mac ? '⌘' : 'Win',
Ctrl: 'Ctrl',
Shift: mac ? '⇧' : 'Shift',
Alt: mac ? '⌥' : 'Alt',
};
const parts = hotkey.modifiers.map((m) => symbols[m] ?? m);
parts.push(hotkey.key.length === 1 ? hotkey.key.toUpperCase() : hotkey.key);
return mac ? parts.join('') : parts.join('+');
}
interface QuickRecordFloaterOptions {
onStop: () => void;
onCancel: () => void;
// Called instead of onCancel when Cancel is clicked while busy (processing), aborting the
// in-flight pipeline rather than being a no-op.
onAbortProcessing: () => void;
getTemplates: () => NoteTemplate[];
getActiveTemplateId: () => string;
onPickTemplate: (t: NoteTemplate) => void;
initialTemplateName: string;
stopHotkey?: string | null;
}
class QuickRecordFloater {
private readonly el: HTMLElement;
private readonly timerEl: HTMLElement;
private readonly warningEl: HTMLElement;
private readonly mobileWarningEl: HTMLElement | null;
private readonly templateBtn: HTMLButtonElement;
private readonly templateLabel: HTMLElement;
private popover: HTMLElement | null = null;
private outsideClickHandler: ((e: MouseEvent) => void) | null = null;
private keyHandler: ((e: KeyboardEvent) => void) | null = null;
// Captured when popover listeners are registered so removal targets the same document.
private popoverDoc: Document | null = null;
private busy = false;
constructor(private readonly options: QuickRecordFloaterOptions) {
this.el = activeDocument.body.createDiv({ cls: 'rewrite-quick-floater' });
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 = row.createEl('button', {
cls: 'rewrite-quick-template',
});
this.templateLabel = this.templateBtn.createSpan({
cls: 'rewrite-quick-template-label',
text: options.initialTemplateName,
});
this.templateBtn.title = 'Change template';
this.templateBtn.addEventListener('click', (e) => {
if (this.busy) return;
e.stopPropagation();
this.togglePopover();
});
if (options.stopHotkey) {
row.createSpan({
cls: 'rewrite-quick-stop-hint',
text: `Press ${options.stopHotkey} or`,
});
}
const stopBtn = row.createEl('button', {
text: 'Stop',
cls: 'mod-cta rewrite-quick-stop',
});
stopBtn.addEventListener('click', () => {
if (this.busy) return;
options.onStop();
});
const cancelBtn = row.createEl('button', {
text: 'Cancel',
cls: 'rewrite-quick-cancel',
});
cancelBtn.addEventListener('click', () => {
if (this.busy) {
options.onAbortProcessing();
return;
}
options.onCancel();
});
this.warningEl = this.el.createDiv({
cls: 'rewrite-quick-silence-warning',
text: SILENCE_WARNING_TEXT,
});
this.warningEl.hide();
// Mobile-only: shown for the whole capture (the floater exists only while recording /
// processing), hidden once the pipeline takes over in setBusy.
this.mobileWarningEl = Platform.isMobile
? this.el.createDiv({ cls: 'rewrite-quick-mobile-warning', text: MOBILE_RECORD_WARNING_TEXT })
: null;
}
setSilenceWarning(show: boolean): void {
if (this.busy || show === false) {
this.warningEl.hide();
return;
}
this.warningEl.show();
}
setTime(label: string): void {
if (!this.busy) this.timerEl.setText(label);
}
setBusy(label: string): void {
this.busy = true;
this.el.addClass('is-busy');
this.timerEl.setText(label);
this.templateBtn.disabled = true;
this.warningEl.hide();
this.mobileWarningEl?.hide();
this.closePopover();
}
setTemplateName(name: string): void {
this.templateLabel.setText(name);
}
dispose(): void {
this.closePopover();
this.el.remove();
}
private togglePopover(): void {
if (this.popover) {
this.closePopover();
return;
}
this.openPopover();
}
private openPopover(): void {
const templates = this.options.getTemplates();
if (templates.length === 0) return;
const activeId = this.options.getActiveTemplateId();
const popover = this.el.createDiv({ cls: 'rewrite-quick-popover' });
popover.setAttribute('role', 'menu');
const items: HTMLButtonElement[] = [];
let activeItem: HTMLButtonElement | null = null;
for (const t of templates) {
const item = popover.createEl('button', {
cls: 'rewrite-quick-popover-item',
text: t.name,
});
item.setAttribute('role', 'menuitem');
if (t.id === activeId) {
item.addClass('is-active');
activeItem = item;
}
item.addEventListener('click', (e) => {
e.stopPropagation();
this.options.onPickTemplate(t);
this.closePopover();
});
items.push(item);
}
this.popover = popover;
this.outsideClickHandler = (e: MouseEvent) => {
if (!this.popover) return;
const target = e.target as Node | null;
if (target && (this.popover.contains(target) || this.templateBtn.contains(target))) return;
this.closePopover();
};
this.keyHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
this.closePopover();
return;
}
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
const doc = this.popoverDoc;
const idx = doc ? items.indexOf(doc.activeElement as HTMLButtonElement) : -1;
if (idx === -1) return;
e.preventDefault();
const next = e.key === 'ArrowDown'
? items[(idx + 1) % items.length]
: items[(idx - 1 + items.length) % items.length];
next?.focus();
};
const doc = activeDocument;
this.popoverDoc = doc;
doc.addEventListener('click', this.outsideClickHandler, true);
doc.addEventListener('keydown', this.keyHandler, true);
// Move focus into the list so a keyboard user doesn't have to Tab through the whole
// floater to reach it; land on the active template when there is one.
(activeItem ?? items[0])?.focus();
}
private closePopover(): void {
const doc = this.popoverDoc ?? activeDocument;
const hadPopover = this.popover !== null;
if (this.outsideClickHandler) {
doc.removeEventListener('click', this.outsideClickHandler, true);
this.outsideClickHandler = null;
}
if (this.keyHandler) {
doc.removeEventListener('keydown', this.keyHandler, true);
this.keyHandler = null;
}
this.popoverDoc = null;
this.popover?.remove();
this.popover = null;
// Return focus to the trigger so a keyboard user isn't dropped onto the document body.
// Guarded on the floater still being attached (dispose() also routes through here while
// tearing the whole floater down, in which case there's nothing sensible to focus).
if (hadPopover && this.el.isConnected) this.templateBtn.focus();
}
}