Compare commits

...

24 commits
0.0.2 ... main

Author SHA1 Message Date
Michael Gorini
63538d3b20 release 0.1.1 2026-05-26 09:13:53 +02:00
Michael Gorini
313916c9fb pr fixes 2026-05-08 08:48:26 +02:00
Michael Gorini
142984bcf3 pr fixes 2026-05-07 09:38:42 +02:00
Michael Gorini
75c5406c49 pr fixes 2026-05-05 15:02:27 +02:00
Michael Gorini
7226641990 manifest 2026-04-30 09:12:13 +02:00
Michael Gorini
1520be5185 polish 2026-04-30 08:43:44 +02:00
Michael Gorini
e7eb786aa5 bulk actions 2026-04-30 08:22:07 +02:00
Michael Gorini
c4970de099 polish 2026-04-29 17:35:08 +02:00
Michael Gorini
cadf157b13 removed ffmpeg 2026-04-29 12:02:32 +02:00
Michael Gorini
e83e26412e audio profiles 2026-04-29 08:25:26 +02:00
Michael Gorini
d3f4bf8eeb refactor 2026-04-27 14:51:02 +02:00
Michael Gorini
6ad5f20725 fix whisper download 2025-09-04 09:22:06 +02:00
Michael Gorini
63a9002dea better settings ui 2025-09-04 09:05:00 +02:00
Michael Gorini
6c1c078655 switch to live transcription 2025-09-04 08:41:19 +02:00
Michael Gorini
c1e27ed20c fix ollama 2025-09-03 15:17:04 +02:00
Michael Gorini
a24a827463 readme 2025-09-03 10:13:23 +02:00
Michael Gorini
9ffa31020d 0.0.4 Release 2025-09-03 10:05:58 +02:00
Michael Gorini
b4141b30a9 0.0.4 Multi LLM 2025-09-03 10:05:48 +02:00
Michael Gorini
ac16953e31 0.0.3 2025-09-01 11:48:22 +02:00
Michael Gorini
cb2b4bfc5e readme aggiornato 2025-09-01 11:33:47 +02:00
Michael Gorini
9b6cd73872 fix input 2025-09-01 10:47:00 +02:00
Michael Gorini
0df836c29f fix selezione input 2025-09-01 10:17:41 +02:00
Michael Gorini
0d2bf03cf7 miglioramenti audio 2025-09-01 09:31:17 +02:00
Blackajiro
fded5b99f2 0.0.2 Windows 2025-08-31 15:11:04 +02:00
62 changed files with 12986 additions and 2663 deletions

1
.gitignore vendored
View file

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

View file

@ -1,80 +0,0 @@
import { Notice, TFile } from "obsidian";
// Utility minima per verificare se un percorso esiste ed è eseguibile.
// In ambiente Obsidian desktop (Electron), l'accesso a Node è disponibile.
// Tuttavia alcuni ambienti potrebbero limitare i permessi: gestiamo gli errori con grazia.
export interface DependencyState {
hasApiKey: boolean;
ffmpegOk: boolean;
whisperOk: boolean;
modelOk: boolean;
missingMessages: string[];
}
export async function checkDependencies(options: {
apiKey: string;
ffmpegPath: string;
whisperMainPath: string;
whisperModelPath: string;
}): Promise<DependencyState> {
const { apiKey, ffmpegPath, whisperMainPath, whisperModelPath } = options;
const missingMessages: string[] = [];
const hasApiKey = !!apiKey && apiKey.trim().length > 0;
if (!hasApiKey) missingMessages.push("Missing API Key");
let ffmpegOk = false;
let whisperOk = false;
let modelOk = false;
try {
const fs = (window as any).require?.("fs");
const access = fs?.access;
const constants = fs?.constants;
if (ffmpegPath) {
await new Promise<void>((resolve) => {
access(ffmpegPath, constants?.X_OK ?? 1, (err: any) => {
ffmpegOk = !err;
resolve();
});
});
if (!ffmpegOk) missingMessages.push("FFmpeg not found or not executable");
} else {
missingMessages.push("FFmpeg path not set");
}
if (whisperMainPath) {
await new Promise<void>((resolve) => {
access(whisperMainPath, constants?.X_OK ?? 1, (err: any) => {
whisperOk = !err;
resolve();
});
});
if (!whisperOk) missingMessages.push("whisper.cpp (main) not found or not executable");
} else {
missingMessages.push("whisper.cpp (main) path not set");
}
if (whisperModelPath) {
await new Promise<void>((resolve) => {
access(whisperModelPath, constants?.R_OK ?? 4, (err: any) => {
modelOk = !err;
resolve();
});
});
if (!modelOk) missingMessages.push("Whisper model not readable/found");
} else {
missingMessages.push("Whisper model path not set");
}
} catch (e) {
// In caso di errore d'accesso a fs (sandbox, permessi), forniamo un fallback prudente
// senza bloccare l'utente, ma indicando l'impossibilità di verificare completamente.
if (!ffmpegOk) missingMessages.push("Unable to verify FFmpeg (runtime restrictions)");
if (!whisperOk) missingMessages.push("Unable to verify whisper.cpp (runtime restrictions)");
if (!modelOk) missingMessages.push("Unable to verify Whisper model (runtime restrictions)");
}
return { hasApiKey, ffmpegOk, whisperOk, modelOk, missingMessages };
}

View file

@ -1,75 +0,0 @@
// DeviceScanner: utility to query FFmpeg and extract device names.
// It invokes ffmpeg with device-list arguments for supported backends.
export interface ListedDevice {
backend: string; // dshow | avfoundation | pulse | alsa
type: 'audio' | 'video' | 'unknown';
name: string; // string to use as ffmpeg spec (e.g., audio=Microphone (...) for dshow; :0 for avfoundation audio)
label: string; // human-readable label
}
export async function scanDevices(ffmpegPath: string, backend: 'dshow' | 'avfoundation' | 'pulse' | 'alsa'): Promise<ListedDevice[]> {
if (!ffmpegPath) throw new Error('FFmpeg path not configured');
const { spawn } = (window as any).require('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 stderr = await new Promise<string>((resolve, reject) => {
let buf = '';
const child = spawn(ffmpegPath, args);
child.stdout?.on('data', (d: Buffer) => { buf += d.toString(); });
child.stderr?.on('data', (d: Buffer) => { buf += d.toString(); });
child.on('error', (e: any) => reject(e));
child.on('close', () => resolve(buf));
});
return parseFfmpegDeviceList(stderr, backend);
}
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 line of lines) {
if (/DirectShow audio devices/i.test(line)) section = 'audio';
else if (/DirectShow video devices/i.test(line)) section = 'video';
const m = line.match(/\s+"(.+?)"/);
if (m) {
const label = m[1];
const name = section === 'audio' ? `audio=${label}` : label;
devices.push({ backend, type: section, name, label });
}
}
} else if (backend === 'avfoundation') {
// Esempio:
// AVFoundation video devices:
// [0] FaceTime HD Camera
// AVFoundation audio devices:
// [0] Built-in Microphone
let section: 'audio' | 'video' | 'unknown' = 'unknown';
for (const line of lines) {
if (/AVFoundation video devices/i.test(line)) section = 'video';
else if (/AVFoundation audio devices/i.test(line)) section = 'audio';
const m = line.match(/\[(\d+)\]\s+(.+)/);
if (m) {
const idx = m[1];
const label = m[2];
const type: 'audio' | 'video' | 'unknown' = section;
const name = section === 'audio' ? `:${idx}` : `${idx}:`;
devices.push({ backend, type, name, label: `${idx}: ${label}` });
}
}
} else {
// pulse/alsa — parser basilare
devices.push({ backend, type: 'audio', name: 'default', label: 'default' });
}
return devices;
}

View file

@ -1,115 +0,0 @@
import { App, Modal } from "obsidian";
export type HelpTopic = 'ffmpeg' | 'whisper' | 'llm' | 'devices' | 'obsidian';
export class HelpModal extends Modal {
private topic: HelpTopic;
constructor(app: App, topic: HelpTopic) {
super(app);
this.topic = topic;
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass('resonance-modal');
const title = {
ffmpeg: 'FFmpeg setup guide',
whisper: 'Whisper.cpp setup guide',
llm: 'LLM (Gemini) setup guide',
devices: 'Audio devices guide (FFmpeg)',
obsidian: 'Obsidian setup guide',
}[this.topic];
contentEl.createEl('h2', { text: title });
const body = contentEl.createEl('div', { cls: 'resonance-help' });
const sections = this.getContent(this.topic);
sections.forEach(sec => {
body.createEl('h3', { text: sec.title });
sec.paragraphs.forEach(p => body.createEl('p', { text: p }));
if (sec.code) {
const pre = body.createEl('pre');
pre.style.position = 'relative';
const code = pre.createEl('code');
const codeText = sec.code.trim();
code.innerText = codeText;
const copyBtn = pre.createEl('button', { cls: 'resonance-btn secondary small', text: 'Copy' });
copyBtn.style.position = 'absolute';
copyBtn.style.top = '6px';
copyBtn.style.right = '6px';
copyBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(codeText);
copyBtn.textContent = 'Copied!';
setTimeout(()=> copyBtn.textContent = 'Copy', 1200);
} catch {
copyBtn.textContent = 'Failed';
setTimeout(()=> copyBtn.textContent = 'Copy', 1200);
}
});
}
});
}
private getContent(topic: HelpTopic): { title: string; paragraphs: string[]; code?: string }[] {
switch (topic) {
case 'ffmpeg':
return [
{ title: 'What is FFmpeg', paragraphs: [
'FFmpeg is a crossplatform tool to capture, convert and process audio/video.',
'Resonance uses FFmpeg to record the microphone and (optionally) system audio.'
]},
{ title: 'macOS', paragraphs: [
'Install Xcode Command Line Tools and Homebrew, then install FFmpeg with the following commands.',
], code: 'brew install ffmpeg' },
{ title: 'Windows', paragraphs: [
'Download a static build from the official website, extract it, then point to ffmpeg.exe.',
'Alternatively, if you use Chocolatey: choco install ffmpeg.',
], code: 'C:/ffmpeg/bin/ffmpeg.exe\n:: or\nchoco install ffmpeg' },
{ title: 'Linux', paragraphs: [
'Install FFmpeg from your package manager (Debian/Ubuntu, Fedora, Arch examples).',
], code: 'sudo apt update && sudo apt install -y ffmpeg\n# Fedora/RHEL\nsudo dnf install -y ffmpeg\n# Arch\nsudo pacman -S ffmpeg' },
];
case 'whisper':
return [
{ title: 'What is whisper.cpp', paragraphs: [
'whisper.cpp is a local speechtotext engine; Resonance runs it on your machine.',
'You must set the repo path and choose/download a model (.bin).'
]},
{ title: 'macOS', paragraphs: [
'Clone the repo and build with make; models go into whisper.cpp/models/.',
'In Settings → Resonance, set the whisper.cpp repo path and the whisper-cli executable.',
'In Settings → Resonance, select and download the model you prefer.'
], code: 'git clone https://github.com/ggerganov/whisper.cpp\ncd whisper.cpp\nmake -j' },
{ title: 'Windows', paragraphs: [
'Clone the repo; build with CMake/Visual Studio in Release; models in whisper.cpp\\models\\.',
'In Settings → Resonance, set the whisper.cpp repo path and the whisper-cli executable.',
'In Settings → Resonance, select and download the model you prefer.'
], code: 'git clone https://github.com/ggerganov/whisper.cpp\ncd whisper.cpp\ncmake -B build -S .\ncmake --build build --config Release -j\n# example model\npowershell -Command "Invoke-WebRequest https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin -OutFile models/ggml-medium.bin"' },
{ title: 'Linux', paragraphs: [
'Build with make; install build tools if needed; download a model.',
'In Settings → Resonance, set the whisper.cpp repo path and the whisper-cli executable.',
'In Settings → Resonance, select and download the model you prefer.'
], code: 'sudo apt update && sudo apt install -y build-essential git\ngit clone https://github.com/ggerganov/whisper.cpp\ncd whisper.cpp && make -j\n./models/download-ggml-model.sh medium' },
{ title: 'Test run', paragraphs: ['Example with Italian language:'], code: './build/bin/whisper-cli -m ./models/ggml-medium.bin -f ./samples/jfk.wav -l it' },
];
case 'llm':
return [
{ title: 'Gemini', paragraphs: ['Create an API Key in Google AI Studio and paste it in Settings. Select the model you prefer. The free version should be enough for personal use.'] },
{ title: 'Privacy', paragraphs: ['Only the text transcript is sent to the service for summarization, audio stays local.'] },
];
case 'devices':
return [
{ title: 'How to select audio devices', paragraphs: ['Use Scan to populate device lists. Select Microphone and optionally System audio depending on your OS.'] },
{ title: 'Windows (dshow)', paragraphs: ['Devices look like "audio=...". Enable Stereo Mix or similar for system audio if available.'] },
{ title: 'macOS (avfoundation)', paragraphs: ['Devices are indexed (:0, :1, …). Full system audio requires a virtual device such as BlackHole/Loopback/Soundflower.'] },
{ title: 'Linux (pulse/alsa)', paragraphs: ['Microphone often is default. Full system audio requires a loopback sink/module (PulseAudio/PipeWire).'] },
];
default:
return [];
}
}
}

View file

@ -1,400 +0,0 @@
import { App, Modal, Notice, TFile } from "obsidian";
interface LibraryItem {
baseName: string; // without extension, e.g., recording_2025-01-01_12-00-00
audioPath: string;
transcriptPath: string;
logPath: string;
mtimeMs: number;
sizeBytes: number;
}
export class LibraryModal extends Modal {
private pluginId: string;
private listEl!: HTMLElement;
private items: LibraryItem[] = [];
private toolbarEl!: HTMLElement;
private fromDate: string = ""; // YYYY-MM-DD
private toDate: string = ""; // YYYY-MM-DD
private sortMode: "date-desc" | "date-asc" | "size-desc" | "size-asc" | "name-asc" | "name-desc" = "date-desc";
private selected: Set<string> = new Set();
private selectAllCb: HTMLInputElement | null = null;
private visibleItems: LibraryItem[] = [];
private deleteBtn: HTMLButtonElement | null = null;
constructor(app: App, pluginId: string) {
super(app);
this.pluginId = pluginId;
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("resonance-modal");
this.modalEl.addClass("resonance-wide");
contentEl.createEl("h2", { text: "Recordings Library" });
const hint = contentEl.createEl("p", { text: "Review, listen, download or delete your recordings." });
hint.style.marginTop = "0";
this.toolbarEl = contentEl.createEl("div", { cls: "resonance-toolbar" });
this.buildToolbar();
this.listEl = contentEl.createEl("div", { cls: "resonance-list" });
void this.refresh();
}
private getRecordingsDir(): string {
const path = (window as any).require("path");
const adapter = (this.app.vault as any).adapter;
const basePath: string = adapter?.getBasePath?.() ?? adapter?.basePath ?? "";
const configDir: string = (this.app.vault as any).configDir ?? ".obsidian";
return path.join(basePath, configDir, "plugins", this.pluginId, "recordings");
}
private async scan(): Promise<LibraryItem[]> {
const fs = (window as any).require("fs");
const path = (window as any).require("path");
const dir = this.getRecordingsDir();
try { if (!fs.existsSync(dir)) return []; } catch { return []; }
const files: string[] = fs.readdirSync(dir) ?? [];
const mp3s = files.filter(f => /\.mp3$/i.test(f));
const items: LibraryItem[] = [];
for (const f of mp3s) {
const audioPath = path.join(dir, f);
let stat: any; try { stat = fs.statSync(audioPath); } catch { continue; }
const base = f.replace(/\.mp3$/i, "");
const transcriptPath = path.join(dir, `${base}.txt`);
const logPath = path.join(dir, `${base}.log`);
items.push({
baseName: base,
audioPath,
transcriptPath,
logPath,
mtimeMs: stat.mtimeMs || stat.ctimeMs || 0,
sizeBytes: stat.size || 0,
});
}
items.sort((a, b) => b.mtimeMs - a.mtimeMs);
return items;
}
private buildToolbar() {
this.toolbarEl.empty();
const fromWrap = this.toolbarEl.createEl("div", { cls: "date" });
const fromLbl = fromWrap.createEl("label", { text: "From" }); fromLbl.style.marginRight = "6px";
const from = fromWrap.createEl("input", { type: "date" }) as HTMLInputElement;
from.value = this.fromDate;
from.addEventListener("change", () => { this.fromDate = from.value || ""; this.render(); });
const toWrap = this.toolbarEl.createEl("div", { cls: "date" });
const toLbl = toWrap.createEl("label", { text: "To" }); toLbl.style.marginRight = "6px";
const to = toWrap.createEl("input", { type: "date" }) as HTMLInputElement;
to.value = this.toDate;
to.addEventListener("change", () => { this.toDate = to.value || ""; this.render(); });
const sort = this.toolbarEl.createEl("select");
const addOpt = (v: typeof this.sortMode, l: string) => { const o = document.createElement('option'); o.value=v; o.text=l; sort.appendChild(o); };
addOpt("date-desc", "Newest first");
addOpt("date-asc", "Oldest first");
addOpt("size-desc", "Size: large → small");
addOpt("size-asc", "Size: small → large");
addOpt("name-asc", "Name A→Z");
addOpt("name-desc", "Name Z→A");
sort.value = this.sortMode;
sort.addEventListener("change", () => { this.sortMode = sort.value as any; this.render(); });
const selAllWrap = this.toolbarEl.createEl("label");
const selAll = selAllWrap.createEl("input", { type: "checkbox" }) as HTMLInputElement;
this.selectAllCb = selAll;
selAll.addEventListener("change", () => this.toggleSelectAll(selAll.checked));
selAllWrap.appendText(" Select all");
const spacer = this.toolbarEl.createEl("div", { cls: "spacer" });
const delSel = this.toolbarEl.createEl("button", { cls: "resonance-btn danger" });
this.deleteBtn = delSel;
delSel.addEventListener("click", () => this.deleteSelected());
delSel.disabled = this.selected.size === 0;
delSel.appendChild(createIcon('trash'));
delSel.appendText(' Delete selected');
const refreshBtn = this.toolbarEl.createEl("button", { cls: "resonance-btn secondary" });
refreshBtn.appendChild(createIcon('refresh'));
refreshBtn.appendText(' Refresh');
refreshBtn.addEventListener("click", () => this.refresh());
}
private updateToolbarSelectionState() {
const btns = Array.from(this.toolbarEl.querySelectorAll('button')) as HTMLButtonElement[];
const del = btns.find(b => b.innerText.trim().toLowerCase() === 'delete selected');
if (this.deleteBtn) this.deleteBtn.disabled = this.selected.size === 0;
this.updateSelectAllState();
}
private updateSelectAllState() {
if (!this.selectAllCb) return;
if (this.visibleItems.length === 0) { this.selectAllCb.indeterminate = false; this.selectAllCb.checked = false; return; }
const visiblePaths = new Set(this.visibleItems.map(it => it.audioPath));
let checkedCount = 0;
for (const p of visiblePaths) if (this.selected.has(p)) checkedCount++;
if (checkedCount === 0) { this.selectAllCb.indeterminate = false; this.selectAllCb.checked = false; }
else if (checkedCount === visiblePaths.size) { this.selectAllCb.indeterminate = false; this.selectAllCb.checked = true; }
else { this.selectAllCb.indeterminate = true; }
}
private toggleSelectAll(checked: boolean) {
const paths = this.visibleItems.map(it => it.audioPath);
if (checked) paths.forEach(p => this.selected.add(p)); else paths.forEach(p => this.selected.delete(p));
this.render();
}
private async deleteSelected() {
if (this.selected.size === 0) return;
const ok = confirm(`Delete ${this.selected.size} selected item(s)?`);
if (!ok) return;
const fs = (window as any).require("fs");
const path = (window as any).require("path");
try {
for (const audioPath of Array.from(this.selected)) {
try {
const dir = path.dirname(audioPath);
const base = path.basename(audioPath).replace(/\.mp3$/i, "");
const txt = path.join(dir, `${base}.txt`);
const log = path.join(dir, `${base}.log`);
try { fs.unlinkSync(audioPath); } catch {}
try { fs.unlinkSync(txt); } catch {}
try { fs.unlinkSync(log); } catch {}
} catch {}
}
new Notice("Selected items deleted");
} finally {
this.selected.clear();
await this.refresh();
this.updateToolbarSelectionState();
}
}
private async refresh() {
this.items = await this.scan();
this.render();
}
private render() {
this.listEl.empty();
const filtered = this.items.filter(it => {
if (this.fromDate) {
const ts = new Date(this.fromDate + 'T00:00:00').getTime();
if (it.mtimeMs < ts) return false;
}
if (this.toDate) {
const ts2 = new Date(this.toDate + 'T23:59:59').getTime();
if (it.mtimeMs > ts2) return false;
}
return true;
});
const items = filtered.sort((a,b)=>{
switch (this.sortMode) {
case "date-asc": return a.mtimeMs - b.mtimeMs;
case "size-desc": return b.sizeBytes - a.sizeBytes;
case "size-asc": return a.sizeBytes - b.sizeBytes;
case "name-asc": return a.baseName.localeCompare(b.baseName);
case "name-desc": return b.baseName.localeCompare(a.baseName);
default: return b.mtimeMs - a.mtimeMs; // date-desc
}
});
this.visibleItems = items;
if (items.length === 0) {
this.listEl.createEl("p", { text: "No recordings found." });
return;
}
for (const it of items) this.renderItem(it);
this.updateSelectAllState();
}
private renderItem(it: LibraryItem) {
const row = this.listEl.createEl("div", { cls: "resonance-item" });
const meta = row.createEl("div", { cls: "resonance-meta" });
const date = new Date(it.mtimeMs);
const left = meta.createEl("div", { cls: "resonance-left" });
const cb = left.createEl("input", { type: "checkbox" }) as HTMLInputElement;
cb.addEventListener("change", () => {
if (cb.checked) this.selected.add(it.audioPath); else this.selected.delete(it.audioPath);
this.updateToolbarSelectionState();
});
cb.checked = this.selected.has(it.audioPath);
const title = left.createEl("div", { cls: "resonance-title", text: it.baseName });
title.setAttr("title", it.audioPath);
left.createEl("div", { cls: "resonance-sub", text: `${date.toLocaleString()}${(it.sizeBytes/1024/1024).toFixed(2)} MB` });
const actions = row.createEl("div", { cls: "resonance-actions" });
const playBtn = actions.createEl("button", { cls: "resonance-btn secondary" }); playBtn.appendChild(createIcon('play')); playBtn.appendText(' Listen');
const openTrBtn = actions.createEl("button", { cls: "resonance-btn secondary" }); openTrBtn.appendChild(createIcon('file-text')); openTrBtn.appendText(' Open transcript');
const dlAudio = actions.createEl("button", { cls: "resonance-btn secondary" }); dlAudio.appendChild(createIcon('download')); dlAudio.appendText(' Download audio');
const dlTxt = actions.createEl("button", { cls: "resonance-btn secondary" }); dlTxt.appendChild(createIcon('download')); dlTxt.appendText(' Download text');
const showBtn = actions.createEl("button", { cls: "resonance-btn secondary" }); showBtn.appendChild(createIcon('folder')); showBtn.appendText(' Show in folder');
const openNoteBtn = actions.createEl("button", { cls: "resonance-btn secondary" }); openNoteBtn.appendChild(createIcon('note')); openNoteBtn.appendText(' Open note');
const delBtn = actions.createEl("button", { cls: "resonance-btn danger" }); delBtn.appendChild(createIcon('trash')); delBtn.appendText(' Delete');
const audioWrap = this.listEl.createEl("div", { cls: "resonance-audio-wrap" });
audioWrap.hide();
playBtn.addEventListener("click", async () => {
if (!audioWrap.isShown()) {
audioWrap.empty();
const el = await this.createAudioElement(it.audioPath);
if (el) audioWrap.appendChild(el);
audioWrap.show();
} else {
audioWrap.hide();
}
});
openTrBtn.addEventListener("click", async () => {
const text = await this.readTextSafe(it.transcriptPath);
if (!text) { new Notice("Transcript not found"); return; }
const sub = new TextPreviewModal(this.app, it.baseName, text);
sub.open();
});
// Note opener (from log)
(async () => {
const notePath = await this.findNoteFromLog(it.logPath);
if (!notePath) {
openNoteBtn.disabled = true;
openNoteBtn.setAttr("title", "No note reference found in log");
return;
}
openNoteBtn.addEventListener("click", async () => {
try {
const file = this.app.vault.getAbstractFileByPath(notePath);
if (file && (file as any).extension === 'md') {
const leaf = this.app.workspace.getLeaf(true);
await leaf.openFile(file as TFile);
} else {
new Notice("Note not found in vault");
}
} catch { new Notice("Failed to open note"); }
});
})();
dlAudio.addEventListener("click", async () => {
await this.downloadFile(it.audioPath, `${it.baseName}.mp3`, "audio/mpeg");
});
dlTxt.addEventListener("click", async () => {
await this.downloadFile(it.transcriptPath, `${it.baseName}.txt`, "text/plain;charset=utf-8");
});
showBtn.addEventListener("click", () => {
try {
const electron = (window as any).require("electron");
electron?.shell?.showItemInFolder?.(it.audioPath);
} catch {}
});
delBtn.addEventListener("click", async () => {
const ok = confirm("Delete this recording and related files?");
if (!ok) return;
const fs = (window as any).require("fs");
try { try { fs.unlinkSync(it.audioPath); } catch {}; try { fs.unlinkSync(it.transcriptPath); } catch {}; try { fs.unlinkSync(it.logPath); } catch {}; new Notice("Deleted"); }
catch (e: any) { new Notice(`Delete error: ${e?.message ?? e}`); }
await this.refresh();
});
}
private async readTextSafe(filePath: string): Promise<string | null> {
try {
const fs = (window as any).require("fs");
if (!fs.existsSync(filePath)) return null;
return fs.readFileSync(filePath, { encoding: "utf8" });
} catch { return null; }
}
private async createAudioElement(filePath: string): Promise<HTMLAudioElement | null> {
try {
const fs = (window as any).require("fs");
if (!fs.existsSync(filePath)) return null;
const buf: any = fs.readFileSync(filePath);
const uint = buf instanceof Uint8Array ? buf : new Uint8Array(buf?.buffer || buf);
const blob = new Blob([uint], { type: "audio/mpeg" });
const url = URL.createObjectURL(blob);
const audio = document.createElement("audio");
audio.controls = true;
audio.src = url;
audio.preload = "metadata";
return audio;
} catch { return null; }
}
private async downloadFile(filePath: string, downloadName: string, mime: string) {
try {
const fs = (window as any).require("fs");
if (!fs.existsSync(filePath)) { new Notice("File not found"); return; }
const buf: any = fs.readFileSync(filePath);
const uint = buf instanceof Uint8Array ? buf : new Uint8Array(buf?.buffer || buf);
const blob = new Blob([uint], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = downloadName;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 5000);
} catch (e: any) {
new Notice(`Download error: ${e?.message ?? e}`);
}
}
private async findNoteFromLog(logPath: string): Promise<string | null> {
try {
const fs = (window as any).require("fs");
if (!fs.existsSync(logPath)) return null;
const txt: string = fs.readFileSync(logPath, { encoding: "utf8" });
// Look for the last occurrence of "Note created: <path>"
const matches = Array.from(txt.matchAll(/Note created:\s*(.+)$/gmi));
if (matches.length === 0) return null;
const last = matches[matches.length - 1][1].trim();
return last;
} catch { return null; }
}
}
class TextPreviewModal extends Modal {
private titleText: string;
private contentText: string;
constructor(app: App, titleText: string, contentText: string) {
super(app);
this.titleText = titleText;
this.contentText = contentText;
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("resonance-modal");
contentEl.createEl("h3", { text: this.titleText });
const pre = contentEl.createEl("pre");
pre.style.whiteSpace = "pre-wrap";
pre.style.maxHeight = "50vh";
pre.style.overflow = "auto";
pre.setText(this.contentText);
}
}
type IconName = 'play' | 'file-text' | 'download' | 'folder' | 'note' | 'trash' | 'refresh';
function createIcon(name: IconName): HTMLElement {
const span = document.createElement('span');
span.addClass('resonance-icon');
span.innerHTML =
name === 'play' ? '<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>' :
name === 'file-text' ? '<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" opacity=".3"/><path d="M14 2v6h6"/><path d="M16 13H8"/><path d="M16 17H8"/></svg>' :
name === 'download' ? '<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M12 3v10m0 0l4-4m-4 4l-4-4" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/><path d="M5 18h14v3H5z"/></svg>' :
name === 'folder' ? '<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M10 4l2 2h8a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"/></svg>' :
name === 'note' ? '<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M4 4h14l2 2v14H4z"/><path d="M8 12h8" stroke="currentColor" stroke-width="2"/><path d="M8 16h6" stroke="currentColor" stroke-width="2"/></svg>' :
name === 'trash' ? '<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M3 6h18" stroke="currentColor" stroke-width="2"/><path d="M8 6V4h8v2"/><path d="M6 6l1 14h10l1-14"/></svg>' :
/* refresh */ '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 11a8.1 8.1 0 0 0-15.5-2M4 13a8.1 8.1 0 0 0 15.5 2"/><path d="M4 4v5h5"/><path d="M20 20v-5h-5"/></svg>';
return span;
}

