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 - - Desktop only - Requires FFmpeg - Transcription via whisper.cpp -

+# Resonance -
-

Resonance

-

- Record → Transcribe → Summarize → Create note.
- A local‑first recording & meeting notes workflow for Obsidian. -

-

- Installation · - Configuration · - Usage · - Settings · - Troubleshooting -

-

- - GitHub Repo - - - Donate - -

-
+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,