diff --git a/.gitignore b/.gitignore
index 4c95bd3..c6fabad 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@ main.js
main.js.map
.DS_Store
dist/
+.test-dist/
diff --git a/README.md b/README.md
index 063a28c..335bd76 100644
--- a/README.md
+++ b/README.md
@@ -1,204 +1,117 @@
-
-
-
-
-
-
-
-
+# Resonance
-
+Resonance is the clean-slate v2 of the Obsidian recording plugin. The product is now built around a local-first pipeline, settings-first setup, and manifest-backed session storage.
-## What it does
+## Fastest Setup
-Resonance captures audio with FFmpeg, transcribes it locally using whisper.cpp, summarizes the transcript with the LLM of your choice, and creates a Markdown note in your vault, all from Obsidian.
+If this machine already has `FFmpeg`, a built `whisper.cpp`, and at least one local ggml model:
-## Features
+1. Open the plugin settings page.
+2. Work through **Step 1**, **Step 2**, and **Step 3** from top to bottom.
+3. Use the Detect buttons to fill paths automatically when possible.
+4. Pick a microphone and run **Quick test**.
+5. Leave **Ollama** as the provider unless you intentionally want a cloud provider.
-- 🎙️ Record microphone and optionally system audio
-- 🧠 Local transcription via whisper.cpp
-- ✨ AI summary (Gemini, OpenAI, Claude, Ollama)
-- 📚 Library to review, play, download or delete recordings/transcripts
+The settings page is now the full setup flow. Nothing important is hidden behind a separate setup modal.
-## Requirements
+## What Changed
-- Obsidian Desktop ≥ 1.5
-- FFmpeg installed locally
-- whisper.cpp built locally (binary and model .bin)
-- LLM API Key or local Ollama (for summaries)
+- `src/` is the only active application tree for v2.
+- The primary entrypoint is now the plugin settings surface instead of a simple start/stop flow.
+- Recording state is driven by an explicit session controller with ordered live transcription.
+- Sessions are stored as structured manifests with `audio/`, `transcript/`, `summary/`, and `diagnostics.log`.
+- The session library is manifest-backed. It no longer scans raw media files blindly.
-## Manual Installation
+## Product Direction
-User install from release:
-1) Download the zip containing `manifest.json`, `main.js`, `styles.css`.
-2) Create `/.obsidian/plugins/resonance/`.
-3) Copy the three files there and enable the plugin in Obsidian.
+- Desktop-only Obsidian plugin
+- Local-first path: `FFmpeg` + `whisper.cpp` + `Ollama`
+- Cloud summary providers remain available as secondary adapters
+- Native Obsidian settings are now the primary surface, with horizontal tabs for diagnostics, library, and setup
-## Configuration
+## Current Information Architecture
-Follow these steps once to fully set up recording, local transcription and summarization.
+- Diagnostics
+ - Health status and blocking issues
+ - Quick test
+ - Startup behavior
+ - Setup guidance when the local path is incomplete
+- Setup & Settings
+ - Horizontal tabs for Diagnostics, Library, Capture, Transcription, Summary, and Output
+ - Capture tab for FFmpeg, devices, and quick test
+ - Transcription tab for whisper.cpp, CLI, and model
+ - Summary tab for Ollama or cloud providers
+ - Output tab for vault, retention, and startup behavior
+- Session Library
+ - Filters for `done` and `failed`
+ - Artifact availability
+ - Transcript, diagnostics, audio, and summary actions
-### 1) FFmpeg
+## Repository Layout
-- macOS:
- - Install with Homebrew: `brew install ffmpeg`
- - Typical path: `/opt/homebrew/bin/ffmpeg` (Apple Silicon) or `/usr/local/bin/ffmpeg` (Intel)
-- Windows:
- - Download a static build (e.g. from the BtbN or Gyan packages)
- - Unzip to `C:/ffmpeg/` so the executable is at `C:/ffmpeg/bin/ffmpeg.exe`
- - Optionally add `C:/ffmpeg/bin` to PATH, or set the full path in settings
-- Linux:
- - Install via your package manager, e.g. Debian/Ubuntu: `sudo apt install ffmpeg`, Fedora: `sudo dnf install ffmpeg`
+```text
+src/ Active Resonance v2 source
+tests/ Pure unit tests for v2
+legacy/ Archived v1 runtime files kept for reference
+dist/ Build output
+```
-In Obsidian → Resonance → FFmpeg:
-- Set “FFmpeg path” or click “Detect”. On macOS you may need to grant microphone permissions to Obsidian.
+## Local Development
-### 2) whisper.cpp (local transcription)
+Requirements:
-Clone and build the project, then select the `whisper-cli` binary.
+- Obsidian desktop
+- Node.js
+- FFmpeg
+- whisper.cpp with a local model
+- Ollama if you want the full tier-1 local path
+
+Commands:
-- macOS/Linux (generic):
```bash
-git clone https://github.com/ggerganov/whisper.cpp
-cd whisper.cpp
-cmake -S . -B build
-cmake --build build -j
+npm run typecheck
+npm test
+npm run build
```
- - The executable is typically at `build/bin/whisper-cli`
-- Windows (CMake + MSVC):
- - Install CMake and Visual Studio Build Tools
- - From a Developer PowerShell:
-```powershell
-git clone https://github.com/ggerganov/whisper.cpp
-cd whisper.cpp
-cmake -S . -B build -A x64
-cmake --build build --config Release
-```
- - The executable is typically at `build/bin/Release/whisper-cli.exe`
-In Obsidian → Resonance → Whisper:
-- Set “whisper.cpp repo path”, then click “Detect” to auto‑find `whisper-cli`, or set it manually.
-- Pick a model preset and click “Download”, or set a model `.bin` path manually.
-- Choose the “Transcription language” (or leave Automatic).
+## Install In Obsidian
-Models (manual download): see the official ggml models, e.g. small/medium/large. Place the `.bin` in `/models/` and select it in settings.
+1. Build the plugin with `npm run build`.
+2. Copy `dist/main.js`, `dist/manifest.json`, and `dist/styles.css` into your vault plugin folder.
+3. Enable `Resonance` in Obsidian community plugins.
+4. Open the plugin settings page.
+5. Use the `Capture` tab for FFmpeg and devices, then `Transcription` and `Summary`.
+6. When setup is done, use the `Diagnostics` tab for health and the `Library` tab for saved session artifacts.
-### 3) LLM
+## Minimum Local Requirements
-- Create an API key from Google AI Studio or the LLM of your choice.
-- In Obsidian → Resonance → LLM: paste the key and pick the model.
+To record and summarize locally, Resonance needs:
-If you want **everything to run locally** (without sending any data to external services), you can use [Ollama](https://ollama.com/) as your LLM provider:
+- `FFmpeg`
+- `whisper.cpp` CLI
+- a readable local ggml model file
+- `Ollama` running locally for summaries
-- Install Ollama following the official instructions for your operating system.
-- Start Ollama (`ollama serve`).
-- In Obsidian → Resonance → LLM:
- - Set the provider to "Ollama"
- - Enter the endpoint (default: `http://localhost:11434`)
- - Choose a supported model (e.g. `qwen3:8b` or others available via `ollama pull `)
+If auto-detect fails, the manual fallback is:
-**Note:** With this setup, both transcription (whisper.cpp) and note generation (LLM) are performed entirely on your computer, with no data sent externally.
+1. Point Step 3 in plugin settings at your `whisper.cpp` repo.
+2. Detect or set the `whisper.cpp` CLI path.
+3. Detect or set the ggml model path.
+4. Return to Step 2 and run the quick test.
-### 4) Audio devices (mic and system audio)
+## Runtime Model
-Select the proper backend for your OS and pick devices from the scanned list.
+Each session persists:
-- Backend:
- - macOS: `avfoundation`
- - Windows: `dshow`
- - Linux: `pulse` (or `alsa`)
- - “Automatic” chooses based on OS
+- `session.json`
+- `audio/recording.mp3`
+- `audio/segments/`
+- `transcript/live-transcript.txt`
+- `summary/summary.md`
+- `diagnostics.log`
-- macOS system audio:
- - Install a virtual loopback driver (e.g. BlackHole 2ch)
- - Route system output to that device (or create a Multi‑Output/aggregate device if needed)
- - Click “Refresh devices”, then select your mic and the virtual device as “System audio”
+The live transcription queue commits segments strictly in order and the stop flow waits for the queue to flush before summary generation starts.
-- Windows system audio:
- - Install VB‑Audio Cable or VoiceMeeter
- - Set Windows output to the virtual device (or use “Stereo Mix” if available)
- - Click “Refresh devices”, pick mic and the virtual device for “System audio”
+## Notes On Legacy Code
-- Linux system audio:
- - With PulseAudio, choose the monitor of your output sink (e.g. `alsa_output.*.monitor`)
- - Click “Refresh devices”, then select mic and the monitor device
-
-Use “Test audio config” to record a 1‑second MP3. If it fails, verify permissions, backend, and selected devices.
-
-### 5) Obsidian output
-
-- Set the “Notes folder” where Resonance will create the generated Markdown notes. If empty, the vault root is used.
-
-### 6) Recording quality and limits
-
-- Adjust sample rate, channels (mono/stereo), MP3 bitrate, and “Max recordings kept”. Older items beyond the limit are auto‑deleted (0 = infinite).
-
-### 7) Done!
-
-- You may need to restart Obsidian after changing settings.
-
-### First run checklist
-
-- FFmpeg path set and working (test passes)
-- Whisper repo path + `whisper-cli` set
-- Model `.bin` selected (or downloaded)
-- API key and model set
-- Mic and (optional) system audio selected
-- Notes folder set
-
-## Usage
-
-1) Click the microphone icon in the ribbon to start/stop. Pick a scenario when prompted.
-2) Watch the timer in the status bar.
-3) When finished, a note named ` YYYY-MM-DD HH-mm.md` is created in your selected folder.
-4) Open the Library (audio file icon) to browse, listen, download or delete recordings and transcripts.
-
-## How it works (overview)
-
-1) FFmpeg writes an `.mp3` to `/.obsidian/plugins/resonance/recordings/`
-2) whisper.cpp transcribes locally and writes a `.txt` transcript
-3) The LLM of your choice summarizes the transcript
-4) Resonance creates a Markdown note in your chosen folder
-
-## Privacy
-
-- Audio never leaves your machine.
-- Only the text transcript is sent for summarization—unless you use Ollama, in which case everything stays local.
-- The API Key is stored locally in your vault.
-
-## Troubleshooting
-
-- Incomplete configuration: set FFmpeg path, whisper main, model, and API key.
-- No audio: verify backend/device; use Scan and the 3‑second Test.
-- Noise in recordings: match the sample rate in settings with your mic.
-- Empty transcription: check the `.mp3` file and the model path.
-- LLM error: verify API key and selected model.
-
-## Contributing
-
-Issues and PRs are welcome. If you’d like to help with docs or UX, please open an issue to coordinate.
-
----
-
-Built with Vite. This plugin is desktop‑only.
\ No newline at end of file
+The previous implementation is archived under `legacy/`. It is not part of the active v2 runtime, build entry, or test surface.
diff --git a/AutoDetect.ts b/legacy/AutoDetect.ts
similarity index 100%
rename from AutoDetect.ts
rename to legacy/AutoDetect.ts
diff --git a/DependencyChecker.ts b/legacy/DependencyChecker.ts
similarity index 100%
rename from DependencyChecker.ts
rename to legacy/DependencyChecker.ts
diff --git a/DeviceScanner.ts b/legacy/DeviceScanner.ts
similarity index 100%
rename from DeviceScanner.ts
rename to legacy/DeviceScanner.ts
diff --git a/HelpModal.ts b/legacy/HelpModal.ts
similarity index 100%
rename from HelpModal.ts
rename to legacy/HelpModal.ts
diff --git a/LibraryModal.ts b/legacy/LibraryModal.ts
similarity index 100%
rename from LibraryModal.ts
rename to legacy/LibraryModal.ts
diff --git a/legacy/README.md b/legacy/README.md
new file mode 100644
index 0000000..fa81628
--- /dev/null
+++ b/legacy/README.md
@@ -0,0 +1,7 @@
+# Legacy Runtime Archive
+
+This folder contains the archived pre-v2 runtime files from the original Resonance plugin implementation.
+
+- They are kept for reference only.
+- They are not part of the active `Resonance Next` build.
+- The active plugin entrypoint is `src/main.ts`.
diff --git a/RecorderService.ts b/legacy/RecorderService.ts
similarity index 97%
rename from RecorderService.ts
rename to legacy/RecorderService.ts
index 4fbcc07..00177aa 100644
--- a/RecorderService.ts
+++ b/legacy/RecorderService.ts
@@ -80,6 +80,12 @@ export class RecorderService {
async start() {
if (this.phase !== "idle" && this.phase !== "error" && this.phase !== "done") return;
try {
+ // Reset stato di sessione precedente
+ this.liveNoteFile = null;
+ (this as any).liveVaultFolder = null;
+ try { this.liveProcessedSegments.clear(); } catch {}
+ try { for (const t of this.livePendingTimers.values()) clearTimeout(t); } catch {}
+ try { this.livePendingTimers.clear(); } catch {}
await this.prepareAudioPath();
await this.beginRecording();
} catch (e: any) {
@@ -518,7 +524,7 @@ export class RecorderService {
private async ensureLiveNoteOpen() {
try {
- const date = window.moment().format("YYYY-MM-DD HH-mm");
+ const date = window.moment().format("YYYY-MM-DD HH-mm-ss");
let scenarioLabel: string | null = null;
try {
const { PROMPT_PRESETS, DEFAULT_PROMPT_KEY } = await import('./prompts');
@@ -533,14 +539,12 @@ export class RecorderService {
const vault = this.app.vault;
try { await (vault as any).createFolder(folderPath); } catch {}
const notePath = `${folderPath}/Live transcript.md`;
- let file = this.liveNoteFile;
- if (!file) {
- try {
- const existing = vault.getAbstractFileByPath(notePath) as TFile | null;
- file = existing || await vault.create(notePath, ``);
- this.liveNoteFile = file;
- } catch {}
- }
+ let file: TFile | null = null;
+ try {
+ const existing = vault.getAbstractFileByPath(notePath) as TFile | null;
+ file = existing || await vault.create(notePath, `# Live transcript\n\n`);
+ this.liveNoteFile = file;
+ } catch {}
const leaf = this.app.workspace.getLeaf(true);
await leaf.openFile(this.liveNoteFile as TFile);
} catch {}
diff --git a/RecordingModal.ts b/legacy/RecordingModal.ts
similarity index 100%
rename from RecordingModal.ts
rename to legacy/RecordingModal.ts
diff --git a/llm.ts b/legacy/llm.ts
similarity index 98%
rename from llm.ts
rename to legacy/llm.ts
index 3334d0a..0cda8f6 100644
--- a/llm.ts
+++ b/legacy/llm.ts
@@ -222,7 +222,13 @@ async function summarizeWithOllama(cfg: LlmConfig, prompt: string, transcript: s
model: cfg.model || 'qwen3:8b',
prompt: fullPrompt,
stream: false,
- options: { temperature: 0 },
+ options: {
+ temperature: 0,
+ top_p: 0.9,
+ top_k: 40,
+ // Aumenta la lunghezza massima dell'output per riassunti lunghi
+ num_predict: 2048
+ },
};
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error(`Ollama API error: ${res.status} ${await res.text().catch(()=> '')}`);
diff --git a/main.ts b/legacy/main.ts
similarity index 100%
rename from main.ts
rename to legacy/main.ts
diff --git a/markdown.ts b/legacy/markdown.ts
similarity index 100%
rename from markdown.ts
rename to legacy/markdown.ts
diff --git a/prompts.ts b/legacy/prompts.ts
similarity index 100%
rename from prompts.ts
rename to legacy/prompts.ts
diff --git a/settings.ts b/legacy/settings.ts
similarity index 100%
rename from settings.ts
rename to legacy/settings.ts
diff --git a/manifest.json b/manifest.json
index 3c936b5..35e85c2 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,10 +1,10 @@
{
- "id": "resonance",
+ "id": "resonance-next",
"name": "Resonance",
- "version": "0.0.4",
+ "version": "0.1.0",
"minAppVersion": "1.5.0",
- "description": "Record meetings, lectures, interviews, and more. Transcribe locally, summarize with AI, and create notes in Obsidian. Free and open source.",
+ "description": "A local first AI Obsidian recorder with resilient live transcription, diagnostics, and session based notes.",
"author": "Michael Gorini",
"authorUrl": "",
"isDesktopOnly": true
-}
+}
\ No newline at end of file
diff --git a/package.json b/package.json
index 98f82a1..3e60916 100644
--- a/package.json
+++ b/package.json
@@ -1,13 +1,15 @@
{
- "name": "resonance",
- "version": "1.0.0",
+ "name": "resonance-next",
+ "version": "2.0.0",
"private": true,
"type": "module",
- "description": "Obsidian plugin: registra, trascrive con whisper.cpp, riassume con Gemini.",
+ "description": "Obsidian plugin v2: local-first recording, live transcription, diagnostics, and resilient note generation.",
"scripts": {
"dev": "node ./node_modules/vite/bin/vite.js build --watch",
"build": "node ./node_modules/vite/bin/vite.js build && node scripts/postbuild.mjs",
- "clean": "node -e \"import('fs').then(fs=>{try{fs.rmSync('dist',{recursive:true,force:true});}catch{}})\""
+ "clean": "node -e \"import('fs').then(fs=>{for(const dir of ['dist','.test-dist']){try{fs.rmSync(dir,{recursive:true,force:true});}catch{}}})\"",
+ "typecheck": "node ./node_modules/typescript/bin/tsc --noEmit -p tsconfig.json",
+ "test": "node ./node_modules/typescript/bin/tsc -p tsconfig.tests.json && node -e \"import('fs').then(fs=>fs.writeFileSync('.test-dist/package.json', JSON.stringify({type:'commonjs'})))\" && node --test .test-dist/tests/**/*.test.js"
},
"devDependencies": {
"@types/node": "^22.18.0",
diff --git a/scripts/postbuild.mjs b/scripts/postbuild.mjs
index 47a91ad..d819909 100644
--- a/scripts/postbuild.mjs
+++ b/scripts/postbuild.mjs
@@ -3,7 +3,7 @@ import { cpSync } from 'node:fs';
try {
cpSync('manifest.json', 'dist/manifest.json', { recursive: false });
cpSync('styles.css', 'dist/styles.css', { recursive: false });
- console.log('Postbuild: copiati manifest.json e styles.css in dist/.');
+ console.log('Postbuild: copied manifest.json and styles.css into dist/.');
} catch (e) {
console.error('Postbuild error:', e);
process.exitCode = 1;
diff --git a/src/application/OrderedSegmentQueue.ts b/src/application/OrderedSegmentQueue.ts
new file mode 100644
index 0000000..205cc80
--- /dev/null
+++ b/src/application/OrderedSegmentQueue.ts
@@ -0,0 +1,78 @@
+export interface SegmentDescriptor {
+ index: number;
+ path: string;
+}
+
+export interface SegmentQueueStats {
+ nextExpectedIndex: number;
+ queuedIndexes: number[];
+ inFlightIndex: number | null;
+}
+
+type CommitHandler = (segment: SegmentDescriptor) => Promise;
+
+export class OrderedSegmentQueue {
+ private readonly pending = new Map();
+ private processing = false;
+ private inFlightIndex: number | null = null;
+ private readonly idleResolvers = new Set<() => void>();
+ private failure: Error | null = null;
+
+ constructor(private readonly commit: CommitHandler, private nextExpectedIndex = 0) {}
+
+ enqueue(segments: SegmentDescriptor[]) {
+ for (const segment of segments) {
+ if (segment.index < this.nextExpectedIndex) continue;
+ if (segment.index === this.inFlightIndex) continue;
+ if (this.pending.has(segment.index)) continue;
+ this.pending.set(segment.index, segment);
+ }
+ void this.process();
+ }
+
+ getStats(): SegmentQueueStats {
+ return {
+ nextExpectedIndex: this.nextExpectedIndex,
+ queuedIndexes: [...this.pending.keys()].sort((left, right) => left - right),
+ inFlightIndex: this.inFlightIndex,
+ };
+ }
+
+ async whenIdle(): Promise {
+ if (!this.processing && this.pending.size === 0) {
+ if (this.failure) throw this.failure;
+ return;
+ }
+
+ await new Promise((resolve) => {
+ this.idleResolvers.add(resolve);
+ });
+
+ if (this.failure) throw this.failure;
+ }
+
+ private async process() {
+ if (this.processing) return;
+ this.processing = true;
+ try {
+ while (this.pending.has(this.nextExpectedIndex)) {
+ const segment = this.pending.get(this.nextExpectedIndex);
+ if (!segment) break;
+ this.pending.delete(this.nextExpectedIndex);
+ this.inFlightIndex = segment.index;
+ await this.commit(segment);
+ this.inFlightIndex = null;
+ this.nextExpectedIndex += 1;
+ }
+ } catch (error) {
+ this.failure = error instanceof Error ? error : new Error(String(error));
+ } finally {
+ this.processing = false;
+ this.inFlightIndex = null;
+ if (this.pending.size === 0 || this.failure) {
+ for (const resolve of this.idleResolvers) resolve();
+ this.idleResolvers.clear();
+ }
+ }
+ }
+}
diff --git a/src/application/SessionController.ts b/src/application/SessionController.ts
new file mode 100644
index 0000000..b70ce94
--- /dev/null
+++ b/src/application/SessionController.ts
@@ -0,0 +1,550 @@
+import type { App } from "obsidian";
+import { getScenario } from "../domain/scenarios";
+import { buildDiagnosticsSummary, type RecordingSessionManifest, type SessionListItem, type SessionRuntimeSnapshot, type SessionState } from "../domain/session";
+import { type PluginSettingsV2 } from "../domain/settings";
+import { OrderedSegmentQueue, type SegmentDescriptor } from "./OrderedSegmentQueue";
+import { deriveSessionListItem } from "./dashboard";
+import { collectSegmentDescriptors } from "./segmentFiles";
+import { AudioCaptureAdapter } from "../infrastructure/adapters/AudioCaptureAdapter";
+import { WhisperTranscriptionAdapter } from "../infrastructure/adapters/TranscriptionAdapter";
+import { SummaryAdapter } from "../infrastructure/adapters/SummaryAdapter";
+import { SessionStore } from "../infrastructure/storage/SessionStore";
+import { VaultAdapter } from "../infrastructure/adapters/VaultAdapter";
+import { DiagnosticsService } from "../infrastructure/system/DiagnosticsService";
+import { formatTranscriptChunkMarkdown, normalizeCheckboxes, sanitizeSummary } from "../utils/markdown";
+import { requireNodeModule } from "../infrastructure/node";
+
+interface SessionControllerOptions {
+ app: App;
+ pluginId: string;
+ getSettings: () => PluginSettingsV2;
+ saveSettings: (updater: (current: PluginSettingsV2) => PluginSettingsV2) => Promise;
+}
+
+const STARTABLE_STATES = new Set(["idle", "done", "failed"]);
+
+export class SessionController {
+ private readonly store: SessionStore;
+ private readonly vaultAdapter: VaultAdapter;
+ private readonly diagnosticsService: DiagnosticsService;
+ private readonly summaryAdapter = new SummaryAdapter();
+
+ private captureAdapter: AudioCaptureAdapter | null = null;
+ private queue: OrderedSegmentQueue | null = null;
+ private manifest: RecordingSessionManifest | null = null;
+ private segmentPollerId: number | null = null;
+ private elapsedTimerId: number | null = null;
+ private startedAtMs: number | null = null;
+ private stopRequested = false;
+ private snapshot: SessionRuntimeSnapshot = {
+ state: "idle",
+ elapsedSeconds: 0,
+ committedSegments: 0,
+ queuedSegments: 0,
+ liveTranscriptChars: 0,
+ };
+
+ onSnapshot?: (snapshot: SessionRuntimeSnapshot) => void;
+ onInfo?: (message: string) => void;
+ onError?: (message: string) => void;
+
+ constructor(private readonly options: SessionControllerOptions) {
+ this.store = new SessionStore(options.app, options.pluginId);
+ this.vaultAdapter = new VaultAdapter(options.app);
+ this.diagnosticsService = new DiagnosticsService(options.app);
+ }
+
+ getSnapshot(): SessionRuntimeSnapshot {
+ return this.snapshot;
+ }
+
+ getActiveLiveTranscriptNotePath(): string | undefined {
+ return this.manifest?.notes.liveTranscriptNotePath;
+ }
+
+ async openActiveLiveTranscript(): Promise {
+ const path = this.manifest?.notes.liveTranscriptNotePath;
+ if (!path) return false;
+ await this.vaultAdapter.openFile(path);
+ return true;
+ }
+
+ async listRecentSessions(limit = 12): Promise {
+ const manifests = await this.store.listSessions();
+ return manifests.slice(0, limit).map((manifest) => deriveSessionListItem(manifest, this.store.getAudioSize(manifest.paths.fullAudioPath)));
+ }
+
+ async runDiagnostics() {
+ const report = await this.diagnosticsService.run(this.options.getSettings());
+ this.patchSnapshot({ diagnosticsReport: report });
+ return report;
+ }
+
+ async runSmokeTest() {
+ return await this.diagnosticsService.runSmokeTest(this.options.getSettings());
+ }
+
+ async regenerateTranscript(rootDir: string): Promise {
+ this.assertNoActiveSession();
+ const manifest = await this.requireStoredSession(rootDir);
+ const settings = this.options.getSettings();
+ if (!manifest.artifacts.hasAudio) {
+ throw new Error("Audio file is missing for this session.");
+ }
+
+ await this.store.appendDiagnostics(manifest, "Manual recovery: transcript regeneration started.");
+ const transcriptionAdapter = new WhisperTranscriptionAdapter(settings.transcription, settings.capture.ffmpegPath);
+ const transcript = (await transcriptionAdapter.transcribeFile(manifest.paths.fullAudioPath)).trim();
+ if (!transcript) {
+ await this.store.appendDiagnostics(manifest, "Manual recovery: transcript regeneration returned empty output.");
+ manifest.status = "failed";
+ manifest.runtime.failureSummary = "Transcript regeneration returned empty output.";
+ await this.store.writeManifest(manifest);
+ throw new Error("Transcript regeneration returned empty output.");
+ }
+
+ await this.store.writeTranscript(manifest, transcript);
+ manifest.artifacts.hasTranscript = true;
+ if (settings.output.storeLiveTranscriptInVault) {
+ const liveTranscriptNotePath = await this.vaultAdapter.createOrUpdateLiveTranscriptNote(manifest, settings.output, transcript);
+ manifest.notes.liveTranscriptNotePath = liveTranscriptNotePath;
+ }
+ if (!manifest.artifacts.hasSummary) {
+ manifest.status = "failed";
+ manifest.runtime.failureSummary = "Transcript regenerated. Summary is still missing.";
+ }
+ await this.store.appendDiagnostics(manifest, "Manual recovery: transcript regeneration completed.");
+ await this.store.writeManifest(manifest);
+ return deriveSessionListItem(manifest, this.store.getAudioSize(manifest.paths.fullAudioPath));
+ }
+
+ async regenerateSummary(rootDir: string): Promise {
+ this.assertNoActiveSession();
+ const manifest = await this.requireStoredSession(rootDir);
+ const settings = this.options.getSettings();
+ const transcript = this.store.readTranscript(manifest).trim();
+ if (!transcript) {
+ throw new Error("Transcript is missing for this session.");
+ }
+
+ await this.store.appendDiagnostics(manifest, "Manual recovery: summary regeneration started.");
+ const scenario = getScenario(manifest.scenarioKey);
+ const summaryResult = await this.summaryAdapter.summarize(
+ settings.summary,
+ scenario.prompt,
+ transcript,
+ settings.transcription.language
+ );
+ const cleanedSummary = normalizeCheckboxes(sanitizeSummary(summaryResult.markdown || ""));
+ if (!cleanedSummary.trim()) {
+ await this.store.appendDiagnostics(manifest, "Manual recovery: summary regeneration returned empty output.");
+ manifest.status = "failed";
+ manifest.runtime.failureSummary = "Summary regeneration returned empty output.";
+ await this.store.writeManifest(manifest);
+ throw new Error("Summary regeneration returned empty output.");
+ }
+
+ await this.store.writeSummary(manifest, cleanedSummary);
+ manifest.artifacts.hasSummary = true;
+ manifest.notes.summaryNotePath = await this.vaultAdapter.createSummaryNote(manifest, settings.output, cleanedSummary);
+ manifest.status = "done";
+ manifest.runtime.failureSummary = undefined;
+ manifest.runtime.finishedAt = new Date().toISOString();
+ await this.store.appendDiagnostics(manifest, `Manual recovery: summary note created at ${manifest.notes.summaryNotePath}.`);
+ await this.store.writeManifest(manifest);
+ return deriveSessionListItem(manifest, this.store.getAudioSize(manifest.paths.fullAudioPath));
+ }
+
+ async startScenario(scenarioKey: string): Promise {
+ if (!STARTABLE_STATES.has(this.snapshot.state)) {
+ throw new Error("A session is already active.");
+ }
+
+ this.resetSnapshot();
+ const settings = this.options.getSettings();
+ const scenario = getScenario(scenarioKey);
+ this.stopRequested = false;
+
+ try {
+ this.transition("preflight", "Running diagnostics...");
+ const diagnosticsReport = await this.diagnosticsService.run(settings);
+ this.patchSnapshot({ diagnosticsReport });
+ if (!diagnosticsReport.isHealthy) {
+ this.transition("failed", diagnosticsReport.summary, diagnosticsReport.summary);
+ throw new Error(diagnosticsReport.summary);
+ }
+
+ this.manifest = await this.store.createSession(scenario, settings, buildDiagnosticsSummary(diagnosticsReport));
+ await this.store.appendDiagnostics(this.manifest, diagnosticsReport.summary);
+ const workspace = await this.vaultAdapter.ensureSessionWorkspace(this.manifest, settings.output);
+ this.manifest.notes.vaultFolderPath = workspace.folderPath;
+ this.manifest.notes.liveTranscriptNotePath = workspace.liveTranscriptNotePath;
+ await this.store.writeManifest(this.manifest);
+
+ this.captureAdapter = new AudioCaptureAdapter();
+ const transcriptionAdapter = new WhisperTranscriptionAdapter(settings.transcription, settings.capture.ffmpegPath);
+ this.queue = new OrderedSegmentQueue(async (segment) => {
+ await this.commitSegment(segment, transcriptionAdapter);
+ }, Math.max(0, this.manifest.live.lastCommittedSegment + 1));
+
+ await this.captureAdapter.start({
+ settings: settings.capture,
+ fullAudioPath: this.manifest.paths.fullAudioPath,
+ segmentsDir: this.manifest.paths.segmentsDir,
+ onLog: (line) => {
+ if (this.manifest) void this.store.appendDiagnostics(this.manifest, line);
+ },
+ onUnexpectedExit: (message) => {
+ void this.failSession(message);
+ },
+ });
+
+ await this.store.appendDiagnostics(this.manifest, "Audio capture started.");
+ await this.options.saveSettings((current) => ({
+ ...current,
+ ui: {
+ ...current.ui,
+ lastScenarioKey: scenario.key,
+ },
+ }));
+
+ this.startedAtMs = Date.now();
+ this.manifest.runtime.startedAt = new Date(this.startedAtMs).toISOString();
+ await this.store.writeManifest(this.manifest);
+ this.startElapsedTimer();
+ this.startSegmentPolling();
+ this.transition("segmenting", "Recorder primed. Waiting for the first segment...");
+ } catch (error) {
+ const message = String((error as Error)?.message ?? error);
+ if (this.manifest) {
+ await this.failSession(message);
+ } else {
+ this.transition("failed", message, message);
+ await this.cleanupRuntime();
+ }
+ throw error;
+ }
+ }
+
+ async stop(): Promise {
+ const manifest = this.manifest;
+ if (!this.captureAdapter || !manifest) return;
+ if (this.stopRequested) return;
+
+ try {
+ this.stopRequested = true;
+ const frozenElapsedSeconds = this.freezeElapsedSeconds();
+ manifest.runtime.elapsedSeconds = frozenElapsedSeconds;
+ this.transition("stopping", "Stopping recorder and flushing live transcript...");
+ await this.store.writeManifest(manifest);
+ this.stopSegmentPolling();
+ await this.captureAdapter.stop();
+ manifest.artifacts.hasAudio = true;
+ manifest.runtime.elapsedSeconds = frozenElapsedSeconds;
+ await this.store.writeManifest(manifest);
+ this.patchSnapshot({
+ state: "stopping",
+ message: "Capture stopped. Finishing transcript and summary in the background...",
+ });
+ void this.finishStoppedSession();
+ } catch (error) {
+ await this.failSession(String((error as Error)?.message ?? error));
+ throw error;
+ }
+ }
+
+ private async finishStoppedSession(): Promise {
+ const manifest = this.requireManifest();
+ try {
+ await this.enqueueStableSegments(true);
+ await this.queue?.whenIdle();
+ this.refreshQueueSnapshot();
+ await this.store.appendDiagnostics(manifest, "Capture stopped and all live segments committed.");
+ await this.finalizeSummary();
+ } catch (error) {
+ await this.failSession(String((error as Error)?.message ?? error));
+ }
+ }
+
+ private async finalizeSummary() {
+ const manifest = this.requireManifest();
+ const settings = this.options.getSettings();
+ const scenario = getScenario(manifest.scenarioKey);
+ const transcript = this.store.readTranscript(manifest);
+ if (!transcript.trim()) {
+ await this.failSession("Transcript is empty after capture flush.");
+ return;
+ }
+
+ this.transition("summarizing", "Generating the final summary...");
+ await this.store.appendDiagnostics(manifest, "Summary generation started.");
+ const summaryResult = await this.summaryAdapter.summarize(
+ settings.summary,
+ scenario.prompt,
+ transcript,
+ settings.transcription.language
+ );
+ const cleanedSummary = normalizeCheckboxes(sanitizeSummary(summaryResult.markdown || ""));
+ if (!cleanedSummary.trim()) {
+ await this.failSession("Summary provider returned an empty result.");
+ return;
+ }
+
+ this.transition("persisting", "Writing notes and session manifest...");
+ await this.store.writeSummary(manifest, cleanedSummary);
+ manifest.artifacts.hasSummary = true;
+ const summaryNotePath = await this.vaultAdapter.createSummaryNote(manifest, settings.output, cleanedSummary);
+ manifest.notes.summaryNotePath = summaryNotePath;
+ manifest.artifacts.hasAudio = true;
+ manifest.runtime.elapsedSeconds = this.snapshot.elapsedSeconds;
+ manifest.runtime.finishedAt = new Date().toISOString();
+ manifest.runtime.failureSummary = undefined;
+ manifest.status = "done";
+ await this.store.appendDiagnostics(manifest, `Summary note created at ${summaryNotePath}.`);
+ await this.store.writeManifest(manifest);
+ await this.store.pruneFinishedSessions(settings.output.maxSessionsKept);
+ this.transition("done", "Session completed.");
+ await this.cleanupRuntime();
+ if (settings.output.openSummaryAfterCreate) {
+ await this.vaultAdapter.openFile(summaryNotePath);
+ }
+ this.onInfo?.("Resonance session completed.");
+ }
+
+ private async commitSegment(segment: SegmentDescriptor, transcriptionAdapter: WhisperTranscriptionAdapter) {
+ const manifest = this.requireManifest();
+ if (this.stopRequested) {
+ this.patchSnapshot({
+ state: "stopping",
+ message: `Finishing remaining segment ${segment.index + 1}...`,
+ });
+ } else {
+ this.transition("transcribing_live", `Transcribing live segment ${segment.index + 1}...`);
+ }
+ await this.store.appendDiagnostics(manifest, `Transcribing segment ${segment.index} (${segment.path}).`);
+ const text = await transcriptionAdapter.transcribeFile(segment.path);
+ manifest.runtime.elapsedSeconds = this.snapshot.elapsedSeconds;
+
+ if (!text.trim()) {
+ await this.store.appendDiagnostics(manifest, `Segment ${segment.index} produced empty transcript.`);
+ await this.store.writeManifest(manifest);
+ this.refreshQueueSnapshot();
+ return;
+ }
+
+ await this.store.appendTranscriptChunk(manifest, text);
+ manifest.live.committedSegments += 1;
+ manifest.live.lastCommittedSegment = segment.index;
+ manifest.artifacts.hasTranscript = true;
+ if (manifest.notes.liveTranscriptNotePath) {
+ const chunk = formatTranscriptChunkMarkdown(segment.index, text);
+ if (chunk) {
+ await this.vaultAdapter.appendToNote(manifest.notes.liveTranscriptNotePath, `\n${chunk}`);
+ }
+ }
+ await this.store.writeManifest(manifest);
+ const currentTranscript = this.store.readTranscript(manifest);
+ this.patchSnapshot({
+ committedSegments: manifest.live.committedSegments,
+ liveTranscriptChars: currentTranscript.length,
+ });
+ this.refreshQueueSnapshot();
+ if (!this.stopRequested) {
+ this.transition("recording", "Recording with ordered live transcription.");
+ }
+ }
+
+ private startSegmentPolling() {
+ this.stopSegmentPolling();
+ this.segmentPollerId = window.setInterval(() => {
+ void this.enqueueStableSegments(false);
+ }, 900);
+ }
+
+ private stopSegmentPolling() {
+ if (this.segmentPollerId !== null) {
+ window.clearInterval(this.segmentPollerId);
+ this.segmentPollerId = null;
+ }
+ }
+
+ private async enqueueStableSegments(allowUnstable: boolean) {
+ const manifest = this.requireManifest();
+ const fs = requireNodeModule<{
+ readdirSync: (path: string) => string[];
+ statSync: (path: string) => { mtimeMs: number; isFile(): boolean };
+ }>("fs");
+ const path = requireNodeModule<{ join: (...parts: string[]) => string }>("path");
+ const now = Date.now();
+ const entries = fs.readdirSync(manifest.paths.segmentsDir).map((entry) => {
+ const fullPath = path.join(manifest.paths.segmentsDir, entry);
+ const stat = fs.statSync(fullPath);
+ return {
+ name: entry,
+ path: fullPath,
+ mtimeMs: stat.mtimeMs,
+ isFile: stat.isFile(),
+ };
+ });
+ const segments = collectSegmentDescriptors(entries, now, allowUnstable);
+ this.queue?.enqueue(segments);
+ this.refreshQueueSnapshot();
+ }
+
+ private refreshQueueSnapshot() {
+ if (!this.queue) {
+ this.patchSnapshot({ queuedSegments: 0 });
+ return;
+ }
+
+ const stats = this.queue.getStats();
+ const queuedSegments = stats.queuedIndexes.length + (stats.inFlightIndex !== null ? 1 : 0);
+ this.patchSnapshot({ queuedSegments });
+ if (this.stopRequested) {
+ const message =
+ queuedSegments > 0
+ ? `Stopping recorder and finishing ${queuedSegments} remaining segment${queuedSegments === 1 ? "" : "s"}...`
+ : "Stopping recorder and finalizing the session...";
+ this.patchSnapshot({ state: "stopping", message });
+ return;
+ }
+
+ if (stats.inFlightIndex !== null || stats.queuedIndexes.length > 0) {
+ this.patchSnapshot({ state: "transcribing_live", message: "Ordered live transcription in progress." });
+ } else if (this.captureAdapter?.isRunning()) {
+ this.patchSnapshot({ state: "recording", message: "Recording with ordered live transcription." });
+ }
+ }
+
+ private startElapsedTimer() {
+ this.stopElapsedTimer();
+ this.elapsedTimerId = window.setInterval(() => {
+ this.patchSnapshot({ elapsedSeconds: this.getElapsedSecondsFromClock() });
+ }, 500);
+ }
+
+ private stopElapsedTimer() {
+ if (this.elapsedTimerId !== null) {
+ window.clearInterval(this.elapsedTimerId);
+ this.elapsedTimerId = null;
+ }
+ }
+
+ private getElapsedSecondsFromClock(): number {
+ if (!this.startedAtMs) return this.snapshot.elapsedSeconds;
+ return Math.max(0, Math.floor((Date.now() - this.startedAtMs) / 1000));
+ }
+
+ private freezeElapsedSeconds(): number {
+ const elapsedSeconds = this.getElapsedSecondsFromClock();
+ this.stopElapsedTimer();
+ this.patchSnapshot({ elapsedSeconds });
+ return elapsedSeconds;
+ }
+
+ private transition(state: SessionState, message?: string, lastError?: string) {
+ if (this.manifest) {
+ this.manifest.status = state;
+ this.manifest.runtime.elapsedSeconds = this.snapshot.elapsedSeconds;
+ if (state === "failed" && lastError) {
+ this.manifest.runtime.failureSummary = lastError;
+ }
+ if ((state === "done" || state === "failed") && !this.manifest.runtime.finishedAt) {
+ this.manifest.runtime.finishedAt = new Date().toISOString();
+ }
+ void this.store.writeManifest(this.manifest);
+ }
+ this.patchSnapshot({ state, message, lastError });
+ }
+
+ private patchSnapshot(patch: Partial) {
+ this.snapshot = { ...this.snapshot, ...patch };
+ if (this.manifest) {
+ if (typeof patch.elapsedSeconds === "number") {
+ this.manifest.runtime.elapsedSeconds = patch.elapsedSeconds;
+ }
+ this.snapshot = {
+ ...this.snapshot,
+ sessionId: this.manifest.sessionId,
+ scenarioKey: this.manifest.scenarioKey,
+ scenarioLabel: this.manifest.scenarioLabel,
+ };
+ }
+ this.onSnapshot?.(this.snapshot);
+ }
+
+ private requireManifest(): RecordingSessionManifest {
+ if (!this.manifest) throw new Error("No active session manifest.");
+ return this.manifest;
+ }
+
+ private async requireStoredSession(rootDir: string): Promise {
+ const manifest = await this.store.readSessionByRootDir(rootDir);
+ if (!manifest) {
+ throw new Error("Unable to load the selected session.");
+ }
+ return manifest;
+ }
+
+ private assertNoActiveSession(): void {
+ if (!STARTABLE_STATES.has(this.snapshot.state)) {
+ throw new Error("Stop the active session before repairing another one.");
+ }
+ }
+
+ private async failSession(message: string) {
+ if (this.snapshot.state === "failed" && !this.manifest) return;
+ this.stopRequested = true;
+ this.stopSegmentPolling();
+ this.stopElapsedTimer();
+
+ const manifest = this.manifest;
+ if (manifest) {
+ manifest.status = "failed";
+ manifest.runtime.elapsedSeconds = this.snapshot.elapsedSeconds;
+ manifest.runtime.finishedAt = new Date().toISOString();
+ manifest.runtime.failureSummary = message;
+ if (!manifest.errors.includes(message)) {
+ manifest.errors.push(message);
+ }
+ await this.store.appendDiagnostics(manifest, `ERROR: ${message}`);
+ await this.store.writeManifest(manifest);
+ }
+
+ this.transition("failed", message, message);
+ await this.cleanupRuntime();
+ this.onError?.(message);
+ }
+
+ private async cleanupRuntime() {
+ this.stopSegmentPolling();
+ this.stopElapsedTimer();
+ await this.stopCaptureIfRunning();
+ this.captureAdapter = null;
+ this.queue = null;
+ this.startedAtMs = null;
+ this.stopRequested = false;
+ this.manifest = null;
+ this.snapshot = { ...this.snapshot, queuedSegments: 0 };
+ this.onSnapshot?.(this.snapshot);
+ }
+
+ private async stopCaptureIfRunning() {
+ if (!this.captureAdapter?.isRunning()) return;
+ try {
+ await this.captureAdapter.stop();
+ } catch {}
+ }
+
+ private resetSnapshot() {
+ this.snapshot = {
+ state: "idle",
+ elapsedSeconds: 0,
+ committedSegments: 0,
+ queuedSegments: 0,
+ liveTranscriptChars: 0,
+ diagnosticsReport: this.snapshot.diagnosticsReport,
+ };
+ this.onSnapshot?.(this.snapshot);
+ }
+}
diff --git a/src/application/dashboard.ts b/src/application/dashboard.ts
new file mode 100644
index 0000000..4bd0e38
--- /dev/null
+++ b/src/application/dashboard.ts
@@ -0,0 +1,115 @@
+import type { DashboardSnapshot, DiagnosticsGroups } from "../domain/dashboard";
+import type { DiagnosticsReport } from "../domain/diagnostics";
+import type { SessionListItem, SessionRuntimeSnapshot, RecordingSessionManifest, SessionHealthBadge } from "../domain/session";
+import { uiCopy } from "../ui/copy";
+
+export function groupDiagnosticsChecks(report?: DiagnosticsReport): DiagnosticsGroups {
+ const checks = report?.checks ?? [];
+ return {
+ blocking: checks.filter((check) => check.severity === "error"),
+ warnings: checks.filter((check) => check.severity === "warning"),
+ healthy: checks.filter((check) => check.severity === "ok"),
+ };
+}
+
+export function deriveSessionHealthBadge(manifest: RecordingSessionManifest): SessionHealthBadge {
+ if (manifest.status === "failed" || manifest.runtime.failureSummary) return "failed";
+ if (manifest.status === "stopping" || manifest.status === "summarizing" || manifest.status === "persisting") {
+ return "warning";
+ }
+ if (manifest.diagnosticsSummary.blockingIssueIds.length > 0) return "failed";
+ if (manifest.diagnosticsSummary.warningIds.length > 0) return "warning";
+ return "healthy";
+}
+
+export function deriveSessionListItem(manifest: RecordingSessionManifest, audioSizeBytes: number): SessionListItem {
+ return {
+ sessionId: manifest.sessionId,
+ scenarioKey: manifest.scenarioKey,
+ scenarioLabel: manifest.scenarioLabel,
+ createdAt: manifest.createdAt,
+ updatedAt: manifest.updatedAt,
+ lastActivityAt: manifest.runtime.lastActivityAt,
+ finishedAt: manifest.runtime.finishedAt,
+ status: manifest.status,
+ elapsedSeconds: manifest.runtime.elapsedSeconds,
+ committedSegments: manifest.live.committedSegments,
+ audioSizeBytes,
+ healthBadge: deriveSessionHealthBadge(manifest),
+ failureSummary: manifest.runtime.failureSummary || manifest.errors[manifest.errors.length - 1],
+ diagnosticsSummary: manifest.diagnosticsSummary.summary,
+ artifactAvailability: {
+ hasAudio: manifest.artifacts.hasAudio,
+ hasTranscript: manifest.artifacts.hasTranscript,
+ hasSummary: manifest.artifacts.hasSummary,
+ },
+ paths: {
+ rootDir: manifest.paths.rootDir,
+ fullAudioPath: manifest.paths.fullAudioPath,
+ transcriptTextPath: manifest.paths.transcriptTextPath,
+ diagnosticsLogPath: manifest.paths.diagnosticsLogPath,
+ },
+ notes: {
+ summaryNotePath: manifest.notes.summaryNotePath,
+ liveTranscriptNotePath: manifest.notes.liveTranscriptNotePath,
+ },
+ };
+}
+
+export function buildDashboardSnapshot(input: {
+ runtime: SessionRuntimeSnapshot;
+ diagnosticsReport?: DiagnosticsReport;
+ recentSessions: SessionListItem[];
+ isCoreConfigured: boolean;
+}): DashboardSnapshot {
+ const groups = groupDiagnosticsChecks(input.diagnosticsReport);
+ const blockingCount = groups.blocking.length;
+ const warningCount = groups.warnings.length;
+ const state = input.runtime.state;
+ const isBusy = !["idle", "done", "failed"].includes(state);
+ const isStoppable = ["preflight", "segmenting", "recording", "transcribing_live", "stopping"].includes(state);
+ const isHealthy = blockingCount === 0 && input.isCoreConfigured;
+ const badge = blockingCount > 0 ? "failed" : warningCount > 0 ? "warning" : "healthy";
+
+ const primaryAction = isStoppable
+ ? {
+ intent: "stop" as const,
+ label: uiCopy.actions.stopSession,
+ disabled: false,
+ }
+ : isBusy
+ ? {
+ intent: "busy" as const,
+ label: uiCopy.status.busy,
+ disabled: true,
+ reason: uiCopy.dashboard.busyReason,
+ }
+ : isHealthy
+ ? {
+ intent: "start" as const,
+ label: uiCopy.actions.startSession,
+ disabled: false,
+ }
+ : {
+ intent: "blocked" as const,
+ label: uiCopy.status.blocked,
+ disabled: true,
+ reason: uiCopy.dashboard.blockedReason,
+ };
+
+ return {
+ generatedAt: new Date().toISOString(),
+ runtime: input.runtime,
+ primaryAction,
+ health: {
+ badge,
+ summary: input.diagnosticsReport?.summary ?? (isHealthy ? uiCopy.status.ready : uiCopy.status.blocked),
+ blockingCount,
+ warningCount,
+ groups,
+ report: input.diagnosticsReport,
+ },
+ recentSessions: input.recentSessions,
+ canOpenDiagnostics: Boolean(input.diagnosticsReport),
+ };
+}
diff --git a/src/application/segmentFiles.ts b/src/application/segmentFiles.ts
new file mode 100644
index 0000000..d9f2f80
--- /dev/null
+++ b/src/application/segmentFiles.ts
@@ -0,0 +1,34 @@
+import type { SegmentDescriptor } from "./OrderedSegmentQueue";
+
+export interface SegmentFileEntry {
+ name: string;
+ path: string;
+ mtimeMs: number;
+ isFile: boolean;
+}
+
+export function collectSegmentDescriptors(entries: SegmentFileEntry[], now: number, allowNewestOpenSegment: boolean): SegmentDescriptor[] {
+ const parsed = entries
+ .map((entry) => {
+ const match = entry.name.match(/^segment-(\d{4})\.mp3$/i);
+ if (!match || !entry.isFile) return null;
+ return {
+ index: Number(match[1]),
+ path: entry.path,
+ mtimeMs: entry.mtimeMs,
+ };
+ })
+ .filter((entry): entry is { index: number; path: string; mtimeMs: number } => Boolean(entry))
+ .sort((left, right) => left.index - right.index);
+
+ if (parsed.length === 0) return [];
+
+ const newestIndex = parsed[parsed.length - 1]?.index ?? -1;
+ return parsed
+ .filter((entry) => {
+ if (allowNewestOpenSegment) return true;
+ if (entry.index === newestIndex) return false;
+ return now - entry.mtimeMs >= 1_500;
+ })
+ .map(({ index, path }) => ({ index, path }));
+}
diff --git a/src/domain/dashboard.ts b/src/domain/dashboard.ts
new file mode 100644
index 0000000..9cd346a
--- /dev/null
+++ b/src/domain/dashboard.ts
@@ -0,0 +1,33 @@
+import type { DiagnosticsReport, DiagnosticCheck } from "./diagnostics";
+import type { SessionListItem, SessionRuntimeSnapshot } from "./session";
+
+export interface DiagnosticsGroups {
+ blocking: DiagnosticCheck[];
+ warnings: DiagnosticCheck[];
+ healthy: DiagnosticCheck[];
+}
+
+export interface DashboardPrimaryAction {
+ intent: "start" | "stop" | "blocked" | "busy";
+ label: string;
+ disabled: boolean;
+ reason?: string;
+}
+
+export interface DashboardHealthState {
+ badge: "healthy" | "warning" | "failed";
+ summary: string;
+ blockingCount: number;
+ warningCount: number;
+ groups: DiagnosticsGroups;
+ report?: DiagnosticsReport;
+}
+
+export interface DashboardSnapshot {
+ generatedAt: string;
+ runtime: SessionRuntimeSnapshot;
+ primaryAction: DashboardPrimaryAction;
+ health: DashboardHealthState;
+ recentSessions: SessionListItem[];
+ canOpenDiagnostics: boolean;
+}
diff --git a/src/domain/diagnostics.ts b/src/domain/diagnostics.ts
new file mode 100644
index 0000000..b3c9ffe
--- /dev/null
+++ b/src/domain/diagnostics.ts
@@ -0,0 +1,22 @@
+import type { SummaryProviderId } from "./providers";
+
+export type DiagnosticSeverity = "ok" | "warning" | "error";
+
+export interface DiagnosticCheck {
+ id: string;
+ label: string;
+ severity: DiagnosticSeverity;
+ detail: string;
+ remediation?: string;
+}
+
+export interface DiagnosticsReport {
+ checkedAt: string;
+ provider: SummaryProviderId;
+ backend: "avfoundation" | "dshow" | "pulse" | "alsa";
+ checks: DiagnosticCheck[];
+ blockingIssueIds: string[];
+ warningIds: string[];
+ isHealthy: boolean;
+ summary: string;
+}
diff --git a/src/domain/providers.ts b/src/domain/providers.ts
new file mode 100644
index 0000000..ffe36bd
--- /dev/null
+++ b/src/domain/providers.ts
@@ -0,0 +1,79 @@
+export type SummaryProviderId = "ollama" | "gemini" | "openai" | "anthropic";
+
+export interface ProviderCapabilities {
+ id: SummaryProviderId;
+ label: string;
+ tier: "tier1" | "experimental";
+ kind: "local" | "cloud";
+ requiresApiKey: boolean;
+ requiresEndpoint: boolean;
+ defaultModel: string;
+ description: string;
+}
+
+export const PROVIDER_CAPABILITIES: Record = {
+ ollama: {
+ id: "ollama",
+ label: "Ollama",
+ tier: "tier1",
+ kind: "local",
+ requiresApiKey: false,
+ requiresEndpoint: true,
+ defaultModel: "gemma3",
+ description: "Default local-first summary provider for Resonance.",
+ },
+ gemini: {
+ id: "gemini",
+ label: "Gemini",
+ tier: "experimental",
+ kind: "cloud",
+ requiresApiKey: true,
+ requiresEndpoint: false,
+ defaultModel: "gemini-2.5-flash",
+ description: "Optional cloud provider.",
+ },
+ openai: {
+ id: "openai",
+ label: "OpenAI",
+ tier: "experimental",
+ kind: "cloud",
+ requiresApiKey: true,
+ requiresEndpoint: false,
+ defaultModel: "gpt-4o-mini",
+ description: "Optional cloud provider.",
+ },
+ anthropic: {
+ id: "anthropic",
+ label: "Anthropic",
+ tier: "experimental",
+ kind: "cloud",
+ requiresApiKey: true,
+ requiresEndpoint: false,
+ defaultModel: "claude-3-5-sonnet-latest",
+ description: "Optional cloud provider.",
+ },
+};
+
+export function getProviderCapabilities(provider: SummaryProviderId | undefined): ProviderCapabilities {
+ return PROVIDER_CAPABILITIES[provider ?? "ollama"];
+}
+
+export function getSelectedSummaryModel(summary: {
+ provider: SummaryProviderId;
+ ollamaModel: string;
+ geminiModel: string;
+ openaiModel: string;
+ anthropicModel: string;
+}): string {
+ switch (summary.provider) {
+ case "gemini":
+ return summary.geminiModel || PROVIDER_CAPABILITIES.gemini.defaultModel;
+ case "openai":
+ return summary.openaiModel || PROVIDER_CAPABILITIES.openai.defaultModel;
+ case "anthropic":
+ return summary.anthropicModel || PROVIDER_CAPABILITIES.anthropic.defaultModel;
+ case "ollama":
+ default:
+ return summary.ollamaModel || PROVIDER_CAPABILITIES.ollama.defaultModel;
+ }
+}
diff --git a/src/domain/scenarios.ts b/src/domain/scenarios.ts
new file mode 100644
index 0000000..d979e34
--- /dev/null
+++ b/src/domain/scenarios.ts
@@ -0,0 +1,109 @@
+export interface ScenarioTemplate {
+ key: string;
+ label: string;
+ description: string;
+ notePrefix: string;
+ prompt: string;
+}
+
+export const DEFAULT_SCENARIO_KEY = "work_meeting";
+
+export const SCENARIOS: ScenarioTemplate[] = [
+ {
+ key: "work_meeting",
+ label: "Meeting",
+ description: "Decision log, topics, action items, owners.",
+ notePrefix: "Meeting",
+ prompt: `You are an expert meeting note-taker writing for a human reader.
+
+Write plain Markdown in the SAME language as the transcript.
+
+Rules:
+- Return raw Markdown only. No code fences. No preamble. No "markdown" label.
+- Do not add a document title. Start directly with the first section.
+- Use natural section titles in the output language. Never mix two languages in one heading.
+- Use only transcript facts. If something is uncertain, leave it out.
+- Omit empty sections.
+- Ignore obvious ASR noise, repeated filler, and bracketed cues when they add no value.
+
+Structure:
+- Opening summary: 2 to 4 concise sentences with context, goal, and outcome.
+- Main topics: 2 to 5 short sections named after the real discussion topics.
+- Decisions: bullet list only if explicit decisions were made.
+- Action items: checklist only if real next steps were mentioned. Include owner or due date only if explicit.`,
+ },
+ {
+ key: "brainstorming",
+ label: "Brainstorming",
+ description: "Idea clusters, assumptions, next experiments.",
+ notePrefix: "Brainstorming",
+ prompt: `You are a concise facilitator turning a brainstorming transcript into usable notes.
+
+Write plain Markdown in the SAME language as the transcript.
+
+Rules:
+- Return raw Markdown only. No code fences. No preamble. No document title.
+- Use natural section titles in the output language. Never mix two languages in one heading.
+- Use only transcript facts. If something is weak or unclear, leave it out.
+- Prefer tight bullets to long paragraphs.
+- Omit empty sections.
+- Ignore obvious ASR noise, repeated filler, and bracketed cues when they add no value.
+
+Structure:
+- Opening summary: 2 to 4 sentences with the opportunity, direction, or key idea.
+- Idea clusters: group related ideas, tradeoffs, risks, and assumptions.
+- Open questions: include only if the transcript contains them.
+- Next experiments: checklist only if concrete validations or follow-ups were mentioned.`,
+ },
+ {
+ key: "lecture",
+ label: "Lecture",
+ description: "Study notes, definitions, examples, follow-ups.",
+ notePrefix: "Lecture",
+ prompt: `You are an expert study note-taker turning a lecture transcript into clear notes.
+
+Write plain Markdown in the SAME language as the transcript.
+
+Rules:
+- Return raw Markdown only. No code fences. No preamble. No document title.
+- Use natural section titles in the output language. Never mix two languages in one heading.
+- Be didactic but concise.
+- Use only transcript facts. Do not add external knowledge.
+- Omit empty sections.
+- Ignore obvious ASR noise, repeated filler, and bracketed cues when they add no value.
+
+Structure:
+- Opening summary: 2 to 4 sentences with the core lesson and main takeaways.
+- Topic sections: organize concepts, definitions, examples, and formulas only if they were actually mentioned.
+- Key takeaways: bullet list if the transcript naturally supports it.
+- Follow-ups: exercises, readings, or next study steps only if mentioned.`,
+ },
+ {
+ key: "interview",
+ label: "Interview",
+ description: "Profile, themes, follow-ups, quotable moments.",
+ notePrefix: "Interview",
+ prompt: `You are an interview note-taker writing clean notes for a human reader.
+
+Write plain Markdown in the SAME language as the transcript.
+
+Rules:
+- Return raw Markdown only. No code fences. No preamble. No document title.
+- Use natural section titles in the output language. Never mix two languages in one heading.
+- Stay objective and avoid exaggerated tone.
+- Use only transcript facts. If something is uncertain, leave it out.
+- Omit empty sections.
+- Use short quotes only when they materially help.
+- Ignore obvious ASR noise, repeated filler, and bracketed cues when they add no value.
+
+Structure:
+- Opening summary: 2 to 4 sentences with who the subject is, what was discussed, and the main takeaways.
+- Theme sections: organize the interview into short thematic sections.
+- Notable quotes: include only if brief and genuinely useful.
+- Follow-ups: checklist only if actual next steps were mentioned.`,
+ },
+];
+
+export function getScenario(key: string | undefined): ScenarioTemplate {
+ return SCENARIOS.find((scenario) => scenario.key === key) ?? SCENARIOS[0];
+}
diff --git a/src/domain/session.ts b/src/domain/session.ts
new file mode 100644
index 0000000..c98d1b4
--- /dev/null
+++ b/src/domain/session.ts
@@ -0,0 +1,134 @@
+import type { DiagnosticsReport } from "./diagnostics";
+import type { SummaryProviderId } from "./providers";
+
+export const SESSION_STATES = [
+ "idle",
+ "preflight",
+ "segmenting",
+ "recording",
+ "transcribing_live",
+ "stopping",
+ "summarizing",
+ "persisting",
+ "done",
+ "failed",
+] as const;
+
+export type SessionState = (typeof SESSION_STATES)[number];
+
+export interface DiagnosticsSummary {
+ checkedAt: string;
+ blockingIssueIds: string[];
+ warningIds: string[];
+ summary: string;
+}
+
+export interface RecordingSessionPaths {
+ rootDir: string;
+ manifestPath: string;
+ diagnosticsLogPath: string;
+ audioDir: string;
+ fullAudioPath: string;
+ segmentsDir: string;
+ transcriptDir: string;
+ transcriptTextPath: string;
+ summaryDir: string;
+ summaryMarkdownPath: string;
+}
+
+export interface RecordingSessionManifest {
+ schemaVersion: 1;
+ sessionId: string;
+ createdAt: string;
+ updatedAt: string;
+ scenarioKey: string;
+ scenarioLabel: string;
+ captureMode: "microphone" | "microphone+system";
+ status: SessionState;
+ paths: RecordingSessionPaths;
+ providerInfo: {
+ summaryProvider: SummaryProviderId;
+ transcriptionEngine: "whisper.cpp";
+ model: string;
+ };
+ diagnosticsSummary: DiagnosticsSummary;
+ notes: {
+ vaultFolderPath?: string;
+ liveTranscriptNotePath?: string;
+ summaryNotePath?: string;
+ };
+ runtime: {
+ startedAt: string;
+ lastActivityAt: string;
+ finishedAt?: string;
+ elapsedSeconds: number;
+ failureSummary?: string;
+ };
+ artifacts: {
+ hasAudio: boolean;
+ hasTranscript: boolean;
+ hasSummary: boolean;
+ };
+ live: {
+ committedSegments: number;
+ lastCommittedSegment: number;
+ };
+ errors: string[];
+}
+
+export interface SessionRuntimeSnapshot {
+ state: SessionState;
+ sessionId?: string;
+ scenarioKey?: string;
+ scenarioLabel?: string;
+ elapsedSeconds: number;
+ committedSegments: number;
+ queuedSegments: number;
+ liveTranscriptChars: number;
+ message?: string;
+ lastError?: string;
+ diagnosticsReport?: DiagnosticsReport;
+}
+
+export type SessionHealthBadge = "healthy" | "warning" | "failed";
+
+export interface SessionListItem {
+ sessionId: string;
+ scenarioKey: string;
+ scenarioLabel: string;
+ createdAt: string;
+ updatedAt: string;
+ lastActivityAt: string;
+ finishedAt?: string;
+ status: SessionState;
+ elapsedSeconds: number;
+ committedSegments: number;
+ audioSizeBytes: number;
+ healthBadge: SessionHealthBadge;
+ failureSummary?: string;
+ diagnosticsSummary: string;
+ artifactAvailability: {
+ hasAudio: boolean;
+ hasTranscript: boolean;
+ hasSummary: boolean;
+ };
+ paths: {
+ rootDir: string;
+ fullAudioPath: string;
+ transcriptTextPath: string;
+ diagnosticsLogPath: string;
+ };
+ notes: {
+ summaryNotePath?: string;
+ liveTranscriptNotePath?: string;
+ };
+}
+
+export function buildDiagnosticsSummary(report: DiagnosticsReport): DiagnosticsSummary {
+ return {
+ checkedAt: report.checkedAt,
+ blockingIssueIds: report.blockingIssueIds,
+ warningIds: report.warningIds,
+ summary: report.summary,
+ };
+}
diff --git a/src/domain/settings.ts b/src/domain/settings.ts
new file mode 100644
index 0000000..6d9c082
--- /dev/null
+++ b/src/domain/settings.ts
@@ -0,0 +1,280 @@
+import { getSelectedSummaryModel, type SummaryProviderId } from "./providers";
+
+export type CaptureBackend = "auto" | "avfoundation" | "dshow" | "pulse" | "alsa";
+
+export interface CaptureSettings {
+ ffmpegPath: string;
+ backend: CaptureBackend;
+ microphoneDevice: string;
+ microphoneLabel: string;
+ systemDevice: string;
+ systemLabel: string;
+ sampleRateHz: number;
+ channels: 1 | 2;
+ bitrateKbps: number;
+ segmentDurationSeconds: number;
+}
+
+export interface TranscriptionSettings {
+ whisperRepoPath: string;
+ whisperCliPath: string;
+ modelPath: string;
+ modelPreset: "base" | "small" | "medium" | "large";
+ language: string;
+ beamSize: number;
+ entropyThreshold: number;
+ logprobThreshold: number;
+}
+
+export interface SummarySettings {
+ provider: SummaryProviderId;
+ ollamaEndpoint: string;
+ ollamaModel: string;
+ geminiApiKey: string;
+ geminiModel: string;
+ openaiApiKey: string;
+ openaiModel: string;
+ anthropicApiKey: string;
+ anthropicModel: string;
+}
+
+export interface OutputSettings {
+ vaultFolder: string;
+ storeLiveTranscriptInVault: boolean;
+ openSummaryAfterCreate: boolean;
+ maxSessionsKept: number;
+}
+
+export interface UiSettings {
+ lastScenarioKey?: string;
+ showSetupWizardOnStartup: boolean;
+ showDiagnosticsOnStartup: boolean;
+}
+
+export interface DiagnosticsSettings {
+ quickTestDurationSeconds: number;
+}
+
+export interface PluginSettingsV2 {
+ version: 2;
+ capture: CaptureSettings;
+ transcription: TranscriptionSettings;
+ summary: SummarySettings;
+ output: OutputSettings;
+ ui: UiSettings;
+ diagnostics: DiagnosticsSettings;
+}
+
+export const DEFAULT_SETTINGS_V2: PluginSettingsV2 = {
+ version: 2,
+ capture: {
+ ffmpegPath: "",
+ backend: "auto",
+ microphoneDevice: "",
+ microphoneLabel: "",
+ systemDevice: "",
+ systemLabel: "",
+ sampleRateHz: 48000,
+ channels: 2,
+ bitrateKbps: 160,
+ segmentDurationSeconds: 20,
+ },
+ transcription: {
+ whisperRepoPath: "",
+ whisperCliPath: "",
+ modelPath: "",
+ modelPreset: "small",
+ language: "auto",
+ beamSize: 5,
+ entropyThreshold: 2.4,
+ logprobThreshold: -1.0,
+ },
+ summary: {
+ provider: "ollama",
+ ollamaEndpoint: "http://localhost:11434",
+ ollamaModel: "gemma3",
+ geminiApiKey: "",
+ geminiModel: "gemini-2.5-flash",
+ openaiApiKey: "",
+ openaiModel: "gpt-4o-mini",
+ anthropicApiKey: "",
+ anthropicModel: "claude-3-5-sonnet-latest",
+ },
+ output: {
+ vaultFolder: "Resonance",
+ storeLiveTranscriptInVault: true,
+ openSummaryAfterCreate: true,
+ maxSessionsKept: 20,
+ },
+ ui: {
+ lastScenarioKey: undefined,
+ showSetupWizardOnStartup: true,
+ showDiagnosticsOnStartup: false,
+ },
+ diagnostics: {
+ quickTestDurationSeconds: 2,
+ },
+};
+
+function clampInteger(value: unknown, fallback: number, min: number, max: number): number {
+ const parsed = Number(value);
+ if (!Number.isFinite(parsed)) return fallback;
+ return Math.max(min, Math.min(max, Math.floor(parsed)));
+}
+
+function asString(value: unknown, fallback = ""): string {
+ return typeof value === "string" ? value : fallback;
+}
+
+function asBoolean(value: unknown, fallback: boolean): boolean {
+ return typeof value === "boolean" ? value : fallback;
+}
+
+function normalizeCaptureSettings(raw: unknown): CaptureSettings {
+ const input = (raw ?? {}) as Partial;
+ const backend = input.backend;
+ return {
+ ffmpegPath: asString(input.ffmpegPath),
+ backend:
+ backend === "avfoundation" || backend === "dshow" || backend === "pulse" || backend === "alsa" || backend === "auto"
+ ? backend
+ : DEFAULT_SETTINGS_V2.capture.backend,
+ microphoneDevice: asString(input.microphoneDevice),
+ microphoneLabel: asString(input.microphoneLabel),
+ systemDevice: asString(input.systemDevice),
+ systemLabel: asString(input.systemLabel),
+ sampleRateHz: clampInteger(input.sampleRateHz, DEFAULT_SETTINGS_V2.capture.sampleRateHz, 8_000, 192_000),
+ channels: clampInteger(input.channels, DEFAULT_SETTINGS_V2.capture.channels, 1, 2) === 1 ? 1 : 2,
+ bitrateKbps: clampInteger(input.bitrateKbps, DEFAULT_SETTINGS_V2.capture.bitrateKbps, 64, 320),
+ segmentDurationSeconds: clampInteger(input.segmentDurationSeconds, DEFAULT_SETTINGS_V2.capture.segmentDurationSeconds, 5, 300),
+ };
+}
+
+function normalizeTranscriptionSettings(raw: unknown): TranscriptionSettings {
+ const input = (raw ?? {}) as Partial;
+ const preset = input.modelPreset;
+ return {
+ whisperRepoPath: asString(input.whisperRepoPath),
+ whisperCliPath: asString(input.whisperCliPath),
+ modelPath: asString(input.modelPath),
+ modelPreset:
+ preset === "base" || preset === "small" || preset === "medium" || preset === "large"
+ ? preset
+ : DEFAULT_SETTINGS_V2.transcription.modelPreset,
+ language: asString(input.language, DEFAULT_SETTINGS_V2.transcription.language),
+ beamSize: clampInteger(input.beamSize, DEFAULT_SETTINGS_V2.transcription.beamSize, 1, 10),
+ entropyThreshold: Number.isFinite(Number(input.entropyThreshold))
+ ? Number(input.entropyThreshold)
+ : DEFAULT_SETTINGS_V2.transcription.entropyThreshold,
+ logprobThreshold: Number.isFinite(Number(input.logprobThreshold))
+ ? Number(input.logprobThreshold)
+ : DEFAULT_SETTINGS_V2.transcription.logprobThreshold,
+ };
+}
+
+function normalizeSummarySettings(raw: unknown): SummarySettings {
+ const input = (raw ?? {}) as Partial;
+ const provider = input.provider;
+ return {
+ provider:
+ provider === "ollama" || provider === "gemini" || provider === "openai" || provider === "anthropic"
+ ? provider
+ : DEFAULT_SETTINGS_V2.summary.provider,
+ ollamaEndpoint: asString(input.ollamaEndpoint, DEFAULT_SETTINGS_V2.summary.ollamaEndpoint),
+ ollamaModel: asString(input.ollamaModel, DEFAULT_SETTINGS_V2.summary.ollamaModel),
+ geminiApiKey: asString(input.geminiApiKey),
+ geminiModel: asString(input.geminiModel, DEFAULT_SETTINGS_V2.summary.geminiModel),
+ openaiApiKey: asString(input.openaiApiKey),
+ openaiModel: asString(input.openaiModel, DEFAULT_SETTINGS_V2.summary.openaiModel),
+ anthropicApiKey: asString(input.anthropicApiKey),
+ anthropicModel: asString(input.anthropicModel, DEFAULT_SETTINGS_V2.summary.anthropicModel),
+ };
+}
+
+function normalizeOutputSettings(raw: unknown): OutputSettings {
+ const input = (raw ?? {}) as Partial;
+ return {
+ vaultFolder: asString(input.vaultFolder, DEFAULT_SETTINGS_V2.output.vaultFolder),
+ storeLiveTranscriptInVault: asBoolean(input.storeLiveTranscriptInVault, DEFAULT_SETTINGS_V2.output.storeLiveTranscriptInVault),
+ openSummaryAfterCreate: asBoolean(input.openSummaryAfterCreate, DEFAULT_SETTINGS_V2.output.openSummaryAfterCreate),
+ maxSessionsKept: clampInteger(input.maxSessionsKept, DEFAULT_SETTINGS_V2.output.maxSessionsKept, 0, 500),
+ };
+}
+
+function normalizeUiSettings(raw: unknown): UiSettings {
+ const input = (raw ?? {}) as Partial;
+ return {
+ lastScenarioKey: asString(input.lastScenarioKey) || undefined,
+ showSetupWizardOnStartup: asBoolean(input.showSetupWizardOnStartup, DEFAULT_SETTINGS_V2.ui.showSetupWizardOnStartup),
+ showDiagnosticsOnStartup: asBoolean(input.showDiagnosticsOnStartup, DEFAULT_SETTINGS_V2.ui.showDiagnosticsOnStartup),
+ };
+}
+
+function normalizeDiagnosticsSettings(raw: unknown): DiagnosticsSettings {
+ const input = (raw ?? {}) as Partial;
+ return {
+ quickTestDurationSeconds: clampInteger(
+ input.quickTestDurationSeconds,
+ DEFAULT_SETTINGS_V2.diagnostics.quickTestDurationSeconds,
+ 1,
+ 10
+ ),
+ };
+}
+
+export function normalizeSettingsV2(raw: unknown): PluginSettingsV2 {
+ const input = (raw ?? {}) as Partial;
+ const summary = normalizeSummarySettings(input.summary);
+ return {
+ version: 2,
+ capture: normalizeCaptureSettings(input.capture),
+ transcription: normalizeTranscriptionSettings(input.transcription),
+ summary,
+ output: normalizeOutputSettings(input.output),
+ ui: normalizeUiSettings(input.ui),
+ diagnostics: normalizeDiagnosticsSettings(input.diagnostics),
+ };
+}
+
+export function resolveCaptureBackend(
+ backend: CaptureBackend,
+ platform: NodeJS.Platform = process.platform
+): "avfoundation" | "dshow" | "pulse" | "alsa" {
+ if (backend !== "auto") return backend;
+ if (platform === "darwin") return "avfoundation";
+ if (platform === "win32") return "dshow";
+ return "pulse";
+}
+
+export function getSelectedProviderApiKey(settings: SummarySettings): string {
+ switch (settings.provider) {
+ case "gemini":
+ return settings.geminiApiKey;
+ case "openai":
+ return settings.openaiApiKey;
+ case "anthropic":
+ return settings.anthropicApiKey;
+ case "ollama":
+ default:
+ return "";
+ }
+}
+
+export function isLikelyTestWhisperModelPath(modelPath: string): boolean {
+ const normalized = modelPath.replace(/\\/g, "/").trim().toLowerCase();
+ if (!normalized) return false;
+ const basename = normalized.split("/").pop() ?? "";
+ return basename.startsWith("for-tests-");
+}
+
+export function isCoreConfigured(settings: PluginSettingsV2): boolean {
+ const selectedModel = getSelectedSummaryModel(settings.summary);
+ if (!settings.capture.ffmpegPath.trim()) return false;
+ if (!settings.transcription.whisperCliPath.trim()) return false;
+ if (!settings.transcription.modelPath.trim()) return false;
+ if (isLikelyTestWhisperModelPath(settings.transcription.modelPath)) return false;
+ if (settings.summary.provider === "ollama") {
+ return !!settings.summary.ollamaEndpoint.trim() && !!selectedModel.trim();
+ }
+ return !!selectedModel.trim() && !!getSelectedProviderApiKey(settings.summary).trim();
+}
diff --git a/src/infrastructure/adapters/AudioCaptureAdapter.ts b/src/infrastructure/adapters/AudioCaptureAdapter.ts
new file mode 100644
index 0000000..31c01d9
--- /dev/null
+++ b/src/infrastructure/adapters/AudioCaptureAdapter.ts
@@ -0,0 +1,187 @@
+import type { CaptureSettings } from "../../domain/settings";
+import { requireNodeModule } from "../node";
+import { resolveCaptureInputs, type ResolvedCaptureInputs } from "./captureUtils";
+
+interface ChildProcessModule {
+ spawn: typeof import("node:child_process").spawn;
+}
+
+interface CaptureChildProcess {
+ pid?: number;
+ stdin?: { write: (chunk: string) => boolean };
+ stderr?: {
+ on: (event: "data", listener: (chunk: Buffer) => void) => void;
+ } | null;
+ on: {
+ (event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void): void;
+ (event: "error", listener: (error: Error) => void): void;
+ };
+ once?: (event: "close", listener: () => void) => void;
+ off?: (event: "close", listener: () => void) => void;
+ kill: (signal?: NodeJS.Signals | number) => boolean;
+}
+
+export interface AudioCaptureStartOptions {
+ settings: CaptureSettings;
+ fullAudioPath: string;
+ segmentsDir: string;
+ onLog?: (line: string) => void;
+ onUnexpectedExit?: (message: string) => void;
+}
+
+export class AudioCaptureAdapter {
+ private child: CaptureChildProcess | null = null;
+ private closePromise: Promise | null = null;
+ private stopRequested = false;
+ private resolvedInputs: ResolvedCaptureInputs | null = null;
+
+ async start(options: AudioCaptureStartOptions): Promise {
+ if (this.child) {
+ throw new Error("Audio capture already running.");
+ }
+ if (!options.settings.ffmpegPath.trim()) {
+ throw new Error("FFmpeg path not configured.");
+ }
+
+ const resolvedInputs = await resolveCaptureInputs(options.settings);
+ const inputArgs: string[] = [];
+ const pushInput = (spec?: string) => {
+ if (!spec) return;
+ inputArgs.push("-f", resolvedInputs.backend, "-thread_queue_size", "1024", "-i", spec);
+ };
+ pushInput(resolvedInputs.micSpec);
+ pushInput(resolvedInputs.systemSpec);
+ if (inputArgs.length === 0) {
+ throw new Error("No audio input configured.");
+ }
+
+ const path = requireNodeModule<{ join: (...parts: string[]) => string }>("path");
+ const { spawn } = requireNodeModule("child_process");
+ const segmentPattern = path.join(options.segmentsDir, "segment-%04d.mp3");
+ const shouldMix = Boolean(resolvedInputs.micSpec && resolvedInputs.systemSpec);
+ const speechPrep = "highpass=f=80,lowpass=f=7000";
+ const filterArgs = shouldMix
+ ? [
+ "-filter_complex",
+ `[0:a]${speechPrep},volume=2.15[mic];` +
+ `[1:a]volume=1.0[sys];` +
+ `[mic][sys]amix=inputs=2:duration=longest:weights='1.65 0.75':normalize=0,alimiter=limit=0.95[aout]`,
+ ]
+ : ["-af", `${speechPrep},volume=2.15,alimiter=limit=0.95`];
+ const mapArgs = shouldMix ? ["-map", "[aout]"] : ["-map", "0:a"];
+ const sampleRate = Math.max(8_000, Math.floor(options.settings.sampleRateHz));
+ const channels = options.settings.channels === 1 ? 1 : 2;
+ const bitrate = Math.max(64, Math.floor(options.settings.bitrateKbps));
+ const segmentDuration = Math.max(5, Math.floor(options.settings.segmentDurationSeconds));
+ const teeSpec = `[f=segment:segment_time=${segmentDuration}:reset_timestamps=1]${segmentPattern}|${options.fullAudioPath}`;
+ const args = [
+ "-y",
+ ...inputArgs,
+ ...filterArgs,
+ ...mapArgs,
+ "-vn",
+ "-ar",
+ String(sampleRate),
+ "-ac",
+ String(channels),
+ "-c:a",
+ "libmp3lame",
+ "-b:a",
+ `${bitrate}k`,
+ "-f",
+ "tee",
+ teeSpec,
+ ];
+
+ options.onLog?.(`FFmpeg start: ${options.settings.ffmpegPath} ${args.join(" ")}`);
+ this.stopRequested = false;
+ this.child = spawn(options.settings.ffmpegPath, args, { detached: false, stdio: ["pipe", "ignore", "pipe"] }) as CaptureChildProcess;
+ this.resolvedInputs = resolvedInputs;
+ const child = this.child;
+ let stderrTail = "";
+
+ child.stderr?.on("data", (chunk: Buffer) => {
+ const text = chunk.toString();
+ stderrTail = `${stderrTail}${text}`.split(/\r?\n/).slice(-10).join("\n");
+ if (text.trim()) options.onLog?.(text.trim());
+ });
+
+ this.closePromise = new Promise((resolve) => {
+ child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
+ const unexpected = !this.stopRequested && (code ?? 0) !== 0;
+ if (unexpected) {
+ options.onUnexpectedExit?.(`FFmpeg exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}\n${stderrTail}`);
+ }
+ this.child = null;
+ resolve();
+ });
+ child.on("error", (error: Error) => {
+ options.onUnexpectedExit?.(error.message);
+ this.child = null;
+ resolve();
+ });
+ });
+
+ return resolvedInputs;
+ }
+
+ isRunning(): boolean {
+ return Boolean(this.child);
+ }
+
+ async stop(): Promise {
+ if (!this.child || !this.closePromise) return;
+ this.stopRequested = true;
+ const child = this.child;
+ try {
+ child.stdin?.write("q\n");
+ } catch {}
+
+ const closedGracefully = await waitForClose(child, 1_500);
+ if (!closedGracefully) {
+ try {
+ child.kill("SIGINT");
+ } catch {}
+ }
+ if (!(await waitForClose(child, 1_000))) {
+ try {
+ child.kill("SIGTERM");
+ } catch {}
+ }
+ if (!(await waitForClose(child, 800))) {
+ if (process.platform === "win32") {
+ try {
+ const { spawn } = requireNodeModule("child_process");
+ spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], { windowsHide: true });
+ } catch {}
+ } else {
+ try {
+ child.kill("SIGKILL");
+ } catch {}
+ }
+ }
+
+ await this.closePromise;
+ }
+
+ getResolvedInputs(): ResolvedCaptureInputs | null {
+ return this.resolvedInputs;
+ }
+}
+
+async function waitForClose(child: CaptureChildProcess, timeoutMs: number): Promise {
+ return new Promise((resolve) => {
+ let settled = false;
+ const finish = (value: boolean) => {
+ if (settled) return;
+ settled = true;
+ try {
+ child.off?.("close", onClose);
+ } catch {}
+ resolve(value);
+ };
+ const onClose = () => finish(true);
+ child.once?.("close", onClose);
+ window.setTimeout(() => finish(false), timeoutMs);
+ });
+}
diff --git a/src/infrastructure/adapters/SummaryAdapter.ts b/src/infrastructure/adapters/SummaryAdapter.ts
new file mode 100644
index 0000000..1b376d5
--- /dev/null
+++ b/src/infrastructure/adapters/SummaryAdapter.ts
@@ -0,0 +1,198 @@
+import { getProviderCapabilities, getSelectedSummaryModel, type SummaryProviderId } from "../../domain/providers";
+import type { SummarySettings } from "../../domain/settings";
+
+export interface SummaryResult {
+ provider: SummaryProviderId;
+ model: string;
+ markdown: string;
+}
+
+function isInvalidTranscript(transcript: string): boolean {
+ const text = transcript.trim();
+ if (!text) return true;
+ const letters = (text.match(/[A-Za-zÀ-ÖØ-öø-ÿ]/g) || []).length;
+ return text.length < 40 || letters < 30;
+}
+
+export function detectLanguageFromTranscript(transcript: string): string {
+ const text = transcript.toLowerCase();
+ const italianWords = ["è", "che", "di", "sono", "con", "per", "una", "abbiamo", "quindi", "però", "anche", "questa"];
+ const englishWords = ["the", "and", "that", "have", "with", "this", "but", "from", "they", "time", "very", "just"];
+ const spanishWords = ["que", "de", "la", "el", "en", "un", "ser", "se", "por", "con", "para", "como"];
+ const frenchWords = ["le", "de", "et", "un", "il", "être", "en", "avec", "pas", "plus", "sans", "où"];
+ const scores = { it: 0, en: 0, es: 0, fr: 0 };
+
+ const countWords = (words: string[]) =>
+ words.reduce((total, word) => total + (text.match(new RegExp(`\\b${word}\\b`, "gi")) || []).length, 0);
+ scores.it += countWords(italianWords);
+ scores.en += countWords(englishWords);
+ scores.es += countWords(spanishWords);
+ scores.fr += countWords(frenchWords);
+ if (/[àèéìòù]/.test(text)) scores.it += 3;
+ if (/[ñáéíóúü]/.test(text)) scores.es += 3;
+ if (/[àâäéèêëïîôöùûüÿç]/.test(text)) scores.fr += 3;
+ const maxScore = Math.max(...Object.values(scores));
+ if (maxScore === 0) return "en";
+ return (Object.entries(scores).find(([, value]) => value === maxScore)?.[0] ?? "en") as string;
+}
+
+function buildSystemGuard(expectedLanguage: string, transcript: string): string {
+ const actualLanguage = expectedLanguage === "auto" ? detectLanguageFromTranscript(transcript) : expectedLanguage;
+ const languageRule =
+ actualLanguage === "it"
+ ? "Output language MUST be Italian."
+ : actualLanguage === "es"
+ ? "Output language MUST be Spanish."
+ : actualLanguage === "fr"
+ ? "Output language MUST be French."
+ : actualLanguage === "auto"
+ ? "Output MUST be in the same language as the transcript."
+ : `Output language MUST be ${actualLanguage}.`;
+
+ return [
+ "You are a careful summarizer that writes clean Markdown for a human reader.",
+ "STRICT RULES:",
+ `- ${languageRule}`,
+ "- Use only transcript facts.",
+ "- Do not invent or infer beyond the transcript.",
+ "- Return raw Markdown only. No code fences. No preamble. No \"markdown\" label.",
+ "- Do not add a document title unless the user explicitly asked for one.",
+ "- Never mix two languages in the same heading.",
+ "- Prefer natural section titles in the output language over literal translations of prompt wording.",
+ "- Ignore obvious ASR glitches, repeated filler, and bracketed cues when they add no value.",
+ "- If the transcript is empty, invalid, or too weak, return an empty string.",
+ ].join("\n");
+}
+
+export class SummaryAdapter {
+ async summarize(settings: SummarySettings, prompt: string, transcript: string, expectedLanguage: string): Promise {
+ if (isInvalidTranscript(transcript)) {
+ return {
+ provider: settings.provider,
+ model: getSelectedSummaryModel(settings),
+ markdown: "",
+ };
+ }
+
+ const provider = settings.provider;
+ const model = getSelectedSummaryModel(settings);
+ switch (provider) {
+ case "gemini":
+ return { provider, model, markdown: await this.summarizeWithGemini(settings, prompt, transcript, expectedLanguage) };
+ case "openai":
+ return { provider, model, markdown: await this.summarizeWithOpenAI(settings, prompt, transcript, expectedLanguage) };
+ case "anthropic":
+ return { provider, model, markdown: await this.summarizeWithAnthropic(settings, prompt, transcript, expectedLanguage) };
+ case "ollama":
+ default:
+ return { provider: "ollama", model, markdown: await this.summarizeWithOllama(settings, prompt, transcript, expectedLanguage) };
+ }
+ }
+
+ private async summarizeWithOllama(
+ settings: SummarySettings,
+ prompt: string,
+ transcript: string,
+ expectedLanguage: string
+ ): Promise {
+ const base = settings.ollamaEndpoint.trim() || "http://localhost:11434";
+ const response = await fetch(`${base}/api/generate`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ model: settings.ollamaModel || getProviderCapabilities("ollama").defaultModel,
+ prompt: `${buildSystemGuard(expectedLanguage, transcript)}\n\n${prompt}\n\nTranscript:\n${transcript}`,
+ stream: false,
+ options: { temperature: 0, top_p: 0.9, top_k: 40, num_predict: 2048 },
+ }),
+ });
+ if (!response.ok) {
+ throw new Error(`Ollama API error: ${response.status} ${await response.text().catch(() => "")}`);
+ }
+ const json = await response.json();
+ return String(json?.response ?? "").trim();
+ }
+
+ private async summarizeWithGemini(
+ settings: SummarySettings,
+ prompt: string,
+ transcript: string,
+ expectedLanguage: string
+ ): Promise {
+ if (!settings.geminiApiKey.trim()) throw new Error("Gemini API key not configured.");
+ const model = settings.geminiModel || getProviderCapabilities("gemini").defaultModel;
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`;
+ const body = {
+ systemInstruction: { role: "system", parts: [{ text: buildSystemGuard(expectedLanguage, transcript) }] },
+ contents: [{ role: "user", parts: [{ text: `${prompt}\n\nTranscript:\n${transcript}` }] }],
+ };
+ const response = await fetch(`${url}?key=${encodeURIComponent(settings.geminiApiKey)}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!response.ok) {
+ throw new Error(`Gemini API error: ${response.status} ${await response.text().catch(() => "")}`);
+ }
+ const json = await response.json();
+ return String(json?.candidates?.[0]?.content?.parts?.map((part: { text?: string }) => part?.text ?? "").join("\n") ?? "").trim();
+ }
+
+ private async summarizeWithOpenAI(
+ settings: SummarySettings,
+ prompt: string,
+ transcript: string,
+ expectedLanguage: string
+ ): Promise {
+ if (!settings.openaiApiKey.trim()) throw new Error("OpenAI API key not configured.");
+ const response = await fetch("https://api.openai.com/v1/chat/completions", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${settings.openaiApiKey}`,
+ },
+ body: JSON.stringify({
+ model: settings.openaiModel || getProviderCapabilities("openai").defaultModel,
+ temperature: 0,
+ messages: [
+ { role: "system", content: buildSystemGuard(expectedLanguage, transcript) },
+ { role: "user", content: `${prompt}\n\nTranscript:\n${transcript}` },
+ ],
+ }),
+ });
+ if (!response.ok) {
+ throw new Error(`OpenAI API error: ${response.status} ${await response.text().catch(() => "")}`);
+ }
+ const json = await response.json();
+ return String(json?.choices?.[0]?.message?.content ?? "").trim();
+ }
+
+ private async summarizeWithAnthropic(
+ settings: SummarySettings,
+ prompt: string,
+ transcript: string,
+ expectedLanguage: string
+ ): Promise {
+ if (!settings.anthropicApiKey.trim()) throw new Error("Anthropic API key not configured.");
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "x-api-key": settings.anthropicApiKey,
+ "anthropic-version": "2023-06-01",
+ },
+ body: JSON.stringify({
+ model: settings.anthropicModel || getProviderCapabilities("anthropic").defaultModel,
+ max_tokens: 2000,
+ temperature: 0,
+ system: buildSystemGuard(expectedLanguage, transcript),
+ messages: [{ role: "user", content: `${prompt}\n\nTranscript:\n${transcript}` }],
+ }),
+ });
+ if (!response.ok) {
+ throw new Error(`Anthropic API error: ${response.status} ${await response.text().catch(() => "")}`);
+ }
+ const json = await response.json();
+ return String(json?.content?.map((part: { text?: string }) => part?.text ?? "").join("\n") ?? "").trim();
+ }
+}
diff --git a/src/infrastructure/adapters/TranscriptionAdapter.ts b/src/infrastructure/adapters/TranscriptionAdapter.ts
new file mode 100644
index 0000000..f6d1e05
--- /dev/null
+++ b/src/infrastructure/adapters/TranscriptionAdapter.ts
@@ -0,0 +1,155 @@
+import type { TranscriptionSettings } from "../../domain/settings";
+import { requireNodeModule } from "../node";
+
+export class WhisperTranscriptionAdapter {
+ constructor(
+ private readonly settings: TranscriptionSettings,
+ private readonly ffmpegPath?: string
+ ) {}
+
+ async transcribeFile(audioPath: string): Promise {
+ if (!this.settings.whisperCliPath.trim()) {
+ throw new Error("whisper.cpp CLI path not configured.");
+ }
+ if (!this.settings.modelPath.trim()) {
+ throw new Error("Whisper model path not configured.");
+ }
+
+ const fs = requireNodeModule<{
+ existsSync: (path: string) => boolean;
+ readFileSync: (path: string, options: { encoding: "utf8" }) => string;
+ unlinkSync: (path: string) => void;
+ }>("fs");
+ const outputPrefix = audioPath.replace(/\.[^.]+$/i, "");
+ const outputTextPath = `${outputPrefix}.txt`;
+ const preparedAudioPath = await this.prepareAudioForWhisper(audioPath, outputPrefix);
+
+ try {
+ let transcript = await this.runWhisper(preparedAudioPath, outputPrefix, outputTextPath, false);
+ if (transcript.trim()) {
+ return transcript;
+ }
+
+ transcript = await this.runWhisper(preparedAudioPath, outputPrefix, outputTextPath, true);
+ return transcript;
+ } finally {
+ if (preparedAudioPath !== audioPath) {
+ try {
+ fs.unlinkSync(preparedAudioPath);
+ } catch {}
+ }
+ try {
+ fs.unlinkSync(outputTextPath);
+ } catch {}
+ }
+ }
+
+ private async prepareAudioForWhisper(audioPath: string, outputPrefix: string): Promise {
+ const extension = audioPath.split(".").pop()?.toLowerCase();
+ if (extension === "wav" || !this.ffmpegPath?.trim()) {
+ return audioPath;
+ }
+
+ const wavPath = `${outputPrefix}.whisper.wav`;
+ await this.spawnProcess(this.ffmpegPath, [
+ "-y",
+ "-i",
+ audioPath,
+ "-vn",
+ "-af",
+ "highpass=f=80,lowpass=f=7000,dynaudnorm=framelen=250:gausssize=31",
+ "-ar",
+ "16000",
+ "-ac",
+ "1",
+ "-c:a",
+ "pcm_s16le",
+ wavPath,
+ ], requireNodeModule<{ dirname: (path: string) => string }>("path").dirname(audioPath), "FFmpeg prepare");
+ return wavPath;
+ }
+
+ private async runWhisper(
+ inputAudioPath: string,
+ outputPrefix: string,
+ outputTextPath: string,
+ relaxed: boolean
+ ): Promise {
+ const fs = requireNodeModule<{
+ existsSync: (path: string) => boolean;
+ readFileSync: (path: string, options: { encoding: "utf8" }) => string;
+ unlinkSync: (path: string) => void;
+ }>("fs");
+
+ if (fs.existsSync(outputTextPath)) {
+ try {
+ fs.unlinkSync(outputTextPath);
+ } catch {}
+ }
+
+ const args = this.buildWhisperArgs(inputAudioPath, outputPrefix, relaxed);
+ const result = await this.spawnProcess(
+ this.settings.whisperCliPath,
+ args,
+ requireNodeModule<{ dirname: (path: string) => string }>("path").dirname(inputAudioPath),
+ "whisper.cpp"
+ );
+
+ if (fs.existsSync(outputTextPath)) {
+ return fs.readFileSync(outputTextPath, { encoding: "utf8" }).trim();
+ }
+
+ return result.stdout.trim();
+ }
+
+ private buildWhisperArgs(inputAudioPath: string, outputPrefix: string, relaxed: boolean): string[] {
+ const args = ["-m", this.settings.modelPath, "-f", inputAudioPath, "-otxt", "-of", outputPrefix];
+ const language = this.settings.language.trim();
+ if (language && language !== "auto") args.push("-l", language);
+ args.push("--max-context", "128", "--max-len", "0", "-bs", String(this.settings.beamSize));
+
+ if (!relaxed) {
+ args.push(
+ "--entropy-thold",
+ String(this.settings.entropyThreshold),
+ "--logprob-thold",
+ String(this.settings.logprobThreshold),
+ "--word-thold",
+ "0.01"
+ );
+ }
+
+ return args;
+ }
+
+ private async spawnProcess(
+ command: string,
+ args: string[],
+ cwd: string,
+ label: string
+ ): Promise<{ stdout: string; stderr: string }> {
+ const { spawn } = requireNodeModule<{ spawn: Function }>("child_process");
+ let stderr = "";
+ let stdout = "";
+
+ await new Promise((resolve, reject) => {
+ const child = spawn(command, args, { cwd });
+ child.stdout?.on("data", (chunk: Buffer) => {
+ stdout += chunk.toString();
+ });
+ child.stderr?.on("data", (chunk: Buffer) => {
+ stderr += chunk.toString();
+ });
+ child.on("error", (error: Error) => reject(error));
+ child.on("close", (code: number) => {
+ if (code === 0) {
+ resolve();
+ } else {
+ reject(new Error(`${label} exited with code ${code}: ${stderr}`));
+ }
+ });
+ });
+
+ return { stdout, stderr };
+ }
+}
diff --git a/src/infrastructure/adapters/VaultAdapter.ts b/src/infrastructure/adapters/VaultAdapter.ts
new file mode 100644
index 0000000..817e34d
--- /dev/null
+++ b/src/infrastructure/adapters/VaultAdapter.ts
@@ -0,0 +1,97 @@
+import { App, TFile, normalizePath } from "obsidian";
+import type { OutputSettings } from "../../domain/settings";
+import type { RecordingSessionManifest } from "../../domain/session";
+import { formatDateForFile, slugifyForPath } from "../../utils/format";
+import { formatLiveTranscriptNote } from "../../utils/markdown";
+
+export class VaultAdapter {
+ constructor(private readonly app: App) {}
+
+ async ensureSessionWorkspace(manifest: RecordingSessionManifest, output: OutputSettings): Promise<{
+ folderPath: string;
+ liveTranscriptNotePath?: string;
+ }> {
+ const folderName = slugifyForPath(`${manifest.scenarioLabel} ${formatDateForFile(manifest.createdAt)}`);
+ const root = output.vaultFolder.trim();
+ const folderPath = normalizePath(root ? `${root}/${folderName}` : folderName);
+ await this.ensureFolderExists(folderPath);
+
+ if (!output.storeLiveTranscriptInVault) {
+ return { folderPath };
+ }
+
+ const liveTranscriptNotePath = normalizePath(`${folderPath}/Live transcript.md`);
+ await this.getOrCreateFile(
+ liveTranscriptNotePath,
+ formatLiveTranscriptNote(`${manifest.scenarioLabel} — Transcript`, "")
+ );
+ return { folderPath, liveTranscriptNotePath };
+ }
+
+ async appendToNote(path: string, text: string): Promise {
+ const existing = this.app.vault.getAbstractFileByPath(path);
+ if (!(existing instanceof TFile)) return;
+ const current = await this.app.vault.read(existing);
+ await this.app.vault.modify(existing, `${current}${text}`);
+ }
+
+ async createSummaryNote(
+ manifest: RecordingSessionManifest,
+ output: OutputSettings,
+ markdown: string
+ ): Promise {
+ const workspace = await this.ensureSessionWorkspace(manifest, output);
+ const summaryNotePath = normalizePath(`${workspace.folderPath}/Summary.md`);
+ const file = await this.getOrCreateFile(summaryNotePath, markdown);
+ await this.app.vault.modify(file, markdown);
+ return summaryNotePath;
+ }
+
+ async createOrUpdateLiveTranscriptNote(
+ manifest: RecordingSessionManifest,
+ output: OutputSettings,
+ transcript: string
+ ): Promise {
+ const workspace = await this.ensureSessionWorkspace(manifest, output);
+ const liveTranscriptNotePath = workspace.liveTranscriptNotePath;
+ if (!liveTranscriptNotePath) return undefined;
+ const contents = formatLiveTranscriptNote(`${manifest.scenarioLabel} — Transcript`, transcript);
+ const file = await this.getOrCreateFile(liveTranscriptNotePath, contents);
+ await this.app.vault.modify(file, contents);
+ return liveTranscriptNotePath;
+ }
+
+ async openFile(path: string | undefined): Promise {
+ if (!path) return;
+ const file = this.app.vault.getAbstractFileByPath(path);
+ if (!(file instanceof TFile)) return;
+ await this.app.workspace.getLeaf(true).openFile(file);
+ }
+
+ async deleteFile(path: string | undefined): Promise {
+ if (!path) return;
+ const file = this.app.vault.getAbstractFileByPath(path);
+ if (!(file instanceof TFile)) return;
+ await this.app.vault.delete(file, true);
+ }
+
+ private async ensureFolderExists(folderPath: string): Promise {
+ const segments = normalizePath(folderPath)
+ .split("/")
+ .filter(Boolean);
+ let current = "";
+ for (const segment of segments) {
+ current = current ? normalizePath(`${current}/${segment}`) : segment;
+ if (this.app.vault.getAbstractFileByPath(current)) continue;
+ try {
+ await this.app.vault.createFolder(current);
+ } catch {}
+ }
+ }
+
+ private async getOrCreateFile(path: string, initialContents: string): Promise {
+ const existing = this.app.vault.getAbstractFileByPath(path);
+ if (existing instanceof TFile) return existing;
+ return await this.app.vault.create(path, initialContents);
+ }
+}
diff --git a/src/infrastructure/adapters/captureUtils.ts b/src/infrastructure/adapters/captureUtils.ts
new file mode 100644
index 0000000..ab7f397
--- /dev/null
+++ b/src/infrastructure/adapters/captureUtils.ts
@@ -0,0 +1,81 @@
+import type { CaptureSettings } from "../../domain/settings";
+import { resolveCaptureBackend } from "../../domain/settings";
+import { scanDevices, type ListedDevice } from "../system/deviceScanner";
+
+export interface ResolvedCaptureInputs {
+ backend: "avfoundation" | "dshow" | "pulse" | "alsa";
+ micSpec?: string;
+ systemSpec?: string;
+ micLabel?: string;
+ systemLabel?: string;
+}
+
+function stripDeviceIndexPrefix(label: string): string {
+ return label.replace(/^\d+:\s*/, "").trim();
+}
+
+function normalizeDshowDevice(value: string): string {
+ if (!value) return value;
+ return /^(audio=|video=|@device_)/i.test(value) ? value : `audio=${value}`;
+}
+
+function normalizeAvfoundationIndex(value: string): string {
+ if (!value) return "";
+ if (/^:\d+$/.test(value)) return value;
+ if (/^\d+$/.test(value)) return `:${value}`;
+ return "";
+}
+
+function findAvfoundationDevice(devices: ListedDevice[], name: string, label: string): ListedDevice | undefined {
+ const byLabel = label ? devices.find((device) => stripDeviceIndexPrefix(device.label) === stripDeviceIndexPrefix(label)) : undefined;
+ if (byLabel) return byLabel;
+ const normalizedName = normalizeAvfoundationIndex(name);
+ if (normalizedName) {
+ const byName = devices.find((device) => device.name === normalizedName);
+ if (byName) return byName;
+ }
+ return name ? devices.find((device) => device.name === name) : undefined;
+}
+
+export async function resolveCaptureInputs(capture: CaptureSettings): Promise {
+ const backend = resolveCaptureBackend(capture.backend);
+ if (backend === "avfoundation") {
+ let devices: ListedDevice[] = [];
+ try {
+ if (capture.ffmpegPath.trim()) {
+ devices = (await scanDevices(capture.ffmpegPath, backend)).filter((device) => device.type === "audio");
+ }
+ } catch {}
+
+ const resolvedMic = findAvfoundationDevice(devices, capture.microphoneDevice, capture.microphoneLabel) ?? devices[0];
+ const resolvedSystem = capture.systemDevice || capture.systemLabel
+ ? findAvfoundationDevice(devices, capture.systemDevice, capture.systemLabel)
+ : undefined;
+
+ return {
+ backend,
+ micSpec: resolvedMic?.name ?? ":0",
+ systemSpec: resolvedSystem?.name,
+ micLabel: resolvedMic?.label ?? capture.microphoneLabel,
+ systemLabel: resolvedSystem?.label ?? capture.systemLabel,
+ };
+ }
+
+ if (backend === "dshow") {
+ return {
+ backend,
+ micSpec: normalizeDshowDevice(capture.microphoneDevice || "audio=Microphone (default)"),
+ systemSpec: capture.systemDevice ? normalizeDshowDevice(capture.systemDevice) : "",
+ micLabel: capture.microphoneLabel,
+ systemLabel: capture.systemLabel,
+ };
+ }
+
+ return {
+ backend,
+ micSpec: capture.microphoneDevice || "default",
+ systemSpec: capture.systemDevice || "",
+ micLabel: capture.microphoneLabel,
+ systemLabel: capture.systemLabel,
+ };
+}
diff --git a/src/infrastructure/node.ts b/src/infrastructure/node.ts
new file mode 100644
index 0000000..d21120e
--- /dev/null
+++ b/src/infrastructure/node.ts
@@ -0,0 +1,22 @@
+import type { App } from "obsidian";
+
+interface DesktopWindow {
+ require?: (moduleName: string) => unknown;
+}
+
+export function requireNodeModule(name: string): T {
+ const req = (window as unknown as DesktopWindow).require;
+ if (!req) {
+ throw new Error("Resonance requires Obsidian desktop runtime.");
+ }
+ return req(name) as T;
+}
+
+export function getVaultBasePath(app: App): string {
+ const adapter = app.vault.adapter as { getBasePath?: () => string; basePath?: string };
+ return adapter?.getBasePath?.() ?? adapter?.basePath ?? "";
+}
+
+export function getVaultConfigDir(app: App): string {
+ return ((app.vault as unknown as { configDir?: string }).configDir ?? ".obsidian").trim() || ".obsidian";
+}
diff --git a/src/infrastructure/obsidianDesktop.ts b/src/infrastructure/obsidianDesktop.ts
new file mode 100644
index 0000000..0266875
--- /dev/null
+++ b/src/infrastructure/obsidianDesktop.ts
@@ -0,0 +1,49 @@
+import type { App, Plugin } from "obsidian";
+
+interface PluginManagerBridge {
+ getPlugin?: (pluginId: string) => Plugin | null | undefined;
+}
+
+interface SettingsBridge {
+ open?: () => void;
+ openTabById?: (pluginId: string) => void;
+}
+
+interface AppDesktopBridge {
+ plugins?: PluginManagerBridge;
+ setting?: SettingsBridge;
+}
+
+type ToggleableElement = HTMLElement & {
+ show?: () => void;
+ hide?: () => void;
+};
+
+export function getPluginInstance(app: App, pluginId: string): Plugin | null {
+ const desktop = app as unknown as App & AppDesktopBridge;
+ return desktop.plugins?.getPlugin?.(pluginId) ?? null;
+}
+
+export function openPluginSettings(app: App, pluginId: string): void {
+ const desktop = app as unknown as App & AppDesktopBridge;
+ desktop.setting?.open?.();
+ desktop.setting?.openTabById?.(pluginId);
+}
+
+export function setElementVisibility(element: HTMLElement, visible: boolean): void {
+ const toggleable = element as ToggleableElement;
+ if (visible) {
+ if (toggleable.show) {
+ toggleable.show();
+ return;
+ }
+ element.style.removeProperty("display");
+ return;
+ }
+
+ if (toggleable.hide) {
+ toggleable.hide();
+ return;
+ }
+ element.style.setProperty("display", "none");
+}
diff --git a/src/infrastructure/storage/SessionStore.ts b/src/infrastructure/storage/SessionStore.ts
new file mode 100644
index 0000000..c8e42b3
--- /dev/null
+++ b/src/infrastructure/storage/SessionStore.ts
@@ -0,0 +1,300 @@
+import type { App } from "obsidian";
+import { getSelectedSummaryModel } from "../../domain/providers";
+import type { PluginSettingsV2 } from "../../domain/settings";
+import type { DiagnosticsSummary, RecordingSessionManifest } from "../../domain/session";
+import type { ScenarioTemplate } from "../../domain/scenarios";
+import { getVaultBasePath, getVaultConfigDir, requireNodeModule } from "../node";
+
+interface FsModule {
+ accessSync: (path: string, mode?: number) => void;
+ appendFileSync: (path: string, contents: string, options?: { encoding: "utf8" }) => void;
+ constants: { F_OK: number };
+ existsSync: (path: string) => boolean;
+ mkdirSync: (path: string, options: { recursive: boolean }) => void;
+ readFileSync: (path: string, options?: { encoding: "utf8" }) => string | Buffer;
+ readdirSync: (path: string) => string[];
+ rmSync: (path: string, options: { recursive: boolean; force: boolean }) => void;
+ statSync: (path: string) => { isDirectory(): boolean; size: number };
+ unlinkSync: (path: string) => void;
+ writeFileSync: (path: string, contents: string, options?: { encoding: "utf8" }) => void;
+}
+
+interface PathModule {
+ join: (...parts: string[]) => string;
+}
+
+export class SessionStore {
+ constructor(private readonly app: App, private readonly pluginId: string) {}
+
+ getSessionsRootDir(): string {
+ const path = requireNodeModule("path");
+ const basePath = getVaultBasePath(this.app);
+ if (!basePath) throw new Error("Unable to determine vault base path.");
+ return path.join(basePath, getVaultConfigDir(this.app), "plugins", this.pluginId, "data", "sessions");
+ }
+
+ async createSession(
+ scenario: ScenarioTemplate,
+ settings: PluginSettingsV2,
+ diagnosticsSummary: DiagnosticsSummary
+ ): Promise {
+ const fs = requireNodeModule("fs");
+ const path = requireNodeModule("path");
+ const createdAt = new Date().toISOString();
+ const sessionId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ const rootDir = path.join(this.getSessionsRootDir(), sessionId);
+ const audioDir = path.join(rootDir, "audio");
+ const segmentsDir = path.join(audioDir, "segments");
+ const transcriptDir = path.join(rootDir, "transcript");
+ const summaryDir = path.join(rootDir, "summary");
+ const manifestPath = path.join(rootDir, "session.json");
+ const diagnosticsLogPath = path.join(rootDir, "diagnostics.log");
+ const transcriptTextPath = path.join(transcriptDir, "live-transcript.txt");
+ const summaryMarkdownPath = path.join(summaryDir, "summary.md");
+ const fullAudioPath = path.join(audioDir, "recording.mp3");
+
+ for (const dir of [this.getSessionsRootDir(), rootDir, audioDir, segmentsDir, transcriptDir, summaryDir]) {
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
+ }
+ fs.writeFileSync(transcriptTextPath, "", { encoding: "utf8" });
+ fs.writeFileSync(summaryMarkdownPath, "", { encoding: "utf8" });
+ fs.writeFileSync(diagnosticsLogPath, "", { encoding: "utf8" });
+
+ const manifest: RecordingSessionManifest = {
+ schemaVersion: 1,
+ sessionId,
+ createdAt,
+ updatedAt: createdAt,
+ scenarioKey: scenario.key,
+ scenarioLabel: scenario.label,
+ captureMode: settings.capture.systemDevice.trim() ? "microphone+system" : "microphone",
+ status: "preflight",
+ paths: {
+ rootDir,
+ manifestPath,
+ diagnosticsLogPath,
+ audioDir,
+ fullAudioPath,
+ segmentsDir,
+ transcriptDir,
+ transcriptTextPath,
+ summaryDir,
+ summaryMarkdownPath,
+ },
+ providerInfo: {
+ summaryProvider: settings.summary.provider,
+ transcriptionEngine: "whisper.cpp",
+ model: getSelectedSummaryModel(settings.summary),
+ },
+ diagnosticsSummary,
+ notes: {},
+ runtime: {
+ startedAt: createdAt,
+ lastActivityAt: createdAt,
+ elapsedSeconds: 0,
+ },
+ artifacts: {
+ hasAudio: false,
+ hasTranscript: false,
+ hasSummary: false,
+ },
+ live: {
+ committedSegments: 0,
+ lastCommittedSegment: -1,
+ },
+ errors: [],
+ };
+
+ await this.writeManifest(manifest);
+ return manifest;
+ }
+
+ async writeManifest(manifest: RecordingSessionManifest): Promise {
+ const fs = requireNodeModule("fs");
+ const now = new Date().toISOString();
+ manifest.updatedAt = now;
+ manifest.runtime.lastActivityAt = now;
+ if ((manifest.status === "done" || manifest.status === "failed") && !manifest.runtime.finishedAt) {
+ manifest.runtime.finishedAt = now;
+ }
+ this.refreshArtifactFlags(fs, manifest);
+ fs.writeFileSync(manifest.paths.manifestPath, JSON.stringify(manifest, null, 2), { encoding: "utf8" });
+ }
+
+ async appendDiagnostics(manifest: RecordingSessionManifest, message: string): Promise {
+ const fs = requireNodeModule("fs");
+ fs.appendFileSync(manifest.paths.diagnosticsLogPath, `[${new Date().toISOString()}] ${message}\n`, { encoding: "utf8" });
+ }
+
+ async appendTranscriptChunk(manifest: RecordingSessionManifest, chunk: string): Promise {
+ const fs = requireNodeModule("fs");
+ const current = fs.existsSync(manifest.paths.transcriptTextPath)
+ ? String(fs.readFileSync(manifest.paths.transcriptTextPath, { encoding: "utf8" })).trim()
+ : "";
+ const prefix = current ? "\n" : "";
+ fs.appendFileSync(manifest.paths.transcriptTextPath, `${prefix}${chunk.trim()}`, { encoding: "utf8" });
+ }
+
+ readTranscript(manifest: RecordingSessionManifest): string {
+ return this.readTextFile(manifest.paths.transcriptTextPath);
+ }
+
+ async writeSummary(manifest: RecordingSessionManifest, markdown: string): Promise {
+ const fs = requireNodeModule("fs");
+ fs.writeFileSync(manifest.paths.summaryMarkdownPath, markdown, { encoding: "utf8" });
+ }
+
+ async writeTranscript(manifest: RecordingSessionManifest, text: string): Promise {
+ const fs = requireNodeModule("fs");
+ fs.writeFileSync(manifest.paths.transcriptTextPath, text.trim(), { encoding: "utf8" });
+ }
+
+ async listSessions(): Promise {
+ const fs = requireNodeModule("fs");
+ const path = requireNodeModule("path");
+ const root = this.getSessionsRootDir();
+ if (!fs.existsSync(root)) return [];
+
+ const manifests: RecordingSessionManifest[] = [];
+ for (const entry of fs.readdirSync(root)) {
+ const dir = path.join(root, entry);
+ try {
+ if (!fs.statSync(dir).isDirectory()) continue;
+ const manifestPath = path.join(dir, "session.json");
+ const raw = JSON.parse(String(fs.readFileSync(manifestPath, { encoding: "utf8" }))) as RecordingSessionManifest;
+ const manifest = this.normalizeManifest(raw);
+ if (manifest.status !== "idle" && manifest.status !== "preflight") {
+ manifests.push(manifest);
+ }
+ } catch {}
+ }
+
+ return manifests.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
+ }
+
+ async readSessionByRootDir(rootDir: string): Promise {
+ const fs = requireNodeModule("fs");
+ const path = requireNodeModule("path");
+ try {
+ const manifestPath = path.join(rootDir, "session.json");
+ if (!fs.existsSync(manifestPath)) return null;
+ const raw = JSON.parse(String(fs.readFileSync(manifestPath, { encoding: "utf8" }))) as RecordingSessionManifest;
+ return this.normalizeManifest(raw);
+ } catch {
+ return null;
+ }
+ }
+
+ async pruneFinishedSessions(maxSessionsKept: number): Promise {
+ if (!Number.isFinite(maxSessionsKept) || maxSessionsKept <= 0) return;
+ const sessions = await this.listSessions();
+ const overflow = sessions.slice(maxSessionsKept);
+ for (const manifest of overflow) {
+ await this.deleteSessionFiles(manifest);
+ }
+ }
+
+ async deleteSessionFiles(manifest: RecordingSessionManifest): Promise {
+ await this.deleteSessionRootDir(manifest.paths.rootDir);
+ }
+
+ async deleteSessionRootDir(rootDir: string): Promise {
+ const fs = requireNodeModule("fs");
+ fs.rmSync(rootDir, { recursive: true, force: true });
+ }
+
+ async deleteAudioArtifacts(manifest: RecordingSessionManifest): Promise {
+ const fs = requireNodeModule("fs");
+ if (fs.existsSync(manifest.paths.fullAudioPath)) {
+ fs.rmSync(manifest.paths.fullAudioPath, { recursive: false, force: true });
+ }
+ if (fs.existsSync(manifest.paths.segmentsDir)) {
+ fs.rmSync(manifest.paths.segmentsDir, { recursive: true, force: true });
+ fs.mkdirSync(manifest.paths.segmentsDir, { recursive: true });
+ }
+ manifest.artifacts.hasAudio = false;
+ }
+
+ async deleteTranscriptArtifacts(manifest: RecordingSessionManifest): Promise {
+ const fs = requireNodeModule("fs");
+ fs.writeFileSync(manifest.paths.transcriptTextPath, "", { encoding: "utf8" });
+ manifest.artifacts.hasTranscript = false;
+ }
+
+ readTextFile(path: string): string {
+ const fs = requireNodeModule("fs");
+ if (!path || !fs.existsSync(path)) return "";
+ return String(fs.readFileSync(path, { encoding: "utf8" }));
+ }
+
+ getAudioSize(path: string): number {
+ const fs = requireNodeModule("fs");
+ try {
+ return fs.statSync(path).size || 0;
+ } catch {
+ return 0;
+ }
+ }
+
+ private normalizeManifest(manifest: RecordingSessionManifest): RecordingSessionManifest {
+ const fallbackTimestamp = manifest.updatedAt || manifest.createdAt || new Date().toISOString();
+ const normalized: RecordingSessionManifest = {
+ ...manifest,
+ notes: manifest.notes ?? {},
+ diagnosticsSummary: manifest.diagnosticsSummary ?? {
+ checkedAt: fallbackTimestamp,
+ blockingIssueIds: [],
+ warningIds: [],
+ summary: "No diagnostics summary was stored for this session.",
+ },
+ runtime: {
+ startedAt: manifest.runtime?.startedAt ?? manifest.createdAt ?? fallbackTimestamp,
+ lastActivityAt: manifest.runtime?.lastActivityAt ?? manifest.updatedAt ?? manifest.createdAt ?? fallbackTimestamp,
+ finishedAt:
+ manifest.runtime?.finishedAt ??
+ (manifest.status === "done" || manifest.status === "failed" ? manifest.updatedAt || manifest.createdAt : undefined),
+ elapsedSeconds: manifest.runtime?.elapsedSeconds ?? 0,
+ failureSummary:
+ manifest.runtime?.failureSummary ??
+ (Array.isArray(manifest.errors) && manifest.errors.length > 0 ? manifest.errors[manifest.errors.length - 1] : undefined),
+ },
+ artifacts: {
+ hasAudio: manifest.artifacts?.hasAudio ?? false,
+ hasTranscript: manifest.artifacts?.hasTranscript ?? false,
+ hasSummary: manifest.artifacts?.hasSummary ?? false,
+ },
+ live: manifest.live ?? {
+ committedSegments: 0,
+ lastCommittedSegment: -1,
+ },
+ errors: Array.isArray(manifest.errors) ? manifest.errors : [],
+ };
+ this.refreshArtifactFlags(requireNodeModule("fs"), normalized);
+ return normalized;
+ }
+
+ private refreshArtifactFlags(fs: FsModule, manifest: RecordingSessionManifest): void {
+ manifest.artifacts.hasAudio = manifest.artifacts.hasAudio || this.fileExists(fs, manifest.paths.fullAudioPath);
+ manifest.artifacts.hasTranscript =
+ manifest.artifacts.hasTranscript || this.readText(fs, manifest.paths.transcriptTextPath).trim().length > 0;
+ manifest.artifacts.hasSummary =
+ manifest.artifacts.hasSummary ||
+ this.readText(fs, manifest.paths.summaryMarkdownPath).trim().length > 0 ||
+ Boolean(manifest.notes.summaryNotePath);
+ }
+
+ private fileExists(fs: FsModule, path: string): boolean {
+ if (!path) return false;
+ try {
+ fs.accessSync(path, fs.constants.F_OK);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ private readText(fs: FsModule, path: string): string {
+ if (!path || !fs.existsSync(path)) return "";
+ return String(fs.readFileSync(path, { encoding: "utf8" }));
+ }
+}
diff --git a/src/infrastructure/system/DiagnosticsService.ts b/src/infrastructure/system/DiagnosticsService.ts
new file mode 100644
index 0000000..cb0f63d
--- /dev/null
+++ b/src/infrastructure/system/DiagnosticsService.ts
@@ -0,0 +1,334 @@
+import type { App } from "obsidian";
+import { getProviderCapabilities } from "../../domain/providers";
+import {
+ getSelectedProviderApiKey,
+ isLikelyTestWhisperModelPath,
+ isCoreConfigured,
+ resolveCaptureBackend,
+ type PluginSettingsV2,
+} from "../../domain/settings";
+import type { DiagnosticCheck, DiagnosticsReport } from "../../domain/diagnostics";
+import { requireNodeModule } from "../node";
+import { resolveCaptureInputs } from "../adapters/captureUtils";
+import { WhisperTranscriptionAdapter } from "../adapters/TranscriptionAdapter";
+import { scanDevices } from "./deviceScanner";
+
+export interface SmokeTestResult {
+ ok: boolean;
+ detail: string;
+}
+
+export class DiagnosticsService {
+ constructor(private readonly app: App) {}
+
+ async run(settings: PluginSettingsV2): Promise {
+ const fs = requireNodeModule<{
+ accessSync: (path: string, mode?: number) => void;
+ constants: { F_OK: number; R_OK: number; X_OK: number };
+ existsSync: (path: string) => boolean;
+ statSync: (path: string) => { size: number };
+ }>("fs");
+ const checks: DiagnosticCheck[] = [];
+ const backend = resolveCaptureBackend(settings.capture.backend);
+ const addCheck = (check: DiagnosticCheck) => checks.push(check);
+ const fileMode = process.platform === "win32" ? fs.constants.F_OK : fs.constants.X_OK;
+
+ const testPath = (path: string, mode: number): boolean => {
+ try {
+ fs.accessSync(path, mode);
+ return true;
+ } catch {
+ return false;
+ }
+ };
+
+ addCheck({
+ id: "core-config",
+ label: "Core configuration",
+ severity: isCoreConfigured(settings) ? "ok" : "error",
+ detail: isCoreConfigured(settings)
+ ? "Core local-first path is configured."
+ : "FFmpeg, whisper.cpp, model, or summary provider configuration is incomplete.",
+ remediation: "Open Setup & Settings in the plugin settings tab and complete the missing dependency step.",
+ });
+
+ addCheck({
+ id: "ffmpeg",
+ label: "FFmpeg binary",
+ severity: testPath(settings.capture.ffmpegPath, fileMode) ? "ok" : "error",
+ detail: settings.capture.ffmpegPath
+ ? `Configured path: ${settings.capture.ffmpegPath}`
+ : "FFmpeg path is empty.",
+ remediation: "Set the FFmpeg executable path or use auto-detect.",
+ });
+
+ addCheck({
+ id: "whisper-cli",
+ label: "whisper.cpp CLI",
+ severity: testPath(settings.transcription.whisperCliPath, fileMode) ? "ok" : "error",
+ detail: settings.transcription.whisperCliPath
+ ? `Configured path: ${settings.transcription.whisperCliPath}`
+ : "whisper.cpp CLI path is empty.",
+ remediation: "Build whisper.cpp and point the plugin to whisper-cli.",
+ });
+
+ addCheck({
+ id: "whisper-model",
+ label: "Whisper model",
+ severity: getWhisperModelSeverity(settings.transcription.modelPath, fs),
+ detail: getWhisperModelDetail(settings.transcription.modelPath, fs),
+ remediation: getWhisperModelRemediation(settings.transcription.modelPath, fs),
+ });
+
+ if (!settings.capture.microphoneDevice.trim()) {
+ addCheck({
+ id: "microphone",
+ label: "Microphone selection",
+ severity: "warning",
+ detail: "No microphone device selected yet.",
+ remediation: "Refresh devices and select the main microphone input.",
+ });
+ }
+
+ try {
+ if (settings.capture.ffmpegPath.trim()) {
+ const devices = await scanDevices(settings.capture.ffmpegPath, backend);
+ const audioDevices = devices.filter((device) => device.type === "audio");
+ const micPresent = !settings.capture.microphoneDevice.trim()
+ ? false
+ : audioDevices.some((device) => device.name === settings.capture.microphoneDevice || device.label === settings.capture.microphoneLabel);
+ addCheck({
+ id: "device-scan",
+ label: "Audio devices",
+ severity: audioDevices.length > 0 ? "ok" : "warning",
+ detail: audioDevices.length > 0
+ ? `${audioDevices.length} audio devices discovered for backend ${backend}.`
+ : `No audio devices discovered for backend ${backend}.`,
+ remediation: "Check OS permissions, FFmpeg backend, and input devices.",
+ });
+ if (settings.capture.microphoneDevice.trim()) {
+ addCheck({
+ id: "selected-microphone",
+ label: "Selected microphone",
+ severity: micPresent ? "ok" : "warning",
+ detail: micPresent
+ ? "Selected microphone is present in the current FFmpeg scan."
+ : "Selected microphone was not found in the current FFmpeg scan.",
+ remediation: "Refresh devices and reselect the microphone if indexes changed.",
+ });
+ }
+ }
+ } catch (error) {
+ addCheck({
+ id: "device-scan",
+ label: "Audio devices",
+ severity: "warning",
+ detail: `Device scan failed: ${String((error as Error)?.message ?? error)}`,
+ remediation: "Verify FFmpeg path and backend compatibility.",
+ });
+ }
+
+ const providerCapabilities = getProviderCapabilities(settings.summary.provider);
+ if (providerCapabilities.kind === "local") {
+ const ollamaHealthy = await this.checkOllamaHealth(settings.summary.ollamaEndpoint);
+ addCheck({
+ id: "ollama",
+ label: "Ollama endpoint",
+ severity: ollamaHealthy.ok ? "ok" : "error",
+ detail: ollamaHealthy.detail,
+ remediation: "Start Ollama locally or fix the configured endpoint.",
+ });
+ } else {
+ addCheck({
+ id: "cloud-provider-key",
+ label: `${providerCapabilities.label} API key`,
+ severity: getSelectedProviderApiKey(settings.summary).trim() ? "ok" : "error",
+ detail: getSelectedProviderApiKey(settings.summary).trim()
+ ? `${providerCapabilities.label} API key is configured.`
+ : `${providerCapabilities.label} API key is missing.`,
+ remediation: "Set the selected provider API key or switch back to Ollama.",
+ });
+ }
+
+ addCheck({
+ id: "vault-output",
+ label: "Vault output",
+ severity: this.app.vault.getName() ? "ok" : "warning",
+ detail: settings.output.vaultFolder.trim()
+ ? `Target vault folder: ${settings.output.vaultFolder}`
+ : "The session folder will be created at the vault root.",
+ remediation: "Set a dedicated output folder if you want generated notes grouped together.",
+ });
+
+ const blockingIssueIds = checks.filter((check) => check.severity === "error").map((check) => check.id);
+ const warningIds = checks.filter((check) => check.severity === "warning").map((check) => check.id);
+ return {
+ checkedAt: new Date().toISOString(),
+ provider: settings.summary.provider,
+ backend,
+ checks,
+ blockingIssueIds,
+ warningIds,
+ isHealthy: blockingIssueIds.length === 0,
+ summary:
+ blockingIssueIds.length === 0
+ ? "Diagnostics passed without blocking issues."
+ : `Diagnostics found ${blockingIssueIds.length} blocking issue(s) and ${warningIds.length} warning(s).`,
+ };
+ }
+
+ async runSmokeTest(settings: PluginSettingsV2): Promise {
+ if (!settings.capture.ffmpegPath.trim()) {
+ return { ok: false, detail: "FFmpeg path is missing." };
+ }
+ const resolvedInputs = await resolveCaptureInputs(settings.capture);
+ if (!resolvedInputs.micSpec) {
+ return { ok: false, detail: "No microphone input is configured." };
+ }
+
+ const fs = requireNodeModule<{
+ mkdtempSync: (prefix: string) => string;
+ existsSync: (path: string) => boolean;
+ unlinkSync: (path: string) => void;
+ rmSync: (path: string, options: { recursive: boolean; force: boolean }) => void;
+ }>("fs");
+ const os = requireNodeModule<{ tmpdir: () => string }>("os");
+ const path = requireNodeModule<{ join: (...parts: string[]) => string }>("path");
+ const { spawn } = requireNodeModule<{ spawn: Function }>("child_process");
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "resonance-next-smoke-"));
+ const audioPath = path.join(tmpRoot, "smoke.mp3");
+ const args = [
+ "-y",
+ "-f",
+ resolvedInputs.backend,
+ "-thread_queue_size",
+ "1024",
+ "-i",
+ resolvedInputs.micSpec,
+ "-t",
+ String(settings.diagnostics.quickTestDurationSeconds),
+ "-vn",
+ "-ar",
+ String(settings.capture.sampleRateHz),
+ "-ac",
+ String(settings.capture.channels),
+ "-c:a",
+ "libmp3lame",
+ "-b:a",
+ `${settings.capture.bitrateKbps}k`,
+ audioPath,
+ ];
+
+ try {
+ await new Promise((resolve, reject) => {
+ const child = spawn(settings.capture.ffmpegPath, args);
+ let stderr = "";
+ child.stderr?.on("data", (chunk: Buffer) => {
+ stderr += chunk.toString();
+ });
+ child.on("error", (error: Error) => reject(error));
+ child.on("close", (code: number) => {
+ if (code === 0) resolve();
+ else reject(new Error(stderr || `FFmpeg exited with code ${code}`));
+ });
+ });
+
+ if (settings.transcription.whisperCliPath.trim() && settings.transcription.modelPath.trim() && fs.existsSync(audioPath)) {
+ const adapter = new WhisperTranscriptionAdapter(settings.transcription, settings.capture.ffmpegPath);
+ const transcript = await adapter.transcribeFile(audioPath);
+ if (!transcript.trim()) {
+ return { ok: false, detail: "Quick test recorded audio, but whisper.cpp returned an empty transcript." };
+ }
+ }
+
+ if (getProviderCapabilities(settings.summary.provider).kind === "local") {
+ const ollamaHealth = await this.checkOllamaHealth(settings.summary.ollamaEndpoint);
+ if (!ollamaHealth.ok) return ollamaHealth;
+ }
+
+ return { ok: true, detail: "Quick smoke test completed." };
+ } catch (error) {
+ return { ok: false, detail: String((error as Error)?.message ?? error) };
+ } finally {
+ try {
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
+ } catch {}
+ }
+ }
+
+ private async checkOllamaHealth(endpoint: string): Promise {
+ const base = endpoint.trim() || "http://localhost:11434";
+ try {
+ const controller = new AbortController();
+ const timeoutId = window.setTimeout(() => controller.abort(), 3_000);
+ const response = await fetch(`${base}/api/tags`, { signal: controller.signal });
+ window.clearTimeout(timeoutId);
+ if (!response.ok) {
+ return { ok: false, detail: `Ollama endpoint returned ${response.status}.` };
+ }
+ return { ok: true, detail: `Ollama reachable at ${base}.` };
+ } catch (error) {
+ return { ok: false, detail: `Ollama check failed: ${String((error as Error)?.message ?? error)}` };
+ }
+ }
+}
+
+function getWhisperModelSeverity(
+ modelPath: string,
+ fs: {
+ constants: { R_OK: number };
+ accessSync: (path: string, mode?: number) => void;
+ existsSync: (path: string) => boolean;
+ statSync: (path: string) => { size: number };
+ }
+): "ok" | "error" {
+ if (!modelPath) return "error";
+ if (isLikelyTestWhisperModelPath(modelPath)) return "error";
+ try {
+ fs.accessSync(modelPath, fs.constants.R_OK);
+ return fs.statSync(modelPath).size >= 10 * 1024 * 1024 ? "ok" : "error";
+ } catch {
+ return "error";
+ }
+}
+
+function getWhisperModelDetail(
+ modelPath: string,
+ fs: {
+ existsSync: (path: string) => boolean;
+ statSync: (path: string) => { size: number };
+ }
+): string {
+ if (!modelPath) return "Whisper model path is empty.";
+ if (isLikelyTestWhisperModelPath(modelPath)) {
+ return `Configured path: ${modelPath} (this is a whisper.cpp test model without real weights).`;
+ }
+ try {
+ if (fs.existsSync(modelPath)) {
+ const sizeMb = (fs.statSync(modelPath).size / (1024 * 1024)).toFixed(1);
+ if (fs.statSync(modelPath).size < 10 * 1024 * 1024) {
+ return `Configured path: ${modelPath} (${sizeMb} MiB, suspiciously small for a real Whisper model).`;
+ }
+ return `Configured path: ${modelPath} (${sizeMb} MiB).`;
+ }
+ } catch {}
+ return `Configured path: ${modelPath}`;
+}
+
+function getWhisperModelRemediation(
+ modelPath: string,
+ fs: {
+ existsSync: (path: string) => boolean;
+ statSync: (path: string) => { size: number };
+ }
+): string {
+ if (isLikelyTestWhisperModelPath(modelPath)) {
+ return "Download a real ggml model such as ggml-base.bin or ggml-small.bin. Do not use files prefixed with for-tests-.";
+ }
+ try {
+ if (modelPath && fs.existsSync(modelPath) && fs.statSync(modelPath).size < 10 * 1024 * 1024) {
+ return "Select a real ggml Whisper model. Valid models are typically tens or hundreds of MiB, not a few KiB or MiB.";
+ }
+ } catch {}
+ return "Download or select a readable ggml model file.";
+}
diff --git a/src/infrastructure/system/autoDetect.ts b/src/infrastructure/system/autoDetect.ts
new file mode 100644
index 0000000..471f721
--- /dev/null
+++ b/src/infrastructure/system/autoDetect.ts
@@ -0,0 +1,282 @@
+import type { TranscriptionSettings } from "../../domain/settings";
+import { requireNodeModule } from "../node";
+
+type WhisperModelPreset = TranscriptionSettings["modelPreset"];
+
+interface ChildProcessModule {
+ spawn: typeof import("node:child_process").spawn;
+}
+
+interface FsModule {
+ existsSync: (path: string) => boolean;
+ statSync: (path: string) => { isFile(): boolean; isDirectory(): boolean };
+ readdirSync: (path: string) => string[];
+}
+
+interface OsModule {
+ homedir: () => string;
+}
+
+interface PathModule {
+ join: (...parts: string[]) => string;
+}
+
+export async function autoDetectFfmpeg(): Promise {
+ const fromPath = await detectCommandInPath("ffmpeg");
+ if (fromPath) return fromPath;
+
+ try {
+ const fs = requireNodeModule("fs");
+ const candidates =
+ process.platform === "win32"
+ ? [
+ "C:/ffmpeg/bin/ffmpeg.exe",
+ "C:/Program Files/ffmpeg/bin/ffmpeg.exe",
+ "C:/Program Files (x86)/ffmpeg/bin/ffmpeg.exe",
+ "C:/ProgramData/chocolatey/bin/ffmpeg.exe",
+ ]
+ : ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"];
+
+ for (const candidate of candidates) {
+ if (fs.existsSync(candidate)) return candidate;
+ }
+ } catch {}
+
+ return null;
+}
+
+export async function autoDetectWhisperRepo(): Promise {
+ const cliFromPath = await autoDetectWhisperCli(undefined, true);
+ const inferred = inferWhisperRepoPath(cliFromPath);
+ if (inferred && directoryLooksLikeWhisperRepo(inferred)) {
+ return inferred;
+ }
+
+ for (const root of getLikelyWhisperRoots()) {
+ if (directoryLooksLikeWhisperRepo(root)) return root;
+ }
+
+ return null;
+}
+
+export async function autoDetectWhisperCli(
+ repoPath: string | undefined | null,
+ skipRepoAutoDetect = false
+): Promise {
+ const directPath = await detectCommandInPath("whisper-cli");
+ if (directPath) return directPath;
+
+ const roots = uniqueStrings([
+ repoPath ?? undefined,
+ skipRepoAutoDetect ? undefined : await autoDetectWhisperRepo(),
+ ...getLikelyWhisperRoots(),
+ ]);
+
+ for (const root of roots) {
+ const candidate = findWhisperCliUnderRoot(root);
+ if (candidate) return candidate;
+ }
+
+ return null;
+}
+
+export async function autoDetectWhisperModel(options: {
+ repoPath?: string | null;
+ whisperCliPath?: string | null;
+ preset?: WhisperModelPreset;
+}): Promise {
+ const preset = options.preset ?? "medium";
+ const roots = uniqueStrings([
+ options.repoPath ?? undefined,
+ inferWhisperRepoPath(options.whisperCliPath),
+ await autoDetectWhisperRepo(),
+ ...getLikelyWhisperRoots(),
+ ]);
+
+ for (const root of roots) {
+ const candidate = findPreferredModelUnderRoot(root, preset);
+ if (candidate) return candidate;
+ }
+
+ return null;
+}
+
+export function inferWhisperRepoPath(whisperCliPath: string | undefined | null): string | null {
+ if (!whisperCliPath) return null;
+ const normalized = whisperCliPath.replace(/\\/g, "/").trim();
+ if (!normalized) return null;
+ const match = normalized.match(/^(.*)\/build\/bin(?:\/Release)?\/(?:whisper-cli|main)(?:\.exe)?$/i);
+ return match?.[1] ?? null;
+}
+
+export function getPreferredWhisperModelBasenames(preset: WhisperModelPreset): string[] {
+ const orderedPresets = uniqueStrings([preset, "base", "small", "medium", "large"]) as WhisperModelPreset[];
+ const basenames: string[] = [];
+ for (const entry of orderedPresets) {
+ basenames.push(`ggml-${entry}.bin`, `ggml-${entry}.en.bin`);
+ }
+ return basenames;
+}
+
+async function detectCommandInPath(command: string): Promise {
+ try {
+ const { spawn } = requireNodeModule("child_process");
+ const lookupCommand = process.platform === "win32" ? "where" : "which";
+ const found = await new Promise((resolve) => {
+ const child = spawn(lookupCommand, [command]);
+ let output = "";
+ child.stdout?.on("data", (chunk: Buffer) => {
+ output += chunk.toString();
+ });
+ child.on("close", () => {
+ const line = output
+ .split(/\r?\n/)
+ .map((value) => value.trim())
+ .find(Boolean);
+ resolve(line ?? null);
+ });
+ child.on("error", () => resolve(null));
+ });
+ return found;
+ } catch {
+ return null;
+ }
+}
+
+function getLikelyWhisperRoots(): string[] {
+ try {
+ const os = requireNodeModule("os");
+ const path = requireNodeModule("path");
+ const home = os.homedir();
+ return uniqueStrings([
+ path.join(home, "whisper.cpp"),
+ path.join(home, "code", "whisper.cpp"),
+ path.join(home, "Code", "whisper.cpp"),
+ path.join(home, "dev", "whisper.cpp"),
+ path.join(home, "Developer", "whisper.cpp"),
+ path.join(home, "Documents", "whisper.cpp"),
+ path.join(process.cwd(), "whisper.cpp"),
+ path.join(process.cwd(), "..", "whisper.cpp"),
+ ]);
+ } catch {
+ return [];
+ }
+}
+
+function directoryLooksLikeWhisperRepo(root: string | undefined | null): boolean {
+ if (!root) return false;
+ try {
+ const fs = requireNodeModule("fs");
+ const path = requireNodeModule("path");
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) return false;
+ return fs.existsSync(path.join(root, "models")) || fs.existsSync(path.join(root, "build"));
+ } catch {
+ return false;
+ }
+}
+
+function findWhisperCliUnderRoot(root: string): string | null {
+ try {
+ const fs = requireNodeModule("fs");
+ const path = requireNodeModule("path");
+ const candidates = [
+ ["build", "bin", "whisper-cli"],
+ ["build", "bin", "whisper-cli.exe"],
+ ["build", "bin", "Release", "whisper-cli"],
+ ["build", "bin", "Release", "whisper-cli.exe"],
+ ["build", "bin", "main"],
+ ["build", "bin", "main.exe"],
+ ["build", "bin", "Release", "main"],
+ ["build", "bin", "Release", "main.exe"],
+ ].map((parts) => path.join(root, ...parts));
+
+ for (const candidate of candidates) {
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate;
+ }
+
+ return walkFind(root, 3, (fullPath) => /(whisper-cli|main)(\.exe)?$/i.test(fullPath));
+ } catch {
+ return null;
+ }
+}
+
+function findPreferredModelUnderRoot(root: string, preset: WhisperModelPreset): string | null {
+ try {
+ const fs = requireNodeModule("fs");
+ const path = requireNodeModule("path");
+ const basenames = getPreferredWhisperModelBasenames(preset);
+ const directCandidates = [
+ ...basenames.map((basename) => path.join(root, "models", basename)),
+ ...basenames.map((basename) => path.join(root, basename)),
+ ...basenames.map((basename) => path.join(root, "build", "bin", basename)),
+ ];
+
+ for (const candidate of directCandidates) {
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile() && !isTestModelPath(candidate)) return candidate;
+ }
+
+ const modelDir = path.join(root, "models");
+ const discovered = walkCollect(modelDir, 2, (fullPath) => /ggml-.*\.bin$/i.test(fullPath) && !isTestModelPath(fullPath));
+ if (discovered.length === 0) return null;
+
+ const preferred = basenames.find((basename) =>
+ discovered.some((candidate) => candidate.replace(/\\/g, "/").toLowerCase().endsWith(`/${basename.toLowerCase()}`))
+ );
+ if (preferred) {
+ return discovered.find((candidate) => candidate.replace(/\\/g, "/").toLowerCase().endsWith(`/${preferred.toLowerCase()}`)) ?? null;
+ }
+
+ return discovered[0] ?? null;
+ } catch {
+ return null;
+ }
+}
+
+function isTestModelPath(fullPath: string): boolean {
+ const normalized = fullPath.replace(/\\/g, "/").toLowerCase();
+ const basename = normalized.split("/").pop() ?? "";
+ return basename.startsWith("for-tests-");
+}
+
+function walkFind(root: string, depth: number, match: (path: string) => boolean): string | null {
+ for (const entry of walkCollect(root, depth, match)) {
+ return entry;
+ }
+ return null;
+}
+
+function walkCollect(root: string, depth: number, match: (path: string) => boolean): string[] {
+ const fs = requireNodeModule("fs");
+ const path = requireNodeModule("path");
+ if (depth < 0 || !fs.existsSync(root)) return [];
+
+ const results: string[] = [];
+ for (const entry of fs.readdirSync(root)) {
+ const fullPath = path.join(root, entry);
+ try {
+ const stat = fs.statSync(fullPath);
+ if (stat.isFile() && match(fullPath)) {
+ results.push(fullPath);
+ continue;
+ }
+ if (stat.isDirectory()) {
+ results.push(...walkCollect(fullPath, depth - 1, match));
+ }
+ } catch {}
+ }
+
+ return results;
+}
+
+function uniqueStrings(values: Array): string[] {
+ const seen = new Set();
+ const result: string[] = [];
+ for (const value of values) {
+ if (!value) continue;
+ const normalized = value.trim();
+ if (!normalized || seen.has(normalized)) continue;
+ seen.add(normalized);
+ result.push(normalized);
+ }
+ return result;
+}
diff --git a/src/infrastructure/system/deviceScanner.ts b/src/infrastructure/system/deviceScanner.ts
new file mode 100644
index 0000000..00c195d
--- /dev/null
+++ b/src/infrastructure/system/deviceScanner.ts
@@ -0,0 +1,109 @@
+import type { ChildProcessWithoutNullStreams } from "node:child_process";
+import { requireNodeModule } from "../node";
+
+export interface ListedDevice {
+ backend: "dshow" | "avfoundation" | "pulse" | "alsa";
+ type: "audio" | "video" | "unknown";
+ name: string;
+ label: string;
+}
+
+interface ChildProcessModule {
+ spawn: typeof import("node:child_process").spawn;
+}
+
+export function parseFfmpegDeviceList(
+ output: string,
+ backend: "dshow" | "avfoundation" | "pulse" | "alsa"
+): ListedDevice[] {
+ const devices: ListedDevice[] = [];
+ const lines = output.split(/\r?\n/);
+
+ if (backend === "dshow") {
+ let section: "audio" | "video" | "unknown" = "unknown";
+ for (const rawLine of lines) {
+ const line = rawLine.trim();
+ if (/DirectShow audio devices/i.test(line)) {
+ section = "audio";
+ continue;
+ }
+ if (/DirectShow video devices/i.test(line)) {
+ section = "video";
+ continue;
+ }
+ if (/Alternative name\s+"/.test(line)) continue;
+ const match = line.match(/\s*"(.+?)"/);
+ if (!match) continue;
+ const label = match[1];
+ if (/^@device_/i.test(label)) continue;
+ const type = section;
+ const name = type === "audio" ? `audio=${label}` : label;
+ devices.push({ backend, type, name, label });
+ }
+
+ const seen = new Set();
+ return devices.filter((device) => {
+ const key = `${device.type}|${device.label}`;
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ });
+ }
+
+ if (backend === "avfoundation") {
+ let section: "audio" | "video" | "unknown" = "unknown";
+ for (const line of lines) {
+ if (/AVFoundation video devices/i.test(line)) {
+ section = "video";
+ continue;
+ }
+ if (/AVFoundation audio devices/i.test(line)) {
+ section = "audio";
+ continue;
+ }
+ const match = line.match(/\[(\d+)\]\s+(.+)/);
+ if (!match) continue;
+ const index = match[1];
+ const label = match[2];
+ devices.push({
+ backend,
+ type: section,
+ name: section === "audio" ? `:${index}` : `${index}:`,
+ label: `${index}: ${label}`,
+ });
+ }
+ return devices;
+ }
+
+ return [{ backend, type: "audio", name: "default", label: "default" }];
+}
+
+export async function scanDevices(
+ ffmpegPath: string,
+ backend: "dshow" | "avfoundation" | "pulse" | "alsa"
+): Promise {
+ const { spawn } = requireNodeModule("child_process");
+ const args =
+ backend === "dshow"
+ ? ["-list_devices", "true", "-f", "dshow", "-i", "dummy"]
+ : backend === "avfoundation"
+ ? ["-f", "avfoundation", "-list_devices", "true", "-i", ""]
+ : backend === "pulse"
+ ? ["-f", "pulse", "-sources", "pulse"]
+ : ["-f", "alsa", "-sources", "alsa"];
+
+ const output = await new Promise((resolve, reject) => {
+ const child: ChildProcessWithoutNullStreams = spawn(ffmpegPath, args);
+ let buffer = "";
+ child.stdout?.on("data", (chunk: Buffer) => {
+ buffer += chunk.toString();
+ });
+ child.stderr?.on("data", (chunk: Buffer) => {
+ buffer += chunk.toString();
+ });
+ child.on("error", (error: Error) => reject(error));
+ child.on("close", () => resolve(buffer));
+ });
+
+ return parseFfmpegDeviceList(output, backend);
+}
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..0b55658
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,202 @@
+import { Notice, Plugin } from "obsidian";
+import { SessionController } from "./application/SessionController";
+import { DEFAULT_SCENARIO_KEY } from "./domain/scenarios";
+import { isCoreConfigured, normalizeSettingsV2, type PluginSettingsV2 } from "./domain/settings";
+import { setElementVisibility, openPluginSettings } from "./infrastructure/obsidianDesktop";
+import { formatDuration } from "./utils/format";
+import { uiCopy } from "./ui/copy";
+import { ResonanceNextSettingTab, setPreferredSettingsTab } from "./ui/SettingsTab";
+import { RecordingModal } from "./ui/modals/RecordingModal";
+
+export default class ResonanceNextPlugin extends Plugin {
+ settings!: PluginSettingsV2;
+ private controller!: SessionController;
+ private controlRibbonEl!: HTMLElement;
+ private libraryRibbonEl!: HTMLElement;
+ private statusBarEl!: HTMLElement;
+ private recordingModal: RecordingModal | null = null;
+
+ async onload() {
+ await this.loadSettings();
+ this.controller = new SessionController({
+ app: this.app,
+ pluginId: this.manifest.id,
+ getSettings: () => this.settings,
+ saveSettings: async (updater) => {
+ await this.updateSettings(updater);
+ },
+ });
+
+ this.controller.onSnapshot = (snapshot) => {
+ this.updateStatusBar(snapshot.state, snapshot.elapsedSeconds, snapshot.message);
+ this.updateRibbonState(snapshot.state);
+ };
+ this.controller.onError = (message) => {
+ new Notice(`Resonance: ${message}`);
+ };
+ this.controller.onInfo = (message) => {
+ new Notice(message);
+ };
+
+ this.controlRibbonEl = this.addRibbonIcon("mic", uiCopy.actions.openRecorder, () => {
+ this.openRecorder();
+ });
+ this.controlRibbonEl.addClass("rxn-ribbon");
+
+ this.libraryRibbonEl = this.addRibbonIcon("audio-file", uiCopy.actions.openLibrary, () => {
+ this.openLibrary();
+ });
+ this.libraryRibbonEl.addClass("rxn-ribbon");
+
+ this.statusBarEl = this.addStatusBarItem();
+ this.statusBarEl.addClass("rxn-statusbar");
+ this.statusBarEl.setText("");
+ this.statusBarEl.addEventListener("click", () => {
+ this.openRecorder();
+ });
+ setElementVisibility(this.statusBarEl, false);
+
+ this.addCommand({
+ id: "resonance-next-open-control-room",
+ name: uiCopy.actions.openRecorder,
+ callback: () => {
+ this.openRecorder();
+ },
+ });
+ this.addCommand({
+ id: "resonance-next-open-diagnostics",
+ name: uiCopy.actions.openDiagnostics,
+ callback: () => {
+ this.openDiagnostics();
+ },
+ });
+ this.addCommand({
+ id: "resonance-next-quick-toggle-session",
+ name: "Start the last scenario or stop the active session",
+ callback: async () => {
+ await this.quickToggleSession();
+ },
+ });
+ this.addCommand({
+ id: "resonance-next-open-library",
+ name: uiCopy.actions.openLibrary,
+ callback: () => {
+ this.openLibrary();
+ },
+ });
+ this.addCommand({
+ id: "resonance-next-open-setup",
+ name: uiCopy.actions.openSetupGuide,
+ callback: () => {
+ this.openSetupGuide();
+ },
+ });
+
+ this.addSettingTab(
+ new ResonanceNextSettingTab(this.app, {
+ pluginId: this.manifest.id,
+ getSettings: () => this.settings,
+ saveSettings: async (updater) => {
+ await this.updateSettings(updater);
+ },
+ controller: this.controller,
+ })
+ );
+
+ this.app.workspace.onLayoutReady(() => {
+ if (!isCoreConfigured(this.settings) && this.settings.ui.showSetupWizardOnStartup) {
+ this.openSetupGuide();
+ } else if (this.settings.ui.showDiagnosticsOnStartup) {
+ this.openDiagnostics();
+ }
+ });
+ }
+
+ async onunload() {
+ this.recordingModal?.close();
+ this.recordingModal = null;
+ }
+
+ private async quickToggleSession() {
+ const state = this.controller.getSnapshot().state;
+ if (["idle", "done", "failed"].includes(state)) {
+ try {
+ await this.controller.startScenario(this.settings.ui.lastScenarioKey || DEFAULT_SCENARIO_KEY);
+ } catch (error) {
+ new Notice(String((error as Error)?.message ?? error));
+ }
+ return;
+ }
+
+ if (["preflight", "segmenting", "recording", "transcribing_live", "stopping"].includes(state)) {
+ try {
+ await this.controller.stop();
+ } catch (error) {
+ new Notice(String((error as Error)?.message ?? error));
+ }
+ return;
+ }
+
+ new Notice(uiCopy.notices.sessionBusy);
+ }
+
+ private updateStatusBar(state: string, elapsedSeconds: number, message?: string) {
+ const status =
+ state === "recording" || state === "transcribing_live"
+ ? `Resonance ${formatDuration(elapsedSeconds)}`
+ : message || "";
+ this.statusBarEl.setText(status);
+ setElementVisibility(this.statusBarEl, Boolean(status));
+ }
+
+ private updateRibbonState(state: string) {
+ if (!this.controlRibbonEl) return;
+ this.controlRibbonEl.removeClass("is-recording");
+ this.controlRibbonEl.removeClass("is-busy");
+ if (state === "recording" || state === "transcribing_live") {
+ this.controlRibbonEl.addClass("is-recording");
+ } else if (!["idle", "done", "failed"].includes(state)) {
+ this.controlRibbonEl.addClass("is-busy");
+ }
+ }
+
+ private openRecorder() {
+ if (this.recordingModal?.isOpen()) return;
+ const modal = new RecordingModal(this.app, {
+ pluginId: this.manifest.id,
+ controller: this.controller,
+ getSettings: () => this.settings,
+ onClosed: () => {
+ if (this.recordingModal === modal) {
+ this.recordingModal = null;
+ }
+ },
+ });
+ this.recordingModal = modal;
+ modal.open();
+ }
+
+ private openLibrary() {
+ setPreferredSettingsTab("library");
+ openPluginSettings(this.app, this.manifest.id);
+ }
+
+ private openDiagnostics() {
+ setPreferredSettingsTab("control-room");
+ openPluginSettings(this.app, this.manifest.id);
+ }
+
+ private openSetupGuide() {
+ setPreferredSettingsTab("capture");
+ openPluginSettings(this.app, this.manifest.id);
+ }
+
+ private async loadSettings() {
+ this.settings = normalizeSettingsV2(await this.loadData());
+ }
+
+ private async updateSettings(updater: (current: PluginSettingsV2) => PluginSettingsV2) {
+ this.settings = normalizeSettingsV2(updater(this.settings));
+ await this.saveData(this.settings);
+ }
+}
diff --git a/src/ui/SettingsTab.ts b/src/ui/SettingsTab.ts
new file mode 100644
index 0000000..5ac8145
--- /dev/null
+++ b/src/ui/SettingsTab.ts
@@ -0,0 +1,1495 @@
+import { App, Notice, PluginSettingTab, Setting } from "obsidian";
+import { buildDashboardSnapshot, deriveSessionListItem } from "../application/dashboard";
+import type { SessionController } from "../application/SessionController";
+import type { DashboardSnapshot } from "../domain/dashboard";
+import type { SessionListItem } from "../domain/session";
+import { isCoreConfigured, resolveCaptureBackend, type PluginSettingsV2 } from "../domain/settings";
+import { VaultAdapter } from "../infrastructure/adapters/VaultAdapter";
+import { requireNodeModule } from "../infrastructure/node";
+import {
+ autoDetectFfmpeg,
+ autoDetectWhisperCli,
+ autoDetectWhisperModel,
+ autoDetectWhisperRepo,
+ inferWhisperRepoPath,
+} from "../infrastructure/system/autoDetect";
+import { getPluginInstance } from "../infrastructure/obsidianDesktop";
+import { scanDevices } from "../infrastructure/system/deviceScanner";
+import { SessionStore } from "../infrastructure/storage/SessionStore";
+import { formatBytes, formatDuration } from "../utils/format";
+import { uiCopy } from "./copy";
+import { TextPreviewModal } from "./modals/TextPreviewModal";
+
+export type SettingsSurfaceTab = "control-room" | "library" | "capture" | "transcription" | "summary" | "output";
+type GuidePlatform = "macos" | "linux" | "windows";
+type LibraryFilter = "all" | "done" | "failed";
+
+interface SettingsTabOptions {
+ pluginId: string;
+ getSettings: () => PluginSettingsV2;
+ saveSettings: (updater: (current: PluginSettingsV2) => PluginSettingsV2) => Promise;
+ controller: SessionController;
+}
+
+interface PlatformGuide {
+ text?: string;
+ bullets?: string[];
+ code?: string;
+ codeBlocks?: Array<{
+ label?: string;
+ code: string;
+ }>;
+}
+
+interface GuideSectionOptions {
+ badge: string;
+ title: string;
+ intro: string;
+ bullets?: string[];
+ code?: string;
+ platformGuides?: Partial>;
+}
+
+interface SettingsTabDefinition {
+ key: SettingsSurfaceTab;
+ label: string;
+ subtitle: string;
+}
+
+const WORKSPACE_TABS: SettingsTabDefinition[] = [
+ {
+ key: "control-room",
+ label: "Diagnostics",
+ subtitle: "Health and blocking issues.",
+ },
+ {
+ key: "library",
+ label: "Library",
+ subtitle: "Sessions, notes, artifacts.",
+ },
+];
+
+const SETUP_TABS: SettingsTabDefinition[] = [
+ {
+ key: "capture",
+ label: "Capture",
+ subtitle: "FFmpeg, devices, quick test.",
+ },
+ {
+ key: "transcription",
+ label: "Transcription",
+ subtitle: "whisper.cpp and model.",
+ },
+ {
+ key: "summary",
+ label: "Summary",
+ subtitle: "Provider and model.",
+ },
+ {
+ key: "output",
+ label: "Output",
+ subtitle: "Notes, retention, startup.",
+ },
+];
+
+const GUIDE_PLATFORM_ORDER: GuidePlatform[] = ["macos", "linux", "windows"];
+
+const GUIDE_PLATFORM_LABELS: Record = {
+ macos: "macOS",
+ linux: "Linux",
+ windows: "Windows",
+};
+
+let preferredSettingsTab: SettingsSurfaceTab = "capture";
+
+export function setPreferredSettingsTab(tab: SettingsSurfaceTab): void {
+ preferredSettingsTab = tab;
+}
+
+export class ResonanceNextSettingTab extends PluginSettingTab {
+ private readonly store: SessionStore;
+ private readonly vaultAdapter: VaultAdapter;
+ private readonly audioUrlCache = new Map();
+ private activeTab: SettingsSurfaceTab = preferredSettingsTab;
+ private libraryItems: SessionListItem[] = [];
+ private libraryFilter: LibraryFilter = "all";
+ private libraryQuery = "";
+ private openAudioSessionId: string | null = null;
+ private libraryBusySessionId: string | null = null;
+ private libraryBusyAction: "transcript" | "summary" | null = null;
+ private isQuickTestRunning = false;
+ private smokeMessage: string | null = null;
+
+ constructor(app: App, private readonly options: SettingsTabOptions) {
+ super(app, getPluginInstance(app, options.pluginId)!);
+ this.store = new SessionStore(app, options.pluginId);
+ this.vaultAdapter = new VaultAdapter(app);
+ }
+
+ async display() {
+ this.activeTab = preferredSettingsTab;
+ if (this.activeTab === "control-room") {
+ await this.refreshControlRoomData(true);
+ } else if (this.activeTab === "library" && this.libraryItems.length === 0) {
+ await this.refreshLibraryData();
+ }
+
+ const { containerEl } = this;
+ containerEl.empty();
+ containerEl.addClass("rxn-settings");
+
+ this.renderHero(containerEl);
+ this.renderTabs(containerEl);
+
+ const body = containerEl.createEl("div", { cls: "rxn-settings-surface" });
+ if (this.activeTab === "control-room") {
+ await this.renderControlRoomTab(body);
+ return;
+ }
+
+ if (this.activeTab === "library") {
+ await this.renderLibraryTab(body);
+ return;
+ }
+
+ if (this.activeTab === "capture") {
+ await this.renderCaptureTab(body);
+ return;
+ }
+
+ if (this.activeTab === "transcription") {
+ await this.renderTranscriptionTab(body);
+ return;
+ }
+
+ if (this.activeTab === "summary") {
+ await this.renderSummaryTab(body);
+ return;
+ }
+
+ await this.renderOutputTab(body);
+ }
+
+ private renderHero(container: HTMLElement) {
+ const hero = container.createEl("div", { cls: "rxn-card rxn-hero" });
+ hero.createEl("h2", { text: uiCopy.appName, cls: "rxn-hero-brand" });
+ hero.createEl("p", {
+ text: uiCopy.settings.title,
+ cls: "rxn-muted rxn-hero-subtitle",
+ });
+ }
+
+ private renderTabs(container: HTMLElement) {
+ const groups = container.createEl("div", { cls: "rxn-settings-tab-groups" });
+ this.renderTabGroup(groups, "Workspace", WORKSPACE_TABS);
+ this.renderTabGroup(groups, "Setup", SETUP_TABS, true);
+ }
+
+ private async switchTab(tab: SettingsSurfaceTab) {
+ preferredSettingsTab = tab;
+ this.activeTab = tab;
+ if (tab === "library") {
+ await this.refreshLibraryData();
+ }
+ await this.display();
+ }
+
+ private renderTabGroup(
+ container: HTMLElement,
+ title: string,
+ tabs: SettingsTabDefinition[],
+ numbered = false
+ ) {
+ const group = container.createEl("div", {
+ cls: numbered ? "rxn-settings-tab-group is-setup" : "rxn-settings-tab-group is-workspace",
+ });
+ group.createEl("span", { text: title, cls: "rxn-settings-tab-group-label" });
+ const row = group.createEl("div", { cls: "rxn-settings-tabs" });
+ tabs.forEach((tab, index) => {
+ const button = row.createEl("button", { cls: "rxn-settings-tab" });
+ if (tab.key === this.activeTab) {
+ button.addClass("is-selected");
+ }
+ button.setAttribute("title", tab.subtitle);
+ button.createEl("strong", { text: numbered ? `${index + 1}. ${tab.label}` : tab.label });
+ button.addEventListener("click", () => {
+ void this.switchTab(tab.key);
+ });
+ });
+ }
+
+ private async refreshControlRoomData(forceDiagnostics: boolean) {
+ if (forceDiagnostics) {
+ await this.options.controller.runDiagnostics();
+ }
+ }
+
+ private async refreshLibraryData() {
+ const manifests = await this.store.listSessions();
+ this.libraryItems = manifests.map((manifest) =>
+ deriveSessionListItem(manifest, this.store.getAudioSize(manifest.paths.fullAudioPath))
+ );
+ }
+
+ private buildDashboardSnapshot(): DashboardSnapshot {
+ return buildDashboardSnapshot({
+ runtime: this.options.controller.getSnapshot(),
+ diagnosticsReport: this.options.controller.getSnapshot().diagnosticsReport,
+ recentSessions: [],
+ isCoreConfigured: isCoreConfigured(this.options.getSettings()),
+ });
+ }
+
+ private async renderControlRoomTab(container: HTMLElement) {
+ const snapshot = this.buildDashboardSnapshot();
+ const intro = this.createGuideSection(container, {
+ badge: "Workspace",
+ title: "Diagnostics",
+ intro: "Check what is blocking the local pipeline and fix it in the setup tabs.",
+ });
+
+ const actions = intro.createDiv({ cls: "rxn-action-bar" });
+ this.createActionButton(actions, uiCopy.actions.refreshHealth, async () => {
+ await this.refreshControlRoomData(true);
+ await this.display();
+ }, "rxn-btn-secondary");
+ this.createActionButton(actions, this.isQuickTestRunning ? "Running quick test..." : uiCopy.actions.runQuickTest, async () => {
+ await this.runQuickTest();
+ }, "rxn-btn-secondary", this.isQuickTestRunning);
+
+ const meta = intro.createDiv({ cls: "rxn-pill-row rxn-diagnostics-meta" });
+ meta.createEl("span", {
+ text: snapshot.health.badge === "failed" ? "Blocked" : snapshot.health.badge === "warning" ? "Needs review" : "Ready",
+ cls: `rxn-status-pill is-${snapshot.health.badge}`,
+ });
+ meta.createEl("span", { text: `${snapshot.health.blockingCount} blocking`, cls: "rxn-pill" });
+ meta.createEl("span", { text: `${snapshot.health.warningCount} warnings`, cls: "rxn-pill" });
+ meta.createEl("span", {
+ text: `Quick test: ${
+ this.isQuickTestRunning ? "running" : this.smokeMessage ? (this.smokeMessage.includes("failed") ? "failed" : "passed") : "not run"
+ }`,
+ cls: "rxn-pill",
+ });
+ meta.createEl("span", { text: `Backend: ${snapshot.health.report?.backend ?? "n/a"}`, cls: "rxn-pill" });
+
+ if (this.smokeMessage) {
+ const smoke = intro.createDiv({ cls: `rxn-inline-note ${this.smokeMessage.includes("failed") ? "is-failed" : "is-healthy"}` });
+ smoke.createEl("strong", { text: "Quick test" });
+ smoke.createEl("p", { text: this.smokeMessage });
+ }
+
+ const issues = intro.createDiv({ cls: "rxn-diagnostics-stack" });
+ const blockingChecks = snapshot.health.groups.blocking;
+ const warningChecks = snapshot.health.groups.warnings;
+ if (blockingChecks.length === 0 && warningChecks.length === 0) {
+ const ok = issues.createDiv({ cls: "rxn-check-card is-healthy" });
+ ok.createEl("strong", { text: "No blocking issues or warnings" });
+ ok.createEl("p", { text: snapshot.health.report?.summary ?? uiCopy.status.ready, cls: "rxn-muted" });
+ } else {
+ this.renderDiagnosticChecks(issues, blockingChecks, "is-failed");
+ this.renderDiagnosticChecks(issues, warningChecks, "is-warning");
+ }
+
+ const settings = this.options.getSettings();
+ const preferences = this.createGuideSection(container, {
+ badge: "Preferences",
+ title: "Quick test and startup",
+ intro: "Use these to tune the smoke test and choose which tab opens first.",
+ });
+
+ new Setting(preferences)
+ .setName("Quick test seconds")
+ .setDesc("Length of the quick recording.")
+ .addText((text) =>
+ text.setValue(String(settings.diagnostics.quickTestDurationSeconds)).onChange(async (value) => {
+ await this.options.saveSettings((current) => ({
+ ...current,
+ diagnostics: {
+ ...current.diagnostics,
+ quickTestDurationSeconds: Math.max(1, Math.min(10, Number(value) || 2)),
+ },
+ }));
+ })
+ );
+
+ new Setting(preferences)
+ .setName("Open setup on startup")
+ .setDesc("Open Capture on launch.")
+ .addToggle((toggle) =>
+ toggle.setValue(settings.ui.showSetupWizardOnStartup).onChange(async (value) => {
+ await this.options.saveSettings((current) => ({
+ ...current,
+ ui: { ...current.ui, showSetupWizardOnStartup: value },
+ }));
+ })
+ );
+
+ new Setting(preferences)
+ .setName("Open diagnostics on startup")
+ .setDesc("Open Diagnostics on launch.")
+ .addToggle((toggle) =>
+ toggle.setValue(settings.ui.showDiagnosticsOnStartup).onChange(async (value) => {
+ await this.options.saveSettings((current) => ({
+ ...current,
+ ui: { ...current.ui, showDiagnosticsOnStartup: value },
+ }));
+ })
+ );
+
+ preferences.createEl("p", {
+ cls: "rxn-muted",
+ text: "Session logs are available in Library with Preview diagnostics.",
+ });
+ }
+
+ private async renderCaptureTab(container: HTMLElement) {
+ const settings = this.options.getSettings();
+ const ffmpeg = this.createGuideSection(container, {
+ badge: "Recorder",
+ title: "FFmpeg",
+ intro: "FFmpeg is required for recording.",
+ platformGuides: {
+ macos: {
+ text: "Install FFmpeg, then paste the path to ffmpeg below.",
+ code: "brew install ffmpeg",
+ },
+ linux: {
+ text: "Install FFmpeg with your package manager, then paste the path below.",
+ code: "sudo apt install ffmpeg",
+ },
+ windows: {
+ text: "Install FFmpeg, then paste the full path to ffmpeg.exe below.",
+ code: "winget install Gyan.FFmpeg",
+ },
+ },
+ });
+
+ new Setting(ffmpeg)
+ .setName("FFmpeg path")
+ .setDesc("Path to the FFmpeg executable.")
+ .addText((text) =>
+ text.setPlaceholder("/opt/homebrew/bin/ffmpeg").setValue(settings.capture.ffmpegPath).onChange(async (value) => {
+ await this.options.saveSettings((current) => ({
+ ...current,
+ capture: { ...current.capture, ffmpegPath: value.trim() },
+ }));
+ })
+ )
+ .addButton((button) =>
+ button.setButtonText(uiCopy.actions.detectFfmpeg).onClick(async () => {
+ const detected = await autoDetectFfmpeg();
+ if (!detected) {
+ new Notice(uiCopy.notices.ffmpegNotDetected);
+ return;
+ }
+ await this.options.saveSettings((current) => ({
+ ...current,
+ capture: { ...current.capture, ffmpegPath: detected },
+ }));
+ new Notice(uiCopy.notices.ffmpegDetected);
+ await this.display();
+ })
+ );
+
+ const capture = this.createGuideSection(container, {
+ badge: "Devices",
+ title: "Inputs",
+ intro: "Pick a microphone. Add system audio only if you really need it.",
+ platformGuides: {
+ macos: {
+ bullets: ["For system audio, install a loopback device such as BlackHole and select it below."],
+ },
+ linux: {
+ bullets: ["For system audio, use a PulseAudio or PipeWire monitor source if available."],
+ },
+ windows: {
+ bullets: ["For system audio, use a loopback device such as VB-Cable and select it below."],
+ },
+ },
+ });
+
+ new Setting(capture)
+ .setName("Backend")
+ .setDesc("Use Automatic unless device scan fails.")
+ .addDropdown((dropdown) => {
+ dropdown.addOption("auto", "Automatic");
+ dropdown.addOption("avfoundation", "avfoundation (macOS)");
+ dropdown.addOption("dshow", "dshow (Windows)");
+ dropdown.addOption("pulse", "pulse (Linux)");
+ dropdown.addOption("alsa", "alsa (Linux)");
+ dropdown.setValue(settings.capture.backend);
+ dropdown.onChange(async (value) => {
+ await this.options.saveSettings((current) => ({
+ ...current,
+ capture: { ...current.capture, backend: value as PluginSettingsV2["capture"]["backend"] },
+ }));
+ });
+ });
+
+ const micSetting = new Setting(capture)
+ .setName("Microphone device")
+ .setDesc("Pick the microphone to record.");
+ const micSelect = micSetting.controlEl.createEl("select");
+ micSelect.addClass("rxn-inline-select");
+
+ const systemSetting = new Setting(capture)
+ .setName("System audio device")
+ .setDesc("Optional loopback or monitor input.");
+ const systemSelect = systemSetting.controlEl.createEl("select");
+ systemSelect.addClass("rxn-inline-select");
+
+ const captureActions = capture.createDiv({ cls: "rxn-action-bar" });
+ this.createActionButton(captureActions, uiCopy.actions.refreshDevices, async () => {
+ await this.populateDevices(micSelect, systemSelect, true);
+ }, "rxn-btn-secondary");
+ this.createActionButton(
+ captureActions,
+ this.isQuickTestRunning ? "Running quick test..." : uiCopy.actions.runQuickTest,
+ async () => {
+ await this.runQuickTest();
+ await this.switchTab("control-room");
+ },
+ "rxn-btn-secondary",
+ this.isQuickTestRunning
+ );
+
+ const captureMeta = capture.createDiv({ cls: "rxn-pill-row rxn-diagnostics-meta" });
+ captureMeta.createEl("span", {
+ text: `Backend: ${resolveCaptureBackend(settings.capture.backend)}`,
+ cls: "rxn-pill",
+ });
+
+ await this.populateDevices(micSelect, systemSelect);
+ micSelect.addEventListener("change", async () => {
+ const selected = micSelect.options[micSelect.selectedIndex];
+ if (!selected?.value) return;
+ await this.options.saveSettings((current) => ({
+ ...current,
+ capture: {
+ ...current.capture,
+ microphoneDevice: selected.value,
+ microphoneLabel: selected.text,
+ },
+ }));
+ });
+ systemSelect.addEventListener("change", async () => {
+ const selected = systemSelect.options[systemSelect.selectedIndex];
+ await this.options.saveSettings((current) => ({
+ ...current,
+ capture: {
+ ...current.capture,
+ systemDevice: selected?.value ?? "",
+ systemLabel: selected?.value ? selected.text : "",
+ },
+ }));
+ });
+
+ new Setting(capture)
+ .setName("Sample rate")
+ .setDesc("Usually 48000.")
+ .addText((text) =>
+ text.setValue(String(settings.capture.sampleRateHz)).onChange(async (value) => {
+ await this.options.saveSettings((current) => ({
+ ...current,
+ capture: { ...current.capture, sampleRateHz: Math.max(8_000, Math.min(192_000, Number(value) || 48_000)) },
+ }));
+ })
+ );
+
+ new Setting(capture)
+ .setName("Channels")
+ .setDesc("Mono is lighter. Stereo keeps more room context.")
+ .addDropdown((dropdown) => {
+ dropdown.addOption("1", "Mono");
+ dropdown.addOption("2", "Stereo");
+ dropdown.setValue(String(settings.capture.channels));
+ dropdown.onChange(async (value) => {
+ await this.options.saveSettings((current) => ({
+ ...current,
+ capture: { ...current.capture, channels: value === "1" ? 1 : 2 },
+ }));
+ });
+ });
+
+ new Setting(capture)
+ .setName("Bitrate / segment seconds")
+ .setDesc("MP3 quality and live chunk length.")
+ .addText((text) =>
+ text.setPlaceholder("160").setValue(String(settings.capture.bitrateKbps)).onChange(async (value) => {
+ await this.options.saveSettings((current) => ({
+ ...current,
+ capture: { ...current.capture, bitrateKbps: Math.max(64, Math.min(320, Number(value) || 160)) },
+ }));
+ })
+ )
+ .addText((text) =>
+ text.setPlaceholder("20").setValue(String(settings.capture.segmentDurationSeconds)).onChange(async (value) => {
+ await this.options.saveSettings((current) => ({
+ ...current,
+ capture: { ...current.capture, segmentDurationSeconds: Math.max(5, Math.min(300, Number(value) || 20)) },
+ }));
+ })
+ );
+ }
+
+ private async renderTranscriptionTab(container: HTMLElement) {
+ const settings = this.options.getSettings();
+ const transcription = this.createGuideSection(container, {
+ badge: "whisper.cpp",
+ title: "Local transcription",
+ intro: "Build whisper.cpp once, then point the plugin to whisper-cli.",
+ platformGuides: {
+ macos: {
+ text: "Clone and build whisper.cpp once. Then set repo and CLI path below.",
+ code: "git clone https://github.com/ggerganov/whisper.cpp\ncd whisper.cpp\ncmake -S . -B build\ncmake --build build -j",
+ },
+ linux: {
+ text: "Clone and build whisper.cpp once. Then set repo and CLI path below.",
+ code: "git clone https://github.com/ggerganov/whisper.cpp\ncd whisper.cpp\ncmake -S . -B build\ncmake --build build -j",
+ },
+ windows: {
+ text: "Clone and build whisper.cpp once. Then set repo and CLI path below.",
+ code: "git clone https://github.com/ggerganov/whisper.cpp\ncd whisper.cpp\ncmake -S . -B build\ncmake --build build --config Release",
+ },
+ },
+ bullets: [
+ "Repo path is optional, but helps auto-detect.",
+ "CLI path is required.",
+ ],
+ });
+
+ new Setting(transcription)
+ .setName("whisper.cpp repo")
+ .setDesc("Optional. Helps auto-detect CLI and model.")
+ .addText((text) =>
+ text.setPlaceholder("/path/to/whisper.cpp").setValue(settings.transcription.whisperRepoPath).onChange(async (value) => {
+ await this.options.saveSettings((current) => ({
+ ...current,
+ transcription: { ...current.transcription, whisperRepoPath: value.trim() },
+ }));
+ })
+ )
+ .addButton((button) =>
+ button.setButtonText(uiCopy.actions.detectWhisperRepo).onClick(async () => {
+ const detected = await autoDetectWhisperRepo();
+ if (!detected) {
+ new Notice(uiCopy.notices.whisperRepoNotDetected);
+ return;
+ }
+ await this.options.saveSettings((current) => ({
+ ...current,
+ transcription: { ...current.transcription, whisperRepoPath: detected },
+ }));
+ new Notice(uiCopy.notices.whisperRepoDetected);
+ await this.display();
+ })
+ );
+
+ new Setting(transcription)
+ .setName("whisper.cpp CLI")
+ .setDesc("Path to whisper-cli.")
+ .addText((text) =>
+ text.setPlaceholder("/path/to/whisper-cli").setValue(settings.transcription.whisperCliPath).onChange(async (value) => {
+ await this.options.saveSettings((current) => ({
+ ...current,
+ transcription: { ...current.transcription, whisperCliPath: value.trim() },
+ }));
+ })
+ )
+ .addButton((button) =>
+ button.setButtonText(uiCopy.actions.detectWhisper).onClick(async () => {
+ const detected = await autoDetectWhisperCli(this.options.getSettings().transcription.whisperRepoPath);
+ if (!detected) {
+ new Notice(uiCopy.notices.whisperNotDetected);
+ return;
+ }
+ await this.options.saveSettings((current) => ({
+ ...current,
+ transcription: {
+ ...current.transcription,
+ whisperCliPath: detected,
+ whisperRepoPath: current.transcription.whisperRepoPath || inferWhisperRepoPath(detected) || "",
+ },
+ }));
+ new Notice(uiCopy.notices.whisperDetected);
+ await this.display();
+ })
+ );
+
+ const selectedModelPreset = settings.transcription.modelPreset;
+ const selectedModelLabel = this.getWhisperModelLabel(selectedModelPreset);
+ const selectedModelFilename = this.getWhisperModelFilename(selectedModelPreset);
+ const model = container.createEl("div", { cls: "rxn-card rxn-step-section" });
+ const modelHeader = model.createEl("div", { cls: "rxn-step-section-header" });
+ modelHeader.createEl("span", { text: "Model", cls: "rxn-step-section-badge" });
+ modelHeader.createEl("h3", { text: "Whisper model" });
+ model.createEl("p", {
+ text: "Choose a model size first. Then download that model once and point Model path to the file.",
+ cls: "rxn-muted",
+ });
+
+ new Setting(model)
+ .setName("Model size")
+ .setDesc("This updates the download commands below and what Detect model looks for first.")
+ .addDropdown((dropdown) => {
+ dropdown.addOption("base", "Base");
+ dropdown.addOption("small", "Small");
+ dropdown.addOption("medium", "Medium");
+ dropdown.addOption("large", "Large");
+ dropdown.setValue(selectedModelPreset);
+ dropdown.onChange(async (value) => {
+ await this.options.saveSettings((current) => ({
+ ...current,
+ transcription: {
+ ...current.transcription,
+ modelPreset: value as PluginSettingsV2["transcription"]["modelPreset"],
+ },
+ }));
+ await this.display();
+ });
+ });
+
+ this.renderPlatformGuide(
+ model,
+ {
+ macos: {
+ codeBlocks: [
+ {
+ label: "Go to the models folder",
+ code: "cd /path/to/whisper.cpp/models",
+ },
+ {
+ label: `Download ${selectedModelLabel.toLowerCase()}`,
+ code: `./download-ggml-model.sh ${selectedModelPreset}`,
+ },
+ ],
+ },
+ linux: {
+ codeBlocks: [
+ {
+ label: "Go to the models folder",
+ code: "cd /path/to/whisper.cpp/models",
+ },
+ {
+ label: `Download ${selectedModelLabel.toLowerCase()}`,
+ code: `./download-ggml-model.sh ${selectedModelPreset}`,
+ },
+ ],
+ },
+ windows: {
+ codeBlocks: [
+ {
+ label: "Go to the models folder",
+ code: "cd C:\\path\\to\\whisper.cpp\\models",
+ },
+ {
+ label: `Download ${selectedModelLabel.toLowerCase()}`,
+ code: `download-ggml-model.cmd ${selectedModelPreset}`,
+ },
+ ],
+ },
+ },
+ true
+ );
+
+ const modelBullets = model.createEl("ul", { cls: "rxn-guide-list" });
+ modelBullets.createEl("li", {
+ text: `${selectedModelFilename} is the first file Detect model will look for.`,
+ });
+ modelBullets.createEl("li", {
+ text: "For higher reliability, try Medium or Large if your machine can handle them.",
+ });
+
+ new Setting(model)
+ .setName("Model path")
+ .setDesc("Path to the ggml model file whisper.cpp should use.")
+ .addText((text) =>
+ text
+ .setPlaceholder(`/path/to/${selectedModelFilename}`)
+ .setValue(settings.transcription.modelPath)
+ .onChange(async (value) => {
+ await this.options.saveSettings((current) => ({
+ ...current,
+ transcription: { ...current.transcription, modelPath: value.trim() },
+ }));
+ })
+ )
+ .addButton((button) =>
+ button.setButtonText(`Detect ${selectedModelLabel}`).onClick(async () => {
+ const current = this.options.getSettings();
+ const detected = await autoDetectWhisperModel({
+ repoPath: current.transcription.whisperRepoPath,
+ whisperCliPath: current.transcription.whisperCliPath,
+ preset: current.transcription.modelPreset,
+ });
+ if (!detected) {
+ new Notice(uiCopy.notices.modelNotDetected);
+ return;
+ }
+ await this.options.saveSettings((currentSettings) => ({
+ ...currentSettings,
+ transcription: { ...currentSettings.transcription, modelPath: detected },
+ }));
+ new Notice(uiCopy.notices.modelDetected);
+ await this.display();
+ })
+ );
+
+ new Setting(transcription)
+ .setName("Language / beam")
+ .setDesc("Leave Automatic unless you always record one language.")
+ .addDropdown((dropdown) => {
+ dropdown.addOption("auto", "Automatic");
+ dropdown.addOption("it", "Italian");
+ dropdown.addOption("en", "English");
+ dropdown.addOption("es", "Spanish");
+ dropdown.addOption("fr", "French");
+ dropdown.setValue(settings.transcription.language);
+ dropdown.onChange(async (value) => {
+ await this.options.saveSettings((current) => ({
+ ...current,
+ transcription: { ...current.transcription, language: value },
+ }));
+ });
+ })
+ .addText((text) =>
+ text.setValue(String(settings.transcription.beamSize)).onChange(async (value) => {
+ await this.options.saveSettings((current) => ({
+ ...current,
+ transcription: { ...current.transcription, beamSize: Math.max(1, Math.min(10, Number(value) || 5)) },
+ }));
+ })
+ );
+
+ new Setting(transcription)
+ .setName("Entropy / logprob thresholds")
+ .setDesc("Advanced. Leave as is unless transcription is unstable.")
+ .addText((text) =>
+ text.setValue(String(settings.transcription.entropyThreshold)).onChange(async (value) => {
+ const parsed = Number(value);
+ await this.options.saveSettings((current) => ({
+ ...current,
+ transcription: {
+ ...current.transcription,
+ entropyThreshold: Number.isFinite(parsed) ? parsed : current.transcription.entropyThreshold,
+ },
+ }));
+ })
+ )
+ .addText((text) =>
+ text.setValue(String(settings.transcription.logprobThreshold)).onChange(async (value) => {
+ const parsed = Number(value);
+ await this.options.saveSettings((current) => ({
+ ...current,
+ transcription: {
+ ...current.transcription,
+ logprobThreshold: Number.isFinite(parsed) ? parsed : current.transcription.logprobThreshold,
+ },
+ }));
+ })
+ );
+ }
+
+ private async renderSummaryTab(container: HTMLElement) {
+ const settings = this.options.getSettings();
+ const summary = this.createGuideSection(container, {
+ badge: "Provider",
+ title: "Final note",
+ intro: "Pick one provider for the final summary.",
+ platformGuides: {
+ macos: {
+ text: "For the local path, install Ollama and pull one model.",
+ code: "brew install ollama\nollama serve\nollama pull gemma3",
+ },
+ linux: {
+ text: "For the local path, install Ollama and pull one model.",
+ code: "curl -fsSL https://ollama.com/install.sh | sh\nollama serve\nollama pull gemma3",
+ },
+ windows: {
+ text: "For the local path, install Ollama and pull one model.",
+ code: "winget install Ollama.Ollama\nollama pull gemma3",
+ },
+ },
+ });
+
+ new Setting(summary)
+ .setName("Provider")
+ .setDesc("Ollama is local. Cloud providers need API key and model.")
+ .addDropdown((dropdown) => {
+ dropdown.addOption("ollama", "Ollama (recommended)");
+ dropdown.addOption("gemini", "Gemini (experimental)");
+ dropdown.addOption("openai", "OpenAI (experimental)");
+ dropdown.addOption("anthropic", "Anthropic (experimental)");
+ dropdown.setValue(settings.summary.provider);
+ dropdown.onChange(async (value) => {
+ await this.options.saveSettings((current) => ({
+ ...current,
+ summary: { ...current.summary, provider: value as PluginSettingsV2["summary"]["provider"] },
+ }));
+ await this.display();
+ });
+ });
+
+ if (settings.summary.provider === "ollama") {
+ new Setting(summary)
+ .setName("Ollama endpoint / model")
+ .setDesc("Local server URL and model tag.")
+ .addText((text) =>
+ text.setPlaceholder("http://localhost:11434").setValue(settings.summary.ollamaEndpoint).onChange(async (value) => {
+ await this.options.saveSettings((current) => ({
+ ...current,
+ summary: { ...current.summary, ollamaEndpoint: value.trim() },
+ }));
+ })
+ )
+ .addText((text) =>
+ text.setPlaceholder("gemma3").setValue(settings.summary.ollamaModel).onChange(async (value) => {
+ await this.options.saveSettings((current) => ({
+ ...current,
+ summary: { ...current.summary, ollamaModel: value.trim() },
+ }));
+ })
+ );
+ return;
+ }
+
+ const fieldMap = {
+ gemini: ["geminiApiKey", "geminiModel"] as const,
+ openai: ["openaiApiKey", "openaiModel"] as const,
+ anthropic: ["anthropicApiKey", "anthropicModel"] as const,
+ };
+ const [apiKeyField, modelField] = fieldMap[settings.summary.provider];
+ new Setting(summary)
+ .setName("API key / model")
+ .setDesc("Cloud provider credentials for this machine.")
+ .addText((text) => {
+ text.setValue(settings.summary[apiKeyField]).onChange(async (value) => {
+ await this.options.saveSettings((current) => ({
+ ...current,
+ summary: { ...current.summary, [apiKeyField]: value } as PluginSettingsV2["summary"],
+ }));
+ });
+ text.inputEl.type = "password";
+ })
+ .addText((text) =>
+ text.setValue(settings.summary[modelField]).onChange(async (value) => {
+ await this.options.saveSettings((current) => ({
+ ...current,
+ summary: { ...current.summary, [modelField]: value } as PluginSettingsV2["summary"],
+ }));
+ })
+ );
+ }
+
+ private async renderOutputTab(container: HTMLElement) {
+ const settings = this.options.getSettings();
+ const output = this.createGuideSection(container, {
+ badge: "Vault",
+ title: "Notes and retention",
+ intro: "Choose where notes go and how much history to keep.",
+ });
+
+ new Setting(output)
+ .setName("Vault folder")
+ .setDesc("Folder for notes inside the current vault.")
+ .addText((text) =>
+ text.setValue(settings.output.vaultFolder).onChange(async (value) => {
+ await this.options.saveSettings((current) => ({
+ ...current,
+ output: { ...current.output, vaultFolder: value.trim() },
+ }));
+ })
+ );
+
+ new Setting(output)
+ .setName("Store live transcript note")
+ .setDesc("Write the live transcript into a vault note while recording.")
+ .addToggle((toggle) =>
+ toggle.setValue(settings.output.storeLiveTranscriptInVault).onChange(async (value) => {
+ await this.options.saveSettings((current) => ({
+ ...current,
+ output: { ...current.output, storeLiveTranscriptInVault: value },
+ }));
+ })
+ );
+
+ new Setting(output)
+ .setName("Open summary automatically")
+ .setDesc("Open the final note after a successful session.")
+ .addToggle((toggle) =>
+ toggle.setValue(settings.output.openSummaryAfterCreate).onChange(async (value) => {
+ await this.options.saveSettings((current) => ({
+ ...current,
+ output: { ...current.output, openSummaryAfterCreate: value },
+ }));
+ })
+ );
+
+ new Setting(output)
+ .setName("Completed sessions kept")
+ .setDesc("Older finished sessions are pruned. Use 0 to disable.")
+ .addText((text) =>
+ text.setValue(String(settings.output.maxSessionsKept)).onChange(async (value) => {
+ await this.options.saveSettings((current) => ({
+ ...current,
+ output: { ...current.output, maxSessionsKept: Math.max(0, Number(value) || 0) },
+ }));
+ })
+ );
+
+ }
+
+ private async renderLibraryTab(container: HTMLElement) {
+ const library = this.createGuideSection(container, {
+ badge: "Workspace",
+ title: uiCopy.library.title,
+ intro: "Review completed and failed sessions here.",
+ });
+
+ const controls = library.createDiv({ cls: "rxn-toolbar" });
+ const filterRow = controls.createDiv({ cls: "rxn-filter-row" });
+ this.renderLibraryFilterChip(filterRow, uiCopy.library.all, "all");
+ this.renderLibraryFilterChip(filterRow, uiCopy.library.done, "done");
+ this.renderLibraryFilterChip(filterRow, uiCopy.library.failed, "failed");
+
+ const search = controls.createEl("input", {
+ cls: "rxn-search-input",
+ attr: { type: "search", placeholder: uiCopy.library.searchPlaceholder },
+ });
+ search.value = this.libraryQuery;
+ search.addEventListener("input", () => {
+ this.libraryQuery = search.value.trim().toLowerCase();
+ void this.display();
+ });
+
+ this.createActionButton(controls, uiCopy.actions.refresh, async () => {
+ await this.refreshLibraryData();
+ await this.display();
+ }, "rxn-btn-secondary");
+ this.createActionButton(controls, uiCopy.actions.openLibraryFolder, async () => {
+ this.openLibraryFolder();
+ }, "rxn-btn-secondary");
+
+ const items = this.getFilteredLibraryItems();
+ if (items.length === 0) {
+ library.createEl("p", { text: uiCopy.library.empty, cls: "rxn-muted" });
+ return;
+ }
+
+ const list = library.createDiv({ cls: "rxn-session-workspace" });
+ for (const item of items) {
+ const isBusy = this.libraryBusySessionId === item.sessionId;
+ const needsTranscript = item.artifactAvailability.hasAudio && !item.artifactAvailability.hasTranscript;
+ const needsSummary = item.artifactAvailability.hasTranscript && !item.artifactAvailability.hasSummary;
+ const card = list.createDiv({ cls: "rxn-session-workspace-card" });
+ const header = card.createDiv({ cls: "rxn-session-header" });
+ const titleBlock = header.createDiv({ cls: "rxn-session-title" });
+ titleBlock.createEl("strong", { text: item.scenarioLabel });
+ titleBlock.createEl("span", {
+ text: item.failureSummary || item.diagnosticsSummary,
+ cls: "rxn-muted",
+ });
+ header.createEl("span", {
+ text: item.status,
+ cls: `rxn-status-pill is-${item.healthBadge}`,
+ });
+
+ const meta = card.createDiv({ cls: "rxn-session-meta-row" });
+ this.renderSessionMeta(meta, "Started", new Date(item.createdAt).toLocaleString());
+ this.renderSessionMeta(meta, "Duration", formatDuration(item.elapsedSeconds));
+ this.renderSessionMeta(meta, "Segments", String(item.committedSegments));
+ this.renderSessionMeta(meta, "Audio", formatBytes(item.audioSizeBytes));
+ this.renderSessionMeta(meta, "Last activity", new Date(item.lastActivityAt).toLocaleString());
+
+ const artifactRow = card.createDiv({ cls: "rxn-pill-row" });
+ artifactRow.createEl("span", {
+ text: item.artifactAvailability.hasAudio ? "Audio ready" : "No audio",
+ cls: `rxn-pill ${item.artifactAvailability.hasAudio ? "is-ok" : ""}`,
+ });
+ artifactRow.createEl("span", {
+ text: item.artifactAvailability.hasTranscript ? "Transcript ready" : "No transcript",
+ cls: `rxn-pill ${item.artifactAvailability.hasTranscript ? "is-ok" : ""}`,
+ });
+ artifactRow.createEl("span", {
+ text: item.artifactAvailability.hasSummary ? "Summary ready" : "No summary",
+ cls: `rxn-pill ${item.artifactAvailability.hasSummary ? "is-ok" : ""}`,
+ });
+
+ if (needsTranscript || needsSummary) {
+ const recovery = card.createDiv({ cls: "rxn-session-recovery" });
+ const recoveryCopy = recovery.createDiv({ cls: "rxn-session-recovery-copy" });
+ recoveryCopy.createEl("strong", { text: "Recovery" });
+ recoveryCopy.createEl("p", {
+ text: needsTranscript
+ ? "Transcript missing. Generate it from the saved audio."
+ : "Summary missing. Generate it from the saved transcript.",
+ cls: "rxn-muted",
+ });
+ const recoveryActions = recovery.createDiv({ cls: "rxn-actions rxn-session-action-row" });
+ this.createActionButton(
+ recoveryActions,
+ isBusy && this.libraryBusyAction === "transcript" ? "Generating transcript..." : uiCopy.actions.regenerateTranscript,
+ async () => {
+ await this.runLibraryRecovery(item, "transcript");
+ },
+ "rxn-btn-secondary",
+ isBusy || !needsTranscript
+ );
+ this.createActionButton(
+ recoveryActions,
+ isBusy && this.libraryBusyAction === "summary" ? "Generating summary..." : uiCopy.actions.regenerateSummary,
+ async () => {
+ await this.runLibraryRecovery(item, "summary");
+ },
+ "rxn-btn-secondary",
+ isBusy || !needsSummary
+ );
+ }
+
+ const menus = card.createDiv({ cls: "rxn-session-menu-row" });
+
+ const openMenu = this.createSessionActionMenu(menus, "Open files");
+ this.createActionButton(openMenu, uiCopy.actions.openSummary, async () => {
+ await this.vaultAdapter.openFile(item.notes.summaryNotePath);
+ }, "rxn-btn-secondary", isBusy || !item.notes.summaryNotePath);
+ this.createActionButton(openMenu, uiCopy.actions.openTranscript, async () => {
+ await this.vaultAdapter.openFile(item.notes.liveTranscriptNotePath);
+ }, "rxn-btn-secondary", isBusy || !item.notes.liveTranscriptNotePath);
+ this.createActionButton(openMenu,
+ this.openAudioSessionId === item.sessionId ? uiCopy.actions.hideAudio : uiCopy.actions.playAudio,
+ async () => {
+ this.openAudioSessionId = this.openAudioSessionId === item.sessionId ? null : item.sessionId;
+ await this.display();
+ },
+ "rxn-btn-secondary",
+ isBusy || !item.artifactAvailability.hasAudio
+ );
+
+ const toolsMenu = this.createSessionActionMenu(menus, "Inspect");
+ this.createActionButton(toolsMenu, uiCopy.actions.previewTranscript, async () => {
+ new TextPreviewModal(this.app, "Raw transcript", this.store.readTextFile(item.paths.transcriptTextPath)).open();
+ }, "rxn-btn-secondary", isBusy || !item.artifactAvailability.hasTranscript);
+ this.createActionButton(toolsMenu, uiCopy.actions.previewDiagnostics, async () => {
+ new TextPreviewModal(this.app, "Diagnostics log", this.store.readTextFile(item.paths.diagnosticsLogPath)).open();
+ }, "rxn-btn-secondary", isBusy);
+ this.createActionButton(toolsMenu, uiCopy.actions.exportAudio, async () => {
+ this.exportAudio(item);
+ }, "rxn-btn-secondary", isBusy || !item.artifactAvailability.hasAudio);
+ this.createActionButton(toolsMenu, uiCopy.actions.showFolder, async () => {
+ try {
+ const electron = requireNodeModule<{ shell?: { showItemInFolder?: (path: string) => void } }>("electron");
+ electron.shell?.showItemInFolder?.(item.paths.rootDir);
+ } catch { }
+ }, "rxn-btn-secondary", isBusy);
+
+ const dangerMenu = this.createSessionActionMenu(menus, "Delete", true);
+ this.createActionButton(dangerMenu, "Delete audio", async () => {
+ const ok = confirm("Delete saved audio for this session and keep the transcript/summary?");
+ if (!ok) return;
+ this.openAudioSessionId = this.openAudioSessionId === item.sessionId ? null : this.openAudioSessionId;
+ const manifest = await this.store.readSessionByRootDir(item.paths.rootDir);
+ if (!manifest) return;
+ await this.store.deleteAudioArtifacts(manifest);
+ await this.store.appendDiagnostics(manifest, "Manual cleanup: audio artifacts deleted.");
+ await this.store.writeManifest(manifest);
+ new Notice("Audio deleted.");
+ await this.refreshLibraryData();
+ await this.display();
+ }, "rxn-btn-danger", isBusy || !item.artifactAvailability.hasAudio);
+ this.createActionButton(dangerMenu, "Delete transcript", async () => {
+ const ok = confirm("Delete saved transcript for this session and keep the summary if it exists?");
+ if (!ok) return;
+ const manifest = await this.store.readSessionByRootDir(item.paths.rootDir);
+ if (!manifest) return;
+ await this.vaultAdapter.deleteFile(manifest.notes.liveTranscriptNotePath);
+ manifest.notes.liveTranscriptNotePath = undefined;
+ await this.store.deleteTranscriptArtifacts(manifest);
+ await this.store.appendDiagnostics(manifest, "Manual cleanup: transcript artifacts deleted.");
+ await this.store.writeManifest(manifest);
+ new Notice("Transcript deleted.");
+ await this.refreshLibraryData();
+ await this.display();
+ }, "rxn-btn-danger", isBusy || !item.artifactAvailability.hasTranscript);
+ this.createActionButton(dangerMenu, uiCopy.actions.deleteSession, async () => {
+ const ok = confirm(uiCopy.library.deleteConfirmation);
+ if (!ok) return;
+ await this.vaultAdapter.deleteFile(item.notes.summaryNotePath);
+ await this.vaultAdapter.deleteFile(item.notes.liveTranscriptNotePath);
+ await this.store.deleteSessionRootDir(item.paths.rootDir);
+ new Notice(uiCopy.notices.sessionDeleted);
+ await this.refreshLibraryData();
+ await this.display();
+ }, "rxn-btn-danger", isBusy);
+
+ if (this.openAudioSessionId === item.sessionId && item.artifactAvailability.hasAudio) {
+ const audio = card.createEl("audio", { attr: { controls: "true" } });
+ audio.src = this.getAudioUrl(item.paths.fullAudioPath);
+ }
+ }
+ }
+
+ private renderSessionMeta(container: HTMLElement, label: string, value: string) {
+ const item = container.createDiv({ cls: "rxn-session-meta-item" });
+ item.createEl("span", { text: `${label}:`, cls: "rxn-session-meta-label" });
+ item.createEl("span", { text: value, cls: "rxn-session-meta-value" });
+ }
+
+ private createSessionActionMenu(container: HTMLElement, label: string, isDanger = false): HTMLElement {
+ const details = container.createEl("details", {
+ cls: `rxn-session-menu${isDanger ? " is-danger" : ""}`,
+ });
+ details.addEventListener("toggle", () => {
+ if (!details.open) return;
+ const allMenus = this.containerEl.querySelectorAll(".rxn-session-menu");
+ allMenus.forEach((menu) => {
+ if (menu !== details) {
+ menu.open = false;
+ }
+ });
+ });
+ const summary = details.createEl("summary", {
+ text: label,
+ cls: isDanger ? "rxn-btn-danger rxn-session-menu-trigger" : "rxn-btn-secondary rxn-session-menu-trigger",
+ });
+ summary.setAttribute("role", "button");
+ const list = details.createDiv({ cls: "rxn-session-menu-list" });
+ list.addEventListener("click", (event) => {
+ const target = event.target as HTMLElement | null;
+ if (target?.closest("button")) {
+ details.open = false;
+ }
+ });
+ return list;
+ }
+
+ private openLibraryFolder() {
+ try {
+ const electron = requireNodeModule<{ shell?: { showItemInFolder?: (path: string) => void } }>("electron");
+ const sessionsRoot = this.store.getSessionsRootDir();
+ electron.shell?.showItemInFolder?.(sessionsRoot);
+ } catch {}
+ }
+
+ private createGuideSection(container: HTMLElement, options: GuideSectionOptions): HTMLElement {
+ const section = container.createEl("div", { cls: "rxn-card rxn-step-section" });
+ const header = section.createEl("div", { cls: "rxn-step-section-header" });
+ header.createEl("span", { text: options.badge, cls: "rxn-step-section-badge" });
+ header.createEl("h3", { text: options.title });
+ section.createEl("p", { text: options.intro, cls: "rxn-muted" });
+ if (options.platformGuides) {
+ this.renderPlatformGuide(section, options.platformGuides, Boolean(options.intro.trim()));
+ }
+ if (options.bullets?.length) {
+ const list = section.createEl("ul", { cls: "rxn-guide-list" });
+ for (const bullet of options.bullets) {
+ list.createEl("li", { text: bullet });
+ }
+ }
+ if (options.code) {
+ this.renderCodeBlock(section, options.code);
+ }
+ return section;
+ }
+
+ private async runLibraryRecovery(item: SessionListItem, action: "transcript" | "summary") {
+ this.libraryBusySessionId = item.sessionId;
+ this.libraryBusyAction = action;
+ await this.display();
+ try {
+ if (action === "transcript") {
+ await this.options.controller.regenerateTranscript(item.paths.rootDir);
+ new Notice("Transcript generated.");
+ } else {
+ await this.options.controller.regenerateSummary(item.paths.rootDir);
+ new Notice("Summary generated.");
+ }
+ await this.refreshLibraryData();
+ } catch (error) {
+ new Notice(String((error as Error)?.message ?? error));
+ } finally {
+ this.libraryBusySessionId = null;
+ this.libraryBusyAction = null;
+ await this.display();
+ }
+ }
+
+ private renderPlatformGuide(
+ section: HTMLElement,
+ platformGuides: Partial>,
+ hasIntro: boolean
+ ) {
+ const entries = GUIDE_PLATFORM_ORDER.flatMap((platform) => {
+ const guide = platformGuides[platform];
+ return guide ? [{ platform, guide }] : [];
+ });
+ if (entries.length === 0) {
+ return;
+ }
+
+ const wrapper = section.createDiv({ cls: "rxn-guide-platform" });
+ const tabs = wrapper.createDiv({ cls: "rxn-guide-platform-tabs" });
+ const panel = wrapper.createDiv({ cls: "rxn-guide-platform-panel" });
+ const buttons = new Map();
+
+ const renderPanel = (platform: GuidePlatform) => {
+ panel.empty();
+ for (const [key, button] of buttons) {
+ button.toggleClass("is-selected", key === platform);
+ }
+
+ const guide = platformGuides[platform];
+ if (!guide) {
+ return;
+ }
+
+ if (guide.text && !hasIntro) {
+ panel.createEl("p", { text: guide.text, cls: "rxn-muted rxn-guide-platform-copy" });
+ }
+ if (guide.bullets?.length) {
+ const list = panel.createEl("ul", { cls: "rxn-guide-list" });
+ for (const bullet of guide.bullets) {
+ list.createEl("li", { text: bullet });
+ }
+ }
+ if (guide.codeBlocks?.length) {
+ for (const block of guide.codeBlocks) {
+ this.renderCodeBlock(panel, block.code, block.label);
+ }
+ }
+ if (guide.code) {
+ this.renderCodeBlock(panel, guide.code);
+ }
+ };
+
+ for (const { platform } of entries) {
+ const button = tabs.createEl("button", {
+ text: GUIDE_PLATFORM_LABELS[platform],
+ cls: "rxn-guide-platform-tab",
+ });
+ buttons.set(platform, button);
+ button.addEventListener("click", () => {
+ renderPanel(platform);
+ });
+ }
+
+ renderPanel(this.getDefaultGuidePlatform(entries.map((entry) => entry.platform)));
+ }
+
+ private renderCodeBlock(container: HTMLElement, code: string, label?: string) {
+ const block = container.createDiv({ cls: "rxn-guide-code-block" });
+ if (label) {
+ block.createEl("div", { text: label, cls: "rxn-guide-code-label" });
+ }
+ const shell = block.createDiv({ cls: "rxn-guide-code-shell" });
+ const copyButton = shell.createEl("button", {
+ text: "Copy",
+ cls: "rxn-guide-code-copy rxn-btn-secondary",
+ attr: { type: "button" },
+ });
+ copyButton.addEventListener("click", async () => {
+ try {
+ await navigator.clipboard.writeText(code);
+ new Notice("Command copied.");
+ } catch {
+ const textarea = document.createElement("textarea");
+ textarea.value = code;
+ document.body.appendChild(textarea);
+ textarea.select();
+ document.execCommand("copy");
+ textarea.remove();
+ new Notice("Command copied.");
+ }
+ });
+
+ const pre = shell.createEl("pre", { cls: "rxn-guide-code" });
+ pre.setText(code);
+ }
+
+ private getWhisperModelLabel(preset: PluginSettingsV2["transcription"]["modelPreset"]): string {
+ return preset.charAt(0).toUpperCase() + preset.slice(1);
+ }
+
+ private getWhisperModelFilename(preset: PluginSettingsV2["transcription"]["modelPreset"]): string {
+ return `ggml-${preset}.bin`;
+ }
+
+ private getDefaultGuidePlatform(platforms: GuidePlatform[]): GuidePlatform {
+ let preferred: GuidePlatform = "macos";
+
+ try {
+ const processModule = requireNodeModule<{ platform?: string }>("process");
+ if (processModule.platform === "win32") {
+ preferred = "windows";
+ } else if (processModule.platform === "linux") {
+ preferred = "linux";
+ }
+ } catch {
+ const agent = typeof navigator === "undefined" ? "" : navigator.userAgent.toLowerCase();
+ preferred = agent.includes("windows") ? "windows" : agent.includes("linux") ? "linux" : "macos";
+ }
+
+ return platforms.includes(preferred) ? preferred : platforms[0] ?? "macos";
+ }
+
+ private getFilteredLibraryItems(): SessionListItem[] {
+ return this.libraryItems.filter((item) => {
+ const statusMatches = this.libraryFilter === "all" ? true : item.status === this.libraryFilter;
+ if (!statusMatches) return false;
+ if (!this.libraryQuery) return true;
+ const haystack = [item.scenarioLabel, item.status, item.failureSummary, item.diagnosticsSummary]
+ .filter(Boolean)
+ .join(" ")
+ .toLowerCase();
+ return haystack.includes(this.libraryQuery);
+ });
+ }
+
+ private renderLibraryFilterChip(container: HTMLElement, label: string, value: LibraryFilter) {
+ const chip = container.createEl("button", { text: label, cls: "rxn-filter-chip" });
+ if (this.libraryFilter === value) {
+ chip.addClass("is-selected");
+ }
+ chip.addEventListener("click", () => {
+ this.libraryFilter = value;
+ void this.display();
+ });
+ }
+
+ private renderDiagnosticChecks(
+ container: HTMLElement,
+ checks: DashboardSnapshot["health"]["groups"]["blocking"],
+ toneClass: "is-failed" | "is-warning"
+ ) {
+ for (const check of checks) {
+ const item = container.createDiv({ cls: `rxn-check-card ${toneClass}` });
+ item.createEl("strong", { text: check.label });
+ item.createEl("p", { text: check.detail, cls: "rxn-muted" });
+ if (check.remediation) {
+ item.createEl("small", { text: check.remediation, cls: "rxn-muted" });
+ }
+ }
+ }
+
+ private async runQuickTest() {
+ if (this.isQuickTestRunning) return;
+
+ this.isQuickTestRunning = true;
+ this.smokeMessage = "Quick test running...";
+ await this.display();
+
+ try {
+ new Notice("Quick test started...");
+ const result = await this.options.controller.runSmokeTest();
+ this.smokeMessage = result.ok ? uiCopy.diagnostics.smokePassed : `${uiCopy.notices.quickTestFailedPrefix}: ${result.detail}`;
+ new Notice(this.smokeMessage);
+ await this.refreshControlRoomData(false);
+ } finally {
+ this.isQuickTestRunning = false;
+ await this.display();
+ }
+ }
+
+ private getAudioUrl(path: string): string {
+ const cached = this.audioUrlCache.get(path);
+ if (cached) return cached;
+ const fs = requireNodeModule<{ readFileSync: (path: string) => Buffer }>("fs");
+ const buffer = fs.readFileSync(path);
+ const bytes = Uint8Array.from(buffer);
+ const url = URL.createObjectURL(new Blob([bytes], { type: "audio/mpeg" }));
+ this.audioUrlCache.set(path, url);
+ return url;
+ }
+
+ private exportAudio(item: SessionListItem) {
+ const fs = requireNodeModule<{ readFileSync: (path: string) => Buffer }>("fs");
+ const bytes = Uint8Array.from(fs.readFileSync(item.paths.fullAudioPath));
+ const blob = new Blob([bytes], { type: "audio/mpeg" });
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = `${item.scenarioLabel}-${item.sessionId}.mp3`;
+ anchor.click();
+ window.setTimeout(() => URL.revokeObjectURL(url), 1_000);
+ }
+
+ private createActionButton(
+ container: HTMLElement,
+ label: string,
+ onClick: () => Promise | void,
+ cls: string,
+ disabled = false
+ ) {
+ const button = container.createEl("button", { text: label });
+ button.addClass(cls);
+ button.disabled = disabled;
+ button.addEventListener("click", () => {
+ void onClick();
+ });
+ return button;
+ }
+
+ private resetDeviceSelects(micSelect: HTMLSelectElement, systemSelect: HTMLSelectElement, placeholder: string) {
+ micSelect.empty();
+ const micPlaceholder = document.createElement("option");
+ micPlaceholder.value = "";
+ micPlaceholder.text = placeholder;
+ micSelect.appendChild(micPlaceholder);
+
+ systemSelect.empty();
+ const none = document.createElement("option");
+ none.value = "";
+ none.text = "(none)";
+ systemSelect.appendChild(none);
+ }
+
+ private async populateDevices(micSelect: HTMLSelectElement, systemSelect: HTMLSelectElement, notifyOnFailure = false) {
+ const settings = this.options.getSettings();
+ this.resetDeviceSelects(
+ micSelect,
+ systemSelect,
+ settings.capture.ffmpegPath.trim() ? "Refresh devices to load microphones" : "Set FFmpeg path first"
+ );
+ if (!settings.capture.ffmpegPath.trim()) return;
+
+ try {
+ const scanned = await scanDevices(settings.capture.ffmpegPath, resolveCaptureBackend(settings.capture.backend));
+ const audioDevices = scanned.filter((device) => device.type === "audio");
+ if (audioDevices.length === 0) {
+ return;
+ }
+
+ micSelect.empty();
+ for (const device of audioDevices) {
+ const option = document.createElement("option");
+ option.value = device.name;
+ option.text = device.label;
+ micSelect.appendChild(option);
+
+ const sysOption = document.createElement("option");
+ sysOption.value = device.name;
+ sysOption.text = device.label;
+ systemSelect.appendChild(sysOption);
+ }
+
+ if (
+ settings.capture.microphoneDevice &&
+ Array.from(micSelect.options).some((option) => option.value === settings.capture.microphoneDevice)
+ ) {
+ micSelect.value = settings.capture.microphoneDevice;
+ } else if (micSelect.options.length > 0) {
+ micSelect.selectedIndex = 0;
+ }
+
+ if (
+ settings.capture.systemDevice &&
+ Array.from(systemSelect.options).some((option) => option.value === settings.capture.systemDevice)
+ ) {
+ systemSelect.value = settings.capture.systemDevice;
+ }
+ } catch (error) {
+ if (notifyOnFailure) {
+ new Notice(`Device scan failed: ${String((error as Error)?.message ?? error)}`);
+ }
+ }
+ }
+}
diff --git a/src/ui/copy.ts b/src/ui/copy.ts
new file mode 100644
index 0000000..922a405
--- /dev/null
+++ b/src/ui/copy.ts
@@ -0,0 +1,82 @@
+export const uiCopy = {
+ appName: "Resonance",
+ actions: {
+ openRecorder: "Open recorder",
+ openLibrary: "Open library",
+ openLibraryFolder: "Open library folder",
+ openDiagnostics: "Open diagnostics",
+ openSetupGuide: "Open setup & settings",
+ startSession: "Start session",
+ stopSession: "Stop session",
+ refresh: "Refresh",
+ refreshHealth: "Refresh health",
+ refreshDevices: "Refresh devices",
+ runQuickTest: "Run quick test",
+ detectFfmpeg: "Detect FFmpeg",
+ detectWhisperRepo: "Detect whisper.cpp repo",
+ detectWhisper: "Detect whisper.cpp",
+ detectModel: "Detect model",
+ openSummary: "Open summary",
+ openTranscript: "Open transcript",
+ openLiveTranscript: "Open live transcript",
+ previewTranscript: "Preview raw transcript",
+ previewDiagnostics: "Preview diagnostics",
+ regenerateTranscript: "Generate transcript",
+ regenerateSummary: "Generate summary",
+ exportAudio: "Export audio",
+ playAudio: "Play audio",
+ hideAudio: "Hide audio",
+ showFolder: "Show folder",
+ deleteSession: "Delete session",
+ close: "Close",
+ },
+ status: {
+ ready: "Ready to record",
+ blocked: "Configuration blocked",
+ busy: "Session busy",
+ healthy: "Healthy",
+ warning: "Needs attention",
+ failed: "Blocked",
+ },
+ dashboard: {
+ blockedReason: "Resolve the blocking checks below before starting a session.",
+ busyReason: "A session is already running. Stop it here when you are ready to finalize the summary.",
+ },
+ diagnostics: {
+ title: "Diagnostics",
+ subtitle: "Health checks for the local-first recording pipeline.",
+ blocking: "Blocking issues",
+ warnings: "Warnings",
+ healthy: "Healthy checks",
+ noBlocking: "No blocking issues detected.",
+ noWarnings: "No warnings detected.",
+ smokePassed: "Quick test completed successfully.",
+ },
+ library: {
+ title: "Session Library",
+ subtitle: "Review finished and failed sessions from manifest-backed metadata.",
+ all: "All",
+ done: "Done",
+ failed: "Failed",
+ searchPlaceholder: "Search sessions",
+ empty: "No sessions match the current filters.",
+ deleteConfirmation: "Delete this session and its vault notes?",
+ },
+ settings: {
+ title: "Local recording, transcription, and summaries for Obsidian.",
+ },
+ notices: {
+ sessionComplete: "Session completed.",
+ sessionDeleted: "Session deleted.",
+ sessionBusy: "Session is busy. Please wait for the current operation to finish.",
+ ffmpegNotDetected: "FFmpeg was not auto-detected.",
+ ffmpegDetected: "FFmpeg path updated.",
+ whisperNotDetected: "whisper.cpp CLI was not auto-detected.",
+ whisperDetected: "whisper.cpp CLI updated.",
+ whisperRepoNotDetected: "whisper.cpp repo was not auto-detected.",
+ whisperRepoDetected: "whisper.cpp repo path updated.",
+ modelNotDetected: "No readable whisper model was auto-detected.",
+ modelDetected: "Whisper model path updated.",
+ quickTestFailedPrefix: "Quick test failed",
+ },
+} as const;
diff --git a/src/ui/modals/RecordingModal.ts b/src/ui/modals/RecordingModal.ts
new file mode 100644
index 0000000..2b8ebeb
--- /dev/null
+++ b/src/ui/modals/RecordingModal.ts
@@ -0,0 +1,301 @@
+import { App, Modal, Notice } from "obsidian";
+import type { SessionController } from "../../application/SessionController";
+import { DEFAULT_SCENARIO_KEY, SCENARIOS, getScenario, type ScenarioTemplate } from "../../domain/scenarios";
+import { isCoreConfigured, type PluginSettingsV2 } from "../../domain/settings";
+import type { SessionRuntimeSnapshot, SessionState } from "../../domain/session";
+import { openPluginSettings } from "../../infrastructure/obsidianDesktop";
+import { formatDuration } from "../../utils/format";
+import { setPreferredSettingsTab } from "../SettingsTab";
+import { uiCopy } from "../copy";
+
+interface RecordingModalOptions {
+ pluginId: string;
+ controller: SessionController;
+ getSettings: () => PluginSettingsV2;
+ onClosed?: () => void;
+}
+
+const STARTABLE_STATES = new Set(["idle", "done", "failed"]);
+const STOPPABLE_STATES = new Set(["segmenting", "recording", "transcribing_live"]);
+
+export class RecordingModal extends Modal {
+ private selectedScenarioKey: string;
+ private refreshTimerId: number | null = null;
+ private actionPending: "start" | "stop" | null = null;
+ private opened = false;
+
+ constructor(app: App, private readonly options: RecordingModalOptions) {
+ super(app);
+ const snapshot = options.controller.getSnapshot();
+ this.selectedScenarioKey =
+ snapshot.scenarioKey || options.getSettings().ui.lastScenarioKey || DEFAULT_SCENARIO_KEY;
+ }
+
+ isOpen(): boolean {
+ return this.opened;
+ }
+
+ onOpen(): void {
+ this.opened = true;
+ this.modalEl.addClass("rxn-recording-modal");
+ this.contentEl.addClass("rxn-modal", "rxn-recording-modal-content");
+ this.render();
+ this.refreshTimerId = window.setInterval(() => {
+ this.render();
+ }, 500);
+ }
+
+ onClose(): void {
+ this.opened = false;
+ if (this.refreshTimerId !== null) {
+ window.clearInterval(this.refreshTimerId);
+ this.refreshTimerId = null;
+ }
+ this.contentEl.empty();
+ this.modalEl.removeClass("rxn-recording-modal");
+ this.contentEl.removeClass("rxn-modal", "rxn-recording-modal-content");
+ this.options.onClosed?.();
+ }
+
+ private render(): void {
+ const snapshot = this.options.controller.getSnapshot();
+ const settings = this.options.getSettings();
+ const scenario = getScenario(snapshot.scenarioKey || this.selectedScenarioKey);
+ const canStart = STARTABLE_STATES.has(snapshot.state);
+ const canStop = STOPPABLE_STATES.has(snapshot.state);
+ const coreConfigured = isCoreConfigured(settings);
+
+ this.contentEl.empty();
+
+ const hero = this.contentEl.createDiv({ cls: "rxn-panel rxn-hero-panel rxn-recording-hero" });
+ const heroHeader = hero.createDiv({ cls: "rxn-recording-hero-header" });
+ const headline = heroHeader.createDiv({ cls: "rxn-section-heading" });
+ headline.createEl("h2", { text: this.getHeadline(snapshot) });
+ headline.createEl("p", { text: this.getSubheadline(snapshot, coreConfigured), cls: "rxn-muted" });
+
+ const runtime = heroHeader.createDiv({ cls: "rxn-recording-runtime" });
+ runtime.createEl("span", { text: this.getStateLabel(snapshot.state), cls: `rxn-status-pill is-${this.getStateTone(snapshot.state)}` });
+ runtime.createEl("strong", { text: formatDuration(snapshot.elapsedSeconds), cls: "rxn-recording-elapsed" });
+ if (!canStart) {
+ runtime.createEl("span", { text: snapshot.scenarioLabel || scenario.label, cls: "rxn-muted" });
+ }
+
+ if (snapshot.lastError) {
+ const note = hero.createDiv({ cls: "rxn-inline-note is-failed" });
+ note.createEl("strong", { text: "Last result" });
+ note.createEl("p", { text: snapshot.lastError });
+ }
+
+ if (canStart) {
+ this.renderScenarioPicker();
+ this.renderStartActions(snapshot, coreConfigured);
+ return;
+ }
+
+ this.renderActiveSession(snapshot, scenario, canStop);
+ }
+
+ private renderScenarioPicker(): void {
+ const picker = this.contentEl.createDiv({ cls: "rxn-panel" });
+
+ const grid = picker.createDiv({ cls: "rxn-scenario-grid" });
+ for (const scenario of SCENARIOS) {
+ const button = grid.createEl("button", { cls: "rxn-scenario-option" });
+ if (scenario.key === this.selectedScenarioKey) {
+ button.addClass("is-selected");
+ }
+ button.disabled = this.actionPending !== null;
+ button.createEl("strong", { text: scenario.label });
+ button.createEl("p", { text: scenario.description });
+ button.addEventListener("click", () => {
+ this.selectedScenarioKey = scenario.key;
+ this.render();
+ });
+ }
+ }
+
+ private renderStartActions(snapshot: SessionRuntimeSnapshot, coreConfigured: boolean): void {
+ const panel = this.contentEl.createDiv({ cls: "rxn-panel" });
+ const meta = panel.createDiv({ cls: "rxn-pill-row" });
+ meta.createEl("span", { text: `Provider: ${this.options.getSettings().summary.provider}`, cls: "rxn-pill" });
+
+ if (!coreConfigured) {
+ const note = panel.createDiv({ cls: "rxn-inline-note is-warning" });
+ note.createEl("strong", { text: "Setup incomplete" });
+ note.createEl("p", { text: "Finish Capture, Transcription, and Summary before starting a recording." });
+ } else if (snapshot.state === "done") {
+ const note = panel.createDiv({ cls: "rxn-inline-note is-healthy" });
+ note.createEl("strong", { text: "Last session completed" });
+ note.createEl("p", { text: "You can start a new recording with the same style or pick a different one." });
+ }
+
+ const actions = panel.createDiv({ cls: "rxn-action-bar" });
+ this.createActionButton(
+ actions,
+ this.actionPending === "start" ? "Starting..." : "Start recording",
+ async () => {
+ this.actionPending = "start";
+ this.render();
+ try {
+ await this.options.controller.startScenario(this.selectedScenarioKey);
+ } catch {}
+ this.actionPending = null;
+ this.render();
+ },
+ "rxn-btn-primary",
+ this.actionPending !== null || !coreConfigured
+ );
+ this.createActionButton(actions, uiCopy.actions.openLibrary, () => {
+ this.close();
+ setPreferredSettingsTab("library");
+ openPluginSettings(this.app, this.options.pluginId);
+ }, "rxn-btn-secondary");
+ this.createActionButton(actions, uiCopy.actions.openLiveTranscript, async () => {
+ const opened = await this.options.controller.openActiveLiveTranscript();
+ if (!opened) {
+ new Notice("Live transcript is not available yet.");
+ }
+ }, "rxn-btn-secondary", !this.options.controller.getActiveLiveTranscriptNotePath());
+ }
+
+ private renderActiveSession(
+ snapshot: SessionRuntimeSnapshot,
+ scenario: ScenarioTemplate,
+ canStop: boolean
+ ): void {
+ const live = this.contentEl.createDiv({ cls: "rxn-panel rxn-recording-live" });
+ live.createEl("h3", { text: snapshot.scenarioLabel || scenario.label });
+ live.createEl("p", {
+ text:
+ snapshot.state === "stopping"
+ ? "Recording has stopped. Resonance is finishing the remaining transcript and summary work."
+ : snapshot.state === "summarizing" || snapshot.state === "persisting"
+ ? "Recording is done. Resonance is writing the final note and session files."
+ : snapshot.state === "transcribing_live"
+ ? "Recording is active and live transcription is catching up."
+ : "Recording is active. Stop when you want to finalize the summary.",
+ cls: "rxn-muted",
+ });
+
+ const actions = live.createDiv({ cls: "rxn-action-bar" });
+ const stopLabel =
+ this.actionPending === "stop"
+ ? "Stopping..."
+ : canStop
+ ? uiCopy.actions.stopSession
+ : snapshot.state === "preflight"
+ ? "Starting..."
+ : "Finalizing...";
+ this.createActionButton(
+ actions,
+ stopLabel,
+ () => {
+ this.actionPending = "stop";
+ this.render();
+ new Notice("Stopping session. Resonance will finish transcript and summary in the background.");
+ this.close();
+ void this.options.controller.stop().catch((error) => {
+ const message = String((error as Error)?.message ?? error);
+ new Notice(`Unable to stop session: ${message}`);
+ });
+ },
+ canStop ? "rxn-btn-danger" : "rxn-btn-secondary",
+ this.actionPending !== null || !canStop
+ );
+ this.createActionButton(actions, uiCopy.actions.openLibrary, () => {
+ this.close();
+ setPreferredSettingsTab("library");
+ openPluginSettings(this.app, this.options.pluginId);
+ }, "rxn-btn-secondary");
+ this.createActionButton(actions, uiCopy.actions.openLiveTranscript, async () => {
+ const opened = await this.options.controller.openActiveLiveTranscript();
+ if (!opened) {
+ new Notice("Live transcript is not available yet.");
+ }
+ }, "rxn-btn-secondary", !this.options.controller.getActiveLiveTranscriptNotePath());
+
+ const metrics = this.contentEl.createDiv({ cls: "rxn-recording-metrics" });
+ this.renderMetric(metrics, "Committed", String(snapshot.committedSegments), "Written");
+ this.renderMetric(metrics, "Queued", String(snapshot.queuedSegments), "Waiting");
+ this.renderMetric(metrics, "Transcript", String(snapshot.liveTranscriptChars), "Characters");
+ }
+
+ private renderMetric(container: HTMLElement, label: string, value: string, detail: string): void {
+ const card = container.createDiv({ cls: "rxn-panel rxn-recording-metric" });
+ card.createEl("span", { text: label, cls: "rxn-eyebrow" });
+ card.createEl("strong", { text: value });
+ card.createEl("p", { text: detail, cls: "rxn-muted" });
+ }
+
+ private createActionButton(
+ container: HTMLElement,
+ label: string,
+ onClick: () => Promise | void,
+ cls: string,
+ disabled = false
+ ): void {
+ const button = container.createEl("button", { text: label, cls });
+ button.disabled = disabled;
+ button.addEventListener("click", () => {
+ void onClick();
+ });
+ }
+
+ private getHeadline(snapshot: SessionRuntimeSnapshot): string {
+ if (STARTABLE_STATES.has(snapshot.state)) {
+ return "Record with Resonance";
+ }
+ if (snapshot.state === "stopping" || snapshot.state === "summarizing" || snapshot.state === "persisting") {
+ return "Finishing the session";
+ }
+ return "Recording in progress";
+ }
+
+ private getSubheadline(snapshot: SessionRuntimeSnapshot, coreConfigured: boolean): string {
+ if (snapshot.state === "failed" && snapshot.lastError) {
+ return snapshot.lastError;
+ }
+ if (snapshot.state === "done") {
+ return "The session is complete. Start another one when you are ready.";
+ }
+ if (STARTABLE_STATES.has(snapshot.state)) {
+ return coreConfigured
+ ? "Choose a style, then start recording."
+ : "Finish setup first, then choose a style and start recording.";
+ }
+ return snapshot.message || "You can stop the recording here when you are ready.";
+ }
+
+ private getStateLabel(state: SessionState): string {
+ switch (state) {
+ case "idle":
+ return "Ready";
+ case "preflight":
+ return "Starting";
+ case "segmenting":
+ case "recording":
+ case "transcribing_live":
+ return "Recording";
+ case "stopping":
+ return "Stopping";
+ case "summarizing":
+ case "persisting":
+ return "Finalizing";
+ case "done":
+ return "Completed";
+ case "failed":
+ return "Failed";
+ }
+ }
+
+ private getStateTone(state: SessionState): "healthy" | "warning" | "failed" {
+ switch (state) {
+ case "done":
+ return "healthy";
+ case "failed":
+ return "failed";
+ default:
+ return "warning";
+ }
+ }
+}
diff --git a/src/ui/modals/TextPreviewModal.ts b/src/ui/modals/TextPreviewModal.ts
new file mode 100644
index 0000000..3ced4a6
--- /dev/null
+++ b/src/ui/modals/TextPreviewModal.ts
@@ -0,0 +1,40 @@
+import { App, Modal, Notice } from "obsidian";
+
+export class TextPreviewModal extends Modal {
+ constructor(app: App, private readonly titleText: string, private readonly contentText: string) {
+ super(app);
+ }
+
+ onOpen(): void {
+ this.contentEl.empty();
+ this.contentEl.addClass("rxn-modal");
+ this.contentEl.createEl("h2", { text: this.titleText });
+ const toolbar = this.contentEl.createDiv({ cls: "rxn-action-bar" });
+ const copy = toolbar.createEl("button", { text: "Copy all", cls: "rxn-btn-secondary" });
+ copy.addEventListener("click", () => {
+ void this.copyAll();
+ });
+ const pre = this.contentEl.createEl("pre", { cls: "rxn-preview", attr: { tabindex: "0" } });
+ pre.setText(this.contentText || "(empty)");
+ }
+
+ private async copyAll(): Promise {
+ const text = this.contentText || "(empty)";
+ try {
+ await navigator.clipboard.writeText(text);
+ new Notice("Copied.");
+ return;
+ } catch {}
+
+ const textarea = document.createElement("textarea");
+ textarea.value = text;
+ textarea.setAttribute("readonly", "true");
+ textarea.style.position = "fixed";
+ textarea.style.opacity = "0";
+ document.body.appendChild(textarea);
+ textarea.select();
+ document.execCommand("copy");
+ document.body.removeChild(textarea);
+ new Notice("Copied.");
+ }
+}
diff --git a/src/utils/format.ts b/src/utils/format.ts
new file mode 100644
index 0000000..a4f2ef2
--- /dev/null
+++ b/src/utils/format.ts
@@ -0,0 +1,33 @@
+export function slugifyForPath(value: string): string {
+ return value.replace(/[\\/:*?"<>|]/g, "-").replace(/\s+/g, " ").trim();
+}
+
+export function formatDateForFile(input: string | Date): string {
+ const date = typeof input === "string" ? new Date(input) : input;
+ const yyyy = String(date.getFullYear());
+ const mm = String(date.getMonth() + 1).padStart(2, "0");
+ const dd = String(date.getDate()).padStart(2, "0");
+ const hh = String(date.getHours()).padStart(2, "0");
+ const min = String(date.getMinutes()).padStart(2, "0");
+ const ss = String(date.getSeconds()).padStart(2, "0");
+ return `${yyyy}-${mm}-${dd} ${hh}-${min}-${ss}`;
+}
+
+export function formatDuration(totalSeconds: number): string {
+ const safe = Math.max(0, Math.floor(totalSeconds));
+ const minutes = String(Math.floor(safe / 60)).padStart(2, "0");
+ const seconds = String(safe % 60).padStart(2, "0");
+ return `${minutes}:${seconds}`;
+}
+
+export function formatBytes(bytes: number): string {
+ if (bytes <= 0) return "0 B";
+ const units = ["B", "KB", "MB", "GB"];
+ let size = bytes;
+ let unitIndex = 0;
+ while (size >= 1024 && unitIndex < units.length - 1) {
+ size /= 1024;
+ unitIndex += 1;
+ }
+ return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
+}
diff --git a/src/utils/markdown.ts b/src/utils/markdown.ts
new file mode 100644
index 0000000..a45c3ef
--- /dev/null
+++ b/src/utils/markdown.ts
@@ -0,0 +1,126 @@
+export function normalizeCheckboxes(markdown: string): string {
+ try {
+ const lines = markdown.split(/\r?\n/);
+ const out: string[] = [];
+ for (let raw of lines) {
+ let line = raw;
+ line = line.replace(/[\uFF3B\u3010]/g, "[").replace(/[\uFF3D\u3011]/g, "]");
+ line = line.replace(/[“”«»]/g, '"').replace(/[‘’]/g, "'");
+
+ const match = line.match(/^(\s*)[-*]?\s*(?:["']\s*)?\[\s*([xX])?\s*\](?:\s*["'])?\s*(.*)$/);
+ if (match) {
+ const indent = match[1] || "";
+ const checked = match[2] ? "x" : " ";
+ const rest = match[3] || "";
+ out.push(`${indent}- [${checked}] ${rest}`.trimEnd());
+ continue;
+ }
+
+ line = line.replace(
+ /^(\s*)[-*]\s*\[\s*([xX ])\s*\]\s*(.*)$/,
+ (_source, indent, checked, rest) => `${indent}- [${checked.toLowerCase() === "x" ? "x" : " "}] ${rest}`
+ );
+ out.push(line);
+ }
+ return out.join("\n");
+ } catch {
+ return markdown;
+ }
+}
+
+export function sanitizeSummary(markdown: string): string {
+ try {
+ let value = String(markdown ?? "").trim();
+ if (!value) return value;
+
+ value = value.replace(/[\uFF1C]/g, "<").replace(/[\uFF1E]/g, ">");
+ const cotTags = ["think", "analysis", "reflection", "reasoning", "chain_of_thought", "chain-of-thought", "cot"];
+ for (const tag of cotTags) {
+ const pattern = new RegExp(`<\\s*${tag}[^>]*>[\\s\\S]*?<\\/\\s*${tag}\\s*>`, "gi");
+ value = value.replace(pattern, "");
+ }
+
+ const fencedMarkdownMatch = value.match(/^```(?:markdown|md)?\s*\n([\s\S]*?)\n```$/i);
+ if (fencedMarkdownMatch) {
+ value = fencedMarkdownMatch[1].trim();
+ }
+
+ value = value.replace(/```\s*(thinking|analysis|reasoning|reflection|chain[_ -]?of[_ -]?thought|cot|log)[\s\S]*?```/gi, "");
+ value = value.replace(/^\s*<(assistant|system|user)[^>]*>\s*/gim, "");
+ value = value.replace(/\n{3,}/g, "\n\n").trim();
+ return value;
+ } catch {
+ return markdown;
+ }
+}
+
+export function formatTranscriptChunkMarkdown(_index: number, text: string): string {
+ const clean = normalizeTranscriptChunk(text);
+ if (!clean) return "";
+ return clean;
+}
+
+export function formatLiveTranscriptNote(title: string, transcript: string): string {
+ const clean = normalizeTranscriptChunk(transcript);
+ const body = clean ? `\n\n${clean}\n` : "\n\n";
+ return `# ${title}\n\n> Live draft while recording. Minor errors are normal.${body}`;
+}
+
+function normalizeTranscriptChunk(text: string): string {
+ const normalized = String(text ?? "").trim().replace(/\r\n/g, "\n");
+ if (!normalized) return "";
+
+ const lines = normalized
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => !isTranscriptAnnotationLine(line));
+
+ const compact: string[] = [];
+ let previousBlank = false;
+ for (const line of lines) {
+ const isBlank = line.length === 0;
+ if (isBlank) {
+ if (!previousBlank && compact.length > 0) {
+ compact.push("");
+ }
+ previousBlank = true;
+ continue;
+ }
+ compact.push(line);
+ previousBlank = false;
+ }
+
+ return compact.join("\n").trim();
+}
+
+function isTranscriptAnnotationLine(line: string): boolean {
+ const value = line.trim();
+ if (!value) return false;
+ if (!/^\[[^\]\n]{1,120}\]$/.test(value)) return false;
+
+ const inner = value.slice(1, -1).trim();
+ if (!inner) return true;
+
+ const normalized = inner
+ .toLowerCase()
+ .normalize("NFD")
+ .replace(/[\u0300-\u036f]/g, "");
+
+ return [
+ "music",
+ "musica",
+ "applause",
+ "applausi",
+ "laughter",
+ "risate",
+ "silence",
+ "silenzio",
+ "noise",
+ "rumore",
+ "sottotitoli",
+ "respondenti del mio canale",
+ "rispondenti del mio canale",
+ "subtitle",
+ "subtitles",
+ ].some((token) => normalized.includes(token));
+}
diff --git a/styles.css b/styles.css
index 77bfef7..8111cc0 100644
--- a/styles.css
+++ b/styles.css
@@ -1,267 +1,1044 @@
-/* Settings cards */
-.resonance-settings .res-card {
- background: var(--background-primary);
- border: 1px solid var(--background-modifier-border);
- border-radius: 12px;
- padding: 16px;
- margin: 16px 0;
- box-shadow: 0 1px 2px rgba(0,0,0,.05);
+:root {
+ --rxn-font: "Avenir Next", "IBM Plex Sans", "Segoe UI", sans-serif;
+ --rxn-panel-bg:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 45%),
+ radial-gradient(circle at top right, rgba(50, 154, 255, 0.08), transparent 30%),
+ var(--background-secondary);
+ --rxn-panel-border: rgba(110, 132, 153, 0.22);
+ --rxn-shadow: 0 16px 40px rgba(0, 0, 0, 0.14);
+ --rxn-shadow-soft: 0 8px 22px rgba(0, 0, 0, 0.08);
+ --rxn-accent: #1b8cff;
+ --rxn-warning: #d98f1c;
+ --rxn-danger: #d94a4a;
+ --rxn-success: #1f9a63;
}
-.resonance-settings .res-card h3 { margin: 0 0 8px 0; }
-.resonance-settings .res-card p.desc { margin: 0 0 12px 0; color: var(--text-muted); }
-.resonance-settings .res-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 12px 16px; }
-.resonance-settings .res-inline { display: flex; gap: 8px; align-items: center; }
-.resonance-settings .res-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 8px; }
-/* Hero */
-.resonance-settings .res-hero {
- background: linear-gradient(135deg, var(--background-secondary) 0%, var(--background-primary) 100%);
- border: 1px solid var(--background-modifier-border);
- border-radius: 16px;
- padding: 20px;
- margin: 8px 0 16px 0;
+.rxn-settings,
+.rxn-modal {
+ font-family: var(--rxn-font);
}
-.resonance-settings .res-hero h2 { margin: 0 0 6px 0; font-size: 20px; }
-.resonance-settings .res-hero p { margin: 0; color: var(--text-muted); }
-.resonance-settings .res-hero .res-actions { margin-top: 12px; }
-.resonance-modal {
- padding: 12px 16px;
+
+.rxn-settings {
+ width: 100%;
+ max-width: 1400px;
+ margin: 0 auto 48px auto;
}
-.modal-container .resonance-wide .modal {
- width: 900px;
- max-width: 92vw;
+
+.vertical-tab-content .rxn-settings {
+ max-width: none;
}
-.resonance-toolbar {
+
+.rxn-control-room {
+ width: min(1520px, calc(100vw - 80px));
+ max-width: none;
+ margin: 0 auto;
+}
+
+.rxn-settings .rxn-card,
+.rxn-panel,
+.rxn-session-workspace-card,
+.rxn-device-card,
+.rxn-check-card {
+ background: var(--rxn-panel-bg);
+ border: 1px solid var(--rxn-panel-border);
+ border-radius: 18px;
+ box-shadow: var(--rxn-shadow-soft);
+}
+
+.rxn-settings .rxn-card,
+.rxn-panel,
+.rxn-session-workspace-card,
+.rxn-check-card,
+.rxn-device-card {
+ padding: 18px;
+}
+
+.rxn-settings .rxn-card {
+ margin: 16px 0;
+}
+
+.rxn-settings .rxn-step-section {
+ overflow: hidden;
+}
+
+.rxn-settings-surface {
+ display: grid;
+ gap: 16px;
+ margin-top: 16px;
+}
+
+.rxn-settings-tab-groups {
+ display: grid;
+ gap: 14px;
+ margin-top: 18px;
+}
+
+.rxn-settings-tab-group {
+ display: grid;
+ gap: 8px;
+}
+
+.rxn-settings-tab-group.is-setup .rxn-settings-tabs {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+}
+
+.rxn-settings-tab-group-label {
+ color: var(--text-muted);
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+.rxn-settings-tabs {
display: flex;
flex-wrap: wrap;
- gap: 8px;
- align-items: center;
- margin: 6px 0 10px 0;
-}
-
-.resonance-toolbar .date { flex: 0 1 auto; }
-.resonance-toolbar .search { flex: 1 1 220px; }
-
-.resonance-toolbar input[type="text"],
-.resonance-toolbar select {
- width: 100%;
- padding: 8px 10px;
- border-radius: 8px;
- border: 1px solid var(--background-modifier-border);
- background: var(--background-secondary);
- color: var(--text-normal);
-}
-
-.resonance-toolbar .spacer { flex: 1 1 auto; }
-
-.resonance-list { display: flex; flex-direction: column; gap: 8px; }
-
-.resonance-item {
- display: flex;
- justify-content: space-between;
- align-items: center;
gap: 12px;
- padding: 10px 12px;
- border: 1px solid var(--background-modifier-border);
- border-radius: 10px;
- background: var(--background-secondary);
+ margin: 0;
}
-.resonance-meta { display: flex; align-items: center; gap: 10px; }
-.resonance-left { display: flex; align-items: center; gap: 10px; }
-.resonance-title { font-weight: 600; }
-.resonance-sub { color: var(--text-muted); font-size: 12px; }
-.resonance-actions { display: flex; flex-wrap: wrap; gap: 6px; }
-
-.resonance-audio-wrap { margin: 6px 0 12px 28px; }
-
-.resonance-modal h2 {
- margin-top: 4px;
- margin-bottom: 12px;
+.rxn-settings-tab {
+ display: inline-flex !important;
+ flex: 1 1 180px;
+ min-width: 160px;
+ min-height: 48px;
+ height: auto !important;
+ align-items: center;
+ justify-content: center;
+ padding: 9px 14px;
+ text-align: center;
+ line-height: 1.15;
+ white-space: normal;
+ border-radius: 16px;
+ background: rgba(16, 23, 33, 0.18);
+ border: 1px solid rgba(110, 132, 153, 0.22);
+ color: var(--text-normal);
+ transition: transform 0.12s ease, border-color 0.12s ease, background 0.12s ease, box-shadow 0.12s ease;
}
-.resonance-status {
- font-size: 14px;
- color: #e03131;
- margin-bottom: 10px;
- transition: color 0.2s ease;
+.rxn-settings-tab:hover {
+ transform: translateY(-1px);
}
-/* Progress bar styles removed - no longer needed */
-
-.resonance-timer {
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
- font-size: 20px;
- font-weight: 600;
- margin-bottom: 10px;
+.rxn-settings-tab strong {
+ display: block;
+ font-size: 15px;
+ line-height: 1.15;
}
-.resonance-controls {
+.rxn-settings-tab.is-selected {
+ border-color: var(--rxn-accent);
+ background: rgba(27, 140, 255, 0.14);
+ box-shadow: 0 12px 28px rgba(27, 140, 255, 0.12);
+}
+
+.rxn-settings .rxn-step-section .setting-item {
+ display: block;
+ margin-top: 14px;
+ padding: 16px;
+ border: 1px solid rgba(110, 132, 153, 0.18);
+ border-radius: 16px;
+ background: rgba(16, 23, 33, 0.14);
+}
+
+.rxn-settings .rxn-step-section .setting-item:first-of-type {
+ margin-top: 18px;
+}
+
+.rxn-settings .rxn-step-section .setting-item-info {
+ max-width: none;
+ width: 100%;
+ margin: 0 0 12px 0;
+}
+
+.rxn-settings .rxn-step-section .setting-item-name {
+ font-size: 15px;
+ font-weight: 700;
+}
+
+.rxn-settings .rxn-step-section .setting-item-description {
+ line-height: 1.45;
+}
+
+.rxn-settings .rxn-step-section .setting-item-control {
+ width: 100%;
display: flex;
- gap: 8px;
+ flex-wrap: wrap;
+ justify-content: flex-start;
+ gap: 12px;
+}
+
+.rxn-settings .rxn-step-section .setting-item-control input[type="text"],
+.rxn-settings .rxn-step-section .setting-item-control input[type="number"],
+.rxn-settings .rxn-step-section .setting-item-control select,
+.rxn-settings .rxn-step-section .setting-item-control textarea {
+ flex: 1 1 320px;
+ width: auto;
+ min-width: 220px;
+ min-height: 48px;
+ height: auto !important;
+ box-sizing: border-box;
+ font: inherit;
+ line-height: 1.2;
+}
+
+.rxn-settings .rxn-step-section .setting-item-control input[type="text"],
+.rxn-settings .rxn-step-section .setting-item-control input[type="number"] {
+ padding: 0 12px;
+}
+
+.rxn-settings .rxn-step-section .setting-item-control select {
+ padding: 0 36px 0 12px;
+}
+
+.rxn-settings .rxn-step-section .setting-item-control textarea {
+ padding: 12px;
+}
+
+.rxn-settings .rxn-step-section .setting-item-control button {
+ flex: 0 0 auto;
+ min-height: 48px;
+ height: auto !important;
+ padding: 0 14px;
+ box-sizing: border-box;
+ font: inherit;
+ line-height: 1.2;
+}
+
+.rxn-control-bar {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ gap: 16px;
+ align-items: flex-start;
+ margin-top: 18px;
+}
+
+.rxn-control-status {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ align-items: center;
+}
+
+.rxn-hero,
+.rxn-hero-panel {
+ background:
+ radial-gradient(circle at top left, rgba(27, 140, 255, 0.18), transparent 36%),
+ radial-gradient(circle at bottom right, rgba(31, 154, 99, 0.1), transparent 28%),
+ linear-gradient(145deg, rgba(14, 24, 36, 0.6), rgba(14, 24, 36, 0.08)),
+ var(--background-secondary);
+}
+
+.rxn-hero h2,
+.rxn-panel h2,
+.rxn-modal h2 {
+ margin: 0;
+ letter-spacing: -0.02em;
+}
+
+.rxn-hero {
+ padding: 24px 26px;
+}
+
+.rxn-hero-brand {
+ max-width: 780px;
+ font-size: clamp(34px, 4vw, 52px);
+ line-height: 0.98;
+}
+
+.rxn-hero-subtitle {
+ max-width: 760px;
+ margin: 10px 0 0 0;
+ font-size: 14px;
+ line-height: 1.4;
+}
+
+.rxn-panel h3 {
+ margin: 0 0 12px 0;
+}
+
+.rxn-eyebrow {
+ margin: 0 0 6px 0;
+ color: var(--text-muted);
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+.rxn-muted {
+ color: var(--text-muted);
+}
+
+.rxn-actions,
+.rxn-action-bar,
+.rxn-pill-row,
+.rxn-filter-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
+.rxn-action-bar,
+.rxn-toolbar {
+ margin-top: 16px;
+}
+
+.rxn-diagnostics-meta {
margin-top: 12px;
}
-.resonance-btn {
+.rxn-toolbar {
+ display: grid;
+ gap: 12px;
+ margin: 14px 0 18px 0;
+}
+
+.rxn-toolbar .mod-cta {
+ justify-self: start;
+}
+
+.rxn-actions button,
+.rxn-action-bar button,
+.rxn-link-btn,
+.rxn-inline-select,
+.rxn-search-input,
+.rxn-filter-chip,
+.rxn-scenario-chip {
+ border-radius: 12px;
+}
+
+.rxn-actions button,
+.rxn-action-bar button,
+.rxn-filter-chip,
+.rxn-scenario-chip,
+.rxn-link-btn {
+ display: inline-flex !important;
+ align-items: center;
+ justify-content: center;
+ min-height: 38px;
+ height: auto !important;
+ padding: 8px 12px;
+ box-sizing: border-box;
+ font: inherit;
+ line-height: 1.2;
+ white-space: normal;
+ transition: transform 0.12s ease, border-color 0.12s ease, background 0.12s ease, box-shadow 0.12s ease;
+}
+
+.rxn-actions button:hover,
+.rxn-action-bar button:hover,
+.rxn-filter-chip:hover,
+.rxn-scenario-chip:hover {
+ transform: translateY(-1px);
+}
+
+.rxn-btn-secondary,
+.rxn-filter-chip,
+.rxn-inline-select,
+.rxn-search-input,
+.rxn-scenario-chip {
+ background: rgba(16, 23, 33, 0.18);
+ border: 1px solid rgba(110, 132, 153, 0.22);
+ color: var(--text-normal);
+}
+
+.rxn-btn-primary {
+ background: linear-gradient(135deg, rgba(27, 140, 255, 0.92), rgba(13, 91, 201, 0.92));
+ border: 1px solid rgba(79, 167, 255, 0.4);
+ color: white;
+ box-shadow: 0 12px 28px rgba(27, 140, 255, 0.2);
+}
+
+.rxn-btn-primary:hover {
+ border-color: rgba(117, 188, 255, 0.5);
+}
+
+.rxn-btn-danger {
+ background: rgba(217, 74, 74, 0.12);
+ border: 1px solid rgba(217, 74, 74, 0.38);
+ color: #ff8d8d;
+}
+
+.rxn-link-btn {
+ background: transparent;
+ border: none;
+ color: var(--interactive-accent);
+ cursor: pointer;
+ padding: 0;
+}
+
+.rxn-pill,
+.rxn-status-pill {
display: inline-flex;
align-items: center;
gap: 6px;
- border: none;
- border-radius: 8px;
- padding: 10px 14px;
- font-weight: 600;
- cursor: pointer;
- transition: transform 0.1s ease, background 0.2s ease, opacity 0.2s ease;
-}
-
-.resonance-btn.primary {
- background: var(--interactive-accent);
- color: var(--text-on-accent);
-}
-
-.resonance-btn.primary:hover { transform: translateY(-1px); }
-
-.resonance-btn.danger {
- background: #e03131;
- color: #fff;
-}
-
-.resonance-btn.danger:hover { transform: translateY(-1px); }
-
-.resonance-btn.disabled {
- background: var(--background-modifier-border);
- color: var(--text-muted);
- cursor: not-allowed;
- opacity: 0.8;
-}
-
-.resonance-btn.secondary {
- background: var(--background-secondary);
- color: var(--text-normal);
- border: 1px solid var(--background-modifier-border);
-}
-
-.resonance-btn.secondary:hover { transform: translateY(-1px); }
-
-/* Icona SVG + spinner */
-.resonance-icon.spin {
- display: inline-flex;
- animation: spin 0.9s linear infinite;
-}
-
-@keyframes spin {
- from { transform: rotate(0deg); }
- to { transform: rotate(360deg); }
-}
-
-/* Animazione onda/cerchio pulsante */
-.resonance-wave {
- position: relative;
- width: 84px;
- height: 84px;
- margin: 8px 0;
- border-radius: 50%;
- background: radial-gradient(circle at center, rgba(100, 180, 255, 0.5) 0%, rgba(100, 180, 255, 0.2) 60%, rgba(100, 180, 255, 0.05) 100%);
- box-shadow: inset 0 0 20px rgba(100, 180, 255, 0.25);
- opacity: 0.4;
- transition: opacity 0.3s ease;
-}
-
-.resonance-wave.active { opacity: 1; }
-
-.resonance-wave::before, .resonance-wave::after {
- content: "";
- position: absolute;
- inset: 0;
- border-radius: 50%;
- border: 2px solid rgba(100, 180, 255, 0.5);
- animation: ripple 2.2s ease-out infinite;
-}
-
-.resonance-wave::after { animation-delay: 1.1s; }
-
-@keyframes ripple {
- 0% { transform: scale(0.9); opacity: 0.75; }
- 70% { transform: scale(1.25); opacity: 0.2; }
- 100% { transform: scale(1.4); opacity: 0.05; }
-}
-
-/* Select inline nelle impostazioni */
-.resonance-inline-select {
- margin-left: 0px;
- margin-bottom: 10px;
- padding: 6px 8px;
- border-radius: 6px;
- border: 1px solid var(--background-modifier-border);
- background: var(--background-secondary);
- color: var(--text-normal);
-}
-
-/* Stili per blocchi help */
-.resonance-help pre {
- background: var(--background-secondary);
- border: 1px solid var(--background-modifier-border);
- border-radius: 8px;
- padding: 8px 10px;
- overflow: auto;
- position: relative;
-}
-.resonance-help code {
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+ padding: 6px 10px;
+ border-radius: 999px;
+ border: 1px solid rgba(110, 132, 153, 0.22);
font-size: 12px;
-}
-.resonance-btn.small { padding: 4px 8px; font-size: 12px; border-radius: 6px; }
-
-/* Ribbon states */
-.workspace-ribbon .resonance-ribbon.recording {
- color: #e03131 !important;
-}
-.workspace-ribbon .resonance-ribbon.processing {
- color: var(--interactive-accent) !important;
- animation: pulse 1.2s ease-in-out infinite;
+ line-height: 1.1;
}
-@keyframes pulse {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0.55; }
+.rxn-pill {
+ background: rgba(16, 23, 33, 0.16);
}
-/* Status bar */
-.status-bar-item.resonance-statusbar {
+.rxn-pill.is-ok,
+.rxn-status-pill.is-healthy {
+ border-color: rgba(31, 154, 99, 0.35);
+ background: rgba(31, 154, 99, 0.12);
+}
+
+.rxn-status-pill.is-warning {
+ border-color: rgba(217, 143, 28, 0.38);
+ background: rgba(217, 143, 28, 0.12);
+}
+
+.rxn-status-pill.is-failed,
+.rxn-pill.is-error {
+ border-color: rgba(217, 74, 74, 0.4);
+ background: rgba(217, 74, 74, 0.12);
+}
+
+.rxn-section-heading {
+ display: grid;
+ gap: 6px;
+}
+
+.rxn-section-heading h3,
+.rxn-section-heading p {
+ margin: 0;
+}
+
+.rxn-hero-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ align-items: flex-start;
+}
+
+.rxn-status-stack {
+ display: grid;
+ justify-items: end;
+ gap: 8px;
+}
+
+.rxn-runtime-label {
+ text-align: right;
+}
+
+.rxn-check-grid,
+.rxn-session-workspace {
+ display: grid;
+ gap: 12px;
+}
+
+.rxn-diagnostics-stack {
+ display: grid;
+ gap: 12px;
+ margin-top: 16px;
+}
+
+.rxn-recording-modal .modal {
+ width: min(980px, calc(100vw - 40px));
+ max-width: calc(100vw - 40px);
+}
+
+.rxn-recording-modal .modal-content {
+ width: 100%;
+ max-width: none;
+}
+
+.rxn-recording-modal-content {
+ display: grid;
+ gap: 14px;
+}
+
+.rxn-recording-hero {
+ padding: 20px 22px;
+}
+
+.rxn-recording-hero-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 18px;
+ align-items: flex-start;
+}
+
+.rxn-recording-runtime {
+ display: grid;
+ gap: 6px;
+ justify-items: end;
+ text-align: right;
+ min-width: 180px;
+}
+
+.rxn-recording-elapsed {
+ font-size: clamp(28px, 4vw, 38px);
+ line-height: 0.92;
+}
+
+.rxn-scenario-grid,
+.rxn-recording-metrics {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+ align-items: stretch;
+}
+
+.rxn-recording-metrics {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+}
+
+.rxn-scenario-option {
+ display: flex !important;
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: flex-start;
+ gap: 8px;
+ width: 100%;
+ min-width: 0;
+ min-height: 96px;
+ padding: 14px 16px;
+ box-sizing: border-box;
+ border-radius: 16px;
+ border: 1px solid rgba(110, 132, 153, 0.22);
+ background: rgba(16, 23, 33, 0.18);
+ color: var(--text-normal);
+ text-align: left;
+ white-space: normal;
+ overflow: hidden;
+ transition: transform 0.12s ease, border-color 0.12s ease, background 0.12s ease, box-shadow 0.12s ease;
+}
+
+.rxn-scenario-option:hover {
+ transform: translateY(-1px);
+}
+
+.rxn-scenario-option.is-selected {
+ border-color: var(--rxn-accent);
+ background: rgba(27, 140, 255, 0.14);
+ box-shadow: 0 12px 28px rgba(27, 140, 255, 0.12);
+}
+
+.rxn-scenario-option strong,
+.rxn-recording-metric strong {
+ display: block;
+ margin: 0;
+}
+
+.rxn-scenario-option p,
+.rxn-recording-metric p {
+ margin: 0;
+}
+
+.rxn-scenario-option strong,
+.rxn-scenario-option p {
+ width: 100%;
+ min-width: 0;
+ white-space: normal;
+ overflow-wrap: anywhere;
+ word-break: normal;
+ line-height: 1.3;
+}
+
+.rxn-scenario-option strong {
+ font-size: 16px;
+}
+
+.rxn-scenario-option p {
+ color: var(--text-muted);
+ font-size: 14px;
+}
+
+.rxn-recording-metric {
+ min-height: 108px;
+ align-content: start;
+}
+
+.rxn-recording-metric strong {
+ font-size: 32px;
+ line-height: 1;
+ margin-bottom: 6px;
+}
+
+.rxn-recording-modal-content .rxn-action-bar {
+ align-items: center;
+}
+
+.rxn-recording-modal-content .rxn-btn-primary {
+ min-width: 168px;
+}
+
+.rxn-recording-live {
+ display: grid;
+ gap: 12px;
+}
+
+.rxn-recording-live h3,
+.rxn-recording-live p {
+ margin: 0;
+}
+
+.rxn-check-card strong,
+.rxn-device-card strong {
+ display: block;
+ margin-bottom: 6px;
+}
+
+.rxn-check-card p,
+.rxn-check-card small {
+ margin: 0;
+}
+
+.rxn-check-card small {
+ display: block;
+ margin-top: 8px;
+}
+
+.rxn-check-card.is-failed,
+.rxn-inline-note.is-failed {
+ border-color: rgba(217, 74, 74, 0.35);
+}
+
+.rxn-check-card.is-warning,
+.rxn-inline-note.is-warning {
+ border-color: rgba(217, 143, 28, 0.35);
+}
+
+.rxn-check-card.is-healthy {
+ border-color: rgba(31, 154, 99, 0.28);
+}
+
+.rxn-inline-note {
+ margin-top: 14px;
+ padding: 14px 16px;
+ border-radius: 16px;
+ border: 1px solid rgba(110, 132, 153, 0.2);
+ background: rgba(16, 23, 33, 0.16);
+}
+
+.rxn-inline-note strong {
+ display: block;
+ margin-bottom: 6px;
+}
+
+.rxn-step-section-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 10px;
+ flex-wrap: wrap;
+}
+
+.rxn-step-section-header h3 {
+ margin: 0;
+}
+
+.rxn-step-section-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 28px;
+ padding: 6px 10px;
+ border-radius: 999px;
+ border: 1px solid rgba(79, 167, 255, 0.36);
+ background: rgba(27, 140, 255, 0.14);
+ color: var(--text-normal);
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+.rxn-guide-list {
+ margin: 14px 0 0 18px;
+ color: var(--text-muted);
+}
+
+.rxn-guide-list li + li {
+ margin-top: 6px;
+}
+
+.rxn-guide-platform {
+ margin-top: 14px;
+}
+
+.rxn-guide-platform-tabs {
+ display: inline-flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.rxn-guide-platform-tab {
+ display: inline-flex !important;
+ align-items: center;
+ justify-content: center;
+ min-height: 34px;
+ height: auto !important;
+ padding: 6px 12px;
+ box-sizing: border-box;
+ border-radius: 999px;
+ border: 1px solid rgba(110, 132, 153, 0.24);
+ background: rgba(16, 23, 33, 0.12);
+ color: var(--text-normal);
+ font-size: 13px;
+ font-weight: 700;
+ transition: background 0.12s ease, border-color 0.12s ease, box-shadow 0.12s ease;
+}
+
+.rxn-guide-platform-tab.is-selected {
+ border-color: var(--rxn-accent);
+ background: rgba(27, 140, 255, 0.14);
+ box-shadow: 0 10px 20px rgba(27, 140, 255, 0.12);
+}
+
+.rxn-guide-platform-panel {
+ margin-top: 12px;
+}
+
+.rxn-guide-platform-copy {
+ margin: 0;
+}
+
+.rxn-guide-code-block {
+ margin-top: 14px;
+}
+
+.rxn-guide-code-label {
+ margin: 0 0 6px;
+ color: var(--text-muted);
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+.rxn-guide-code-shell {
+ position: relative;
+}
+
+.rxn-guide-code-copy {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ z-index: 1;
+}
+
+.rxn-guide-code {
+ margin: 0;
+ padding: 16px 88px 16px 14px;
+ border-radius: 14px;
+ border: 1px solid rgba(110, 132, 153, 0.22);
+ background: rgba(10, 14, 20, 0.34);
+ overflow: auto;
+ white-space: pre-wrap;
+}
+
+.rxn-scenario-row {
+ display: grid;
+ gap: 10px;
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+}
+
+.rxn-scenario-chip {
+ justify-content: flex-start;
+ min-height: 48px;
+ padding: 10px 14px;
+ text-align: left;
+ cursor: pointer;
+}
+
+.rxn-scenario-chip strong {
+ display: block;
+ line-height: 1.15;
+}
+
+.rxn-scenario-chip.is-selected,
+.rxn-filter-chip.is-selected {
+ border-color: var(--rxn-accent);
+ background: rgba(27, 140, 255, 0.14);
+ box-shadow: 0 12px 28px rgba(27, 140, 255, 0.12);
+}
+
+.rxn-filter-chip {
+ padding: 8px 12px;
+}
+
+.rxn-inline-select,
+.rxn-search-input {
+ width: 100%;
+ min-height: 48px;
+ padding: 0 12px;
+}
+
+.rxn-search-input {
+ min-height: 42px;
+ padding: 10px 12px;
+}
+
+.rxn-session-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ align-items: flex-start;
+}
+
+.rxn-session-title {
+ display: grid;
+ gap: 8px;
+}
+
+.rxn-session-title strong {
+ font-size: 1.15rem;
+ line-height: 1.15;
+}
+
+.rxn-session-workspace-card {
+ display: grid;
+ gap: 14px;
+}
+
+.rxn-session-meta-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px 20px;
+}
+
+.rxn-session-meta-item {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 6px;
+ color: var(--text-muted);
+ font-size: 0.95rem;
+}
+
+.rxn-session-meta-label {
+ color: var(--text-faint);
+}
+
+.rxn-session-meta-value {
color: var(--text-normal);
}
-/* Context menu per Library "More" */
-.resonance-menu {
- background: var(--background-secondary);
- border: 1px solid var(--background-modifier-border);
- border-radius: 10px;
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
- padding: 8px;
- min-width: 240px;
- z-index: 9999;
-}
-.resonance-menu-item {
+.rxn-session-section-heading {
display: flex;
align-items: center;
gap: 10px;
- width: 100%;
- text-align: left;
+}
+
+.rxn-session-recovery {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 12px;
padding: 12px 14px;
- border: none;
- background: transparent;
+ border-radius: 14px;
+ background: rgba(217, 143, 28, 0.05);
+}
+
+.rxn-session-recovery-copy {
+ display: grid;
+ gap: 4px;
+ max-width: 680px;
+}
+
+.rxn-session-recovery p {
+ margin: 0;
+}
+
+.rxn-session-menu-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
+.rxn-session-menu {
+ position: relative;
+}
+
+.rxn-session-menu > summary {
+ list-style: none;
+}
+
+.rxn-session-menu > summary::-webkit-details-marker {
+ display: none;
+}
+
+.rxn-session-menu-trigger {
+ min-width: 112px;
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: flex-start;
+ min-height: 40px;
+ padding: 8px 34px 8px 14px;
+ border-radius: 999px;
+ border: 1px solid rgba(110, 132, 153, 0.22);
+ background: rgba(16, 23, 33, 0.18);
+ box-sizing: border-box;
+ padding-right: 28px;
+}
+
+.rxn-session-menu-trigger::after {
+ content: "▾";
+ position: absolute;
+ right: 12px;
+ top: 50%;
+ transform: translateY(-50%);
+ opacity: 0.75;
+}
+
+.rxn-session-menu[open] .rxn-session-menu-trigger {
+ border-color: var(--rxn-accent);
+ background: rgba(27, 140, 255, 0.14);
+ box-shadow: 0 10px 24px rgba(27, 140, 255, 0.12);
+}
+
+.rxn-session-menu.is-danger .rxn-session-menu-trigger {
+ border-color: rgba(217, 74, 74, 0.32);
+ background: rgba(217, 74, 74, 0.08);
+ color: #ff9a9a;
+}
+
+.rxn-session-menu.is-danger[open] .rxn-session-menu-trigger {
+ border-color: rgba(217, 74, 74, 0.38);
+ background: rgba(217, 74, 74, 0.12);
+ box-shadow: 0 10px 24px rgba(217, 74, 74, 0.12);
+}
+
+.rxn-session-menu-list {
+ position: absolute;
+ top: calc(100% + 8px);
+ left: 0;
+ z-index: 20;
+ display: grid;
+ gap: 8px;
+ min-width: 220px;
+ padding: 10px;
+ border-radius: 14px;
+ border: 1px solid rgba(110, 132, 153, 0.18);
+ background: rgba(12, 16, 22, 0.96);
+ box-shadow: 0 16px 40px rgba(0, 0, 0, 0.28);
+}
+
+.rxn-session-menu-list button {
+ width: 100%;
+ justify-content: flex-start;
+}
+
+.rxn-session-workspace-card audio {
+ width: 100%;
+ margin-top: 14px;
+}
+
+.rxn-preview {
+ margin: 12px 0 0 0;
+ padding: 14px;
+ border-radius: 14px;
+ background: rgba(16, 23, 33, 0.16);
+ border: 1px solid rgba(110, 132, 153, 0.22);
+ overflow: auto;
+ max-height: 60vh;
+ white-space: pre-wrap;
+ user-select: text;
+ -webkit-user-select: text;
+ cursor: text;
+}
+
+.rxn-ribbon.is-recording {
+ color: var(--rxn-danger) !important;
+}
+
+.rxn-ribbon.is-busy {
+ color: var(--rxn-accent) !important;
+}
+
+.status-bar-item.rxn-statusbar {
color: var(--text-normal);
- border-radius: 8px;
cursor: pointer;
- font-size: 14px;
- transition: background 0.15s ease;
}
-.resonance-menu-item:hover {
- background: var(--background-modifier-hover);
+
+.rxn-wide-modal .modal {
+ width: min(1560px, calc(100vw - 40px));
+ max-width: calc(100vw - 40px);
}
-.resonance-menu-item .resonance-icon {
- width: 16px;
- height: 16px;
- flex-shrink: 0;
- opacity: 0.7;
+
+.rxn-wide-modal .modal-content {
+ width: 100%;
+ max-width: none;
+}
+
+@media (max-width: 920px) {
+ .rxn-settings-tab-group.is-setup .rxn-settings-tabs {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .rxn-hero-header {
+ flex-direction: column;
+ }
+
+ .rxn-status-stack {
+ justify-items: start;
+ }
+
+ .rxn-runtime-label {
+ text-align: left;
+ }
+
+ .rxn-step-section-header {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .rxn-control-bar {
+ flex-direction: column;
+ }
+
+ .rxn-recording-hero-header {
+ flex-direction: column;
+ }
+
+ .rxn-recording-runtime {
+ justify-items: start;
+ text-align: left;
+ }
+
+ .rxn-recording-metrics {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 720px) {
+ .rxn-settings-tab-group.is-setup .rxn-settings-tabs {
+ grid-template-columns: 1fr;
+ }
+
+ .rxn-scenario-row,
+ .rxn-wide-modal .modal {
+ width: 96vw;
+ }
+
+ .rxn-recording-modal .modal {
+ width: 96vw;
+ max-width: 96vw;
+ }
+
+ .rxn-scenario-grid,
+ .rxn-recording-metrics {
+ grid-template-columns: 1fr;
+ }
+
+ .rxn-settings .rxn-step-section .setting-item-control input[type="text"],
+ .rxn-settings .rxn-step-section .setting-item-control input[type="number"],
+ .rxn-settings .rxn-step-section .setting-item-control select,
+ .rxn-settings .rxn-step-section .setting-item-control textarea,
+ .rxn-settings .rxn-step-section .setting-item-control button {
+ width: 100%;
+ min-width: 0;
+ }
}
diff --git a/tests/autoDetect.test.ts b/tests/autoDetect.test.ts
new file mode 100644
index 0000000..336f31c
--- /dev/null
+++ b/tests/autoDetect.test.ts
@@ -0,0 +1,24 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { getPreferredWhisperModelBasenames, inferWhisperRepoPath } from "../src/infrastructure/system/autoDetect";
+
+test("inferWhisperRepoPath resolves repo root from whisper-cli path", () => {
+ assert.equal(
+ inferWhisperRepoPath("/Users/test/whisper.cpp/build/bin/whisper-cli"),
+ "/Users/test/whisper.cpp"
+ );
+ assert.equal(
+ inferWhisperRepoPath("C:\\dev\\whisper.cpp\\build\\bin\\Release\\whisper-cli.exe"),
+ "C:/dev/whisper.cpp"
+ );
+});
+
+test("getPreferredWhisperModelBasenames prioritizes the selected preset first", () => {
+ const names = getPreferredWhisperModelBasenames("large");
+ assert.deepEqual(names.slice(0, 4), [
+ "ggml-large.bin",
+ "ggml-large.en.bin",
+ "ggml-base.bin",
+ "ggml-base.en.bin",
+ ]);
+});
diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts
new file mode 100644
index 0000000..511561b
--- /dev/null
+++ b/tests/dashboard.test.ts
@@ -0,0 +1,135 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { buildDashboardSnapshot, deriveSessionHealthBadge, deriveSessionListItem, groupDiagnosticsChecks } from "../src/application/dashboard";
+import type { DiagnosticsReport } from "../src/domain/diagnostics";
+import type { RecordingSessionManifest, SessionRuntimeSnapshot } from "../src/domain/session";
+
+const diagnosticsReport: DiagnosticsReport = {
+ checkedAt: "2026-04-22T08:00:00.000Z",
+ provider: "ollama",
+ backend: "avfoundation",
+ checks: [
+ { id: "ffmpeg", label: "FFmpeg", severity: "ok", detail: "Ready." },
+ { id: "ollama", label: "Ollama", severity: "warning", detail: "Slow response." },
+ { id: "model", label: "Whisper model", severity: "error", detail: "Missing model." },
+ ],
+ blockingIssueIds: ["model"],
+ warningIds: ["ollama"],
+ isHealthy: false,
+ summary: "Diagnostics found blocking issues.",
+};
+
+const manifest: RecordingSessionManifest = {
+ schemaVersion: 1,
+ sessionId: "session-1",
+ createdAt: "2026-04-22T08:00:00.000Z",
+ updatedAt: "2026-04-22T08:30:00.000Z",
+ scenarioKey: "work_meeting",
+ scenarioLabel: "Meeting",
+ captureMode: "microphone",
+ status: "failed",
+ paths: {
+ rootDir: "/tmp/session-1",
+ manifestPath: "/tmp/session-1/session.json",
+ diagnosticsLogPath: "/tmp/session-1/diagnostics.log",
+ audioDir: "/tmp/session-1/audio",
+ fullAudioPath: "/tmp/session-1/audio/recording.mp3",
+ segmentsDir: "/tmp/session-1/audio/segments",
+ transcriptDir: "/tmp/session-1/transcript",
+ transcriptTextPath: "/tmp/session-1/transcript/live-transcript.txt",
+ summaryDir: "/tmp/session-1/summary",
+ summaryMarkdownPath: "/tmp/session-1/summary/summary.md",
+ },
+ providerInfo: {
+ summaryProvider: "ollama",
+ transcriptionEngine: "whisper.cpp",
+ model: "gemma3",
+ },
+ diagnosticsSummary: {
+ checkedAt: "2026-04-22T08:00:00.000Z",
+ blockingIssueIds: ["model"],
+ warningIds: ["ollama"],
+ summary: "Diagnostics found blocking issues.",
+ },
+ notes: {
+ liveTranscriptNotePath: "Resonance/Live transcript.md",
+ },
+ runtime: {
+ startedAt: "2026-04-22T08:00:00.000Z",
+ lastActivityAt: "2026-04-22T08:20:00.000Z",
+ finishedAt: "2026-04-22T08:25:00.000Z",
+ elapsedSeconds: 1500,
+ failureSummary: "Summary provider returned an empty result.",
+ },
+ artifacts: {
+ hasAudio: true,
+ hasTranscript: true,
+ hasSummary: false,
+ },
+ live: {
+ committedSegments: 5,
+ lastCommittedSegment: 4,
+ },
+ errors: ["Summary provider returned an empty result."],
+};
+
+test("groupDiagnosticsChecks splits checks by severity", () => {
+ const grouped = groupDiagnosticsChecks(diagnosticsReport);
+ assert.equal(grouped.blocking.length, 1);
+ assert.equal(grouped.warnings.length, 1);
+ assert.equal(grouped.healthy.length, 1);
+});
+
+test("deriveSessionListItem exposes dashboard and library metadata", () => {
+ const item = deriveSessionListItem(manifest, 42_000);
+ assert.equal(item.paths.rootDir, "/tmp/session-1");
+ assert.equal(item.audioSizeBytes, 42_000);
+ assert.equal(item.healthBadge, "failed");
+ assert.equal(item.failureSummary, "Summary provider returned an empty result.");
+ assert.equal(item.artifactAvailability.hasTranscript, true);
+});
+
+test("deriveSessionHealthBadge marks finalizing sessions as warning", () => {
+ assert.equal(deriveSessionHealthBadge({ ...manifest, status: "stopping", runtime: { ...manifest.runtime, failureSummary: undefined } }), "warning");
+});
+
+test("buildDashboardSnapshot blocks start when diagnostics fail", () => {
+ const runtime: SessionRuntimeSnapshot = {
+ state: "idle",
+ elapsedSeconds: 0,
+ committedSegments: 0,
+ queuedSegments: 0,
+ liveTranscriptChars: 0,
+ diagnosticsReport,
+ };
+
+ const snapshot = buildDashboardSnapshot({
+ runtime,
+ diagnosticsReport,
+ recentSessions: [deriveSessionListItem(manifest, 42_000)],
+ isCoreConfigured: false,
+ });
+
+ assert.equal(snapshot.primaryAction.intent, "blocked");
+ assert.equal(snapshot.health.badge, "failed");
+ assert.equal(snapshot.recentSessions.length, 1);
+});
+
+test("buildDashboardSnapshot exposes stop while a session is active", () => {
+ const runtime: SessionRuntimeSnapshot = {
+ state: "recording",
+ elapsedSeconds: 15,
+ committedSegments: 2,
+ queuedSegments: 1,
+ liveTranscriptChars: 120,
+ };
+
+ const snapshot = buildDashboardSnapshot({
+ runtime,
+ recentSessions: [],
+ isCoreConfigured: true,
+ });
+
+ assert.equal(snapshot.primaryAction.intent, "stop");
+ assert.equal(snapshot.primaryAction.disabled, false);
+});
diff --git a/tests/deviceScanner.test.ts b/tests/deviceScanner.test.ts
new file mode 100644
index 0000000..87af19d
--- /dev/null
+++ b/tests/deviceScanner.test.ts
@@ -0,0 +1,36 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { parseFfmpegDeviceList } from "../src/infrastructure/system/deviceScanner";
+
+test("parseFfmpegDeviceList parses avfoundation audio indexes", () => {
+ const output = `
+[AVFoundation indev @ 0x0] AVFoundation video devices:
+[AVFoundation indev @ 0x0] [0] FaceTime HD Camera
+[AVFoundation indev @ 0x0] AVFoundation audio devices:
+[AVFoundation indev @ 0x0] [0] Built-in Microphone
+[AVFoundation indev @ 0x0] [1] BlackHole 2ch
+ `;
+
+ const devices = parseFfmpegDeviceList(output, "avfoundation");
+ assert.equal(devices.length, 3);
+ assert.equal(devices[1].name, ":0");
+ assert.equal(devices[2].label, "1: BlackHole 2ch");
+});
+
+test("parseFfmpegDeviceList ignores dshow alternative names", () => {
+ const output = `
+[dshow @ 0x0] DirectShow audio devices
+[dshow @ 0x0] "Microphone Array"
+[dshow @ 0x0] Alternative name "@device_cm_{GUID}\\wave_{GUID}"
+ `;
+
+ const devices = parseFfmpegDeviceList(output, "dshow");
+ assert.deepEqual(devices, [
+ {
+ backend: "dshow",
+ type: "audio",
+ name: "audio=Microphone Array",
+ label: "Microphone Array",
+ },
+ ]);
+});
diff --git a/tests/markdown.test.ts b/tests/markdown.test.ts
new file mode 100644
index 0000000..6a99829
--- /dev/null
+++ b/tests/markdown.test.ts
@@ -0,0 +1,31 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { formatLiveTranscriptNote, formatTranscriptChunkMarkdown, sanitizeSummary } from "../src/utils/markdown";
+
+test("formatTranscriptChunkMarkdown emits plain transcript text without segment headings", () => {
+ assert.equal(
+ formatTranscriptChunkMarkdown(3, "Ciao.\n\nCome va?"),
+ "Ciao.\n\nCome va?"
+ );
+});
+
+test("formatLiveTranscriptNote builds a user-facing transcript note", () => {
+ const note = formatLiveTranscriptNote("Meeting — Transcript", "Prima riga.\n\nSeconda riga.");
+ assert.match(note, /^# Meeting — Transcript/);
+ assert.match(note, /> Live draft while recording/);
+ assert.ok(!note.includes("Segment 0000"));
+});
+
+test("formatTranscriptChunkMarkdown removes standalone annotation lines in brackets", () => {
+ assert.equal(
+ formatTranscriptChunkMarkdown(1, "[Musica]\n\nCiao a tutti.\n[Sottotitoli e rispondenti del mio canale]\nCome va?"),
+ "Ciao a tutti.\nCome va?"
+ );
+});
+
+test("sanitizeSummary removes a wrapping markdown code fence", () => {
+ assert.equal(
+ sanitizeSummary("```markdown\n## Riassunto\n\nTesto.\n```"),
+ "## Riassunto\n\nTesto."
+ );
+});
diff --git a/tests/orderedSegmentQueue.test.ts b/tests/orderedSegmentQueue.test.ts
new file mode 100644
index 0000000..c5e3ccb
--- /dev/null
+++ b/tests/orderedSegmentQueue.test.ts
@@ -0,0 +1,49 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { OrderedSegmentQueue } from "../src/application/OrderedSegmentQueue";
+
+test("OrderedSegmentQueue commits strictly in segment order", async () => {
+ const committed: number[] = [];
+ const queue = new OrderedSegmentQueue(async (segment) => {
+ committed.push(segment.index);
+ });
+
+ queue.enqueue([
+ { index: 2, path: "segment-0002.mp3" },
+ { index: 0, path: "segment-0000.mp3" },
+ { index: 1, path: "segment-0001.mp3" },
+ ]);
+
+ await queue.whenIdle();
+ assert.deepEqual(committed, [0, 1, 2]);
+});
+
+test("OrderedSegmentQueue ignores already committed segments", async () => {
+ const committed: number[] = [];
+ const queue = new OrderedSegmentQueue(async (segment) => {
+ committed.push(segment.index);
+ }, 1);
+
+ queue.enqueue([
+ { index: 0, path: "segment-0000.mp3" },
+ { index: 1, path: "segment-0001.mp3" },
+ ]);
+
+ await queue.whenIdle();
+ assert.deepEqual(committed, [1]);
+});
+
+test("OrderedSegmentQueue resumes from the next expected segment index", async () => {
+ const committed: number[] = [];
+ const queue = new OrderedSegmentQueue(async (segment) => {
+ committed.push(segment.index);
+ }, 1);
+
+ queue.enqueue([
+ { index: 1, path: "segment-0001.mp3" },
+ { index: 2, path: "segment-0002.mp3" },
+ ]);
+
+ await queue.whenIdle();
+ assert.deepEqual(committed, [1, 2]);
+});
diff --git a/tests/sessionController.test.ts b/tests/sessionController.test.ts
new file mode 100644
index 0000000..bddc7f9
--- /dev/null
+++ b/tests/sessionController.test.ts
@@ -0,0 +1,45 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { collectSegmentDescriptors } from "../src/application/segmentFiles";
+
+test("collectSegmentDescriptors skips the newest open segment during live polling", () => {
+ const now = 10_000;
+ const segments = collectSegmentDescriptors(
+ [
+ {
+ name: "segment-0000.mp3",
+ path: "/tmp/segment-0000.mp3",
+ mtimeMs: now - 5_000,
+ isFile: true,
+ },
+ {
+ name: "segment-0001.mp3",
+ path: "/tmp/segment-0001.mp3",
+ mtimeMs: now - 100,
+ isFile: true,
+ },
+ ],
+ now,
+ false
+ );
+
+ assert.deepEqual(segments, [{ index: 0, path: "/tmp/segment-0000.mp3" }]);
+});
+
+test("collectSegmentDescriptors includes the newest segment during stop flush", () => {
+ const now = 10_000;
+ const segments = collectSegmentDescriptors(
+ [
+ {
+ name: "segment-0000.mp3",
+ path: "/tmp/segment-0000.mp3",
+ mtimeMs: now - 100,
+ isFile: true,
+ },
+ ],
+ now,
+ true
+ );
+
+ assert.deepEqual(segments, [{ index: 0, path: "/tmp/segment-0000.mp3" }]);
+});
diff --git a/tests/settings.test.ts b/tests/settings.test.ts
new file mode 100644
index 0000000..ceadee9
--- /dev/null
+++ b/tests/settings.test.ts
@@ -0,0 +1,42 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import {
+ DEFAULT_SETTINGS_V2,
+ isLikelyTestWhisperModelPath,
+ normalizeSettingsV2,
+ resolveCaptureBackend,
+} from "../src/domain/settings";
+import { getSelectedSummaryModel } from "../src/domain/providers";
+
+test("normalizeSettingsV2 keeps defaults and clamps invalid values", () => {
+ const settings = normalizeSettingsV2({
+ capture: { sampleRateHz: 1, channels: 7, bitrateKbps: 999 },
+ transcription: { beamSize: 99, language: "it" },
+ summary: { provider: "ollama", ollamaModel: "" },
+ });
+
+ assert.equal(settings.capture.sampleRateHz, 8_000);
+ assert.equal(settings.capture.channels, 2);
+ assert.equal(settings.capture.bitrateKbps, 320);
+ assert.equal(settings.transcription.beamSize, 10);
+ assert.equal(settings.transcription.language, "it");
+ assert.equal(settings.output.vaultFolder, DEFAULT_SETTINGS_V2.output.vaultFolder);
+});
+
+test("resolveCaptureBackend maps auto to OS defaults", () => {
+ assert.equal(resolveCaptureBackend("auto", "darwin"), "avfoundation");
+ assert.equal(resolveCaptureBackend("auto", "win32"), "dshow");
+ assert.equal(resolveCaptureBackend("auto", "linux"), "pulse");
+});
+
+test("getSelectedSummaryModel follows the selected provider", () => {
+ const base = DEFAULT_SETTINGS_V2.summary;
+ assert.equal(getSelectedSummaryModel(base), "gemma3");
+ assert.equal(getSelectedSummaryModel({ ...base, provider: "openai", openaiModel: "gpt-test" }), "gpt-test");
+ assert.equal(getSelectedSummaryModel({ ...base, provider: "anthropic", anthropicModel: "claude-test" }), "claude-test");
+});
+
+test("isLikelyTestWhisperModelPath flags whisper.cpp CI models", () => {
+ assert.equal(isLikelyTestWhisperModelPath("/Users/test/whisper.cpp/models/for-tests-ggml-base.bin"), true);
+ assert.equal(isLikelyTestWhisperModelPath("/Users/test/whisper.cpp/models/ggml-base.bin"), false);
+});
diff --git a/tsconfig.json b/tsconfig.json
index 0839222..b08841a 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -6,11 +6,13 @@
"lib": ["ES2020", "DOM"],
"allowJs": false,
"strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
"noEmit": true,
"types": ["node"],
"baseUrl": ".",
"paths": {}
},
- "include": ["**/*.ts"],
- "exclude": ["node_modules", "main.js", "main.js.map"]
+ "include": ["src/**/*.ts", "tests/**/*.ts", "vite.config.ts"],
+ "exclude": ["node_modules", "main.js", "main.js.map", "dist", ".test-dist"]
}
diff --git a/tsconfig.tests.json b/tsconfig.tests.json
new file mode 100644
index 0000000..b99cc26
--- /dev/null
+++ b/tsconfig.tests.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "CommonJS",
+ "moduleResolution": "Node",
+ "lib": ["ES2020", "DOM"],
+ "strict": true,
+ "esModuleInterop": true,
+ "types": ["node"],
+ "outDir": ".test-dist",
+ "rootDir": ".",
+ "skipLibCheck": true
+ },
+ "include": [
+ "src/domain/**/*.ts",
+ "src/application/OrderedSegmentQueue.ts",
+ "src/application/dashboard.ts",
+ "src/infrastructure/system/autoDetect.ts",
+ "src/infrastructure/system/deviceScanner.ts",
+ "tests/**/*.ts"
+ ]
+}
diff --git a/vite.config.ts b/vite.config.ts
index 4d97c99..2c09469 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -4,10 +4,10 @@ import { builtinModules } from 'module';
export default defineConfig({
build: {
lib: {
- entry: 'main.ts',
+ entry: 'src/main.ts',
formats: ['cjs'],
fileName: () => 'main',
- name: 'resonance',
+ name: 'resonanceNext',
},
outDir: 'dist',
emptyOutDir: true,