226
README.md
View file

@ -1,99 +1,179 @@
<p align="center">
<a href="https://obsidian.md/" target="_blank" rel="noopener">
<img alt="Resonance" src="https://img.shields.io/badge/Obsidian-Community%20Plugin-7c3aed?logo=obsidian&logoColor=white" />
</a>
<img alt="Desktop only" src="https://img.shields.io/badge/Desktop-only-informational" />
<img alt="Requires FFmpeg" src="https://img.shields.io/badge/Requires-FFmpeg-green" />
<img alt="Transcription via whisper.cpp" src="https://img.shields.io/badge/Transcription-whisper.cpp-orange" />
</p>
# Resonance
<div align="center">
<h2>Resonance</h2>
<p>
Record → Transcribe → Summarize → Create note. <br/>
A localfirst recording & meeting notes workflow for Obsidian.
</p>
<p>
<a href="#installation">Installation</a> ·
<a href="#usage">Usage</a> ·
<a href="#settings">Settings</a> ·
<a href="#troubleshooting">Troubleshooting</a>
</p>
</div>
![Resonance banner](assets/banner.png)
## What it does
Resonance is a local-first AI recorder for Obsidian.
Resonance captures audio with FFmpeg, transcribes it locally using whisper.cpp, summarizes the transcript with Google Gemini, and creates a Markdown note in your vault — all from Obsidian.
It is built around:
## Features
- Web Audio capture
- Whisper for transcription
- Ollama or an optional cloud provider for summaries
- Session storage with a built-in library
- 🎙️ Record microphone and optionally system audio (depends on OS setup)
- 🧠 Local transcription via whisper.cpp
- ✨ AI summary via Google Gemini
- ⏱️ Statusbar timer and ribbon shortcuts
- 📚 Library to review, play, download or delete recordings/transcripts
The product is desktop-only.
## Requirements
## What Resonance Does
- Obsidian Desktop ≥ 1.5
- FFmpeg installed locally
- whisper.cpp built locally (binary and model .bin)
- Google Gemini API Key (for summaries)
- Records from one main microphone plus optional extra audio inputs in the same Web Audio graph
- Writes live transcript updates while recording
- Builds a final summary note after the recording stops
- Stores each session with audio, transcript, summary, and diagnostics
- Lets you inspect, recover, clean up, and bulk-delete session artifacts from the Library
## Installation
## Screenshots
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.
### Recorder
![Recorder UI](assets/recorder.png)
### Library
![Library UI](assets/library.png)
## First Successful Session
1. Install the plugin in your vault.
2. Open the Resonance settings page.
3. In `Capture`, choose your microphone.
4. Optionally add loopback or monitor inputs under `Additional sources` if you want call or desktop audio in the same recording.
5. In `Transcription`, set the `whisper.cpp` repo, CLI, and model paths.
6. In `Summary`, keep `Ollama` or choose a cloud provider.
7. Run `Quick test`.
8. Open the recorder and make a short recording.
9. Stop the session and confirm that the transcript and summary appear in the Library.
## Setup Overview
### Capture
- `Microphone device`: the voice input you speak into
- `Additional sources`: optional extra audio inputs such as loopback or monitor devices
- `Segment seconds`: how often live transcript updates are committed
- `Quick test`: verifies microphone access and the local pipeline
### Transcription
Resonance expects a local `whisper.cpp` build plus a readable ggml model.
Typical setup:
From source (developers):
```bash
npm install
npm run dev # watch build
npm run build # production build (outputs to dist/)
git clone https://github.com/ggerganov/whisper.cpp
cd whisper.cpp
cmake -S . -B build
cmake --build build -j
```
Artifacts are emitted to `dist/`.
## Usage
Then download a model, for example:
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 `Meeting 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.
```bash
cd /path/to/whisper.cpp/models
./download-ggml-model.sh small
```
## Settings
Set:
- **FFmpeg**: executable path; backend (auto/dshow/avfoundation/pulse/alsa); device scan; 3second test.
- **Whisper**: repo path, whispercli path, model picker or autodownload; transcription language (auto or ISO code).
- **LLM**: Gemini API Key and model.
- **Obsidian**: output folder for generated notes.
- **Library & retention**: maximum recordings to keep (0 = infinite).
- `whisper.cpp repo`
- `whisper.cpp CLI`
- `Model path`
## How it works (overview)
### Summary
1) FFmpeg writes an `.mp3` to `<vault>/.obsidian/plugins/resonance/recordings/`
2) whisper.cpp transcribes locally and writes a `.txt` transcript
3) Gemini summarizes the transcript
4) Resonance creates a Markdown note in your chosen folder
Recommended local path:
## Privacy
- provider: `Ollama`
- endpoint: `http://localhost:11434`
- model: `gemma3`
- Audio never leaves your machine.
- Only the text transcript is sent to Gemini for summarization.
- The API Key is stored locally in your vault.
Cloud providers are also supported, but they require API credentials on the machine where the plugin runs.
## Troubleshooting
### Library
- “Incomplete configuration”: set FFmpeg path, whisper main, model, and API key.
- No audio: verify backend/device; use Scan and the 3second Test.
- Empty transcription: check the `.mp3` file and the model path.
- Gemini error: verify API key and selected model.
The Library is the operational workspace for finished and failed sessions.
## Contributing
It supports:
Issues and PRs are welcome. If youd like to help with docs or UX, please open an issue to coordinate.
- previewing transcript and diagnostics
- opening transcript and summary notes
- audio playback and export
- recovery actions when transcript or summary is missing
- bulk cleanup with storage estimates
- storage stats for visible sessions
---
## Runtime Artifacts
Built with Vite. This plugin is desktoponly.
Each supported session persists:
- `session.json`
- `audio/recording.wav`
- `audio/segments/`
- `transcript/live-transcript.txt`
- `summary/summary.md`
- `diagnostics.log`
The session Library reads these manifests rather than scanning arbitrary files.
## Common Failures
### Microphone access denied
Symptoms:
- Quick test fails before recording starts
- Capture diagnostics show microphone access denied
Fix:
- Re-enable microphone access for Obsidian in the operating system settings
- Return to Resonance and run `Quick test` again
### whisper.cpp or model missing
Symptoms:
- Diagnostics block recording
- Quick test captures audio but transcription fails
Fix:
- Point Resonance to a working `whisper.cpp` CLI
- Set a readable ggml model file such as `ggml-small.bin`
### Additional source unavailable
Symptoms:
- A saved loopback or monitor input no longer appears
- Diagnostics warn that extra sources will be skipped
Fix:
- Re-select the source in `Capture`
- Remove stale additional inputs you no longer use
## Local Development
Requirements:
- Obsidian desktop
- Node.js
- `whisper.cpp` with a local ggml model
- Ollama if you want the default local summary path
Commands:
```bash
npm run typecheck
npm test
npm run build
```
## Manual Install in Obsidian
1. Run `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 Resonance settings page.
5. Work through `Capture`, `Transcription`, and `Summary`.
6. Use `Diagnostics` for health checks and `Library` for saved sessions.

View file

@ -1,359 +0,0 @@
import { App, Notice, TFile } from "obsidian";
import type { ResonanceSettings } from "./settings";
export type RecorderPhase = "idle" | "recording" | "transcribing" | "summarizing" | "error" | "done";
export class RecorderService {
private app: App;
private settings: ResonanceSettings;
private pluginId: string;
private saveSettings: (partial: Partial<ResonanceSettings>) => Promise<void>;
private phase: RecorderPhase = "idle";
private startTs: number | null = null;
private elapsedIntervalId: number | null = null;
private ffmpegChild: any | null = null;
private stopRecordingFn: (() => Promise<void>) | null = null;
private explicitStopRequested: boolean = false;
private audioDir: string = "";
private audioPath: string = "";
private transcriptPath: string = "";
private logPath: string = "";
private selectedPresetKey: string | null = null;
onPhaseChange?: (phase: RecorderPhase) => void;
onElapsed?: (seconds: number) => void;
onError?: (message: string) => void;
onInfo?: (message: string) => void;
constructor(app: App, settings: ResonanceSettings, pluginId: string, saveSettings: (partial: Partial<ResonanceSettings>) => Promise<void>) {
this.app = app;
this.settings = settings;
this.pluginId = pluginId;
this.saveSettings = saveSettings;
}
getPhase(): RecorderPhase {
return this.phase;
}
private setPhase(p: RecorderPhase) {
this.phase = p;
this.onPhaseChange?.(p);
}
private startElapsedTimer() {
this.startTs = Date.now();
this.stopElapsedTimer();
this.elapsedIntervalId = window.setInterval(() => {
if (!this.startTs) return;
const sec = Math.floor((Date.now() - this.startTs) / 1000);
this.onElapsed?.(sec);
}, 500);
}
private stopElapsedTimer() {
if (this.elapsedIntervalId) {
window.clearInterval(this.elapsedIntervalId);
this.elapsedIntervalId = null;
}
}
async start() {
if (this.phase !== "idle" && this.phase !== "error" && this.phase !== "done") return;
try {
await this.prepareAudioPath();
await this.beginRecording();
} catch (e: any) {
const msg = String(e?.message ?? e);
this.onError?.(msg);
this.setPhase("error");
}
}
async startWithPreset(presetKey: string) {
this.selectedPresetKey = presetKey;
await this.start();
}
async stop() {
if (this.phase !== "recording") return;
try {
// Segnala che stiamo fermando intenzionalmente FFmpeg
this.explicitStopRequested = true;
if (this.stopRecordingFn) await this.stopRecordingFn();
this.stopRecordingFn = null;
this.stopElapsedTimer();
await waitMs(500);
try {
const fs = (window as any).require("fs");
const stat = fs.statSync(this.audioPath);
this.appendLog(`Recording finished. File: ${this.audioPath} (${stat.size} bytes)`);
} catch { this.appendLog(`Recording finished. File: ${this.audioPath}`); }
this.onInfo?.(`Transcribing...`);
await this.enforceRetention();
await this.transcribe();
} catch (e: any) {
const msg = String(e?.message ?? e);
this.onError?.(msg);
this.setPhase("error");
}
}
private async prepareAudioPath() {
const os = (window as any).require("os");
const path = (window as any).require("path");
const fs = (window as any).require("fs");
// Determine plugin folder inside the vault: <vault>/.obsidian/plugins/<pluginId>/recordings
const adapter = (this.app.vault as any).adapter;
const basePath: string = adapter?.getBasePath?.() ?? adapter?.basePath ?? "";
if (!basePath) throw new Error("Unable to determine vault base path (Desktop only)");
const configDir: string = (this.app.vault as any).configDir ?? ".obsidian";
const pluginDir = path.join(basePath, configDir, "plugins", this.pluginId);
const recDir = path.join(pluginDir, "recordings");
try { if (!fs.existsSync(pluginDir)) fs.mkdirSync(pluginDir, { recursive: true }); } catch {}
try { if (!fs.existsSync(recDir)) fs.mkdirSync(recDir, { recursive: true }); } catch {}
const stamp = new Date();
const name = `recording_${
stamp.getFullYear()
}-${String(stamp.getMonth() + 1).padStart(2, "0")}-${String(stamp.getDate()).padStart(2, "0")}_${String(stamp.getHours()).padStart(2, "0")}-${String(stamp.getMinutes()).padStart(2, "0")}-${String(stamp.getSeconds()).padStart(2, "0")}.mp3`;
this.audioDir = recDir;
this.audioPath = path.join(recDir, name);
const base = name.replace(/\.mp3$/i, "");
this.transcriptPath = path.join(recDir, `${base}.txt`);
this.logPath = path.join(recDir, `${base}.log`);
try { fs.appendFileSync(this.logPath, `[${new Date().toISOString()}] Sessione iniziata\n`); } catch {}
}
private async enforceRetention() {
try {
const max = Number(this.settings.maxRecordingsKept || 0);
if (!isFinite(max) || max <= 0) return; // 0 or invalid = infinite
const fs = (window as any).require("fs");
const path = (window as any).require("path");
const dir = this.audioDir;
if (!dir || !fs.existsSync(dir)) return;
const files: string[] = fs.readdirSync(dir) ?? [];
const mp3s = files.filter(f => /\.mp3$/i.test(f));
const entries = mp3s.map(f => {
const p = path.join(dir, f);
let stat: any; try { stat = fs.statSync(p); } catch { stat = { mtimeMs: 0 }; }
return { name: f, path: p, mtime: stat.mtimeMs || stat.ctimeMs || 0 };
}).sort((a,b)=> b.mtime - a.mtime);
if (entries.length <= max) return;
const toDelete = entries.slice(max);
for (const e of toDelete) {
try {
const base = e.name.replace(/\.mp3$/i, "");
const txt = path.join(dir, `${base}.txt`);
const log = path.join(dir, `${base}.log`);
try { fs.unlinkSync(e.path); } catch {}
try { fs.unlinkSync(txt); } catch {}
try { fs.unlinkSync(log); } catch {}
} catch {}
}
} catch {}
}
private resolveFfmpegInput(): { format: string; micSpec?: string; systemSpec?: string } {
const platform = process.platform;
let format = this.settings.ffmpegInputFormat;
if (format === "auto") {
format = platform === "darwin" ? "avfoundation" : platform === "win32" ? "dshow" : "pulse";
}
const mic = (this.settings.ffmpegMicDevice || "").trim();
const sys = (this.settings.ffmpegSystemDevice || "").trim();
// macOS: audio-only -> ":index" (not "index:")
if (format === "avfoundation") return { format, micSpec: mic || ":0", systemSpec: sys || "" };
if (format === "dshow") return { format, micSpec: mic || "audio=Microphone (default)", systemSpec: sys || "" };
return { format, micSpec: mic || "default", systemSpec: sys || "" };
}
private async beginRecording() {
const { ffmpegPath } = this.settings;
if (!ffmpegPath) throw new Error("FFmpeg path not configured");
const { spawn } = (window as any).require("child_process");
const { format, micSpec, systemSpec } = this.resolveFfmpegInput();
const inputs: string[] = [];
if (micSpec) inputs.push("-f", format, "-i", micSpec);
if (systemSpec) inputs.push("-f", format, "-i", systemSpec);
if (inputs.length === 0) throw new Error("No FFmpeg input device configured. Set at least the microphone.");
const shouldMix = Boolean(micSpec && systemSpec);
const mixArgs = shouldMix ? ["-filter_complex", "[0:a][1:a]amix=inputs=2:duration=longest"] : [];
const outputArgs = ["-acodec", "libmp3lame", "-ab", "192k", this.audioPath];
this.setPhase("recording");
this.startElapsedTimer();
const args = ["-y", ...inputs, ...mixArgs, ...outputArgs];
this.ffmpegChild = spawn(ffmpegPath, args, { detached: false });
this.appendLog(`FFmpeg started: ${ffmpegPath} ${args.join(" ")}`);
// Collect stderr to surface meaningful tail on errors
let ffErr = "";
this.ffmpegChild.stderr?.on("data", (d: Buffer) => { ffErr += d.toString(); });
this.ffmpegChild.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
const signaled = Boolean(signal);
const userStop = this.explicitStopRequested || signal === "SIGINT" || signal === "SIGTERM" || signal === "SIGKILL";
if (userStop) {
this.appendLog(`FFmpeg terminated on request (code=${code ?? 0}, signal=${signal ?? "none"}).`);
// Do not treat as error; stop() flow will continue
} else if (this.phase === "recording" && (code ?? 0) !== 0) {
const tail = ffErr.split(/\r?\n/).slice(-8).join("\n");
this.onError?.(`FFmpeg exited with code ${code}.\n${tail}`);
this.appendLog(`FFmpeg error (${code}):\n${tail}`);
this.setPhase("error");
}
this.explicitStopRequested = false;
this.ffmpegChild = null;
});
this.stopRecordingFn = async () => {
try {
const child = this.ffmpegChild;
if (!child) return;
if (process.platform === "win32") {
try { child.kill("SIGINT"); } catch {}
await waitMs(900);
try { child.kill("SIGTERM"); } catch {}
await waitMs(700);
if (!child.killed) {
const { spawn: sp } = (window as any).require("child_process");
try { sp("taskkill", ["/pid", String(child.pid), "/t", "/f"], { windowsHide: true }); } catch {}
await waitMs(600);
}
} else {
try { child.kill("SIGINT"); } catch {}
await waitMs(900);
try { child.kill("SIGTERM"); } catch {}
await waitMs(700);
try { child.kill("SIGKILL"); } catch {}
}
} catch {}
};
}
private async transcribe() {
this.setPhase("transcribing");
const { whisperMainPath, whisperModelPath, whisperLanguage } = this.settings;
if (!whisperMainPath) throw new Error("whisper-cli path not configured");
if (!whisperModelPath) throw new Error("Whisper model path not configured");
const { spawn } = (window as any).require("child_process");
const fs = (window as any).require("fs");
const args = ["-m", whisperModelPath, "-f", this.audioPath];
const lang = (whisperLanguage || "auto").trim();
if (lang && lang !== "auto") { args.push("-l", lang); }
this.appendLog(`Transcription: ${whisperMainPath} ${args.join(" ")}`);
const stdoutBuf: string[] = [];
let stderrBuf = "";
await new Promise<void>((resolve, reject) => {
const child = spawn(whisperMainPath, args, { cwd: this.audioDir });
child.stdout?.on("data", (d: Buffer) => stdoutBuf.push(d.toString()));
child.stderr?.on("data", (d: Buffer) => { stderrBuf += d.toString(); });
child.on("error", (err: any) => reject(err));
child.on("close", (code: number) => {
if (code === 0) resolve(); else reject(new Error(`whisper-cli exited with code ${code}: ${stderrBuf}`));
});
});
const transcript = stdoutBuf.join("").trim();
if (!transcript) throw new Error("Empty transcription");
try { fs.writeFileSync(this.transcriptPath, transcript, { encoding: "utf8" }); this.appendLog(`Transcription saved: ${this.transcriptPath} (${transcript.length} chars)`); } catch {}
this.onInfo?.(`Summarizing...`);
await this.summarize(transcript);
}
private async summarize(transcript: string) {
this.setPhase("summarizing");
const { geminiApiKey, geminiModel } = this.settings;
if (!geminiApiKey) throw new Error("Gemini API Key not configured");
const { PROMPT_PRESETS, DEFAULT_PROMPT_KEY } = await import('./prompts');
const preset = PROMPT_PRESETS[this.selectedPresetKey || this.settings.lastPromptKey || DEFAULT_PROMPT_KEY] || PROMPT_PRESETS[DEFAULT_PROMPT_KEY];
const prompt = preset.prompt;
const model = (geminiModel || "gemini-1.5-pro").trim();
const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`;
const body = {
contents: [
{
role: "user",
parts: [
{ text: prompt },
{ text: `\n\nTrascrizione:\n${transcript}` },
],
},
],
} as any;
const res = await fetch(`${url}?key=${encodeURIComponent(geminiApiKey)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const errTxt = await res.text().catch(() => "");
throw new Error(`Gemini API error: ${res.status} ${errTxt}`);
}
const json = await res.json();
const summary = extractMarkdownFromGemini(json) || "";
if (!summary) throw new Error("Empty summary from Gemini");
this.appendLog(`Summary generated (${summary.length} chars)`);
await this.createNote(summary);
}
private async createNote(markdown: string) {
const fileName = `Meeting ${window.moment().format("YYYY-MM-DD HH-mm")}.md`;
const folder = this.settings.outputFolder?.trim();
const fullPath = folder ? `${folder}/${fileName}` : fileName;
const tfile = await this.app.vault.create(fullPath, markdown);
new Notice("Note created!");
this.appendLog(`Note created: ${fullPath}`);
this.setPhase("done");
const leaf = this.app.workspace.getLeaf(true);
await leaf.openFile(tfile as TFile);
}
private appendLog(message: string) {
try {
if (!this.logPath) return;
const fs = (window as any).require("fs");
fs.appendFileSync(this.logPath, `[${new Date().toISOString()}] ${message}\n`);
} catch {}
}
}
function extractMarkdownFromGemini(json: any): string | null {
try {
const text = json?.candidates?.[0]?.content?.parts?.map((p: any) => p?.text ?? "").join("\n");
if (text && typeof text === "string") return text.trim();
} catch {}
return null;
}
function waitMs(ms: number) {
return new Promise<void>(resolve => setTimeout(resolve, ms));
}

View file

@ -1,414 +0,0 @@
import { App, Modal, Notice, TFile, moment } from "obsidian";
import { ResonanceSettings } from "./settings";
// Note on external processes:
// Obsidian Desktop provides Node.js (Electron) to community plugins.
// We invoke ffmpeg and whisper.cpp via child_process in a controlled way.
interface RecordingModalState {
phase: "idle" | "recording" | "transcribing" | "summarizing" | "error" | "done";
startTs: number | null;
elapsedSec: number;
errorMessage?: string;
}
export class RecordingModal extends Modal {
private settings: ResonanceSettings;
private state: RecordingModalState = { phase: "idle", startTs: null, elapsedSec: 0 };
private intervalId: number | null = null;
private tempDir: string = "";
private tempAudio: string = "";
private ffmpegChild: any | null = null;
private stopRecordingFn: (() => Promise<void>) | null = null;
// UI element refs for dynamic updates
private statusEl!: HTMLElement;
private timerEl!: HTMLElement;
private controlBtn!: HTMLButtonElement;
private cancelBtn!: HTMLButtonElement;
private waveEl!: HTMLElement;
constructor(app: App, settings: ResonanceSettings) {
super(app);
this.settings = settings;
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("resonance-modal");
contentEl.createEl("h2", { text: "Meeting Recorder" });
const status = contentEl.createEl("div", { cls: "resonance-status" });
this.statusEl = status;
this.setStatus("Ready to record");
const timer = contentEl.createEl("div", { cls: "resonance-timer", text: "00:00" });
this.timerEl = timer;
const wave = contentEl.createEl("div", { cls: "resonance-wave" });
this.waveEl = wave;
const controls = contentEl.createEl("div", { cls: "resonance-controls" });
const btn = controls.createEl("button", { cls: "resonance-btn primary" });
btn.appendChild(createIcon("microphone"));
btn.appendText(" Start Recording");
btn.addEventListener("click", () => this.handleControlClick());
this.controlBtn = btn as HTMLButtonElement;
const cancel = controls.createEl("button", { cls: "resonance-btn secondary" });
cancel.appendChild(createIcon("square"));
cancel.appendText(" Cancel");
cancel.addEventListener("click", () => this.cancelFlow());
this.cancelBtn = cancel as HTMLButtonElement;
this.cancelBtn.hide();
}
onClose(): void {
this.clearTimer();
}
private setStatus(text: string) {
this.statusEl.setText(text);
}
private setPhase(phase: RecordingModalState["phase"]) {
this.state.phase = phase;
switch (phase) {
case "idle":
this.setStatus("Ready to record");
this.updateButton("primary", "microphone", "Start Recording", true);
this.cancelBtn.hide();
this.waveEl.removeClass("active");
break;
case "recording":
this.setStatus("Recording...");
this.updateButton("danger", "square", "Stop Recording", true);
this.cancelBtn.hide();
this.waveEl.addClass("active");
break;
case "transcribing":
this.setStatus("Transcribing...");
this.updateButton("disabled", "loader", "Processing...", false, true);
this.cancelBtn.show();
this.waveEl.removeClass("active");
break;
case "summarizing":
this.setStatus("Summarizing...");
this.updateButton("disabled", "loader", "Processing...", false, true);
this.cancelBtn.show();
this.waveEl.removeClass("active");
break;
case "error":
this.updateButton("primary", "microphone", "Retry", true);
this.cancelBtn.hide();
this.waveEl.removeClass("active");
break;
case "done":
this.updateButton("primary", "microphone", "New Recording", true);
this.cancelBtn.hide();
this.waveEl.removeClass("active");
break;
}
}
private updateButton(style: "primary" | "danger" | "disabled", icon: IconName, label: string, enabled: boolean, spinning = false) {
this.controlBtn.setAttr("class", `resonance-btn ${style}`);
this.controlBtn.empty();
this.controlBtn.appendChild(createIcon(icon, spinning));
this.controlBtn.appendText(` ${label}`);
this.controlBtn.disabled = !enabled;
}
private startTimer() {
this.state.startTs = Date.now();
this.state.elapsedSec = 0;
this.timerEl.setText("00:00");
this.clearTimer();
this.intervalId = window.setInterval(() => {
if (this.state.startTs) {
const diff = Math.floor((Date.now() - this.state.startTs) / 1000);
this.state.elapsedSec = diff;
const mm = String(Math.floor(diff / 60)).padStart(2, "0");
const ss = String(diff % 60).padStart(2, "0");
this.timerEl.setText(`${mm}:${ss}`);
}
}, 500);
}
private clearTimer() {
if (this.intervalId) {
window.clearInterval(this.intervalId);
this.intervalId = null;
}
}
private async handleControlClick() {
try {
if (this.state.phase === "idle" || this.state.phase === "error" || this.state.phase === "done") {
await this.beginRecordingFlow();
} else if (this.state.phase === "recording") {
await this.stopRecordingFlow();
}
} catch (e: any) {
new Notice(`Error: ${e?.message ?? e}`);
this.state.errorMessage = String(e?.message ?? e);
this.setPhase("error");
await this.cleanupTemp();
}
}
private async cancelFlow() {
try {
if (this.state.phase === "recording" && this.stopRecordingFn) {
await this.stopRecordingFn();
}
} finally {
this.setPhase("idle");
await this.cleanupTemp();
}
}
// Phase 1: Audio recording with ffmpeg (crossplatform)
private async beginRecordingFlow() {
const { ffmpegPath } = this.settings;
if (!ffmpegPath) throw new Error("FFmpeg path not configured");
const os = (window as any).require("os");
const path = (window as any).require("path");
const fs = (window as any).require("fs");
const { spawn } = (window as any).require("child_process");
this.tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "resonance-"));
this.tempAudio = path.join(this.tempDir, "recording.mp3");
const { format, micSpec, systemSpec } = this.resolveFfmpegInput();
const inputs: string[] = [];
if (micSpec) {
inputs.push("-f", format, "-i", micSpec);
}
if (systemSpec) {
inputs.push("-f", format, "-i", systemSpec);
}
if (inputs.length === 0) throw new Error("No FFmpeg input device configured. Set at least the microphone.");
const shouldMix = Boolean(micSpec && systemSpec);
const mixArgs = shouldMix
? ["-filter_complex", "[0:a][1:a]amix=inputs=2:duration=longest"]
: [];
const outputArgs = ["-acodec", "libmp3lame", "-ab", "192k", this.tempAudio];
this.setPhase("recording");
this.startTimer();
const args = [...inputs, ...mixArgs, ...outputArgs];
this.ffmpegChild = spawn(ffmpegPath, args, { detached: false });
// Stop reference (crossplatform)
this.stopRecordingFn = async () => {
try {
const child = this.ffmpegChild;
if (!child) return;
if (process.platform === "win32") {
try { child.kill("SIGINT"); } catch {}
await waitMs(900);
try { child.kill("SIGTERM"); } catch {}
await waitMs(700);
if (!child.killed) {
const { spawn: sp } = (window as any).require("child_process");
try { sp("taskkill", ["/pid", String(child.pid), "/t", "/f"], { windowsHide: true }); } catch {}
await waitMs(600);
}
} else {
try { child.kill("SIGINT"); } catch {}
await waitMs(900);
try { child.kill("SIGTERM"); } catch {}
await waitMs(700);
try { child.kill("SIGKILL"); } catch {}
}
} catch {}
};
}
private resolveFfmpegInput(): { format: string; micSpec?: string; systemSpec?: string } {
const platform = process.platform; // 'darwin' | 'win32' | 'linux'
let format = this.settings.ffmpegInputFormat;
if (format === "auto") {
format = platform === "darwin" ? "avfoundation" : platform === "win32" ? "dshow" : "pulse";
}
const mic = (this.settings.ffmpegMicDevice || "").trim();
const sys = (this.settings.ffmpegSystemDevice || "").trim();
if (format === "avfoundation") {
// macOS: audio-only -> ":index" (not "index:")
return { format, micSpec: mic || ":0", systemSpec: sys || "" };
}
if (format === "dshow") {
// Windows: requires 'audio=Device Name'
return { format, micSpec: mic || "audio=Microphone (default)", systemSpec: sys || "" };
}
// Linux (pulse/alsa): 'default' often works for mic; system audio requires loopback setup
return { format, micSpec: mic || "default", systemSpec: sys || "" };
}
private async stopRecordingFlow() {
if (this.stopRecordingFn) {
await this.stopRecordingFn();
this.stopRecordingFn = null;
}
this.clearTimer();
await waitMs(500); // allow file to be closed
await this.transcribeFlow();
}
// Phase 2: Transcription with whisper.cpp
private async transcribeFlow() {
this.setPhase("transcribing");
const { whisperMainPath, whisperModelPath, whisperLanguage } = this.settings;
if (!whisperMainPath) throw new Error("whisper-cli path not configured");
if (!whisperModelPath) throw new Error("Whisper model path not configured");
const fs = (window as any).require("fs");
const { spawn } = (window as any).require("child_process");
const args = ["-m", whisperModelPath, "-f", this.tempAudio];
const lang = (whisperLanguage || 'auto').trim();
if (lang && lang !== 'auto') { args.push('-l', lang); }
const stdoutBuf: string[] = [];
let stderrBuf = "";
await new Promise<void>((resolve, reject) => {
const child = spawn(whisperMainPath, args, { cwd: this.tempDir });
child.stdout?.on("data", (d: Buffer) => stdoutBuf.push(d.toString()));
child.stderr?.on("data", (d: Buffer) => { stderrBuf += d.toString(); });
child.on("error", (err: any) => reject(err));
child.on("close", (code: number) => {
if (code === 0) resolve(); else reject(new Error(`whisper-cli exited with code ${code}: ${stderrBuf}`));
});
});
// whisper-cli prints the transcript to stdout; use that.
const transcript = stdoutBuf.join("").trim();
if (!transcript) throw new Error("Empty transcription");
await this.summarizeFlow(transcript);
}
// Phase 3: Summarization with Google Gemini
private async summarizeFlow(transcript: string) {
this.setPhase("summarizing");
const { geminiApiKey, geminiModel } = this.settings;
if (!geminiApiKey) throw new Error("Gemini API Key not configured");
const prompt = `You are a senior AI assistant specialized in distilling meetings into actionable notes. Read the transcript and produce a concise, wellstructured Markdown report.
## Highlights
- Bulleted list of key outcomes, insights, and themes.
## Decisions
- Bullet list of confirmed decisions. If none, write 'No explicit decisions.'
## Action Items
- Checklist of tasks: - [ ] Clear task description @Owner (if known)
If a section would be empty, omit it. Prefer clarity and brevity.`;
const model = (geminiModel || "gemini-1.5-pro").trim();
const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`;
const body = {
contents: [
{
role: "user",
parts: [
{ text: prompt },
{ text: `\n\nTranscript:\n${transcript}` },
],
},
],
};
const res = await fetch(`${url}?key=${encodeURIComponent(geminiApiKey)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const errTxt = await res.text().catch(() => "");
throw new Error(`Gemini API error: ${res.status} ${errTxt}`);
}
const json = await res.json();
const summary = extractMarkdownFromGemini(json) || "";
if (!summary) throw new Error("Empty summary from Gemini response");
await this.createNoteAndFinish(summary);
}
// Phase 4: Note creation
private async createNoteAndFinish(markdown: string) {
const date = window.moment().format("YYYY-MM-DD HH-mm");
const fileName = `Meeting ${date}.md`;
const folder = this.settings.outputFolder?.trim();
const fullPath = folder ? `${folder}/${fileName}` : fileName;
const vault = this.app.vault;
const tfile = await vault.create(fullPath, markdown);
new Notice("Note created!");
this.setPhase("done");
await this.cleanupTemp();
this.close();
const leaf = this.app.workspace.getLeaf(true);
await leaf.openFile(tfile);
}
private async cleanupTemp() {
try {
if (!this.tempDir) return;
const fs = (window as any).require("fs");
const path = (window as any).require("path");
const files: string[] = fs.readdirSync(this.tempDir) ?? [];
for (const f of files) {
try { fs.unlinkSync(path.join(this.tempDir, f)); } catch {}
}
try { fs.rmdirSync(this.tempDir); } catch {}
} catch {}
}
}
type IconName = "microphone" | "square" | "loader";
function createIcon(name: IconName, spinning = false): HTMLElement {
const span = document.createElement("span");
span.addClass("resonance-icon");
if (spinning) span.addClass("spin");
span.innerHTML =
name === "microphone"
? '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M12 14a3 3 0 0 0 3-3V6a3 3 0 0 0-6 0v5a3 3 0 0 0 3 3zm5-3a5 5 0 0 1-10 0H5a7 7 0 0 0 14 0h-2zM11 19h2v3h-2v-3z"/></svg>'
: name === "square"
? '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>'
: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25"/><path d="M12 2a10 10 0 0 1 10 10"/></svg>';
return span;
}
function extractMarkdownFromGemini(json: any): string | null {
try {
const text = json?.candidates?.[0]?.content?.parts?.map((p: any) => p?.text ?? "").join("\n");
if (text && typeof text === "string") return text.trim();
} catch {}
return null;
}
function waitMs(ms: number) {
return new Promise<void>(resolve => setTimeout(resolve, ms));
}

View file

@ -1,192 +0,0 @@
import { App, Modal, Notice } from "obsidian";
import { ResonanceSettings } from "./settings";
import { scanDevices, ListedDevice } from "./DeviceScanner";
export class SetupWizard extends Modal {
private settings: ResonanceSettings;
private save: (partial: Partial<ResonanceSettings>) => Promise<void>;
private step = 0;
private steps = ["API", "FFmpeg", "Devices", "Whisper", "Test", "Finish"];
private content!: HTMLElement;
private scanResults: ListedDevice[] = [];
constructor(app: App, settings: ResonanceSettings, save: (partial: Partial<ResonanceSettings>) => Promise<void>) {
super(app);
this.settings = settings;
this.save = save;
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("resonance-modal");
const title = contentEl.createEl("h2", { text: "Welcome to Resonance Setup Wizard" });
const indicator = contentEl.createEl("div", { cls: "resonance-steps" });
this.steps.forEach((s, i) => {
const dot = indicator.createEl("span", { text: s });
if (i === this.step) dot.addClass("active");
});
this.content = contentEl.createEl("div", { cls: "resonance-wizard-content" });
const controls = contentEl.createEl("div", { cls: "resonance-controls" });
const back = controls.createEl("button", { cls: "resonance-btn secondary", text: "Back" });
const next = controls.createEl("button", { cls: "resonance-btn primary", text: "Next" });
back.addEventListener("click", () => this.prev());
next.addEventListener("click", () => this.next());
this.renderStep();
}
private refreshIndicator() {
const el = this.contentEl.querySelector(".resonance-steps");
if (!el) return;
el.empty();
this.steps.forEach((s, i) => {
const dot = el.createEl("span", { text: s });
if (i === this.step) dot.addClass("active");
});
}
private async next() {
if (!(await this.validateAndSaveStep())) return;
this.step = Math.min(this.step + 1, this.steps.length - 1);
this.refreshIndicator();
this.renderStep();
}
private prev() {
this.step = Math.max(this.step - 1, 0);
this.refreshIndicator();
this.renderStep();
}
private renderStep() {
this.content.empty();
switch (this.step) {
case 0: this.renderApiStep(); break;
case 1: this.renderFfmpegStep(); break;
case 2: this.renderDevicesStep(); break;
case 3: this.renderWhisperStep(); break;
case 4: this.renderTestStep(); break;
case 5: this.renderFinishStep(); break;
}
}
private renderApiStep() {
this.content.createEl("h3", { text: "1) Gemini API Key" });
const input = this.content.createEl("input", { type: "password" });
input.placeholder = "gai-...";
input.value = this.settings.geminiApiKey || "";
input.addEventListener('input', () => (input.dataset.changed = '1'));
this.content.createEl("p", { text: "The key is stored locally in Obsidian. Model is configurable in Settings." });
}
private renderFfmpegStep() {
this.content.createEl("h3", { text: "2) FFmpeg" });
const p = this.content.createEl("p", { text: "Specify or auto-detect the FFmpeg path." });
const input = this.content.createEl("input");
input.placeholder = process.platform === 'win32' ? 'C:/ffmpeg/bin/ffmpeg.exe' : '/opt/homebrew/bin/ffmpeg';
input.value = this.settings.ffmpegPath || '';
const detect = this.content.createEl("button", { cls: "resonance-btn secondary", text: "Detect" });
detect.addEventListener('click', async () => {
const guess = await (window as any).resonanceAutoDetectFfmpeg?.();
if (guess) input.value = guess;
else new Notice('No FFmpeg found');
});
}
private renderDevicesStep() {
this.content.createEl("h3", { text: "3) Audio Devices" });
const scanBtn = this.content.createEl("button", { cls: "resonance-btn secondary", text: "Scan devices" });
const micSel = this.content.createEl("select");
const sysSel = this.content.createEl("select");
const none = document.createElement('option'); none.value = ''; none.text = '(none)'; sysSel.appendChild(none);
scanBtn.addEventListener('click', async () => {
try {
const backend = this.resolveBackend();
this.scanResults = await scanDevices(this.settings.ffmpegPath, backend);
micSel.empty(); sysSel.length = 1; // keep none
this.scanResults.filter(d => d.type !== 'video').forEach(d => {
const o1 = document.createElement('option'); o1.value = d.name; o1.text = d.label; micSel.appendChild(o1);
const o2 = document.createElement('option'); o2.value = d.name; o2.text = d.label; sysSel.appendChild(o2);
});
} catch (e: any) {
new Notice(`Scan error: ${e?.message ?? e}`);
}
});
micSel.value = this.settings.ffmpegMicDevice || '';
sysSel.value = this.settings.ffmpegSystemDevice || '';
micSel.addEventListener('change', async () => await this.save({ ffmpegMicDevice: micSel.value }));
sysSel.addEventListener('change', async () => await this.save({ ffmpegSystemDevice: sysSel.value }));
}
private renderWhisperStep() {
this.content.createEl("h3", { text: "4) whisper.cpp" });
const mainInput = this.content.createEl("input");
mainInput.placeholder = process.platform === 'win32' ? 'C:/whisper/main.exe' : '/usr/local/bin/main';
mainInput.value = this.settings.whisperMainPath || '';
const detect = this.content.createEl("button", { cls: "resonance-btn secondary", text: "Detect" });
detect.addEventListener('click', async () => {
const guess = await (window as any).resonanceAutoDetectWhisper?.();
if (guess) mainInput.value = guess; else new Notice('No whisper main found');
});
const modelInput = this.content.createEl("input");
modelInput.placeholder = process.platform === 'win32' ? 'C:/modelli/ggml-base.en.bin' : '/path/ggml-base.en.bin';
modelInput.value = this.settings.whisperModelPath || '';
}
private renderTestStep() {
this.content.createEl("h3", { text: "5) Quick test" });
const p2 = this.content.createEl("p", { text: "Runs a 3s mic recording to verify FFmpeg." });
const btn = this.content.createEl("button", { cls: "resonance-btn primary", text: "Run 3s test" });
btn.addEventListener('click', async () => {
const ok = await (window as any).resonanceQuickTest?.();
if (ok) new Notice('Test completed'); else new Notice('Test failed');
});
}
private renderFinishStep() {
this.content.createEl("h3", { text: "6) All set" });
this.content.createEl("p", { text: "Basic configuration complete. You can tweak advanced options under Settings → Resonance." });
const close = this.content.createEl("button", { cls: "resonance-btn primary", text: "Close" });
close.addEventListener('click', async () => {
await this.save({ simpleMode: true });
this.close();
});
}
private async validateAndSaveStep(): Promise<boolean> {
if (this.step === 0) {
const val = (this.content.querySelector('input') as HTMLInputElement)?.value?.trim() || '';
await this.save({ geminiApiKey: val });
if (!val) { new Notice('Enter the API Key'); return false; }
}
if (this.step === 1) {
const val = (this.content.querySelector('input') as HTMLInputElement)?.value?.trim() || '';
await this.save({ ffmpegPath: val });
if (!val) { new Notice('Set FFmpeg'); return false; }
}
if (this.step === 3) {
const inputs = this.content.querySelectorAll('input');
const main = (inputs[0] as HTMLInputElement)?.value?.trim() || '';
const model = (inputs[1] as HTMLInputElement)?.value?.trim() || '';
await this.save({ whisperMainPath: main, whisperModelPath: model });
if (!main || !model) { new Notice('Set whisper main and model'); return false; }
}
return true;
}
private resolveBackend(): 'dshow' | 'avfoundation' | 'pulse' | 'alsa' {
if (this.settings.ffmpegInputFormat !== 'auto') return this.settings.ffmpegInputFormat as any;
if (process.platform === 'win32') return 'dshow';
if (process.platform === 'darwin') return 'avfoundation';
return 'pulse';
}
}

BIN
assets/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
assets/library.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

BIN
assets/recorder.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

35
eslint.config.mjs Normal file
View file

@ -0,0 +1,35 @@
import tsparser from "@typescript-eslint/parser";
import { defineConfig } from "eslint/config";
import obsidianmd from "eslint-plugin-obsidianmd";
const obsidianJsonConfig = obsidianmd.configs.recommended.find((config) => config.language === "json/json");
export default defineConfig([
{
ignores: ["dist/**", ".test-dist/**", "node_modules/**"],
},
...obsidianmd.configs.recommended,
{
files: ["manifest.json"],
plugins: obsidianJsonConfig?.plugins,
language: "json/json",
rules: {
"no-irregular-whitespace": "off",
"obsidianmd/validate-manifest": "error",
},
},
{
files: ["src/**/*.ts"],
languageOptions: {
parser: tsparser,
parserOptions: {
project: "./tsconfig.json",
},
},
rules: {
"@typescript-eslint/require-await": "error",
"@typescript-eslint/unbound-method": "error",
"obsidianmd/ui/sentence-case": "error",
},
},
]);

213
main.ts
View file

@ -1,213 +0,0 @@
import { App, Modal, Notice, Plugin } from "obsidian";
import { ResonanceSettings, DEFAULT_SETTINGS, ResonanceSettingTab } from "./settings";
import { checkDependencies } from "./DependencyChecker";
// @ts-expect-error: module is resolved at runtime by the bundler
import { RecorderService, type RecorderPhase } from "./RecorderService";
import { PROMPT_PRESETS, DEFAULT_PROMPT_KEY, getPresetKeys } from "./prompts";
declare global {
interface Window {
resonanceAutoDetectFfmpeg?: () => Promise<string | null>;
resonanceAutoDetectWhisper?: () => Promise<string | null>;
resonanceQuickTest?: () => Promise<boolean>;
}
}
export default class ResonancePlugin extends Plugin {
settings!: ResonanceSettings;
private recorder!: RecorderService;
private ribbonIconEl!: HTMLElement;
private statusBarEl!: HTMLElement;
private statusTimerId: number | null = null;
async onload() {
await this.loadSettings();
this.recorder = new RecorderService(this.app, this.settings, this.manifest.id, async (partial: Partial<ResonanceSettings>) => {
await this.saveSettings(partial);
});
// Library icon first (so it appears above the microphone icon)
const libRibbon = this.addRibbonIcon("audio-file", "Resonance - Library", async () => {
const { LibraryModal } = await import('./LibraryModal');
new LibraryModal(this.app, this.manifest.id).open();
});
libRibbon.addClass("resonance-ribbon");
// Sposta in fondo (con flex column-reverse = primo figlio)
this.moveRibbonToBottom(libRibbon);
// Ripeti a layout pronto (altri plugin possono aggiungere icone dopo)
this.app.workspace.onLayoutReady(() => {
window.setTimeout(() => this.moveRibbonToBottom(libRibbon), 0);
window.setTimeout(() => this.moveRibbonToBottom(libRibbon), 500);
});
// Microphone icon below library icon
const ribbonIconEl = this.addRibbonIcon("mic", "Resonance - Meeting recorder", async () => {
await this.toggleFromRibbonWithPreset();
});
this.ribbonIconEl = ribbonIconEl;
ribbonIconEl.addClass("resonance-ribbon");
// Sposta in fondo (con flex column-reverse = primo figlio)
this.moveRibbonToBottom(ribbonIconEl);
// Ripeti a layout pronto (altri plugin possono aggiungere icone dopo)
this.app.workspace.onLayoutReady(() => {
window.setTimeout(() => this.moveRibbonToBottom(ribbonIconEl), 0);
window.setTimeout(() => this.moveRibbonToBottom(ribbonIconEl), 500);
});
// Status bar for timer/status
this.statusBarEl = this.addStatusBarItem();
this.statusBarEl.addClass("resonance-statusbar");
this.statusBarEl.setText("");
(this.statusBarEl as any).hide?.();
this.addCommand({
id: "resonance-open-recorder",
name: "Start/stop recording (Resonance)",
callback: async () => {
await this.toggleFromRibbonWithPreset();
},
});
this.addCommand({
id: "resonance-open-library",
name: "Open Library",
callback: async () => {
const { LibraryModal } = await import('./LibraryModal');
new LibraryModal(this.app, this.manifest.id).open();
},
});
this.addSettingTab(new ResonanceSettingTab(this.app, this.settings, async (partial) => {
await this.saveSettings(partial);
}));
// Wire up service events → UI
this.recorder.onPhaseChange = (phase: RecorderPhase) => {
this.updateRibbonState(phase);
this.updateStatusBarState(phase);
};
this.recorder.onElapsed = (sec: number) => {
this.updateStatusBarTimer(sec);
};
this.recorder.onError = (message: string) => {
new Notice(`Resonance error: ${message}`);
};
this.recorder.onInfo = (message: string) => {
new Notice(message);
};
}
async onunload() {}
private async ensureDepsOk(): Promise<boolean> {
const deps = await checkDependencies({
apiKey: this.settings.geminiApiKey,
ffmpegPath: this.settings.ffmpegPath,
whisperMainPath: this.settings.whisperMainPath,
whisperModelPath: this.settings.whisperModelPath,
});
if (!deps.hasApiKey || !deps.ffmpegOk || !deps.whisperOk || !deps.modelOk) {
new Notice("Incomplete configuration. Go to Settings → Resonance to fill in the required fields.");
return false;
}
return true;
}
private async toggleFromRibbonWithPreset() {
if (!(await this.ensureDepsOk())) return;
const phase = this.recorder.getPhase();
if (phase === "idle" || phase === "error" || phase === "done") {
const presetKey = await this.selectPreset(this.settings.lastPromptKey || DEFAULT_PROMPT_KEY);
if (!presetKey) return;
await this.recorder.startWithPreset(presetKey);
await this.saveSettings({ lastPromptKey: presetKey });
} else if (phase === "recording") {
await this.recorder.stop();
} else {
new Notice("Processing… please wait.");
}
}
private updateRibbonState(phase: RecorderPhase) {
if (!this.ribbonIconEl) return;
this.ribbonIconEl.removeClass("recording");
this.ribbonIconEl.removeClass("processing");
if (phase === "recording") this.ribbonIconEl.addClass("recording");
if (phase === "transcribing" || phase === "summarizing") this.ribbonIconEl.addClass("processing");
}
private moveRibbonToBottom(el: HTMLElement) {
const parent = el?.parentElement;
if (!parent) return;
const first = parent.firstElementChild;
if (first !== el) parent.insertBefore(el, first);
}
private updateStatusBarState(phase: RecorderPhase) {
const el = this.statusBarEl;
if (!el) return;
if (phase === "recording") {
(el as any).show?.();
el.setText("Rec 00:00");
} else if (phase === "transcribing") {
(el as any).show?.();
el.setText("Transcribing…");
} else if (phase === "summarizing") {
(el as any).show?.();
el.setText("Summarizing…");
} else {
el.setText("");
(el as any).hide?.();
}
}
private updateStatusBarTimer(sec: number) {
if (!this.statusBarEl) return;
const mm = String(Math.floor(sec / 60)).padStart(2, "0");
const ss = String(sec % 60).padStart(2, "0");
this.statusBarEl.setText(`Rec ${mm}:${ss}`);
}
private selectPreset(defaultKey: string): Promise<string | null> {
return new Promise<string | null>((resolve) => {
const modal = new (class extends Modal {
private value: string = defaultKey;
constructor(app: App, private onResult: (key: string | null) => void) { super(app); }
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("resonance-modal");
contentEl.createEl("h2", { text: "Choose scenario" });
const select = contentEl.createEl("select");
select.addClass("resonance-inline-select");
const keys = getPresetKeys();
keys.forEach((k) => {
const opt = document.createElement("option");
opt.value = k; opt.text = PROMPT_PRESETS[k].label; select.appendChild(opt);
});
select.value = defaultKey;
select.addEventListener("change", () => { this.value = select.value; });
const controls = contentEl.createEl("div", { cls: "resonance-controls" });
const okBtn = controls.createEl("button", { cls: "resonance-btn primary", text: "Start" });
okBtn.addEventListener("click", () => { this.onResult(this.value); this.close(); });
const cancelBtn = controls.createEl("button", { cls: "resonance-btn secondary", text: "Cancel" });
cancelBtn.addEventListener("click", () => { this.onResult(null); this.close(); });
}
onClose(): void { /* keep last selection on close */ }
})(this.app, (key) => resolve(key));
modal.open();
});
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings(partial: Partial<ResonanceSettings>) {
this.settings = { ...this.settings, ...partial };
await this.saveData(this.settings);
}
}

View file

@ -1,10 +1,11 @@
{
"id": "resonance",
"id": "resonance-next",
"name": "Resonance",
"version": "0.0.1",
"minAppVersion": "1.5.0",
"description": "Record meetings, lessons, interviews, and more. Transcribe locally, summarize with AI, and create notes in Obsidian. Free and open source.",
"version": "0.1.1",
"minAppVersion": "1.6.6",
"description": "A local-first AI recorder with live transcription, diagnostics, and session-based notes.",
"author": "Michael Gorini",
"authorUrl": "",
"authorUrl": "https://buymeacoffee.com/michaelgorini",
"fundingUrl": "https://buymeacoffee.com/michaelgorini",
"isDesktopOnly": true
}

4545
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,16 +1,22 @@
{
"name": "resonance",
"version": "1.0.0",
"name": "resonance-next",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Obsidian plugin: registra, trascrive con whisper.cpp, riassume con Gemini.",
"description": "A local-first AI recorder with live transcription, diagnostics, and session-based notes.",
"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{}}})\"",
"lint": "eslint manifest.json \"src/**/*.ts\"",
"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",
"@typescript-eslint/parser": "^8.59.2",
"eslint": "^9.39.4",
"eslint-plugin-obsidianmd": "^0.2.9",
"obsidian": "^1.8.7",
"typescript": "^5.9.2",
"vite": "^5.4.19"

View file

@ -1,87 +0,0 @@
export type PromptPreset = {
key: string;
label: string;
prompt: string;
};
export const DEFAULT_PROMPT_KEY = "work_meeting";
export const PROMPT_PRESETS: Record<string, PromptPreset> = {
work_meeting: {
key: "work_meeting",
label: "Work meeting",
prompt: `You are an elite meeting notetaker. Produce a clean, skimmable Markdown report in the SAME language as the transcript.
Always include, in this order:
1) Overview 36 concise sentences capturing goals, outcomes, and context.
2) Topic sections Create any number of sections with short headings based on the natural topics of the conversation. Allocate space according to how much time/attention each topic received. Merge minor topics together. Do NOT invent sections if they add no value.
3) Action items a checklist with [ ] Task @Owner (due date if mentioned). Keep tasks atomic and actionable.
Guidelines: short sentences, no fluff, avoid empty sections, use bullet points when better than paragraphs, keep headings concise.
IMPORTANT: If the transcript is empty or invalid, return an empty string.
`,
},
university_lecture: {
key: "university_lecture",
label: "University lecture",
prompt: `You are an expert study notetaker. Produce clear Markdown notes in the SAME language as the transcript.
Always include:
1) Overview 36 sentences summarizing learning objectives and key results.
2) Topic sections Derive sections from the lecture's natural segments. For each section, capture definitions, key formulas (use LaTeX if present), examples, and common pitfalls.
3) Action items [ ] Followups (exercises to try, readings, next steps).
Keep it concise and didactic; avoid redundant text.
IMPORTANT: If the transcript is empty or invalid, return an empty string.`,
},
brainstorming: {
key: "brainstorming",
label: "Brainstorming",
prompt: `You are a creative facilitator. Write a concise Markdown summary in the SAME language as the transcript.
Always include:
1) Overview 36 sentences capturing the main opportunity and where the group aligned.
2) Topic sections Group ideas into natural clusters with a brief rationale; highlight highsignal pros/cons and assumptions.
3) Action items [ ] Experiments or validations with owners.
Keep it lightweight, avoid filler, prefer bullets.
IMPORTANT: If the transcript is empty or invalid, return an empty string.`,
},
interview: {
key: "interview",
label: "Interview",
prompt: `You are an interview notetaker. Produce a crisp Markdown report in the SAME language as the transcript.
Always include:
1) Overview 36 sentences on profile, goals, and main takeaways.
2) Topic sections Organize insights by theme. Include short quotable lines (in quotes) where helpful.
3) Action items [ ] Followups with owners.
Be objective and concise.
IMPORTANT: If the transcript is empty or invalid, return an empty string.`,
},
standup: {
key: "standup",
label: "Daily standup",
prompt: `Create a brief Markdown standup report in the SAME language as the transcript.
Always include:
1) Overview 24 sentences on overall progress and risks.
2) Topic sections Group by person or stream; list yesterday/today succinctly; call out blockers and dependencies.
3) Action items [ ] Unblockers or followups.
Be minimal and direct.
IMPORTANT: If the transcript is empty or invalid, return an empty string.`,
},
};
export function getPresetKeys(): string[] {
return Object.keys(PROMPT_PRESETS);
}

View file

@ -3,7 +3,7 @@ import { cpSync } from 'node:fs';
try {
cpSync('manifest.json', 'dist/manifest.json', { recursive: false });
cpSync('styles.css', 'dist/styles.css', { recursive: false });
console.log('Postbuild: copiati manifest.json e styles.css in dist/.');
console.log('Postbuild: copied manifest.json and styles.css into dist/.');
} catch (e) {
console.error('Postbuild error:', e);
process.exitCode = 1;

View file

@ -1,453 +0,0 @@
import { App, PluginSettingTab, Setting, Notice } from "obsidian";
import { scanDevices, ListedDevice } from "./DeviceScanner";
import { HelpModal } from "./HelpModal";
import { LibraryModal } from "./LibraryModal";
export interface ResonanceSettings {
geminiApiKey: string;
geminiModel: string;
ffmpegPath: string;
ffmpegInputFormat: "auto" | "avfoundation" | "dshow" | "pulse" | "alsa";
ffmpegMicDevice: string;
ffmpegSystemDevice: string;
whisperRepoPath: string; // repo root path for whisper.cpp
whisperMainPath: string; // whisper-cli resolved automatically from repo
whisperModelPath: string;
whisperModelPreset: "small" | "medium" | "large"; // quick choices
whisperLanguage: string; // ISO code or 'auto'
outputFolder: string;
lastPromptKey?: string;
maxRecordingsKept: number; // 0 = infinite
}
export const DEFAULT_SETTINGS: ResonanceSettings = {
geminiApiKey: "",
geminiModel: "gemini-1.5-pro",
ffmpegPath: "",
ffmpegInputFormat: "auto",
ffmpegMicDevice: "",
ffmpegSystemDevice: "",
whisperRepoPath: "",
whisperMainPath: "",
whisperModelPath: "",
whisperModelPreset: "medium",
whisperLanguage: "auto",
outputFolder: "",
lastPromptKey: undefined,
maxRecordingsKept: 20,
};
export class ResonanceSettingTab extends PluginSettingTab {
private settings: ResonanceSettings;
private save: (settings: Partial<ResonanceSettings>) => Promise<void>;
private lastScan: ListedDevice[] = [];
private pluginId: string;
constructor(app: App, settings: ResonanceSettings, save: (settings: Partial<ResonanceSettings>) => Promise<void>) {
super(app, (app as any).plugins.getPlugin("resonance"));
this.settings = settings;
this.save = save;
this.pluginId = ((app as any).plugins.getPlugin("resonance")?.manifest?.id) || "resonance";
}
async display() {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h3", { text: "Resonance" });
containerEl.createEl("p", {
text: "Thanks for using Resonance :)"
});
containerEl.createEl("p", {
text: "I know, setup can be a bit of a hassle... but once youre done, youll be recording and transcribing like a pro! Take a few minutes to complete all the steps,future you will thank you."
});
containerEl.createEl("p", {
text: "After you've completed the setup, use the microphone icon in the ribbon to start/stop, select a scenario from the menu that appears, and watch the timer in the status bar. "
});
containerEl.createEl("p", {
text: "When finished, a note will be created in the Notes folder you've selected. You can also open the Library (audio file icon) to listen, copy the transcription, or manage recordings."
});
// STEP 1: FFmpeg
containerEl.createEl("h3", { text: "FFmpeg" });
containerEl.createEl("p", { text: "Used to capture audio from your microphone and system audio." });
new Setting(containerEl)
.setName("Installation")
.setDesc("Install on macOS via Homebrew, Windows static build, or Linux via package manager.")
.addButton((btn)=> btn.setButtonText("Guide").onClick(()=> new HelpModal(this.app, 'ffmpeg').open()));
const ffmpegSetting = new Setting(containerEl)
.setName("FFmpeg path")
.setDesc("Choose the ffmpeg executable or use Detect to autofind.")
.addText(text =>
text
.setPlaceholder("/opt/homebrew/bin/ffmpeg or C:/ffmpeg/bin/ffmpeg.exe")
.setValue(this.settings.ffmpegPath)
.onChange(async (value) => { await this.save({ ffmpegPath: value.trim() }); })
);
ffmpegSetting.addButton((btn) => btn.setButtonText("Detect").onClick(async () => {
const guess = await this.autoDetectFfmpeg();
if (guess) { await this.save({ ffmpegPath: guess }); new Notice('FFmpeg detected'); this.display(); }
else new Notice('No FFmpeg found');
}));
// STEP 2: Whisper
containerEl.createEl("h3", { text: "Whisper" });
containerEl.createEl("p", { text: "Used to transcribe audio locally." });
new Setting(containerEl)
.setName("Installation")
.setDesc("Manual installation required.")
.addButton((btn)=> btn.setButtonText("Guide").onClick(()=> new HelpModal(this.app, 'whisper').open()));
new Setting(containerEl)
.setName("whisper.cpp repo path")
.setDesc("Repo root folder (e.g., /path/whisper.cpp)")
.addText(text =>
text
.setPlaceholder(process.platform === 'win32' ? 'C:/whisper.cpp' : '/path/whisper.cpp')
.setValue(this.settings.whisperRepoPath || '')
.onChange(async (value) => { await this.save({ whisperRepoPath: value.trim() }); })
);
const whisperSetting = new Setting(containerEl)
.setName("whisper-cli executable")
.setDesc("Autoresolved from repo; you can override it.")
.addText(text =>
text
.setPlaceholder(process.platform === 'win32' ? 'C:/whisper/build/bin/whisper-cli.exe' : '/path/whisper.cpp/build/bin/whisper-cli')
.setValue(this.settings.whisperMainPath)
.onChange(async (value) => { await this.save({ whisperMainPath: value.trim() }); })
);
whisperSetting.addButton((btn)=> btn.setButtonText("Find from repo").onClick(async ()=>{
const cli = await this.findWhisperCliFromRepo(this.settings.whisperRepoPath);
if (cli) { await this.save({ whisperMainPath: cli }); new Notice('whisper-cli found'); this.display(); }
else new Notice('whisper-cli not found. Build the repo (cmake/make).');
}));
const modelPreset = new Setting(containerEl)
.setName("Model")
.setDesc("Choose the model size. It will be downloaded automatically if missing.")
.addDropdown(drop => {
drop.addOption('small','small (fast)');
drop.addOption('medium','medium (balanced)');
drop.addOption('large','large (quality)');
drop.setValue(this.settings.whisperModelPreset || 'medium');
drop.onChange(async (value)=> { await this.save({ whisperModelPreset: value as any }); });
});
modelPreset.addButton((btn)=> btn.setButtonText("Download model").onClick(async ()=>{
try {
const file = await this.downloadModelPreset();
if (file) { await this.save({ whisperModelPath: file }); new Notice('Model ready: ' + file); this.display(); }
else new Notice('Model download failed');
} catch (e: any) { new Notice('Download error: ' + (e?.message ?? e)); }
}));
new Setting(containerEl)
.setName("Whisper model (.bin)")
.setDesc("Full path to the model.")
.addText(text =>
text
.setPlaceholder(process.platform === 'win32' ? 'C:/whisper/models/ggml-medium.bin' : '/path/whisper.cpp/models/ggml-medium.bin')
.setValue(this.settings.whisperModelPath)
.onChange(async (value) => { await this.save({ whisperModelPath: value.trim() }); })
);
new Setting(containerEl)
.setName("Transcription language")
.setDesc("Choose expected language or leave Automatic.")
.addDropdown(drop => {
const opts: [string, string][] = [
['auto','Automatic'],
['it','Italiano'],
['en','English'],
['es','Español'],
['fr','Français'],
['de','Deutsch'],
['pt','Português'],
];
opts.forEach(([v,l])=> drop.addOption(v,l));
drop.setValue(this.settings.whisperLanguage || 'auto');
drop.onChange(async (value)=> { await this.save({ whisperLanguage: value }); });
});
// STEP 3: LLM (Gemini)
containerEl.createEl("h3", { text: "LLM" });
containerEl.createEl("p", { text: "Used to summarize the transcription." });
new Setting(containerEl)
.setName("Installation")
.setDesc("Use your own API key.")
.addButton((btn)=> btn.setButtonText("Guide").onClick(()=> new HelpModal(this.app, 'llm').open()));
const apiSetting = new Setting(containerEl)
.setName("Google Gemini API Key")
.setDesc("Key is stored locally in your vault.")
.addText(text =>
text
.setPlaceholder("gai-...")
.setValue(this.settings.geminiApiKey)
.onChange(async (value) => { await this.save({ geminiApiKey: value }); })
);
apiSetting.settingEl.querySelector("input")?.setAttribute("type", "password");
new Setting(containerEl)
.setName("Gemini model")
.setDesc("Available: 1.5pro, 2.5flash, 2.5pro.")
.addDropdown(drop => {
const allowed: string[] = ["gemini-1.5-pro", "gemini-2.5-flash", "gemini-2.5-pro"];
const labels: Record<string, string> = {
"gemini-1.5-pro": "gemini-1.5-pro",
"gemini-2.5-flash": "gemini-2.5-flash",
"gemini-2.5-pro": "gemini-2.5-pro",
};
allowed.forEach(k => drop.addOption(k, labels[k]));
const current = allowed.includes(this.settings.geminiModel) ? this.settings.geminiModel : "gemini-2.5-pro";
drop.setValue(current);
drop.onChange(async (value) => { await this.save({ geminiModel: value }); });
});
// STEP 4: Audio devices
containerEl.createEl("h3", { text: "Audio devices" });
containerEl.createEl("p", { text: "Select backend and choose devices." });
new Setting(containerEl)
.setName("FFmpeg backend")
.setDesc("Automatic picks based on OS, choose manually if needed.")
.addDropdown(drop => {
drop.addOption("auto", "Automatic");
drop.addOption("avfoundation", "avfoundation (macOS)");
drop.addOption("dshow", "dshow (Windows)");
drop.addOption("pulse", "pulse (Linux)");
drop.addOption("alsa", "alsa (Linux)");
drop.setValue(this.settings.ffmpegInputFormat || "auto");
drop.onChange(async (value) => { await this.save({ ffmpegInputFormat: value as ResonanceSettings["ffmpegInputFormat"] }); });
});
const micSetting = new Setting(containerEl).setName("Microphone").setDesc("Choose from the list after scanning.");
const micSelect = micSetting.settingEl.createEl("select");
micSelect.addClass("resonance-inline-select");
const sysSetting = new Setting(containerEl).setName("System audio").setDesc("Choose after scanning. Leave empty if not available.");
const sysSelect = sysSetting.settingEl.createEl("select");
sysSelect.addClass("resonance-inline-select");
const none = document.createElement('option'); none.value=''; none.text='(none)'; sysSelect.appendChild(none);
new Setting(containerEl)
.setName("Device tools")
.setDesc("Refresh devices and test audio config.")
.addButton((btn) => btn.setButtonText("Refresh devices").onClick(async () => { await this.performScanAndPopulate(micSelect, sysSelect); }))
.addButton((btn) => btn.setButtonText("Test audio config").onClick(async () => { await this.quickTestRecording(); }));
await this.performScanAndPopulate(micSelect, sysSelect).catch(()=>{});
micSelect.addEventListener('change', async () => { await this.save({ ffmpegMicDevice: micSelect.value }); });
sysSelect.addEventListener('change', async () => { await this.save({ ffmpegSystemDevice: sysSelect.value }); });
// STEP 5: Obsidian
containerEl.createEl("h3", { text: "Obsidian" });
containerEl.createEl("p", { text: "Choose the folder where generated notes will be saved." });
const obs = new Setting(containerEl)
.setName("Notes folder")
.setDesc("Example: Meeting Notes, if empty, root of the vault.")
.addText(text => text.setPlaceholder("Meeting Notes").setValue(this.settings.outputFolder).onChange(async (value) => { await this.save({ outputFolder: value.trim() }); }));
// STEP 6: Library & retention
containerEl.createEl("h3", { text: "Library & retention" });
new Setting(containerEl)
.setName("Max recordings kept")
.setDesc("0 = infinite, older ones will be deleted automatically.")
.addText((text) =>
text
.setPlaceholder("5")
.setValue(String(this.settings.maxRecordingsKept ?? 5))
.onChange(async (value) => {
const v = Math.max(0, Math.floor(Number(value || 0)));
await this.save({ maxRecordingsKept: v });
})
);
new Setting(containerEl)
.setName("Open Library")
.setDesc("See recordings list and actions")
.addButton((btn)=> btn.setButtonText("Open").onClick(()=>{
try {
new LibraryModal(this.app, this.pluginId).open();
} catch (e: any) { new Notice(`Failed to open Library: ${e?.message ?? e}`); }
}));
}
private resolveBackend(): 'dshow' | 'avfoundation' | 'pulse' | 'alsa' {
if (this.settings.ffmpegInputFormat !== 'auto') return this.settings.ffmpegInputFormat as any;
if (process.platform === 'win32') return 'dshow';
if (process.platform === 'darwin') return 'avfoundation';
return 'pulse';
}
private async performScanAndPopulate(micSelect: HTMLSelectElement, sysSelect: HTMLSelectElement) {
if (!this.settings.ffmpegPath) return;
const backend = this.resolveBackend();
this.lastScan = await scanDevices(this.settings.ffmpegPath, backend);
micSelect.empty();
while (sysSelect.options.length > 1) sysSelect.remove(1);
const audioDevices = this.lastScan.filter(d => d.type !== 'video');
audioDevices.forEach(d => {
const o1 = document.createElement('option'); o1.value = d.name; o1.text = d.label; micSelect.appendChild(o1);
const o2 = document.createElement('option'); o2.value = d.name; o2.text = d.label; sysSelect.appendChild(o2);
});
const availableMicValues = new Set(Array.from(micSelect.options).map(o => o.value));
if (this.settings.ffmpegMicDevice && availableMicValues.has(this.settings.ffmpegMicDevice)) {
micSelect.value = this.settings.ffmpegMicDevice;
} else if (micSelect.options.length > 0) {
micSelect.selectedIndex = 0;
await this.save({ ffmpegMicDevice: micSelect.value });
new Notice(`Microphone auto-selected: ${micSelect.options[micSelect.selectedIndex].text}`);
}
const availableSysValues = new Set(Array.from(sysSelect.options).map(o => o.value));
if (this.settings.ffmpegSystemDevice && availableSysValues.has(this.settings.ffmpegSystemDevice)) {
sysSelect.value = this.settings.ffmpegSystemDevice;
}
}
async autoDetectFfmpeg(): Promise<string | null> {
try {
const { spawn } = (window as any).require('child_process');
const cmd = process.platform === 'win32' ? 'where' : 'which';
const found = await new Promise<string | null>((resolve) => {
const child = spawn(cmd, ['ffmpeg']);
let out = '';
child.stdout?.on('data', (d: Buffer) => out += d.toString());
child.on('close', () => { const line = out.split(/\r?\n/).map(s=>s.trim()).find(Boolean); resolve(line || null); });
child.on('error', () => resolve(null));
});
if (found) return found;
} catch {}
try {
const fs = (window as any).require('fs');
const candidates = process.platform === 'win32' ? ['C:/ffmpeg/bin/ffmpeg.exe', 'C:/Program Files/ffmpeg/bin/ffmpeg.exe'] : ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg', '/usr/bin/ffmpeg'];
for (const c of candidates) { if (fs.existsSync(c)) return c; }
} catch {}
return null;
}
private async findWhisperCliFromRepo(repoPath: string): Promise<string | null> {
try {
if (!repoPath) return null;
const path = (window as any).require('path');
const fs = (window as any).require('fs');
const isExe = (p: string) => fs.existsSync(p) && fs.statSync(p).isFile();
const candidates = [
['build','bin','whisper-cli'],
['build','bin','whisper-cli.exe'],
['build','bin','Release','whisper-cli'],
['build','bin','Release','whisper-cli.exe'],
['main'],
['main.exe'],
].map(parts => path.join(repoPath, ...parts));
for (const c of candidates) if (isExe(c)) return c;
// ricerca ricorsiva shallow (max 3 livelli) per file che contengono 'whisper-cli'
const maxDepth = 3;
const found = this.walkFind(repoPath, (p)=>/whisper-cli(\.exe)?$/i.test(p), maxDepth);
if (found) return found;
} catch {}
return null;
}
private walkFind(root: string, match: (p:string)=>boolean, depth: number): string | null {
try {
const fs = (window as any).require('fs');
const path = (window as any).require('path');
if (depth < 0) return null;
const items = fs.readdirSync(root);
for (const name of items) {
const full = path.join(root, name);
try {
const stat = fs.statSync(full);
if (stat.isFile() && match(full)) return full;
if (stat.isDirectory()) {
const r = this.walkFind(full, match, depth - 1);
if (r) return r;
}
} catch {}
}
} catch {}
return null;
}
private async downloadModelPreset(): Promise<string | null> {
const preset = this.settings.whisperModelPreset || 'medium';
const url = preset === 'small'
? 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin'
: preset === 'large'
? 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large.bin'
: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin';
const path = (window as any).require('path');
const fs = (window as any).require('fs');
const https = (window as any).require('https');
const repo = this.settings.whisperRepoPath?.trim();
const modelsDir = repo ? path.join(repo, 'models') : (this.settings.whisperModelPath ? path.dirname(this.settings.whisperModelPath) : '');
if (!modelsDir) throw new Error('Set the repo folder first or provide a model path');
try { fs.mkdirSync(modelsDir, { recursive: true }); } catch {}
const outFile = path.join(modelsDir, url.split('/').pop());
await new Promise<void>((resolve, reject) => {
const file = fs.createWriteStream(outFile);
https.get(url, (res: any) => {
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
https.get(res.headers.location, (res2: any) => res2.pipe(file).on('finish', resolve)).on('error', reject);
} else if (res.statusCode === 200) {
res.pipe(file).on('finish', resolve);
} else {
reject(new Error('HTTP ' + res.statusCode));
}
}).on('error', reject);
});
return outFile;
}
private async quickTestRecording() {
const ffmpeg = this.settings.ffmpegPath.trim();
if (!ffmpeg) { new Notice('Set FFmpeg first'); return; }
const backend = this.resolveBackend();
const mic = this.settings.ffmpegMicDevice.trim();
if (!mic) { new Notice('Select a microphone'); return; }
const { spawn } = (window as any).require('child_process');
const os = (window as any).require('os');
const path = (window as any).require('path');
const fs = (window as any).require('fs');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'resonance-test-'));
const out = path.join(tmpDir, 'test.mp3');
const args: string[] = ['-y', '-f', backend, '-i', mic, '-t', '3', '-acodec', 'libmp3lame', '-ab', '128k', out];
const child = spawn(ffmpeg, args);
let stderr = '';
child.stderr?.on('data', (d: Buffer) => { stderr += d.toString(); });
child.on('close', (code: number) => {
if (code === 0) new Notice('Test completed: 3s recording created');
else {
const hint = stderr.split(/\r?\n/).slice(-6).join('\n');
new Notice(`Test failed (code ${code}).\n${hint}`);
}
try { fs.unlinkSync(out); fs.rmdirSync(tmpDir); } catch {}
});
child.on('error', (e: any) => {
new Notice(`FFmpeg test error: ${e?.message ?? e}`);
try { fs.rmdirSync(tmpDir); } catch {}
});
}
}

View file

@ -0,0 +1,78 @@
export interface SegmentDescriptor {
index: number;
path: string;
}
export interface SegmentQueueStats {
nextExpectedIndex: number;
queuedIndexes: number[];
inFlightIndex: number | null;
}
type CommitHandler = (segment: SegmentDescriptor) => Promise<void>;
export class OrderedSegmentQueue {
private readonly pending = new Map<number, SegmentDescriptor>();
private processing = false;
private inFlightIndex: number | null = null;
private readonly idleResolvers = new Set<() => void>();
private failure: Error | null = null;
constructor(private readonly commit: CommitHandler, private nextExpectedIndex = 0) {}
enqueue(segments: SegmentDescriptor[]) {
for (const segment of segments) {
if (segment.index < this.nextExpectedIndex) continue;
if (segment.index === this.inFlightIndex) continue;
if (this.pending.has(segment.index)) continue;
this.pending.set(segment.index, segment);
}
void this.process();
}
getStats(): SegmentQueueStats {
return {
nextExpectedIndex: this.nextExpectedIndex,
queuedIndexes: [...this.pending.keys()].sort((left, right) => left - right),
inFlightIndex: this.inFlightIndex,
};
}
async whenIdle(): Promise<void> {
if (!this.processing && this.pending.size === 0) {
if (this.failure) throw this.failure;
return;
}
await new Promise<void>((resolve) => {
this.idleResolvers.add(resolve);
});
if (this.failure) throw this.failure;
}
private async process() {
if (this.processing) return;
this.processing = true;
try {
while (this.pending.has(this.nextExpectedIndex)) {
const segment = this.pending.get(this.nextExpectedIndex);
if (!segment) break;
this.pending.delete(this.nextExpectedIndex);
this.inFlightIndex = segment.index;
await this.commit(segment);
this.inFlightIndex = null;
this.nextExpectedIndex += 1;
}
} catch (error) {
this.failure = error instanceof Error ? error : new Error(String(error));
} finally {
this.processing = false;
this.inFlightIndex = null;
if (this.pending.size === 0 || this.failure) {
for (const resolve of this.idleResolvers) resolve();
this.idleResolvers.clear();
}
}
}
}

View file

@ -0,0 +1,521 @@
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 { PluginSettings } from "../domain/settings";
import { OrderedSegmentQueue, type SegmentDescriptor } from "./OrderedSegmentQueue";
import { deriveSessionListItem } from "./dashboard";
import { WebCaptureAdapter } from "../infrastructure/adapters/WebCaptureAdapter";
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";
interface SessionControllerOptions {
app: App;
pluginId: string;
getSettings: () => PluginSettings;
saveSettings: (updater: (current: PluginSettings) => PluginSettings) => Promise<void>;
}
const STARTABLE_STATES = new Set<SessionState>(["idle", "done", "failed"]);
interface ActiveCaptureRuntime {
stop(): Promise<void>;
isRunning(): boolean;
}
export class SessionController {
private readonly store: SessionStore;
private readonly vaultAdapter: VaultAdapter;
private readonly diagnosticsService: DiagnosticsService;
private readonly summaryAdapter = new SummaryAdapter();
private captureAdapter: ActiveCaptureRuntime | null = null;
private queue: OrderedSegmentQueue | null = null;
private manifest: RecordingSessionManifest | 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.getSessionStorageBreakdown(manifest)));
}
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);
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.getSessionStorageBreakdown(manifest));
}
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.getSessionStorageBreakdown(manifest));
}
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);
const transcriptionAdapter = new WhisperTranscriptionAdapter(settings.transcription);
this.queue = new OrderedSegmentQueue(async (segment) => {
await this.commitSegment(segment, transcriptionAdapter);
}, Math.max(0, this.manifest.live.lastCommittedSegment + 1));
const adapter = new WebCaptureAdapter();
this.captureAdapter = adapter;
await adapter.start({
fullAudioPath: this.manifest.paths.fullAudioPath,
segmentsDir: this.manifest.paths.segmentsDir,
segmentDurationSeconds: settings.capture.segmentDurationSeconds,
microphoneDevice: settings.capture.microphone.deviceId,
additionalSources: settings.capture.additionalSources,
onSegmentReady: (segment) => {
this.queue?.enqueue([segment]);
this.refreshQueueSnapshot();
},
onLog: (line) => {
if (this.manifest) void this.store.appendDiagnostics(this.manifest, line);
},
onError: (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.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);
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.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 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.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.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 {
// Capture shutdown is best effort when the session is already failing.
}
}
private resetSnapshot() {
this.snapshot = {
state: "idle",
elapsedSeconds: 0,
committedSegments: 0,
queuedSegments: 0,
liveTranscriptChars: 0,
diagnosticsReport: this.snapshot.diagnosticsReport,
};
this.onSnapshot?.(this.snapshot);
}
}

View file

@ -0,0 +1,158 @@
import type { DashboardSnapshot, DiagnosticsGroups } from "../domain/dashboard";
import type { DiagnosticsReport } from "../domain/diagnostics";
import type {
RecordingSessionManifest,
SessionHealthBadge,
SessionLibraryStats,
SessionListItem,
SessionRuntimeSnapshot,
SessionArtifactSizeBreakdown,
} 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,
storageBreakdown: SessionArtifactSizeBreakdown
): SessionListItem {
return {
sessionId: manifest.sessionId,
scenarioKey: manifest.scenarioKey,
scenarioLabel: manifest.scenarioLabel,
captureMode: manifest.captureMode,
sourceLabel: describeSessionSource(manifest),
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: storageBreakdown.audioBytes,
storageBytes: storageBreakdown.totalBytes,
storageBreakdown,
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 summarizeSessionListItems(items: SessionListItem[]): SessionLibraryStats {
return items.reduce<SessionLibraryStats>(
(summary, item) => {
summary.sessionCount += 1;
summary.audioBytes += item.storageBreakdown.audioBytes;
summary.transcriptBytes += item.storageBreakdown.transcriptBytes;
summary.summaryBytes += item.storageBreakdown.summaryBytes;
summary.diagnosticsBytes += item.storageBreakdown.diagnosticsBytes;
summary.totalBytes += item.storageBreakdown.totalBytes;
return summary;
},
{
sessionCount: 0,
audioBytes: 0,
transcriptBytes: 0,
summaryBytes: 0,
diagnosticsBytes: 0,
totalBytes: 0,
}
);
}
function describeSessionSource(manifest: RecordingSessionManifest): string {
const additionalCount = manifest.captureSources.additionalSources.length;
if (additionalCount <= 0) return "Microphone";
if (additionalCount === 1) return "Microphone + 1 extra source";
return `Microphone + ${additionalCount} extra sources`;
}
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),
};
}

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

@ -0,0 +1,33 @@
import type { DiagnosticsReport, DiagnosticCheck } from "./diagnostics";
import type { SessionListItem, SessionRuntimeSnapshot } from "./session";
export interface DiagnosticsGroups {
blocking: DiagnosticCheck[];
warnings: DiagnosticCheck[];
healthy: DiagnosticCheck[];
}
export interface DashboardPrimaryAction {
intent: "start" | "stop" | "blocked" | "busy";
label: string;
disabled: boolean;
reason?: string;
}
export interface DashboardHealthState {
badge: "healthy" | "warning" | "failed";
summary: string;
blockingCount: number;
warningCount: number;
groups: DiagnosticsGroups;
report?: DiagnosticsReport;
}
export interface DashboardSnapshot {
generatedAt: string;
runtime: SessionRuntimeSnapshot;
primaryAction: DashboardPrimaryAction;
health: DashboardHealthState;
recentSessions: SessionListItem[];
canOpenDiagnostics: boolean;
}

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

@ -0,0 +1,22 @@
import type { SummaryProviderId } from "./providers";
export type DiagnosticSeverity = "ok" | "warning" | "error";
export interface DiagnosticCheck {
id: string;
label: string;
severity: DiagnosticSeverity;
detail: string;
remediation?: string;
}
export interface DiagnosticsReport {
checkedAt: string;
provider: SummaryProviderId;
capture: "web-audio";
checks: DiagnosticCheck[];
blockingIssueIds: string[];
warningIds: string[];
isHealthy: boolean;
summary: string;
}

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

@ -0,0 +1,79 @@
export type SummaryProviderId = "ollama" | "gemini" | "openai" | "anthropic";
export interface ProviderCapabilities {
id: SummaryProviderId;
label: string;
tier: "tier1" | "experimental";
kind: "local" | "cloud";
requiresApiKey: boolean;
requiresEndpoint: boolean;
defaultModel: string;
description: string;
}
export const PROVIDER_CAPABILITIES: Record<SummaryProviderId, ProviderCapabilities> = {
ollama: {
id: "ollama",
label: "Ollama",
tier: "tier1",
kind: "local",
requiresApiKey: false,
requiresEndpoint: true,
defaultModel: "gemma3",
description: "Default local-first summary provider for Resonance.",
},
gemini: {
id: "gemini",
label: "Gemini",
tier: "experimental",
kind: "cloud",
requiresApiKey: true,
requiresEndpoint: false,
defaultModel: "gemini-2.5-flash",
description: "Optional cloud provider.",
},
openai: {
id: "openai",
label: "OpenAI",
tier: "experimental",
kind: "cloud",
requiresApiKey: true,
requiresEndpoint: false,
defaultModel: "gpt-4o-mini",
description: "Optional cloud provider.",
},
anthropic: {
id: "anthropic",
label: "Anthropic",
tier: "experimental",
kind: "cloud",
requiresApiKey: true,
requiresEndpoint: false,
defaultModel: "claude-3-5-sonnet-latest",
description: "Optional cloud provider.",
},
};
export function getProviderCapabilities(provider: SummaryProviderId | undefined): ProviderCapabilities {
return PROVIDER_CAPABILITIES[provider ?? "ollama"];
}
export function getSelectedSummaryModel(summary: {
provider: SummaryProviderId;
ollamaModel: string;
geminiModel: string;
openaiModel: string;
anthropicModel: string;
}): string {
switch (summary.provider) {
case "gemini":
return summary.geminiModel || PROVIDER_CAPABILITIES.gemini.defaultModel;
case "openai":
return summary.openaiModel || PROVIDER_CAPABILITIES.openai.defaultModel;
case "anthropic":
return summary.anthropicModel || PROVIDER_CAPABILITIES.anthropic.defaultModel;
case "ollama":
default:
return summary.ollamaModel || PROVIDER_CAPABILITIES.ollama.defaultModel;
}
}

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

@ -0,0 +1,109 @@
export interface ScenarioTemplate {
key: string;
label: string;
description: string;
notePrefix: string;
prompt: string;
}
export const DEFAULT_SCENARIO_KEY = "work_meeting";
export const SCENARIOS: ScenarioTemplate[] = [
{
key: "work_meeting",
label: "Meeting",
description: "Decision log, topics, action items, owners.",
notePrefix: "Meeting",
prompt: `You are an expert meeting note-taker writing for a human reader.
Write plain Markdown in the SAME language as the transcript.
Rules:
- Return raw Markdown only. No code fences. No preamble. No "markdown" label.
- Do not add a document title. Start directly with the first section.
- Use natural section titles in the output language. Never mix two languages in one heading.
- Use only transcript facts. If something is uncertain, leave it out.
- Omit empty sections.
- Ignore obvious ASR noise, repeated filler, and bracketed cues when they add no value.
Structure:
- Opening summary: 2 to 4 concise sentences with context, goal, and outcome.
- Main topics: 2 to 5 short sections named after the real discussion topics.
- Decisions: bullet list only if explicit decisions were made.
- Action items: checklist only if real next steps were mentioned. Include owner or due date only if explicit.`,
},
{
key: "brainstorming",
label: "Brainstorming",
description: "Idea clusters, assumptions, next experiments.",
notePrefix: "Brainstorming",
prompt: `You are a concise facilitator turning a brainstorming transcript into usable notes.
Write plain Markdown in the SAME language as the transcript.
Rules:
- Return raw Markdown only. No code fences. No preamble. No document title.
- Use natural section titles in the output language. Never mix two languages in one heading.
- Use only transcript facts. If something is weak or unclear, leave it out.
- Prefer tight bullets to long paragraphs.
- Omit empty sections.
- Ignore obvious ASR noise, repeated filler, and bracketed cues when they add no value.
Structure:
- Opening summary: 2 to 4 sentences with the opportunity, direction, or key idea.
- Idea clusters: group related ideas, tradeoffs, risks, and assumptions.
- Open questions: include only if the transcript contains them.
- Next experiments: checklist only if concrete validations or follow-ups were mentioned.`,
},
{
key: "lecture",
label: "Lecture",
description: "Study notes, definitions, examples, follow-ups.",
notePrefix: "Lecture",
prompt: `You are an expert study note-taker turning a lecture transcript into clear notes.
Write plain Markdown in the SAME language as the transcript.
Rules:
- Return raw Markdown only. No code fences. No preamble. No document title.
- Use natural section titles in the output language. Never mix two languages in one heading.
- Be didactic but concise.
- Use only transcript facts. Do not add external knowledge.
- Omit empty sections.
- Ignore obvious ASR noise, repeated filler, and bracketed cues when they add no value.
Structure:
- Opening summary: 2 to 4 sentences with the core lesson and main takeaways.
- Topic sections: organize concepts, definitions, examples, and formulas only if they were actually mentioned.
- Key takeaways: bullet list if the transcript naturally supports it.
- Follow-ups: exercises, readings, or next study steps only if mentioned.`,
},
{
key: "interview",
label: "Interview",
description: "Profile, themes, follow-ups, quotable moments.",
notePrefix: "Interview",
prompt: `You are an interview note-taker writing clean notes for a human reader.
Write plain Markdown in the SAME language as the transcript.
Rules:
- Return raw Markdown only. No code fences. No preamble. No document title.
- Use natural section titles in the output language. Never mix two languages in one heading.
- Stay objective and avoid exaggerated tone.
- Use only transcript facts. If something is uncertain, leave it out.
- Omit empty sections.
- Use short quotes only when they materially help.
- Ignore obvious ASR noise, repeated filler, and bracketed cues when they add no value.
Structure:
- Opening summary: 2 to 4 sentences with who the subject is, what was discussed, and the main takeaways.
- Theme sections: organize the interview into short thematic sections.
- Notable quotes: include only if brief and genuinely useful.
- Follow-ups: checklist only if actual next steps were mentioned.`,
},
];
export function getScenario(key: string | undefined): ScenarioTemplate {
return SCENARIOS.find((scenario) => scenario.key === key) ?? SCENARIOS[0];
}

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

@ -0,0 +1,166 @@
import type { DiagnosticsReport } from "./diagnostics";
import type { SummaryProviderId } from "./providers";
import type { CaptureSourceSelection } from "./settings";
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 type RecordingCaptureMode = "microphone" | "multiple-input";
export type SessionCleanupAction = "audio" | "transcript" | "session";
export const SUPPORTED_SESSION_SCHEMA_VERSION = 4 as const;
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: typeof SUPPORTED_SESSION_SCHEMA_VERSION;
sessionId: string;
createdAt: string;
updatedAt: string;
scenarioKey: string;
scenarioLabel: string;
captureMode: RecordingCaptureMode;
captureSources: {
microphone: CaptureSourceSelection;
additionalSources: CaptureSourceSelection[];
};
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 SessionArtifactSizeBreakdown {
audioBytes: number;
transcriptBytes: number;
summaryBytes: number;
diagnosticsBytes: number;
totalBytes: number;
}
export interface SessionLibraryStats extends SessionArtifactSizeBreakdown {
sessionCount: number;
}
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;
captureMode: RecordingCaptureMode;
sourceLabel: string;
createdAt: string;
updatedAt: string;
lastActivityAt: string;
finishedAt?: string;
status: SessionState;
elapsedSeconds: number;
committedSegments: number;
audioSizeBytes: number;
storageBytes: number;
storageBreakdown: SessionArtifactSizeBreakdown;
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,
};
}
export function isSupportedSessionManifest(manifest: unknown): manifest is RecordingSessionManifest {
return Boolean(
manifest &&
typeof manifest === "object" &&
(manifest as { schemaVersion?: unknown }).schemaVersion === SUPPORTED_SESSION_SCHEMA_VERSION
);
}

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

@ -0,0 +1,289 @@
import { getSelectedSummaryModel, type SummaryProviderId } from "./providers";
export interface CaptureSourceSelection {
deviceId: string;
label: string;
}
export interface CaptureSettings {
microphone: CaptureSourceSelection;
additionalSources: CaptureSourceSelection[];
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 PluginSettings {
version: 3;
capture: CaptureSettings;
transcription: TranscriptionSettings;
summary: SummarySettings;
output: OutputSettings;
ui: UiSettings;
diagnostics: DiagnosticsSettings;
}
export const DEFAULT_SETTINGS: PluginSettings = {
version: 3,
capture: {
microphone: {
deviceId: "",
label: "",
},
additionalSources: [],
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 normalizeCaptureSource(raw: unknown, allowDefault = true): CaptureSourceSelection {
const input = (raw ?? {}) as Partial<CaptureSourceSelection>;
const rawDeviceId = asString(input.deviceId).trim();
const deviceId = allowDefault && rawDeviceId === "default" ? "" : rawDeviceId;
return {
deviceId,
label: asString(input.label).trim(),
};
}
function normalizeAdditionalSources(raw: unknown, microphoneDeviceId: string): CaptureSourceSelection[] {
const rawSources = Array.isArray(raw) ? raw : [];
const seen = new Set<string>();
const normalized: CaptureSourceSelection[] = [];
for (const entry of rawSources) {
const source = normalizeCaptureSource(entry, false);
if (!source.deviceId || source.deviceId === microphoneDeviceId || seen.has(source.deviceId)) continue;
seen.add(source.deviceId);
normalized.push(source);
}
return normalized;
}
function normalizeCaptureSettings(raw: unknown): CaptureSettings {
const input = (raw ?? {}) as Record<string, unknown>;
const microphone = normalizeCaptureSource(input.microphone, true);
const additionalSources = normalizeAdditionalSources(input.additionalSources, microphone.deviceId);
return {
microphone,
additionalSources,
segmentDurationSeconds: clampInteger(
input.segmentDurationSeconds,
DEFAULT_SETTINGS.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.transcription.modelPreset,
language: asString(input.language, DEFAULT_SETTINGS.transcription.language),
beamSize: clampInteger(input.beamSize, DEFAULT_SETTINGS.transcription.beamSize, 1, 10),
entropyThreshold: Number.isFinite(Number(input.entropyThreshold))
? Number(input.entropyThreshold)
: DEFAULT_SETTINGS.transcription.entropyThreshold,
logprobThreshold: Number.isFinite(Number(input.logprobThreshold))
? Number(input.logprobThreshold)
: DEFAULT_SETTINGS.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.summary.provider,
ollamaEndpoint: asString(input.ollamaEndpoint, DEFAULT_SETTINGS.summary.ollamaEndpoint),
ollamaModel: asString(input.ollamaModel, DEFAULT_SETTINGS.summary.ollamaModel),
geminiApiKey: asString(input.geminiApiKey),
geminiModel: asString(input.geminiModel, DEFAULT_SETTINGS.summary.geminiModel),
openaiApiKey: asString(input.openaiApiKey),
openaiModel: asString(input.openaiModel, DEFAULT_SETTINGS.summary.openaiModel),
anthropicApiKey: asString(input.anthropicApiKey),
anthropicModel: asString(input.anthropicModel, DEFAULT_SETTINGS.summary.anthropicModel),
};
}
function normalizeOutputSettings(raw: unknown): OutputSettings {
const input = (raw ?? {}) as Partial<OutputSettings>;
return {
vaultFolder: asString(input.vaultFolder, DEFAULT_SETTINGS.output.vaultFolder),
storeLiveTranscriptInVault: asBoolean(
input.storeLiveTranscriptInVault,
DEFAULT_SETTINGS.output.storeLiveTranscriptInVault
),
openSummaryAfterCreate: asBoolean(input.openSummaryAfterCreate, DEFAULT_SETTINGS.output.openSummaryAfterCreate),
maxSessionsKept: clampInteger(input.maxSessionsKept, DEFAULT_SETTINGS.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.ui.showSetupWizardOnStartup),
showDiagnosticsOnStartup: asBoolean(input.showDiagnosticsOnStartup, DEFAULT_SETTINGS.ui.showDiagnosticsOnStartup),
};
}
function normalizeDiagnosticsSettings(raw: unknown): DiagnosticsSettings {
const input = (raw ?? {}) as Partial<DiagnosticsSettings>;
return {
quickTestDurationSeconds: clampInteger(
input.quickTestDurationSeconds,
DEFAULT_SETTINGS.diagnostics.quickTestDurationSeconds,
1,
10
),
};
}
export function normalizeSettings(raw: unknown): PluginSettings {
const input = (raw ?? {}) as Partial<PluginSettings>;
return {
version: 3,
capture: normalizeCaptureSettings(input.capture),
transcription: normalizeTranscriptionSettings(input.transcription),
summary: normalizeSummarySettings(input.summary),
output: normalizeOutputSettings(input.output),
ui: normalizeUiSettings(input.ui),
diagnostics: normalizeDiagnosticsSettings(input.diagnostics),
};
}
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: PluginSettings): boolean {
const selectedModel = getSelectedSummaryModel(settings.summary);
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();
}
export function hasAdditionalCaptureSources(capture: CaptureSettings): boolean {
return capture.additionalSources.length > 0;
}

View file

@ -0,0 +1,222 @@
import { requestUrl, type RequestUrlParam } from "obsidian";
import { getProviderCapabilities, getSelectedSummaryModel, type SummaryProviderId } from "../../domain/providers";
import type { SummarySettings } from "../../domain/settings";
export interface SummaryResult {
provider: SummaryProviderId;
model: string;
markdown: string;
}
interface OllamaGenerateResponse {
response?: string;
}
interface GeminiResponse {
candidates?: Array<{
content?: {
parts?: Array<{ text?: string }>;
};
}>;
}
interface OpenAIResponse {
choices?: Array<{
message?: {
content?: string;
};
}>;
}
interface AnthropicResponse {
content?: Array<{ text?: 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");
}
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 json = await requestProviderJson<OllamaGenerateResponse>("Ollama", {
url: `${base}/api/generate`,
method: "POST",
contentType: "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 },
}),
});
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 json = await requestProviderJson<GeminiResponse>("Gemini", {
url: `${url}?key=${encodeURIComponent(settings.geminiApiKey)}`,
method: "POST",
contentType: "application/json",
body: JSON.stringify(body),
});
return String(json.candidates?.[0]?.content?.parts?.map((part) => 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 json = await requestProviderJson<OpenAIResponse>("OpenAI", {
url: "https://api.openai.com/v1/chat/completions",
method: "POST",
headers: {
Authorization: `Bearer ${settings.openaiApiKey}`,
},
contentType: "application/json",
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}` },
],
}),
});
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 json = await requestProviderJson<AnthropicResponse>("Anthropic", {
url: "https://api.anthropic.com/v1/messages",
method: "POST",
headers: {
"x-api-key": settings.anthropicApiKey,
"anthropic-version": "2023-06-01",
},
contentType: "application/json",
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}` }],
}),
});
return String(json.content?.map((part) => part.text ?? "").join("\n") ?? "").trim();
}
}
async function requestProviderJson<T>(providerLabel: string, request: RequestUrlParam): Promise<T> {
const response = await requestUrl({
...request,
throw: false,
});
if (response.status >= 400) {
throw new Error(`${providerLabel} API error: ${response.status} ${response.text}`);
}
return response.json as T;
}

View file

@ -0,0 +1,175 @@
import type { TranscriptionSettings } from "../../domain/settings";
import { requireNodeModule } from "../node";
interface ProcessOutputStream {
on(event: "data", listener: (chunk: Buffer) => void): void;
}
interface ChildProcessHandle {
stdout?: ProcessOutputStream;
stderr?: ProcessOutputStream;
on(event: "error", listener: (error: Error) => void): void;
on(event: "close", listener: (code: number | null) => void): void;
}
interface ChildProcessModule {
spawn(command: string, args: string[], options: { cwd: string }): ChildProcessHandle;
}
export class WhisperTranscriptionAdapter {
constructor(private readonly settings: TranscriptionSettings) {}
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 = audioPath;
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 {
// Temporary audio cleanup is best effort.
}
}
try {
fs.unlinkSync(outputTextPath);
} catch {
// whisper.cpp may not have created an output file.
}
}
}
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 {
// Remove stale output when possible before invoking whisper.cpp.
}
}
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"
);
let rawTranscript = "";
if (fs.existsSync(outputTextPath)) {
rawTranscript = fs.readFileSync(outputTextPath, { encoding: "utf8" }).trim();
} else {
rawTranscript = result.stdout.trim();
}
return this.cleanTranscript(rawTranscript);
}
private cleanTranscript(text: string): string {
let cleaned = text
// Rimuove tag di speaker o suoni ambientali [Suono], [Speaker 1], [PROFESSORI]
.replace(/\[.*?\]/g, "")
// Rimuove caratteri ripetuti in modo anomalo
.replace(/[-_]{2,}/g, " ")
.replace(/\.{4,}/g, "...")
// Rimuove alcune allucinazioni note (in italiano e inglese)
.replace(/(Sottotitoli|Subtitles) (creati|a cura|di).*$/gim, "")
.replace(/Traduzione di.*$/gim, "")
.replace(/Iscriviti.*$/gim, "")
.replace(/Amara\.org/gim, "")
// Normalizza gli spazi
.replace(/\s+/g, " ")
.trim();
// Se la stringa pulita contiene solo punteggiatura o spazi, la consideriamo vuota
if (/^[.,!?\-;: ]*$/.test(cleaned)) {
return "";
}
return cleaned;
}
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 childProcess = requireNodeModule<ChildProcessModule>("child_process");
let stderr = "";
let stdout = "";
await new Promise<void>((resolve, reject) => {
const child = childProcess.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 | null) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`${label} exited with code ${code}: ${stderr}`));
}
});
});
return { stdout, stderr };
}
}

View file

@ -0,0 +1,99 @@
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.fileManager.trashFile(file);
}
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 {
// Another plugin or sync process may have created the folder first.
}
}
}
private async getOrCreateFile(path: string, initialContents: string): Promise<TFile> {
const existing = this.app.vault.getAbstractFileByPath(path);
if (existing instanceof TFile) return existing;
return await this.app.vault.create(path, initialContents);
}
}

View file

@ -0,0 +1,362 @@
import {
resolvePreferredWebAudioInput,
resolveWebAudioInputById,
type WebAudioInputDevice,
} from "../system/webAudio";
import { requireNodeModule } from "../node";
import { StreamingWavWriter, writeWavFile } from "./wavWriter";
export interface WebCaptureSegmentDescriptor {
index: number;
path: string;
}
export interface WebCaptureStartOptions {
fullAudioPath: string;
segmentsDir: string;
segmentDurationSeconds: number;
microphoneDevice?: string;
additionalSources?: WebCaptureInputSource[];
onSegmentReady: (segment: WebCaptureSegmentDescriptor) => void;
onLog?: (line: string) => void;
onError?: (message: string) => void;
}
export interface WebCaptureInputSource {
deviceId: string;
label?: string;
gain?: number;
}
interface WebAudioContextConstructor {
new (): AudioContext;
}
const AUDIO_WORKLET_PROCESSOR_NAME = "resonance-mix-processor";
const AUDIO_WORKLET_SOURCE = `
class ResonanceMixProcessor extends AudioWorkletProcessor {
process(inputs, outputs) {
const channels = inputs[0] || [];
const length = channels[0]?.length || 0;
if (length > 0) {
const mono = new Float32Array(length);
for (let sampleIndex = 0; sampleIndex < length; sampleIndex += 1) {
let sum = 0;
for (let channelIndex = 0; channelIndex < channels.length; channelIndex += 1) {
sum += channels[channelIndex]?.[sampleIndex] || 0;
}
mono[sampleIndex] = channels.length > 0 ? sum / channels.length : 0;
}
this.port.postMessage(mono, [mono.buffer]);
}
const output = outputs[0] || [];
for (const channel of output) {
channel.fill(0);
}
return true;
}
}
registerProcessor("${AUDIO_WORKLET_PROCESSOR_NAME}", ResonanceMixProcessor);
`;
export class WebCaptureAdapter {
private context: AudioContext | null = null;
private streams: MediaStream[] = [];
private sourceNodes: MediaStreamAudioSourceNode[] = [];
private gainNodes: GainNode[] = [];
private workletNode: AudioWorkletNode | null = null;
private workletModuleUrl: string | null = null;
private sinkNode: GainNode | null = null;
private mixBus: GainNode | null = null;
private segmentBuffers: Float32Array[] = [];
private segmentSampleCount = 0;
private segmentTargetSamples = 0;
private segmentIndex = 0;
private running = false;
private sampleRate = 48_000;
private fullRecordingWriter: StreamingWavWriter | null = null;
private options: WebCaptureStartOptions | null = null;
private readonly path = requireNodeModule<{ join: (...parts: string[]) => string }>("path");
async start(options: WebCaptureStartOptions): Promise<void> {
if (this.running) {
throw new Error("Web audio capture already running.");
}
const AudioContextCtor = getAudioContextConstructor();
const browserNavigator = getBrowserNavigator();
if (!AudioContextCtor || !browserNavigator?.mediaDevices?.getUserMedia) {
throw new Error("Web Audio capture is not available in this runtime.");
}
this.options = options;
this.segmentBuffers = [];
this.segmentSampleCount = 0;
this.segmentIndex = 0;
const preferredDevice = await resolvePreferredWebAudioInput(options.microphoneDevice);
const requestedDeviceId = preferredDevice?.deviceId && preferredDevice.deviceId !== "default" ? preferredDevice.deviceId : undefined;
if (options.microphoneDevice?.trim() && requestedDeviceId !== options.microphoneDevice.trim()) {
options.onLog?.(`Web Audio fallback: microphone ${options.microphoneDevice.trim()} is unavailable. Using the system default microphone.`);
}
const additionalSources = options.additionalSources ?? [];
const resolvedAdditionalSources: Array<{
requested: WebCaptureInputSource;
resolved: WebAudioInputDevice;
}> = [];
const seenAdditionalIds = new Set<string>();
for (const source of additionalSources) {
if (!source.deviceId.trim()) continue;
if (requestedDeviceId && source.deviceId === requestedDeviceId) {
options.onLog?.(
source.label?.trim()
? `Web Audio skip: additional source "${source.label}" matches the selected microphone.`
: "Web Audio skip: additional source matches the selected microphone."
);
continue;
}
if (seenAdditionalIds.has(source.deviceId)) {
options.onLog?.(
source.label?.trim()
? `Web Audio skip: additional source "${source.label}" is duplicated.`
: "Web Audio skip: duplicated additional source."
);
continue;
}
const resolved = await resolveWebAudioInputById(source.deviceId);
if (!resolved) {
options.onLog?.(
source.label?.trim()
? `Web Audio skip: additional source "${source.label}" is unavailable.`
: "Web Audio skip: selected additional source is unavailable."
);
continue;
}
seenAdditionalIds.add(source.deviceId);
resolvedAdditionalSources.push({
requested: source,
resolved,
});
}
try {
this.streams = [];
this.streams.push(await browserNavigator.mediaDevices.getUserMedia(buildAudioConstraints(requestedDeviceId)));
for (const source of resolvedAdditionalSources) {
this.streams.push(await browserNavigator.mediaDevices.getUserMedia(buildAudioConstraints(source.resolved.deviceId)));
}
this.context = new AudioContextCtor();
await this.installAudioWorklet(this.context);
this.sampleRate = this.context.sampleRate || 48_000;
this.segmentTargetSamples = Math.max(1, Math.floor(this.sampleRate * Math.max(1, options.segmentDurationSeconds)));
this.fullRecordingWriter = new StreamingWavWriter(options.fullAudioPath, this.sampleRate, 1);
this.workletNode = new AudioWorkletNode(this.context, AUDIO_WORKLET_PROCESSOR_NAME, {
numberOfInputs: 1,
numberOfOutputs: 1,
outputChannelCount: [1],
});
this.mixBus = this.context.createGain();
this.mixBus.gain.value = 1;
this.sinkNode = this.context.createGain();
this.sinkNode.gain.value = 0;
this.workletNode.port.onmessage = (event: MessageEvent<Float32Array>) => {
try {
this.handleAudioChunk(event.data);
} catch (error) {
const message = String((error as Error)?.message ?? error);
this.options?.onError?.(message);
}
};
const sourceLabels: string[] = [];
const sourceConfigs = [
{
stream: this.streams[0],
label: preferredDevice?.label || "System default input",
gain: 1,
},
...resolvedAdditionalSources.map((source, index) => ({
stream: this.streams[index + 1],
label: source.resolved.label || source.requested.label || "Additional source",
gain: source.requested.gain ?? 1,
})),
];
for (const config of sourceConfigs) {
const sourceNode = this.context.createMediaStreamSource(config.stream);
const gainNode = this.context.createGain();
gainNode.gain.value = config.gain;
sourceNode.connect(gainNode);
gainNode.connect(this.mixBus);
this.sourceNodes.push(sourceNode);
this.gainNodes.push(gainNode);
sourceLabels.push(config.label);
}
this.mixBus.connect(this.workletNode);
this.workletNode.connect(this.sinkNode);
this.sinkNode.connect(this.context.destination);
this.running = true;
options.onLog?.(
`Web Audio capture started: sampleRate=${this.sampleRate}Hz, mixedSources=${sourceConfigs.length}, inputs=${sourceLabels.join(", ")}.`
);
} catch (error) {
await this.dispose();
throw error;
}
}
isRunning(): boolean {
return this.running;
}
async stop(): Promise<void> {
if (!this.running) return;
this.running = false;
if (this.workletNode) {
this.workletNode.port.onmessage = null;
}
this.flushCurrentSegmentSync();
this.fullRecordingWriter?.finalize();
await this.dispose();
}
private handleAudioChunk(monoSamples: Float32Array): void {
if (!this.running || !this.options) return;
if (monoSamples.length === 0) return;
this.fullRecordingWriter?.append(monoSamples);
let offset = 0;
while (offset < monoSamples.length) {
const remainingInSegment = this.segmentTargetSamples - this.segmentSampleCount;
const take = Math.min(remainingInSegment, monoSamples.length - offset);
const chunk = monoSamples.slice(offset, offset + take);
this.segmentBuffers.push(chunk);
this.segmentSampleCount += take;
offset += take;
if (this.segmentSampleCount >= this.segmentTargetSamples) {
this.flushCurrentSegmentSync();
}
}
}
private flushCurrentSegmentSync(): void {
if (!this.options || this.segmentSampleCount === 0) return;
const samples = concatFloat32Arrays(this.segmentBuffers, this.segmentSampleCount);
const segmentPath = this.path.join(this.options.segmentsDir, `segment-${String(this.segmentIndex).padStart(4, "0")}.wav`);
writeWavFile(segmentPath, samples, this.sampleRate, 1);
this.options.onSegmentReady({ index: this.segmentIndex, path: segmentPath });
this.segmentIndex += 1;
this.segmentBuffers = [];
this.segmentSampleCount = 0;
}
private async installAudioWorklet(context: AudioContext): Promise<void> {
if (!context.audioWorklet?.addModule) {
throw new Error("AudioWorklet is not available in this runtime.");
}
const blob = new Blob([AUDIO_WORKLET_SOURCE], { type: "application/javascript" });
this.workletModuleUrl = window.URL.createObjectURL(blob);
await context.audioWorklet.addModule(this.workletModuleUrl);
}
private async dispose(): Promise<void> {
try {
this.sourceNodes.forEach((node) => node.disconnect());
} catch {
// Source nodes may already be disconnected during browser teardown.
}
try {
this.gainNodes.forEach((node) => node.disconnect());
} catch {
// Gain nodes may already be disconnected during browser teardown.
}
try {
this.mixBus?.disconnect();
} catch {
// The mix bus may already be disconnected after a failed start.
}
try {
this.workletNode?.disconnect();
this.workletNode?.port.close();
} catch {
// Worklet teardown can race with AudioContext closure.
}
try {
this.sinkNode?.disconnect();
} catch {
// The silent sink may already be disconnected after a failed start.
}
try {
this.streams.forEach((stream) => stream.getTracks().forEach((track) => track.stop()));
} catch {
// Tracks may already be stopped by the OS or browser.
}
try {
if (this.context && this.context.state !== "closed") {
await this.context.close();
}
} catch {
// AudioContext closure is best effort during plugin shutdown.
}
if (this.workletModuleUrl) {
window.URL.revokeObjectURL(this.workletModuleUrl);
}
this.context = null;
this.streams = [];
this.sourceNodes = [];
this.gainNodes = [];
this.workletNode = null;
this.workletModuleUrl = null;
this.sinkNode = null;
this.mixBus = null;
this.fullRecordingWriter = null;
this.options = null;
this.running = false;
}
}
function getAudioContextConstructor(): WebAudioContextConstructor | null {
if (typeof window === "undefined") return null;
const candidate = window.AudioContext ?? ((window as unknown as { webkitAudioContext?: WebAudioContextConstructor }).webkitAudioContext ?? null);
return candidate ?? null;
}
function getBrowserNavigator(): Navigator | undefined {
return typeof window === "undefined" ? undefined : window.navigator;
}
function concatFloat32Arrays(chunks: Float32Array[], totalLength: number): Float32Array {
const output = new Float32Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
output.set(chunk, offset);
offset += chunk.length;
}
return output;
}
function buildAudioConstraints(deviceId?: string): MediaStreamConstraints {
if (deviceId?.trim()) {
return {
audio: {
deviceId: { exact: deviceId.trim() },
},
video: false,
};
}
return {
audio: true,
video: false,
};
}

View file

@ -0,0 +1,86 @@
import { requireNodeModule } from "../node";
interface FsModule {
closeSync: (fd: number) => void;
openSync: (path: string, flags: string) => number;
writeFileSync: (path: string, data: Buffer) => void;
writeSync: (fd: number, buffer: Buffer, offset?: number, length?: number, position?: number) => number;
}
const WAV_HEADER_BYTES = 44;
const PCM_SAMPLE_BYTES = 2;
export function createWavHeader(dataSize: number, sampleRate: number, channels: number): Buffer {
const blockAlign = channels * PCM_SAMPLE_BYTES;
const byteRate = sampleRate * blockAlign;
const header = Buffer.alloc(WAV_HEADER_BYTES);
header.write("RIFF", 0, "ascii");
header.writeUInt32LE(36 + dataSize, 4);
header.write("WAVE", 8, "ascii");
header.write("fmt ", 12, "ascii");
header.writeUInt32LE(16, 16);
header.writeUInt16LE(1, 20);
header.writeUInt16LE(channels, 22);
header.writeUInt32LE(sampleRate, 24);
header.writeUInt32LE(byteRate, 28);
header.writeUInt16LE(blockAlign, 32);
header.writeUInt16LE(16, 34);
header.write("data", 36, "ascii");
header.writeUInt32LE(dataSize, 40);
return header;
}
export function writeWavFile(filePath: string, samples: Float32Array, sampleRate: number, channels: number): void {
const fs = requireNodeModule<FsModule>("fs");
const pcm = encodePcm16(samples);
const header = createWavHeader(pcm.length, sampleRate, channels);
fs.writeFileSync(filePath, Buffer.concat([header, pcm]));
}
export class StreamingWavWriter {
private readonly fs = requireNodeModule<FsModule>("fs");
private readonly fd: number;
private dataSize = 0;
private finalized = false;
constructor(
private readonly filePath: string,
private readonly sampleRate: number,
private readonly channels: number
) {
this.fd = this.fs.openSync(filePath, "w");
this.fs.writeSync(this.fd, createWavHeader(0, sampleRate, channels));
}
append(samples: Float32Array): void {
if (this.finalized || samples.length === 0) return;
const pcm = encodePcm16(samples);
if (pcm.length === 0) return;
this.fs.writeSync(this.fd, pcm);
this.dataSize += pcm.length;
}
finalize(): void {
if (this.finalized) return;
this.finalized = true;
const header = createWavHeader(this.dataSize, this.sampleRate, this.channels);
this.fs.writeSync(this.fd, header, 0, WAV_HEADER_BYTES, 0);
this.fs.closeSync(this.fd);
}
getPath(): string {
return this.filePath;
}
}
function encodePcm16(samples: Float32Array): Buffer {
const buffer = Buffer.alloc(samples.length * PCM_SAMPLE_BYTES);
for (let index = 0; index < samples.length; index += 1) {
const sample = Math.max(-1, Math.min(1, samples[index] ?? 0));
const value = sample < 0 ? Math.round(sample * 0x8000) : Math.round(sample * 0x7fff);
buffer.writeInt16LE(value, index * PCM_SAMPLE_BYTES);
}
return buffer;
}

View file

@ -0,0 +1,26 @@
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 {
const configDir = app.vault.configDir.trim();
if (!configDir) {
throw new Error("Unable to determine vault configuration directory.");
}
return configDir;
}

View file

@ -0,0 +1,49 @@
import type { App, Plugin } from "obsidian";
interface PluginManagerBridge {
getPlugin?: (pluginId: string) => Plugin | null | undefined;
}
interface SettingsBridge {
open?: () => void;
openTabById?: (pluginId: string) => void;
}
interface AppDesktopBridge {
plugins?: PluginManagerBridge;
setting?: SettingsBridge;
}
type ToggleableElement = HTMLElement & {
show?: () => void;
hide?: () => void;
};
export function getPluginInstance(app: App, pluginId: string): Plugin | null {
const desktop = app as unknown as App & AppDesktopBridge;
return desktop.plugins?.getPlugin?.(pluginId) ?? null;
}
export function openPluginSettings(app: App, pluginId: string): void {
const desktop = app as unknown as App & AppDesktopBridge;
desktop.setting?.open?.();
desktop.setting?.openTabById?.(pluginId);
}
export function setElementVisibility(element: HTMLElement, visible: boolean): void {
const toggleable = element as ToggleableElement;
if (visible) {
if (toggleable.show) {
toggleable.show();
return;
}
element.removeClass("rxn-hidden");
return;
}
if (toggleable.hide) {
toggleable.hide();
return;
}
element.addClass("rxn-hidden");
}

View file

@ -0,0 +1,418 @@
import type { App } from "obsidian";
import { getSelectedSummaryModel } from "../../domain/providers";
import { type PluginSettings, type CaptureSourceSelection } from "../../domain/settings";
import {
SUPPORTED_SESSION_SCHEMA_VERSION,
isSupportedSessionManifest,
type DiagnosticsSummary,
type RecordingSessionManifest,
type SessionArtifactSizeBreakdown,
type SessionCleanupAction,
type SessionLibraryStats,
} 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: PluginSettings,
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.wav");
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 captureSources = {
microphone: cloneSource(settings.capture.microphone),
additionalSources: settings.capture.additionalSources.map(cloneSource),
};
const manifest: RecordingSessionManifest = {
schemaVersion: SUPPORTED_SESSION_SCHEMA_VERSION,
sessionId,
createdAt,
updatedAt: createdAt,
scenarioKey: scenario.key,
scenarioLabel: scenario.label,
captureMode: captureSources.additionalSources.length > 0 ? "multiple-input" : "microphone",
captureSources,
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;
}
writeManifest(manifest: RecordingSessionManifest): Promise<void> {
const fs = requireNodeModule<FsModule>("fs");
const now = new Date().toISOString();
manifest.schemaVersion = SUPPORTED_SESSION_SCHEMA_VERSION;
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" });
return Promise.resolve();
}
appendDiagnostics(manifest: RecordingSessionManifest, message: string): Promise<void> {
const fs = requireNodeModule<FsModule>("fs");
fs.appendFileSync(manifest.paths.diagnosticsLogPath, `[${new Date().toISOString()}] ${message}\n`, { encoding: "utf8" });
return Promise.resolve();
}
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" });
return Promise.resolve();
}
readTranscript(manifest: RecordingSessionManifest): string {
return this.readTextFile(manifest.paths.transcriptTextPath);
}
writeSummary(manifest: RecordingSessionManifest, markdown: string): Promise<void> {
const fs = requireNodeModule<FsModule>("fs");
fs.writeFileSync(manifest.paths.summaryMarkdownPath, markdown, { encoding: "utf8" });
return Promise.resolve();
}
writeTranscript(manifest: RecordingSessionManifest, text: string): Promise<void> {
const fs = requireNodeModule<FsModule>("fs");
fs.writeFileSync(manifest.paths.transcriptTextPath, text.trim(), { encoding: "utf8" });
return Promise.resolve();
}
listSessions(): Promise<RecordingSessionManifest[]> {
const fs = requireNodeModule<FsModule>("fs");
const path = requireNodeModule<PathModule>("path");
const root = this.getSessionsRootDir();
if (!fs.existsSync(root)) return Promise.resolve([]);
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 unknown;
if (!isSupportedSessionManifest(raw)) continue;
const manifest = this.normalizeManifest(raw);
if (manifest.status !== "idle" && manifest.status !== "preflight") {
manifests.push(manifest);
}
} catch {
// Ignore incomplete or corrupt session folders in the library view.
}
}
return Promise.resolve(manifests.sort((left, right) => right.createdAt.localeCompare(left.createdAt)));
}
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 Promise.resolve(null);
const raw = JSON.parse(String(fs.readFileSync(manifestPath, { encoding: "utf8" }))) as unknown;
if (!isSupportedSessionManifest(raw)) return Promise.resolve(null);
return Promise.resolve(this.normalizeManifest(raw));
} catch {
return Promise.resolve(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 deleteSessionFilesMany(manifests: RecordingSessionManifest[]): Promise<void> {
for (const manifest of manifests) {
await this.deleteSessionFiles(manifest);
}
}
deleteSessionRootDir(rootDir: string): Promise<void> {
const fs = requireNodeModule<FsModule>("fs");
fs.rmSync(rootDir, { recursive: true, force: true });
return Promise.resolve();
}
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;
return Promise.resolve();
}
async deleteAudioArtifactsMany(manifests: RecordingSessionManifest[]): Promise<void> {
for (const manifest of manifests) {
await this.deleteAudioArtifacts(manifest);
}
}
deleteTranscriptArtifacts(manifest: RecordingSessionManifest): Promise<void> {
const fs = requireNodeModule<FsModule>("fs");
fs.writeFileSync(manifest.paths.transcriptTextPath, "", { encoding: "utf8" });
manifest.artifacts.hasTranscript = false;
return Promise.resolve();
}
async deleteTranscriptArtifactsMany(manifests: RecordingSessionManifest[]): Promise<void> {
for (const manifest of manifests) {
await this.deleteTranscriptArtifacts(manifest);
}
}
readTextFile(path: string): string {
const fs = requireNodeModule<FsModule>("fs");
if (!path || !fs.existsSync(path)) return "";
return String(fs.readFileSync(path, { encoding: "utf8" }));
}
getSessionStorageBreakdown(manifest: RecordingSessionManifest): SessionArtifactSizeBreakdown {
const fs = requireNodeModule<FsModule>("fs");
const audioBytes = this.getPathSize(fs, manifest.paths.fullAudioPath) + this.getPathSize(fs, manifest.paths.segmentsDir);
const transcriptBytes = this.getPathSize(fs, manifest.paths.transcriptTextPath);
const summaryBytes = this.getPathSize(fs, manifest.paths.summaryMarkdownPath);
const diagnosticsBytes = this.getPathSize(fs, manifest.paths.diagnosticsLogPath);
return {
audioBytes,
transcriptBytes,
summaryBytes,
diagnosticsBytes,
totalBytes: audioBytes + transcriptBytes + summaryBytes + diagnosticsBytes,
};
}
getLibraryStorageStats(manifests: RecordingSessionManifest[]): SessionLibraryStats {
return manifests.reduce<SessionLibraryStats>(
(stats, manifest) => {
const breakdown = this.getSessionStorageBreakdown(manifest);
stats.sessionCount += 1;
stats.audioBytes += breakdown.audioBytes;
stats.transcriptBytes += breakdown.transcriptBytes;
stats.summaryBytes += breakdown.summaryBytes;
stats.diagnosticsBytes += breakdown.diagnosticsBytes;
stats.totalBytes += breakdown.totalBytes;
return stats;
},
{
sessionCount: 0,
audioBytes: 0,
transcriptBytes: 0,
summaryBytes: 0,
diagnosticsBytes: 0,
totalBytes: 0,
}
);
}
getReclaimableBytes(manifest: RecordingSessionManifest, action: SessionCleanupAction): number {
const breakdown = this.getSessionStorageBreakdown(manifest);
switch (action) {
case "audio":
return breakdown.audioBytes;
case "transcript":
return breakdown.transcriptBytes;
case "session":
default:
return breakdown.totalBytes;
}
}
getReclaimableBytesForSessions(manifests: RecordingSessionManifest[], action: SessionCleanupAction): number {
return manifests.reduce((total, manifest) => total + this.getReclaimableBytes(manifest, action), 0);
}
private normalizeManifest(manifest: RecordingSessionManifest): RecordingSessionManifest {
const fallbackTimestamp = manifest.updatedAt || manifest.createdAt || new Date().toISOString();
const normalized: RecordingSessionManifest = {
...manifest,
schemaVersion: SUPPORTED_SESSION_SCHEMA_VERSION,
captureSources: {
microphone: cloneSource(manifest.captureSources?.microphone),
additionalSources: Array.isArray(manifest.captureSources?.additionalSources)
? manifest.captureSources.additionalSources.map(cloneSource)
: [],
},
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" }));
}
private getPathSize(fs: FsModule, path: string): number {
if (!path || !fs.existsSync(path)) return 0;
try {
const stat = fs.statSync(path);
if (!stat.isDirectory()) return stat.size || 0;
return fs
.readdirSync(path)
.reduce((total, entry) => total + this.getPathSize(fs, requireNodeModule<PathModule>("path").join(path, entry)), 0);
} catch {
return 0;
}
}
}
function cloneSource(source: Partial<CaptureSourceSelection> | undefined): CaptureSourceSelection {
return {
deviceId: typeof source?.deviceId === "string" ? source.deviceId : "",
label: typeof source?.label === "string" ? source.label : "",
};
}

View file

@ -0,0 +1,374 @@
import { requestUrl, type App } from "obsidian";
import { getProviderCapabilities } from "../../domain/providers";
import {
getSelectedProviderApiKey,
isCoreConfigured,
isLikelyTestWhisperModelPath,
type PluginSettings,
} from "../../domain/settings";
import type { DiagnosticCheck, DiagnosticsReport } from "../../domain/diagnostics";
import { requireNodeModule } from "../node";
import { WebCaptureAdapter } from "../adapters/WebCaptureAdapter";
import { WhisperTranscriptionAdapter } from "../adapters/TranscriptionAdapter";
import {
getMicrophonePermissionState,
getWebAudioCapability,
listWebAudioInputDevices,
} from "./webAudio";
export interface SmokeTestResult {
ok: boolean;
detail: string;
cancelled?: boolean;
}
export class DiagnosticsService {
constructor(private readonly app: App) {}
async run(settings: PluginSettings): 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 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."
: "Recording is ready, but transcription or summary setup is still incomplete.",
remediation: "Open Setup & Settings in the plugin settings tab and complete the missing dependency step.",
});
const capability = getWebAudioCapability();
const permissionState = await getMicrophonePermissionState();
const deviceSnapshot = await listWebAudioInputDevices().catch(() => ({
devices: [],
permissionState,
labelsAvailable: false,
}));
const availableIds = new Set(deviceSnapshot.devices.map((device) => device.deviceId));
addCheck({
id: "web-audio",
label: "Web Audio capture",
severity: capability.hasGetUserMedia && capability.hasEnumerateDevices ? "ok" : "error",
detail: capability.hasGetUserMedia && capability.hasEnumerateDevices
? `Web Audio APIs are available${permissionState === "granted" ? " and microphone access is granted." : permissionState === "prompt" ? ". Microphone access will be requested on the first recording or quick test." : permissionState === "denied" ? ", but microphone access is denied." : "."}`
: "Required browser audio APIs are unavailable in this runtime.",
remediation:
permissionState === "denied"
? "Re-enable microphone permission for Obsidian in the operating system settings."
: "Use Obsidian desktop on a Chromium-based runtime that exposes getUserMedia and enumerateDevices.",
});
addCheck({
id: "device-scan",
label: "Audio input devices",
severity: deviceSnapshot.devices.length > 0 ? "ok" : "warning",
detail: deviceSnapshot.devices.length > 0
? `${deviceSnapshot.devices.length} audio input${deviceSnapshot.devices.length === 1 ? "" : "s"} discovered.${deviceSnapshot.labelsAvailable ? "" : " Labels become more descriptive after microphone access is granted."}`
: "No audio inputs were discovered via Web Audio.",
remediation: "Grant microphone permission and check the OS input devices if the list stays empty.",
});
const selectedMic = settings.capture.microphone.deviceId.trim();
const selectedMicPresent = !selectedMic || availableIds.has(selectedMic);
addCheck({
id: "selected-microphone",
label: "Selected microphone",
severity: selectedMicPresent ? "ok" : "warning",
detail: selectedMic
? selectedMicPresent
? "Selected microphone is available."
: "Selected microphone is unavailable. Resonance will use the current system default input instead."
: "No specific microphone selected. Resonance will use the current system default input.",
remediation: "Choose a microphone device if you want to pin one instead of following the OS default.",
});
const additionalSources = settings.capture.additionalSources;
const duplicateIds = new Set<string>();
const seenIds = new Set<string>();
const missingSources = additionalSources.filter((source) => !availableIds.has(source.deviceId));
const sameAsMicSources = selectedMic
? additionalSources.filter((source) => source.deviceId === selectedMic)
: [];
for (const source of additionalSources) {
if (seenIds.has(source.deviceId)) {
duplicateIds.add(source.deviceId);
}
seenIds.add(source.deviceId);
}
addCheck({
id: "additional-sources",
label: "Additional sources",
severity:
duplicateIds.size > 0 || sameAsMicSources.length > 0 || missingSources.length > 0
? "warning"
: "ok",
detail:
additionalSources.length === 0
? "No additional sources selected. Recordings will use only the microphone."
: duplicateIds.size > 0
? "Some additional sources are duplicated and will be ignored."
: sameAsMicSources.length > 0
? "Some additional sources match the selected microphone and will be ignored."
: missingSources.length > 0
? `${missingSources.length} selected additional source${missingSources.length === 1 ? "" : "s"} are unavailable and will be skipped.`
: `${additionalSources.length} additional source${additionalSources.length === 1 ? "" : "s"} selected for the recording mix.`,
remediation:
"Keep only the extra audio inputs you actually want to mix in, such as loopback or monitor sources.",
});
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),
});
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,
capture: "web-audio",
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: PluginSettings): Promise<SmokeTestResult> {
const capability = getWebAudioCapability();
if (!capability.hasGetUserMedia || !capability.hasEnumerateDevices) {
return { ok: false, detail: "Web Audio capture is unavailable in this runtime." };
}
const fs = requireNodeModule<{
existsSync: (path: string) => boolean;
mkdirSync: (path: string, options: { recursive: boolean }) => void;
mkdtempSync: (prefix: string) => string;
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 tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "resonance-next-web-smoke-"));
const segmentsDir = path.join(tmpRoot, "segments");
const audioPath = path.join(tmpRoot, "smoke.wav");
if (!fs.existsSync(segmentsDir)) {
fs.mkdirSync(segmentsDir, { recursive: true });
}
const adapter = new WebCaptureAdapter();
try {
await adapter.start({
fullAudioPath: audioPath,
segmentsDir,
segmentDurationSeconds: Math.max(1, settings.diagnostics.quickTestDurationSeconds),
microphoneDevice: settings.capture.microphone.deviceId,
additionalSources: settings.capture.additionalSources,
onSegmentReady: () => {},
});
await new Promise<void>((resolve) => {
window.setTimeout(resolve, Math.max(300, settings.diagnostics.quickTestDurationSeconds * 1_000));
});
await adapter.stop();
if (settings.transcription.whisperCliPath.trim() && settings.transcription.modelPath.trim() && fs.existsSync(audioPath)) {
const transcriptionAdapter = new WhisperTranscriptionAdapter(settings.transcription);
const transcript = await transcriptionAdapter.transcribeFile(audioPath);
if (!transcript.trim()) {
return { ok: false, detail: "Quick test captured 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 {
if (adapter.isRunning()) {
await adapter.stop();
}
} catch {
// Best effort cleanup after a failed smoke test.
}
try {
fs.rmSync(tmpRoot, { recursive: true, force: true });
} catch {
// The temp directory is disposable; leave it behind if the OS keeps a handle open.
}
}
}
private async checkOllamaHealth(endpoint: string): Promise<SmokeTestResult> {
const base = endpoint.trim() || "http://localhost:11434";
let timeoutId: number | null = null;
try {
const timeout = new Promise<SmokeTestResult>((resolve) => {
timeoutId = window.setTimeout(() => {
resolve({ ok: false, detail: "Ollama check timed out after 3 seconds." });
}, 3_000);
});
const request = requestUrl({
url: `${base}/api/tags`,
method: "GET",
throw: false,
}).then((response): SmokeTestResult => {
if (response.status >= 400) {
return { ok: false, detail: `Ollama endpoint returned ${response.status}.` };
}
return { ok: true, detail: `Ollama reachable at ${base}.` };
});
const result = await Promise.race([request, timeout]);
if (timeoutId !== null) {
window.clearTimeout(timeoutId);
}
return result;
} catch (error) {
return { ok: false, detail: `Ollama check failed: ${String((error as Error)?.message ?? error)}` };
} finally {
if (timeoutId !== null) {
window.clearTimeout(timeoutId);
}
}
}
}
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: {
accessSync: (path: string, mode?: number) => void;
constants: { R_OK: number };
statSync: (path: string) => { size: number };
}
): string {
if (!modelPath) return "Whisper model path is empty.";
if (isLikelyTestWhisperModelPath(modelPath)) {
return "Configured model is a for-tests whisper.cpp CI file, not a real speech model.";
}
try {
fs.accessSync(modelPath, fs.constants.R_OK);
const sizeBytes = fs.statSync(modelPath).size;
if (sizeBytes < 10 * 1024 * 1024) {
return `Configured model looks too small (${Math.round(sizeBytes / 1024)} KB).`;
}
return `Configured model: ${modelPath}`;
} catch {
return `Configured model is unreadable: ${modelPath}`;
}
}
function getWhisperModelRemediation(
modelPath: string,
fs: {
accessSync: (path: string, mode?: number) => void;
constants: { R_OK: number };
statSync: (path: string) => { size: number };
}
): string {
if (!modelPath) return "Download a real ggml model such as ggml-small.bin and set Model path.";
if (isLikelyTestWhisperModelPath(modelPath)) {
return "Replace the for-tests model with a real ggml model such as ggml-small.bin.";
}
try {
fs.accessSync(modelPath, fs.constants.R_OK);
const sizeBytes = fs.statSync(modelPath).size;
if (sizeBytes < 10 * 1024 * 1024) {
return "Download a real ggml model and point Model path to it.";
}
return "Model path looks healthy.";
} catch {
return "Fix the model path or download a real ggml model.";
}
}

View file

@ -0,0 +1,260 @@
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 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 {
// Skip unreadable directories while probing likely local installs.
}
}
return results;
}
function uniqueStrings(values: Array<string | undefined | null>): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
if (!value) continue;
const normalized = value.trim();
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
result.push(normalized);
}
return result;
}

View file

@ -0,0 +1,95 @@
export type WebAudioPermissionState = PermissionState | "unsupported" | "unknown";
export interface WebAudioInputDevice {
deviceId: string;
label: string;
groupId: string;
isDefault: boolean;
}
export interface WebAudioDeviceSnapshot {
devices: WebAudioInputDevice[];
permissionState: WebAudioPermissionState;
labelsAvailable: boolean;
}
export interface WebAudioCapability {
supported: boolean;
hasGetUserMedia: boolean;
hasEnumerateDevices: boolean;
}
export function getWebAudioCapability(): WebAudioCapability {
const mediaDevices = getBrowserNavigator()?.mediaDevices;
return {
supported: Boolean(mediaDevices),
hasGetUserMedia: typeof mediaDevices?.getUserMedia === "function",
hasEnumerateDevices: typeof mediaDevices?.enumerateDevices === "function",
};
}
export async function getMicrophonePermissionState(): Promise<WebAudioPermissionState> {
const browserNavigator = getBrowserNavigator();
if (!browserNavigator) return "unsupported";
try {
const permissions = browserNavigator.permissions;
if (!permissions?.query) return "unknown";
const status = await permissions.query({ name: "microphone" });
return status.state;
} catch {
return "unknown";
}
}
export async function listWebAudioInputDevices(): Promise<WebAudioDeviceSnapshot> {
const capability = getWebAudioCapability();
const browserNavigator = getBrowserNavigator();
if (!capability.hasEnumerateDevices || !browserNavigator?.mediaDevices) {
return {
devices: [],
permissionState: capability.supported ? "unknown" : "unsupported",
labelsAvailable: false,
};
}
const permissionState = await getMicrophonePermissionState();
const devices = await browserNavigator.mediaDevices.enumerateDevices();
const inputs = devices
.filter((device) => device.kind === "audioinput")
.map((device) => ({
deviceId: device.deviceId,
label: device.label || (device.deviceId === "default" ? "System default input" : "Audio input"),
groupId: device.groupId,
isDefault: device.deviceId === "default",
}));
return {
devices: inputs,
permissionState,
labelsAvailable: inputs.some((device) => {
const normalized = device.label.trim().toLowerCase();
return normalized !== "" && normalized !== "microphone" && normalized !== "system default microphone";
}),
};
}
export async function resolvePreferredWebAudioInput(selectedDeviceId?: string): Promise<WebAudioInputDevice | undefined> {
const snapshot = await listWebAudioInputDevices();
if (!snapshot.devices.length) return undefined;
if (selectedDeviceId?.trim()) {
const exact = snapshot.devices.find((device) => device.deviceId === selectedDeviceId.trim());
if (exact) return exact;
}
return snapshot.devices.find((device) => device.isDefault) ?? snapshot.devices[0];
}
export async function resolveWebAudioInputById(selectedDeviceId?: string): Promise<WebAudioInputDevice | undefined> {
if (!selectedDeviceId?.trim()) return undefined;
const snapshot = await listWebAudioInputDevices();
return snapshot.devices.find((device) => device.deviceId === selectedDeviceId.trim());
}
function getBrowserNavigator(): Navigator | undefined {
return typeof window === "undefined" ? undefined : window.navigator;
}

202
src/main.ts Normal file
View file

@ -0,0 +1,202 @@
import { Notice, Plugin } from "obsidian";
import { SessionController } from "./application/SessionController";
import { DEFAULT_SCENARIO_KEY } from "./domain/scenarios";
import { isCoreConfigured, normalizeSettings, type PluginSettings } 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!: PluginSettings;
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: "open-control-room",
name: uiCopy.actions.openRecorder,
callback: () => {
this.openRecorder();
},
});
this.addCommand({
id: "open-diagnostics",
name: uiCopy.actions.openDiagnostics,
callback: () => {
this.openDiagnostics();
},
});
this.addCommand({
id: "quick-toggle-session",
name: "Start the last scenario or stop the active session",
callback: () => {
void this.quickToggleSession();
},
});
this.addCommand({
id: "open-library",
name: uiCopy.actions.openLibrary,
callback: () => {
this.openLibrary();
},
});
this.addCommand({
id: "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();
}
});
}
onunload(): void {
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 = normalizeSettings(await this.loadData());
}
private async updateSettings(updater: (current: PluginSettings) => PluginSettings) {
this.settings = normalizeSettings(updater(this.settings));
await this.saveData(this.settings);
}
}

1675
src/ui/SettingsTab.ts Normal file

File diff suppressed because it is too large Load diff

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

@ -0,0 +1,83 @@
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",
selectAllVisible: "Select all visible",
clearSelection: "Clear selection",
refreshHealth: "Refresh health",
refreshDevices: "Refresh devices",
runQuickTest: "Run quick test",
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",
deleteAudio: "Delete audio",
deleteTranscript: "Delete transcript",
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: "Check permissions, devices, whisper.cpp, and summary provider health.",
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 saved sessions, notes, cleanup actions, and storage usage.",
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 recordings, transcripts, and summaries inside Obsidian.",
},
notices: {
sessionComplete: "Session completed.",
sessionDeleted: "Session deleted.",
sessionBusy: "Session is busy. Please wait for the current operation to finish.",
whisperNotDetected: "whisper.cpp CLI was not auto-detected.",
whisperDetected: "whisper.cpp CLI updated.",
whisperRepoNotDetected: "whisper.cpp repo was not auto-detected.",
whisperRepoDetected: "whisper.cpp repo path updated.",
modelNotDetected: "No readable whisper model was auto-detected.",
modelDetected: "Whisper model path updated.",
quickTestFailedPrefix: "Quick test failed",
},
} as const;

View file

@ -0,0 +1,65 @@
import { App, Modal } from "obsidian";
interface ConfirmationModalOptions {
title: string;
message: string;
confirmText: string;
cancelText?: string;
danger?: boolean;
}
export class ConfirmationModal extends Modal {
private resolved = false;
private resolveResult: ((confirmed: boolean) => void) | null = null;
constructor(app: App, private readonly options: ConfirmationModalOptions) {
super(app);
}
openAndWait(): Promise<boolean> {
return new Promise((resolve) => {
this.resolveResult = resolve;
this.open();
});
}
onOpen(): void {
this.setTitle(this.options.title);
this.contentEl.empty();
this.contentEl.addClass("rxn-modal");
this.contentEl.createEl("p", { text: this.options.message, cls: "rxn-muted" });
const actions = this.contentEl.createDiv({ cls: "rxn-action-bar" });
const cancel = actions.createEl("button", {
text: this.options.cancelText ?? "Cancel",
cls: "rxn-btn-secondary",
attr: { type: "button" },
});
cancel.addEventListener("click", () => {
this.finish(false);
});
const confirm = actions.createEl("button", {
text: this.options.confirmText,
cls: this.options.danger ? "rxn-btn-danger" : "rxn-btn-primary",
attr: { type: "button" },
});
confirm.addEventListener("click", () => {
this.finish(true);
});
}
onClose(): void {
this.contentEl.empty();
this.contentEl.removeClass("rxn-modal");
if (!this.resolved) {
this.resolved = true;
this.resolveResult?.(false);
}
}
private finish(confirmed: boolean): void {
if (this.resolved) return;
this.resolved = true;
this.resolveResult?.(confirmed);
this.close();
}
}

View file

@ -0,0 +1,303 @@
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 PluginSettings } 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: () => PluginSettings;
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.createSpan({ 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.createSpan({ 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.createSpan({ 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 setup before starting your first 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 {
// The controller publishes the error through the plugin notice channel.
}
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("The live transcript note appears after the first transcript chunk is committed.");
}
}, "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 are ready to generate the final 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("The live transcript note appears after the first transcript chunk is committed.");
}
}, "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.createSpan({ text: label, cls: "rxn-eyebrow" });
card.createEl("strong", { text: value });
card.createEl("p", { text: detail, cls: "rxn-muted" });
}
private createActionButton(
container: HTMLElement,
label: string,
onClick: () => Promise<void> | void,
cls: string,
disabled = false
): void {
const button = container.createEl("button", { text: label, cls });
button.disabled = disabled;
button.addEventListener("click", () => {
void onClick();
});
}
private getHeadline(snapshot: SessionRuntimeSnapshot): string {
if (STARTABLE_STATES.has(snapshot.state)) {
return "Record with Resonance";
}
if (snapshot.state === "stopping" || snapshot.state === "summarizing" || snapshot.state === "persisting") {
return "Finishing the session";
}
return "Recording in progress";
}
private getSubheadline(snapshot: SessionRuntimeSnapshot, coreConfigured: boolean): string {
if (snapshot.state === "failed" && snapshot.lastError) {
return snapshot.lastError;
}
if (snapshot.state === "done") {
return "The session is complete. Start another one when you are ready.";
}
if (STARTABLE_STATES.has(snapshot.state)) {
return coreConfigured
? "Choose a style, then start recording."
: "Finish setup first, then choose a style and start recording.";
}
return snapshot.message || "You can stop the recording here when you are ready.";
}
private getStateLabel(state: SessionState): string {
switch (state) {
case "idle":
return "Ready";
case "preflight":
return "Starting";
case "segmenting":
case "recording":
case "transcribing_live":
return "Recording";
case "stopping":
return "Stopping";
case "summarizing":
case "persisting":
return "Finalizing";
case "done":
return "Completed";
case "failed":
return "Failed";
}
}
private getStateTone(state: SessionState): "healthy" | "warning" | "failed" {
switch (state) {
case "done":
return "healthy";
case "failed":
return "failed";
default:
return "warning";
}
}
}

View file

@ -0,0 +1,30 @@
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.setTitle(this.titleText);
this.contentEl.empty();
this.contentEl.addClass("rxn-modal");
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.");
} catch (error) {
new Notice(`Copy failed: ${String((error as Error)?.message ?? error)}`);
}
}
}

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

@ -0,0 +1,33 @@
export function slugifyForPath(value: string): string {
return value.replace(/[\\/:*?"<>|]/g, "-").replace(/\s+/g, " ").trim();
}
export function formatDateForFile(input: string | Date): string {
const date = typeof input === "string" ? new Date(input) : input;
const yyyy = String(date.getFullYear());
const mm = String(date.getMonth() + 1).padStart(2, "0");
const dd = String(date.getDate()).padStart(2, "0");
const hh = String(date.getHours()).padStart(2, "0");
const min = String(date.getMinutes()).padStart(2, "0");
const ss = String(date.getSeconds()).padStart(2, "0");
return `${yyyy}-${mm}-${dd} ${hh}-${min}-${ss}`;
}
export function formatDuration(totalSeconds: number): string {
const safe = Math.max(0, Math.floor(totalSeconds));
const minutes = String(Math.floor(safe / 60)).padStart(2, "0");
const seconds = String(safe % 60).padStart(2, "0");
return `${minutes}:${seconds}`;
}
export function formatBytes(bytes: number): string {
if (bytes <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}

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

@ -0,0 +1,127 @@
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: string, indent: string, checked: string, rest: string) =>
`${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));
}

1260
styles.css

File diff suppressed because it is too large Load diff

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

@ -0,0 +1,24 @@
import test from "node:test";
import assert from "node:assert/strict";
import { getPreferredWhisperModelBasenames, inferWhisperRepoPath } from "../src/infrastructure/system/autoDetect";
test("inferWhisperRepoPath resolves repo root from whisper-cli path", () => {
assert.equal(
inferWhisperRepoPath("/Users/test/whisper.cpp/build/bin/whisper-cli"),
"/Users/test/whisper.cpp"
);
assert.equal(
inferWhisperRepoPath("C:\\dev\\whisper.cpp\\build\\bin\\Release\\whisper-cli.exe"),
"C:/dev/whisper.cpp"
);
});
test("getPreferredWhisperModelBasenames prioritizes the selected preset first", () => {
const names = getPreferredWhisperModelBasenames("large");
assert.deepEqual(names.slice(0, 4), [
"ggml-large.bin",
"ggml-large.en.bin",
"ggml-base.bin",
"ggml-base.en.bin",
]);
});

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

@ -0,0 +1,232 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
buildDashboardSnapshot,
deriveSessionHealthBadge,
deriveSessionListItem,
groupDiagnosticsChecks,
summarizeSessionListItems,
} 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",
capture: "web-audio",
checks: [
{ id: "web-audio", label: "Web Audio capture", 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: 4,
sessionId: "session-1",
createdAt: "2026-04-22T08:00:00.000Z",
updatedAt: "2026-04-22T08:30:00.000Z",
scenarioKey: "work_meeting",
scenarioLabel: "Meeting",
captureMode: "microphone",
captureSources: {
microphone: { deviceId: "", label: "System default input" },
additionalSources: [],
},
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.wav",
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, {
audioBytes: 42_000,
transcriptBytes: 1_500,
summaryBytes: 0,
diagnosticsBytes: 500,
totalBytes: 44_000,
});
assert.equal(item.paths.rootDir, "/tmp/session-1");
assert.equal(item.audioSizeBytes, 42_000);
assert.equal(item.storageBytes, 44_000);
assert.equal(item.healthBadge, "failed");
assert.equal(item.failureSummary, "Summary provider returned an empty result.");
assert.equal(item.artifactAvailability.hasTranscript, true);
assert.equal(item.sourceLabel, "Microphone");
});
test("deriveSessionListItem labels multi-input sessions clearly", () => {
const item = deriveSessionListItem(
{
...manifest,
sessionId: "session-2",
captureMode: "multiple-input",
captureSources: {
microphone: { deviceId: "mic-1", label: "USB Microphone" },
additionalSources: [
{ deviceId: "loopback-1", label: "BlackHole 2ch" },
{ deviceId: "loopback-2", label: "Monitor Source" },
],
},
},
{
audioBytes: 24_000,
transcriptBytes: 750,
summaryBytes: 300,
diagnosticsBytes: 120,
totalBytes: 25_170,
}
);
assert.equal(item.sourceLabel, "Microphone + 2 extra sources");
});
test("summarizeSessionListItems aggregates visible library stats", () => {
const first = deriveSessionListItem(manifest, {
audioBytes: 42_000,
transcriptBytes: 1_500,
summaryBytes: 0,
diagnosticsBytes: 500,
totalBytes: 44_000,
});
const second = deriveSessionListItem(
{
...manifest,
sessionId: "session-2",
captureMode: "multiple-input",
captureSources: {
microphone: { deviceId: "mic-1", label: "USB Microphone" },
additionalSources: [{ deviceId: "loopback-1", label: "BlackHole 2ch" }],
},
},
{
audioBytes: 24_000,
transcriptBytes: 750,
summaryBytes: 300,
diagnosticsBytes: 120,
totalBytes: 25_170,
}
);
const stats = summarizeSessionListItems([first, second]);
assert.deepEqual(stats, {
sessionCount: 2,
audioBytes: 66_000,
transcriptBytes: 2_250,
summaryBytes: 300,
diagnosticsBytes: 620,
totalBytes: 69_170,
});
});
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, {
audioBytes: 42_000,
transcriptBytes: 1_500,
summaryBytes: 0,
diagnosticsBytes: 500,
totalBytes: 44_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);
});

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

@ -0,0 +1,31 @@
import test from "node:test";
import assert from "node:assert/strict";
import { formatLiveTranscriptNote, formatTranscriptChunkMarkdown, sanitizeSummary } from "../src/utils/markdown";
test("formatTranscriptChunkMarkdown emits plain transcript text without segment headings", () => {
assert.equal(
formatTranscriptChunkMarkdown(3, "Ciao.\n\nCome va?"),
"Ciao.\n\nCome va?"
);
});
test("formatLiveTranscriptNote builds a user-facing transcript note", () => {
const note = formatLiveTranscriptNote("Meeting — Transcript", "Prima riga.\n\nSeconda riga.");
assert.match(note, /^# Meeting — Transcript/);
assert.match(note, /> Live draft while recording/);
assert.ok(!note.includes("Segment 0000"));
});
test("formatTranscriptChunkMarkdown removes standalone annotation lines in brackets", () => {
assert.equal(
formatTranscriptChunkMarkdown(1, "[Musica]\n\nCiao a tutti.\n[Sottotitoli e rispondenti del mio canale]\nCome va?"),
"Ciao a tutti.\nCome va?"
);
});
test("sanitizeSummary removes a wrapping markdown code fence", () => {
assert.equal(
sanitizeSummary("```markdown\n## Riassunto\n\nTesto.\n```"),
"## Riassunto\n\nTesto."
);
});

View file

@ -0,0 +1,49 @@
import test from "node:test";
import assert from "node:assert/strict";
import { OrderedSegmentQueue } from "../src/application/OrderedSegmentQueue";
test("OrderedSegmentQueue commits strictly in segment order", async () => {
const committed: number[] = [];
const queue = new OrderedSegmentQueue(async (segment) => {
committed.push(segment.index);
});
queue.enqueue([
{ index: 2, path: "segment-0002.mp3" },
{ index: 0, path: "segment-0000.mp3" },
{ index: 1, path: "segment-0001.mp3" },
]);
await queue.whenIdle();
assert.deepEqual(committed, [0, 1, 2]);
});
test("OrderedSegmentQueue ignores already committed segments", async () => {
const committed: number[] = [];
const queue = new OrderedSegmentQueue(async (segment) => {
committed.push(segment.index);
}, 1);
queue.enqueue([
{ index: 0, path: "segment-0000.mp3" },
{ index: 1, path: "segment-0001.mp3" },
]);
await queue.whenIdle();
assert.deepEqual(committed, [1]);
});
test("OrderedSegmentQueue resumes from the next expected segment index", async () => {
const committed: number[] = [];
const queue = new OrderedSegmentQueue(async (segment) => {
committed.push(segment.index);
}, 1);
queue.enqueue([
{ index: 1, path: "segment-0001.mp3" },
{ index: 2, path: "segment-0002.mp3" },
]);
await queue.whenIdle();
assert.deepEqual(committed, [1, 2]);
});

View file

@ -0,0 +1,9 @@
import test from "node:test";
import assert from "node:assert/strict";
import { SUPPORTED_SESSION_SCHEMA_VERSION, isSupportedSessionManifest } from "../src/domain/session";
test("isSupportedSessionManifest accepts only the current schema version", () => {
assert.equal(isSupportedSessionManifest({ schemaVersion: SUPPORTED_SESSION_SCHEMA_VERSION }), true);
assert.equal(isSupportedSessionManifest({ schemaVersion: 3 }), false);
assert.equal(isSupportedSessionManifest({}), false);
});

111
tests/sessionStore.test.ts Normal file
View file

@ -0,0 +1,111 @@
import test, { type TestContext } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { SessionStore } from "../src/infrastructure/storage/SessionStore";
import { DEFAULT_SETTINGS } from "../src/domain/settings";
import type { RecordingSessionManifest } from "../src/domain/session";
import type { ScenarioTemplate } from "../src/domain/scenarios";
const scenario: ScenarioTemplate = {
key: "work_meeting",
label: "Meeting",
description: "Decision log",
notePrefix: "Meeting",
prompt: "Summarize the meeting.",
};
function createTestStore(t: TestContext) {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "resonance-store-"));
(globalThis as any).window = { require };
const app = {
vault: {
adapter: {
getBasePath: () => rootDir,
},
configDir: "custom-config",
},
} as any;
const store = new SessionStore(app, "resonance-next");
t.after(() => {
fs.rmSync(rootDir, { recursive: true, force: true });
});
return { store };
}
async function createSessionWithArtifacts(
t: TestContext,
overrides: Partial<RecordingSessionManifest["artifacts"]> = {}
) {
const { store } = createTestStore(t);
const manifest = await store.createSession(scenario, DEFAULT_SETTINGS, {
checkedAt: "2026-04-29T10:00:00.000Z",
blockingIssueIds: [],
warningIds: [],
summary: "Ready.",
});
fs.writeFileSync(manifest.paths.fullAudioPath, Buffer.alloc(100));
fs.writeFileSync(path.join(manifest.paths.segmentsDir, "segment-0000.wav"), Buffer.alloc(25));
fs.writeFileSync(path.join(manifest.paths.segmentsDir, "segment-0001.wav"), Buffer.alloc(35));
fs.writeFileSync(manifest.paths.transcriptTextPath, "hello transcript");
fs.writeFileSync(manifest.paths.summaryMarkdownPath, "summary");
fs.writeFileSync(manifest.paths.diagnosticsLogPath, "diag");
manifest.artifacts = {
hasAudio: overrides.hasAudio ?? true,
hasTranscript: overrides.hasTranscript ?? true,
hasSummary: overrides.hasSummary ?? true,
};
await store.writeManifest(manifest);
return { store, manifest };
}
test("SessionStore computes per-session storage breakdowns and library totals", async (t) => {
const { store, manifest } = await createSessionWithArtifacts(t);
const breakdown = store.getSessionStorageBreakdown(manifest);
assert.equal(breakdown.audioBytes, 160);
assert.equal(breakdown.transcriptBytes, Buffer.byteLength("hello transcript"));
assert.equal(breakdown.summaryBytes, Buffer.byteLength("summary"));
assert.equal(breakdown.diagnosticsBytes, Buffer.byteLength("diag"));
assert.equal(
breakdown.totalBytes,
breakdown.audioBytes + breakdown.transcriptBytes + breakdown.summaryBytes + breakdown.diagnosticsBytes
);
const stats = store.getLibraryStorageStats([manifest]);
assert.deepEqual(stats, {
sessionCount: 1,
...breakdown,
});
});
test("SessionStore estimates reclaimable bytes for each cleanup action", async (t) => {
const { store, manifest } = await createSessionWithArtifacts(t);
const breakdown = store.getSessionStorageBreakdown(manifest);
assert.equal(store.getReclaimableBytes(manifest, "audio"), breakdown.audioBytes);
assert.equal(store.getReclaimableBytes(manifest, "transcript"), breakdown.transcriptBytes);
assert.equal(store.getReclaimableBytes(manifest, "session"), breakdown.totalBytes);
assert.equal(store.getReclaimableBytesForSessions([manifest], "session"), breakdown.totalBytes);
});
test("SessionStore bulk cleanup helpers preserve the expected remaining artifacts", async (t) => {
const { store, manifest } = await createSessionWithArtifacts(t);
await store.deleteAudioArtifactsMany([manifest]);
assert.equal(fs.existsSync(manifest.paths.fullAudioPath), false);
assert.equal(fs.existsSync(manifest.paths.segmentsDir), true);
assert.equal(fs.readFileSync(manifest.paths.summaryMarkdownPath, "utf8"), "summary");
fs.writeFileSync(manifest.paths.fullAudioPath, Buffer.alloc(10));
await store.deleteTranscriptArtifactsMany([manifest]);
assert.equal(fs.readFileSync(manifest.paths.transcriptTextPath, "utf8"), "");
assert.equal(fs.readFileSync(manifest.paths.summaryMarkdownPath, "utf8"), "summary");
await store.deleteSessionFilesMany([manifest]);
assert.equal(fs.existsSync(manifest.paths.rootDir), false);
});

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

