mirror of
https://github.com/blackajiro/Resonance.git
synced 2026-07-22 06:51:15 +00:00
refactor
This commit is contained in:
parent
6ad5f20725
commit
d3f4bf8eeb
57 changed files with 7045 additions and 422 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -3,3 +3,4 @@ main.js
|
|||
main.js.map
|
||||
.DS_Store
|
||||
dist/
|
||||
.test-dist/
|
||||
|
|
|
|||
257
README.md
257
README.md
|
|
@ -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 local‑first 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 auto‑find `whisper-cli`, or set it manually.
|
||||
- Pick a model preset and click “Download”, or set a model `.bin` path manually.
|
||||
- Choose the “Transcription language” (or leave Automatic).
|
||||
## Install In Obsidian
|
||||
|
||||
Models (manual download): see the official ggml models, e.g. small/medium/large. Place the `.bin` in `<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 Multi‑Output/aggregate device if needed)
|
||||
- Click “Refresh devices”, then select your mic and the virtual device as “System audio”
|
||||
The live transcription queue commits segments strictly in order and the stop flow waits for the queue to flush before summary generation starts.
|
||||
|
||||
- Windows system audio:
|
||||
- Install VB‑Audio Cable or VoiceMeeter
|
||||
- Set Windows output to the virtual device (or use “Stereo Mix” if available)
|
||||
- Click “Refresh devices”, pick mic and the virtual device for “System audio”
|
||||
## Notes On Legacy Code
|
||||
|
||||
- Linux system audio:
|
||||
- With PulseAudio, choose the monitor of your output sink (e.g. `alsa_output.*.monitor`)
|
||||
- Click “Refresh devices”, then select mic and the monitor device
|
||||
|
||||
Use “Test audio config” to record a 1‑second MP3. If it fails, verify permissions, backend, and selected devices.
|
||||
|
||||
### 5) Obsidian output
|
||||
|
||||
- Set the “Notes folder” where Resonance will create the generated Markdown notes. If empty, the vault root is used.
|
||||
|
||||
### 6) Recording quality and limits
|
||||
|
||||
- Adjust sample rate, channels (mono/stereo), MP3 bitrate, and “Max recordings kept”. Older items beyond the limit are auto‑deleted (0 = infinite).
|
||||
|
||||
### 7) Done!
|
||||
|
||||
- You may need to restart Obsidian after changing settings.
|
||||
|
||||
### First run checklist
|
||||
|
||||
- FFmpeg path set and working (test passes)
|
||||
- Whisper repo path + `whisper-cli` set
|
||||
- Model `.bin` selected (or downloaded)
|
||||
- API key and model set
|
||||
- Mic and (optional) system audio selected
|
||||
- Notes folder set
|
||||
|
||||
## Usage
|
||||
|
||||
1) Click the microphone icon in the ribbon to start/stop. Pick a scenario when prompted.
|
||||
2) Watch the timer in the status bar.
|
||||
3) When finished, a note named `<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 3‑second Test.
|
||||
- Noise in recordings: match the sample rate in settings with your mic.
|
||||
- Empty transcription: check the `.mp3` file and the model path.
|
||||
- LLM error: verify API key and selected model.
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and PRs are welcome. If you’d like to help with docs or UX, please open an issue to coordinate.
|
||||
|
||||
---
|
||||
|
||||
Built with Vite. This plugin is desktop‑only.
|
||||
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
7
legacy/README.md
Normal 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`.
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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(()=> '')}`);
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
10
package.json
10
package.json
|
|
@ -1,13 +1,15 @@
|
|||
{
|
||||
"name": "resonance",
|
||||
"version": "1.0.0",
|
||||
"name": "resonance-next",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Obsidian plugin: registra, trascrive con whisper.cpp, riassume con Gemini.",
|
||||
"description": "Obsidian plugin v2: local-first recording, live transcription, diagnostics, and resilient note generation.",
|
||||
"scripts": {
|
||||
"dev": "node ./node_modules/vite/bin/vite.js build --watch",
|
||||
"build": "node ./node_modules/vite/bin/vite.js build && node scripts/postbuild.mjs",
|
||||
"clean": "node -e \"import('fs').then(fs=>{try{fs.rmSync('dist',{recursive:true,force:true});}catch{}})\""
|
||||
"clean": "node -e \"import('fs').then(fs=>{for(const dir of ['dist','.test-dist']){try{fs.rmSync(dir,{recursive:true,force:true});}catch{}}})\"",
|
||||
"typecheck": "node ./node_modules/typescript/bin/tsc --noEmit -p tsconfig.json",
|
||||
"test": "node ./node_modules/typescript/bin/tsc -p tsconfig.tests.json && node -e \"import('fs').then(fs=>fs.writeFileSync('.test-dist/package.json', JSON.stringify({type:'commonjs'})))\" && node --test .test-dist/tests/**/*.test.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.18.0",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
78
src/application/OrderedSegmentQueue.ts
Normal file
78
src/application/OrderedSegmentQueue.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
550
src/application/SessionController.ts
Normal file
550
src/application/SessionController.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
115
src/application/dashboard.ts
Normal file
115
src/application/dashboard.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
34
src/application/segmentFiles.ts
Normal file
34
src/application/segmentFiles.ts
Normal 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
33
src/domain/dashboard.ts
Normal 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
22
src/domain/diagnostics.ts
Normal 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
79
src/domain/providers.ts
Normal 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
109
src/domain/scenarios.ts
Normal 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
134
src/domain/session.ts
Normal 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
280
src/domain/settings.ts
Normal 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();
|
||||
}
|
||||
187
src/infrastructure/adapters/AudioCaptureAdapter.ts
Normal file
187
src/infrastructure/adapters/AudioCaptureAdapter.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
198
src/infrastructure/adapters/SummaryAdapter.ts
Normal file
198
src/infrastructure/adapters/SummaryAdapter.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
155
src/infrastructure/adapters/TranscriptionAdapter.ts
Normal file
155
src/infrastructure/adapters/TranscriptionAdapter.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
97
src/infrastructure/adapters/VaultAdapter.ts
Normal file
97
src/infrastructure/adapters/VaultAdapter.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
81
src/infrastructure/adapters/captureUtils.ts
Normal file
81
src/infrastructure/adapters/captureUtils.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
22
src/infrastructure/node.ts
Normal file
22
src/infrastructure/node.ts
Normal 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";
|
||||
}
|
||||
49
src/infrastructure/obsidianDesktop.ts
Normal file
49
src/infrastructure/obsidianDesktop.ts
Normal 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");
|
||||
}
|
||||
300
src/infrastructure/storage/SessionStore.ts
Normal file
300
src/infrastructure/storage/SessionStore.ts
Normal 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" }));
|
||||
}
|
||||
}
|
||||
334
src/infrastructure/system/DiagnosticsService.ts
Normal file
334
src/infrastructure/system/DiagnosticsService.ts
Normal 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.";
|
||||
}
|
||||
282
src/infrastructure/system/autoDetect.ts
Normal file
282
src/infrastructure/system/autoDetect.ts
Normal 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;
|
||||
}
|
||||
109
src/infrastructure/system/deviceScanner.ts
Normal file
109
src/infrastructure/system/deviceScanner.ts
Normal 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
202
src/main.ts
Normal 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
1495
src/ui/SettingsTab.ts
Normal file
File diff suppressed because it is too large
Load diff
82
src/ui/copy.ts
Normal file
82
src/ui/copy.ts
Normal 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;
|
||||
301
src/ui/modals/RecordingModal.ts
Normal file
301
src/ui/modals/RecordingModal.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
40
src/ui/modals/TextPreviewModal.ts
Normal file
40
src/ui/modals/TextPreviewModal.ts
Normal 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
33
src/utils/format.ts
Normal 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
126
src/utils/markdown.ts
Normal 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
1231
styles.css
File diff suppressed because it is too large
Load diff
24
tests/autoDetect.test.ts
Normal file
24
tests/autoDetect.test.ts
Normal 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
135
tests/dashboard.test.ts
Normal 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);
|
||||
});
|
||||
36
tests/deviceScanner.test.ts
Normal file
36
tests/deviceScanner.test.ts
Normal 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
31
tests/markdown.test.ts
Normal 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."
|
||||
);
|
||||
});
|
||||
49
tests/orderedSegmentQueue.test.ts
Normal file
49
tests/orderedSegmentQueue.test.ts
Normal 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]);
|
||||
});
|
||||
45
tests/sessionController.test.ts
Normal file
45
tests/sessionController.test.ts
Normal 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
42
tests/settings.test.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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
22
tsconfig.tests.json
Normal 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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue