This commit is contained in:
Michael Gorini 2026-04-27 14:51:02 +02:00
parent 6ad5f20725
commit d3f4bf8eeb
57 changed files with 7045 additions and 422 deletions

1
.gitignore vendored
View file

@ -3,3 +3,4 @@ main.js
main.js.map
.DS_Store
dist/
.test-dist/

257
README.md
View file

@ -1,204 +1,117 @@
<p align="center">
<a href="https://obsidian.md/" target="_blank" rel="noopener">
<img alt="Resonance" src="https://img.shields.io/badge/Obsidian-Community%20Plugin-7c3aed?logo=obsidian&logoColor=white" />
</a>
<img alt="Desktop only" src="https://img.shields.io/badge/Desktop-only-informational" />
<img alt="Requires FFmpeg" src="https://img.shields.io/badge/Requires-FFmpeg-green" />
<img alt="Transcription via whisper.cpp" src="https://img.shields.io/badge/Transcription-whisper.cpp-orange" />
</p>
# Resonance
<div align="center">
<h2>Resonance</h2>
<p>
Record → Transcribe → Summarize → Create note. <br/>
A localfirst recording & meeting notes workflow for Obsidian.
</p>
<p>
<a href="#installation">Installation</a> ·
<a href="#configuration">Configuration</a> ·
<a href="#usage">Usage</a> ·
<a href="#settings">Settings</a> ·
<a href="#troubleshooting">Troubleshooting</a>
</p>
<p>
<a href="https://github.com/TeamGTTN/Resonance" target="_blank" rel="noopener">
<img alt="GitHub Repo" src="https://img.shields.io/badge/GitHub-Resonance-black?logo=github" />
</a>
<a href="https://buymeacoffee.com/michaelgorini" target="_blank" rel="noopener">
<img alt="Donate" src="https://img.shields.io/badge/Donate-Buy%20me%20a%20coffee-yellow" />
</a>
</p>
</div>
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 `<YourVault>/.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 autofind `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 `<repo>/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 <model>`)
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 MultiOutput/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 VBAudio 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 1second 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 autodeleted (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 `<Scenario> 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 `<vault>/.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 3second 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 youd like to help with docs or UX, please open an issue to coordinate.
---
Built with Vite. This plugin is desktoponly.
The previous implementation is archived under `legacy/`. It is not part of the active v2 runtime, build entry, or test surface.

7
legacy/README.md Normal file
View file

@ -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`.

View file

@ -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 {}

View file

@ -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(()=> '')}`);

View file

@ -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
}
}

View file

@ -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",

View file

@ -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;

View file

@ -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<void>;
export class OrderedSegmentQueue {
private readonly pending = new Map<number, SegmentDescriptor>();
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<void> {
if (!this.processing && this.pending.size === 0) {
if (this.failure) throw this.failure;
return;
}
await new Promise<void>((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();
}
}
}
}

View file

@ -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<void>;
}
const STARTABLE_STATES = new Set<SessionState>(["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<boolean> {
const path = this.manifest?.notes.liveTranscriptNotePath;
if (!path) return false;
await this.vaultAdapter.openFile(path);
return true;
}
async listRecentSessions(limit = 12): Promise<SessionListItem[]> {
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<SessionListItem> {
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<SessionListItem> {
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<void> {
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<void> {
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<void> {
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<SessionRuntimeSnapshot>) {
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<RecordingSessionManifest> {
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);
}
}

View file

@ -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),
};
}

View file

@ -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 }));
}

33
src/domain/dashboard.ts Normal file
View file

@ -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;
}

22
src/domain/diagnostics.ts Normal file
View file

@ -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;
}

79
src/domain/providers.ts Normal file
View file

@ -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<SummaryProviderId, ProviderCapabilities> = {
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;
}
}

109
src/domain/scenarios.ts Normal file
View file

@ -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];
}

134
src/domain/session.ts Normal file
View file

@ -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,
};
}

280
src/domain/settings.ts Normal file
View file

@ -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<CaptureSettings>;
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<TranscriptionSettings>;
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<SummarySettings>;
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<OutputSettings>;
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<UiSettings>;
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<DiagnosticsSettings>;
return {
quickTestDurationSeconds: clampInteger(
input.quickTestDurationSeconds,
DEFAULT_SETTINGS_V2.diagnostics.quickTestDurationSeconds,
1,
10
),
};
}
export function normalizeSettingsV2(raw: unknown): PluginSettingsV2 {
const input = (raw ?? {}) as Partial<PluginSettingsV2>;
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();
}

View file

@ -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<void> | null = null;
private stopRequested = false;
private resolvedInputs: ResolvedCaptureInputs | null = null;
async start(options: AudioCaptureStartOptions): Promise<ResolvedCaptureInputs> {
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<ChildProcessModule>("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<void>((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<void> {
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<ChildProcessModule>("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<boolean> {
return new Promise<boolean>((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);
});
}

View file

@ -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<SummaryResult> {
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<string> {
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<string> {
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<string> {
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<string> {
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();
}
}

View file

@ -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<string> {
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<string> {
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<string> {
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<void>((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 };
}
}

View file

@ -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<void> {
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<string> {
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<string | undefined> {
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<void> {
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<void> {
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<void> {
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<TFile> {
const existing = this.app.vault.getAbstractFileByPath(path);
if (existing instanceof TFile) return existing;
return await this.app.vault.create(path, initialContents);
}
}

View file

@ -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<ResolvedCaptureInputs> {
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,
};
}

View file

@ -0,0 +1,22 @@
import type { App } from "obsidian";
interface DesktopWindow {
require?: (moduleName: string) => unknown;
}
export function requireNodeModule<T>(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";
}

View file

@ -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");
}

View file

@ -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<PathModule>("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<RecordingSessionManifest> {
const fs = requireNodeModule<FsModule>("fs");
const path = requireNodeModule<PathModule>("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<void> {
const fs = requireNodeModule<FsModule>("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<void> {
const fs = requireNodeModule<FsModule>("fs");
fs.appendFileSync(manifest.paths.diagnosticsLogPath, `[${new Date().toISOString()}] ${message}\n`, { encoding: "utf8" });
}
async appendTranscriptChunk(manifest: RecordingSessionManifest, chunk: string): Promise<void> {
const fs = requireNodeModule<FsModule>("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<void> {
const fs = requireNodeModule<FsModule>("fs");
fs.writeFileSync(manifest.paths.summaryMarkdownPath, markdown, { encoding: "utf8" });
}
async writeTranscript(manifest: RecordingSessionManifest, text: string): Promise<void> {
const fs = requireNodeModule<FsModule>("fs");
fs.writeFileSync(manifest.paths.transcriptTextPath, text.trim(), { encoding: "utf8" });
}
async listSessions(): Promise<RecordingSessionManifest[]> {
const fs = requireNodeModule<FsModule>("fs");
const path = requireNodeModule<PathModule>("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<RecordingSessionManifest | null> {
const fs = requireNodeModule<FsModule>("fs");
const path = requireNodeModule<PathModule>("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<void> {
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<void> {
await this.deleteSessionRootDir(manifest.paths.rootDir);
}
async deleteSessionRootDir(rootDir: string): Promise<void> {
const fs = requireNodeModule<FsModule>("fs");
fs.rmSync(rootDir, { recursive: true, force: true });
}
async deleteAudioArtifacts(manifest: RecordingSessionManifest): Promise<void> {
const fs = requireNodeModule<FsModule>("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<void> {
const fs = requireNodeModule<FsModule>("fs");
fs.writeFileSync(manifest.paths.transcriptTextPath, "", { encoding: "utf8" });
manifest.artifacts.hasTranscript = false;
}
readTextFile(path: string): string {
const fs = requireNodeModule<FsModule>("fs");
if (!path || !fs.existsSync(path)) return "";
return String(fs.readFileSync(path, { encoding: "utf8" }));
}
getAudioSize(path: string): number {
const fs = requireNodeModule<FsModule>("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<FsModule>("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" }));
}
}

View file

@ -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<DiagnosticsReport> {
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<SmokeTestResult> {
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<void>((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<SmokeTestResult> {
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.";
}

View file

@ -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<string | null> {
const fromPath = await detectCommandInPath("ffmpeg");
if (fromPath) return fromPath;
try {
const fs = requireNodeModule<FsModule>("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<string | null> {
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<string | null> {
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<string | null> {
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<string | null> {
try {
const { spawn } = requireNodeModule<ChildProcessModule>("child_process");
const lookupCommand = process.platform === "win32" ? "where" : "which";
const found = await new Promise<string | null>((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<OsModule>("os");
const path = requireNodeModule<PathModule>("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<FsModule>("fs");
const path = requireNodeModule<PathModule>("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<FsModule>("fs");
const path = requireNodeModule<PathModule>("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<FsModule>("fs");
const path = requireNodeModule<PathModule>("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<FsModule>("fs");
const path = requireNodeModule<PathModule>("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 | undefined | null>): string[] {
const seen = new Set<string>();
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;
}

View file

@ -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<string>();
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<ListedDevice[]> {
const { spawn } = requireNodeModule<ChildProcessModule>("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<string>((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);
}

202
src/main.ts Normal file
View file

@ -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);
}
}

1495
src/ui/SettingsTab.ts Normal file

File diff suppressed because it is too large Load diff

82
src/ui/copy.ts Normal file
View file

@ -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;

View file

@ -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<SessionState>(["idle", "done", "failed"]);
const STOPPABLE_STATES = new Set<SessionState>(["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> | 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";
}
}
}

View file

@ -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<void> {
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.");
}
}

33
src/utils/format.ts Normal file
View file

@ -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]}`;
}

126
src/utils/markdown.ts Normal file
View file

@ -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));
}

1231
styles.css

File diff suppressed because it is too large Load diff

24
tests/autoDetect.test.ts Normal file
View file

@ -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",
]);
});

135
tests/dashboard.test.ts Normal file
View file

@ -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);
});

View file

@ -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",
},
]);
});

31
tests/markdown.test.ts Normal file
View file

@ -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."
);
});

View file

@ -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]);
});

View file

@ -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" }]);
});

42
tests/settings.test.ts Normal file
View file

@ -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);
});

View file

@ -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"]
}

22
tsconfig.tests.json Normal file
View file

@ -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"
]
}

View file

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