@ -0,0 +1,104 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
DEFAULT_SETTINGS,
hasAdditionalCaptureSources,
isCoreConfigured,
isLikelyTestWhisperModelPath,
normalizeSettings,
} from "../src/domain/settings";
import { getSelectedSummaryModel } from "../src/domain/providers";
test("normalizeSettings keeps defaults and clamps invalid values", () => {
const settings = normalizeSettings({
capture: { segmentDurationSeconds: 1 },
transcription: { beamSize: 99, language: "it" },
summary: { provider: "ollama", ollamaModel: "" },
});
assert.equal(settings.capture.segmentDurationSeconds, 5);
assert.equal(settings.transcription.beamSize, 10);
assert.equal(settings.transcription.language, "it");
assert.equal(settings.output.vaultFolder, DEFAULT_SETTINGS.output.vaultFolder);
assert.deepEqual(settings.capture.additionalSources, []);
});
test("normalizeSettings ignores deprecated capture fields and resets to the new schema", () => {
const settings = normalizeSettings({
capture: {
microphoneDevice: "mic-1",
microphoneLabel: "USB Microphone",
systemDevice: "loopback-1",
systemLabel: "BlackHole 2ch",
},
});
assert.deepEqual(settings.capture.microphone, {
deviceId: "",
label: "",
});
assert.deepEqual(settings.capture.additionalSources, []);
});
test("normalizeSettings deduplicates additional sources and removes the microphone from the extras list", () => {
const settings = normalizeSettings({
capture: {
microphone: { deviceId: "mic-1", label: "USB Microphone" },
additionalSources: [
{ deviceId: "loopback-1", label: "BlackHole 2ch" },
{ deviceId: "loopback-1", label: "BlackHole 2ch" },
{ deviceId: "mic-1", label: "USB Microphone" },
],
},
});
assert.deepEqual(settings.capture.additionalSources, [
{
deviceId: "loopback-1",
label: "BlackHole 2ch",
},
]);
});
test("getSelectedSummaryModel follows the selected provider", () => {
const base = DEFAULT_SETTINGS.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);
});
test("hasAdditionalCaptureSources reports whether extra inputs are configured", () => {
assert.equal(hasAdditionalCaptureSources(DEFAULT_SETTINGS.capture), false);
assert.equal(
hasAdditionalCaptureSources({
...DEFAULT_SETTINGS.capture,
additionalSources: [{ deviceId: "loopback-1", label: "BlackHole 2ch" }],
}),
true
);
});
test("isCoreConfigured does not depend on extra sources, only on transcription and summary readiness", () => {
const settings = normalizeSettings({
capture: {
microphone: { deviceId: "mic-1", label: "USB Microphone" },
additionalSources: [{ deviceId: "loopback-1", label: "BlackHole 2ch" }],
},
transcription: {
whisperCliPath: "/tmp/whisper-cli",
modelPath: "/tmp/ggml-small.bin",
},
summary: {
provider: "ollama",
ollamaEndpoint: "http://localhost:11434",
ollamaModel: "gemma3",
},
});
assert.equal(isCoreConfigured(settings), true);
});

64
tests/wavWriter.test.ts Normal file
View file

@ -0,0 +1,64 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { StreamingWavWriter, createWavHeader, writeWavFile } from "../src/infrastructure/adapters/wavWriter";
installDesktopRuntime();
test("createWavHeader writes a valid RIFF/WAVE header", () => {
const header = createWavHeader(1_600, 16_000, 1);
assert.equal(header.length, 44);
assert.equal(header.toString("ascii", 0, 4), "RIFF");
assert.equal(header.toString("ascii", 8, 12), "WAVE");
assert.equal(header.toString("ascii", 12, 16), "fmt ");
assert.equal(header.toString("ascii", 36, 40), "data");
assert.equal(header.readUInt32LE(4), 1_636);
assert.equal(header.readUInt32LE(24), 16_000);
assert.equal(header.readUInt16LE(22), 1);
assert.equal(header.readUInt32LE(40), 1_600);
});
test("writeWavFile writes PCM data after the header", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "resonance-wav-file-"));
const filePath = path.join(tmpDir, "segment.wav");
writeWavFile(filePath, new Float32Array([0, 0.5, -0.5, 1]), 16_000, 1);
const buffer = fs.readFileSync(filePath);
assert.equal(buffer.toString("ascii", 0, 4), "RIFF");
assert.equal(buffer.readUInt32LE(40), 8);
assert.equal(buffer.length, 52);
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test("StreamingWavWriter patches the header with the final data size", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "resonance-wav-stream-"));
const filePath = path.join(tmpDir, "recording.wav");
const writer = new StreamingWavWriter(filePath, 48_000, 1);
writer.append(new Float32Array([0.25, -0.25]));
writer.append(new Float32Array([0.5, -0.5, 0]));
writer.finalize();
const buffer = fs.readFileSync(filePath);
assert.equal(buffer.toString("ascii", 0, 4), "RIFF");
assert.equal(buffer.readUInt32LE(40), 10);
assert.equal(buffer.readUInt32LE(4), 46);
assert.equal(buffer.length, 54);
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function installDesktopRuntime(): void {
const desktopWindow = {
require: require,
};
Object.defineProperty(globalThis, "window", {
value: desktopWindow,
configurable: true,
writable: true,
});
}

60
tests/webAudio.test.ts Normal file
View file

@ -0,0 +1,60 @@
import test from "node:test";
import assert from "node:assert/strict";
import { getWebAudioCapability, resolveWebAudioInputById } from "../src/infrastructure/system/webAudio";
test("getWebAudioCapability reports supported browser APIs", () => {
installNavigator({
mediaDevices: {
getUserMedia: async () => ({}) as MediaStream,
enumerateDevices: async () => [] as MediaDeviceInfo[],
} as unknown as MediaDevices,
});
assert.deepEqual(getWebAudioCapability(), {
supported: true,
hasGetUserMedia: true,
hasEnumerateDevices: true,
});
});
test("resolveWebAudioInputById returns a matching audioinput device", async () => {
installNavigator({
mediaDevices: {
getUserMedia: async () => ({}) as MediaStream,
enumerateDevices: async () =>
[
{
kind: "audioinput",
deviceId: "default",
label: "System default input",
groupId: "default-group",
},
{
kind: "audioinput",
deviceId: "loopback-1",
label: "BlackHole 2ch",
groupId: "group-2",
},
] as MediaDeviceInfo[],
} as unknown as MediaDevices,
});
const device = await resolveWebAudioInputById("loopback-1");
assert.equal(device?.label, "BlackHole 2ch");
});
function installNavigator(navigatorValue: Partial<Navigator>): void {
Object.defineProperty(globalThis, "window", {
value: {
navigator: navigatorValue,
},
configurable: true,
writable: true,
});
Object.defineProperty(globalThis, "navigator", {
value: navigatorValue,
configurable: true,
writable: true,
});
}

312
tests/webCapture.test.ts Normal file
View file

@ -0,0 +1,312 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { WebCaptureAdapter } from "../src/infrastructure/adapters/WebCaptureAdapter";
installDesktopRuntime();
let requestedConstraints: MediaStreamConstraints[] = [];
test("WebCaptureAdapter writes wav segments and final recording without polling", async () => {
installFakeWebAudioEnvironment();
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "resonance-web-capture-"));
const segmentsDir = path.join(tmpDir, "segments");
fs.mkdirSync(segmentsDir, { recursive: true });
const ready: Array<{ index: number; path: string }> = [];
const adapter = new WebCaptureAdapter();
await adapter.start({
fullAudioPath: path.join(tmpDir, "recording.wav"),
segmentsDir,
segmentDurationSeconds: 1,
microphoneDevice: "mic-1",
onSegmentReady: (segment) => ready.push(segment),
});
const worklet = FakeAudioWorkletNode.lastNode;
assert.ok(worklet, "expected the fake AudioWorklet node to be created");
worklet.emit([new Float32Array([0, 0.1, 0.2, 0.3, 0.4, 0.5])]);
worklet.emit([new Float32Array([0.6, 0.7, 0.8, 0.9, 1])]);
await adapter.stop();
assert.deepEqual(
ready.map((segment) => segment.index),
[0, 1]
);
assert.equal(fs.existsSync(path.join(tmpDir, "recording.wav")), true);
assert.equal(fs.existsSync(path.join(segmentsDir, "segment-0000.wav")), true);
assert.equal(fs.existsSync(path.join(segmentsDir, "segment-0001.wav")), true);
assert.equal(fs.readFileSync(path.join(segmentsDir, "segment-0000.wav")).readUInt32LE(40), 20);
assert.equal(fs.readFileSync(path.join(segmentsDir, "segment-0001.wav")).readUInt32LE(40), 2);
assert.equal(fs.readFileSync(path.join(tmpDir, "recording.wav")).readUInt32LE(40), 22);
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test("WebCaptureAdapter falls back to the default microphone when the saved device is missing", async () => {
installFakeWebAudioEnvironment();
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "resonance-web-fallback-"));
const segmentsDir = path.join(tmpDir, "segments");
fs.mkdirSync(segmentsDir, { recursive: true });
const logs: string[] = [];
const adapter = new WebCaptureAdapter();
await adapter.start({
fullAudioPath: path.join(tmpDir, "recording.wav"),
segmentsDir,
segmentDurationSeconds: 1,
microphoneDevice: "missing-device",
onSegmentReady: () => {},
onLog: (line) => logs.push(line),
});
const lastConstraints = requestedConstraints[requestedConstraints.length - 1] ?? null;
const requestedAudio =
lastConstraints && lastConstraints.audio && lastConstraints.audio !== true ? lastConstraints.audio : undefined;
assert.equal(typeof requestedAudio?.deviceId, "undefined");
assert.ok(logs.some((line) => line.includes("fallback")), "expected a fallback log when the saved device is missing");
await adapter.stop();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test("WebCaptureAdapter opens a second Web Audio input when an additional source is configured", async () => {
installFakeWebAudioEnvironment();
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "resonance-web-dual-input-"));
const segmentsDir = path.join(tmpDir, "segments");
fs.mkdirSync(segmentsDir, { recursive: true });
const adapter = new WebCaptureAdapter();
await adapter.start({
fullAudioPath: path.join(tmpDir, "recording.wav"),
segmentsDir,
segmentDurationSeconds: 1,
microphoneDevice: "mic-1",
additionalSources: [{ deviceId: "loopback-1", label: "BlackHole 2ch" }],
onSegmentReady: () => {},
});
assert.equal(requestedConstraints.length, 2);
const micConstraints = requestedConstraints[0]?.audio;
const systemConstraints = requestedConstraints[1]?.audio;
assert.deepEqual(micConstraints, { deviceId: { exact: "mic-1" } });
assert.deepEqual(systemConstraints, { deviceId: { exact: "loopback-1" } });
await adapter.stop();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test("WebCaptureAdapter skips missing additional sources and continues recording", async () => {
installFakeWebAudioEnvironment();
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "resonance-web-missing-source-"));
const segmentsDir = path.join(tmpDir, "segments");
fs.mkdirSync(segmentsDir, { recursive: true });
const logs: string[] = [];
const adapter = new WebCaptureAdapter();
await adapter.start({
fullAudioPath: path.join(tmpDir, "recording.wav"),
segmentsDir,
segmentDurationSeconds: 1,
additionalSources: [{ deviceId: "missing-loopback", label: "BlackHole 2ch" }],
onSegmentReady: () => {},
onLog: (line) => logs.push(line),
});
assert.equal(requestedConstraints.length, 1);
assert.ok(logs.some((line) => line.includes("unavailable")));
await adapter.stop();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test("WebCaptureAdapter skips duplicated additional sources", async () => {
installFakeWebAudioEnvironment();
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "resonance-web-duplicate-source-"));
const segmentsDir = path.join(tmpDir, "segments");
fs.mkdirSync(segmentsDir, { recursive: true });
const logs: string[] = [];
const adapter = new WebCaptureAdapter();
await adapter.start({
fullAudioPath: path.join(tmpDir, "recording.wav"),
segmentsDir,
segmentDurationSeconds: 1,
microphoneDevice: "mic-1",
additionalSources: [
{ deviceId: "loopback-1", label: "BlackHole 2ch" },
{ deviceId: "loopback-1", label: "BlackHole 2ch" },
{ deviceId: "mic-1", label: "USB Microphone" },
],
onSegmentReady: () => {},
onLog: (line) => logs.push(line),
});
assert.equal(requestedConstraints.length, 2);
assert.ok(logs.some((line) => line.includes("duplicated")));
assert.ok(logs.some((line) => line.includes("matches the selected microphone")));
await adapter.stop();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function installDesktopRuntime(): void {
Object.defineProperty(globalThis, "window", {
value: { require },
configurable: true,
writable: true,
});
}
function installFakeWebAudioEnvironment(): void {
requestedConstraints = [];
FakeAudioWorkletNode.lastNode = null;
const navigatorValue = {
mediaDevices: {
getUserMedia: async (constraints: MediaStreamConstraints) => {
requestedConstraints.push(constraints);
return new FakeMediaStream();
},
enumerateDevices: async () =>
[
{
kind: "audioinput",
deviceId: "default",
label: "System default microphone",
groupId: "default-group",
},
{
kind: "audioinput",
deviceId: "mic-1",
label: "USB Microphone",
groupId: "group-1",
},
{
kind: "audioinput",
deviceId: "loopback-1",
label: "BlackHole 2ch",
groupId: "group-2",
},
] as MediaDeviceInfo[],
},
permissions: {
query: async () => ({ state: "prompt" }),
},
} as unknown as Navigator;
Object.defineProperty(globalThis, "window", {
value: {
require,
navigator: navigatorValue,
AudioContext: FakeAudioContext,
URL: {
createObjectURL: () => "blob:fake-worklet",
revokeObjectURL: () => {},
},
setTimeout,
clearTimeout,
},
configurable: true,
writable: true,
});
Object.defineProperty(globalThis, "navigator", {
value: navigatorValue,
configurable: true,
writable: true,
});
Object.defineProperty(globalThis, "AudioWorkletNode", {
value: FakeAudioWorkletNode,
configurable: true,
writable: true,
});
}
class FakeMediaStream {
private readonly tracks = [new FakeMediaStreamTrack()];
getTracks(): MediaStreamTrack[] {
return this.tracks as unknown as MediaStreamTrack[];
}
}
class FakeMediaStreamTrack {
stop(): void {}
}
class FakeSourceNode {
connect(): void {}
disconnect(): void {}
}
class FakeGainNode {
gain = { value: 1 };
connect(): void {}
disconnect(): void {}
}
class FakeMessagePort {
onmessage: ((event: MessageEvent<Float32Array>) => void) | null = null;
close(): void {
this.onmessage = null;
}
emit(samples: Float32Array): void {
this.onmessage?.({ data: samples } as MessageEvent<Float32Array>);
}
}
class FakeAudioWorkletNode {
static lastNode: FakeAudioWorkletNode | null = null;
readonly port = new FakeMessagePort();
constructor() {
FakeAudioWorkletNode.lastNode = this;
}
connect(): void {}
disconnect(): void {}
emit(channels: Float32Array[]): void {
const length = channels[0]?.length ?? 0;
const samples = new Float32Array(length);
for (let sampleIndex = 0; sampleIndex < length; sampleIndex += 1) {
let sum = 0;
for (const channel of channels) {
sum += channel[sampleIndex] ?? 0;
}
samples[sampleIndex] = channels.length > 0 ? sum / channels.length : 0;
}
this.port.emit(samples);
}
}
class FakeAudioContext {
readonly audioWorklet = {
addModule: async () => {},
};
readonly sampleRate = 10;
readonly destination = {} as AudioDestinationNode;
state: AudioContextState = "running";
createMediaStreamSource(): MediaStreamAudioSourceNode {
return new FakeSourceNode() as unknown as MediaStreamAudioSourceNode;
}
createGain(): GainNode {
return new FakeGainNode() as unknown as GainNode;
}
async close(): Promise<void> {
this.state = "closed";
}
}

View file

@ -6,11 +6,13 @@
"lib": ["ES2020", "DOM"],
"allowJs": false,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noEmit": true,
"types": ["node"],
"baseUrl": ".",
"paths": {}
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "main.js", "main.js.map"]
"include": ["src/**/*.ts", "tests/**/*.ts", "vite.config.ts"],
"exclude": ["node_modules", "main.js", "main.js.map", "dist", ".test-dist"]
}

22
tsconfig.tests.json Normal file
View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"moduleResolution": "Node",
"lib": ["ES2020", "DOM"],
"strict": true,
"esModuleInterop": true,
"types": ["node"],
"outDir": ".test-dist",
"rootDir": ".",
"skipLibCheck": true
},
"include": [
"src/domain/**/*.ts",
"src/application/OrderedSegmentQueue.ts",
"src/application/dashboard.ts",
"src/infrastructure/system/autoDetect.ts",
"src/infrastructure/system/deviceScanner.ts",
"tests/**/*.ts"
]
}

View file

@ -4,10 +4,10 @@ import { builtinModules } from 'module';
export default defineConfig({
build: {
lib: {
entry: 'main.ts',
entry: 'src/main.ts',
formats: ['cjs'],
fileName: () => 'main',
name: 'resonance',
name: 'resonanceNext',
},
outDir: 'dist',
emptyOutDir: true,