mirror of
https://github.com/blackajiro/Resonance.git
synced 2026-07-22 06:51:15 +00:00
Compare commits
24 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63538d3b20 | ||
|
|
313916c9fb | ||
|
|
142984bcf3 | ||
|
|
75c5406c49 | ||
|
|
7226641990 | ||
|
|
1520be5185 | ||
|
|
e7eb786aa5 | ||
|
|
c4970de099 | ||
|
|
cadf157b13 | ||
|
|
e83e26412e | ||
|
|
d3f4bf8eeb | ||
|
|
6ad5f20725 | ||
|
|
63a9002dea | ||
|
|
6c1c078655 | ||
|
|
c1e27ed20c | ||
|
|
a24a827463 | ||
|
|
9ffa31020d | ||
|
|
b4141b30a9 | ||
|
|
ac16953e31 | ||
|
|
cb2b4bfc5e | ||
|
|
9b6cd73872 | ||
|
|
0df836c29f | ||
|
|
0d2bf03cf7 | ||
|
|
fded5b99f2 |
62 changed files with 12986 additions and 2663 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -3,3 +3,4 @@ main.js
|
|||
main.js.map
|
||||
.DS_Store
|
||||
dist/
|
||||
.test-dist/
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
115
HelpModal.ts
115
HelpModal.ts
|
|
@ -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 cross‑platform 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 speech‑to‑text 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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
400
LibraryModal.ts
400
LibraryModal.ts
|
|
@ -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
226
README.md
|
|
@ -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 local‑first 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>
|
||||

|
||||
|
||||
## 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
|
||||
- ⏱️ Status‑bar 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
|
||||
|
||||

|
||||
|
||||
### Library
|
||||
|
||||

|
||||
|
||||
## 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; 3‑second test.
|
||||
- **Whisper**: repo path, whisper‑cli path, model picker or auto‑download; 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 3‑second 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 you’d 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 desktop‑only.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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 (cross‑platform)
|
||||
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 (cross‑platform)
|
||||
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, well‑structured 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));
|
||||
}
|
||||
192
SetupWizard.ts
192
SetupWizard.ts
|
|
@ -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
BIN
assets/banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
BIN
assets/library.png
Normal file
BIN
assets/library.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 235 KiB |
BIN
assets/recorder.png
Normal file
BIN
assets/recorder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 330 KiB |
35
eslint.config.mjs
Normal file
35
eslint.config.mjs
Normal 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
213
main.ts
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
4545
package-lock.json
generated
File diff suppressed because it is too large
Load diff
14
package.json
14
package.json
|
|
@ -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"
|
||||
|
|
|
|||
87
prompts.ts
87
prompts.ts
|
|
@ -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 note‑taker. Produce a clean, skimmable Markdown report in the SAME language as the transcript.
|
||||
|
||||
Always include, in this order:
|
||||
1) Overview — 3–6 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 note‑taker. Produce clear Markdown notes in the SAME language as the transcript.
|
||||
|
||||
Always include:
|
||||
1) Overview — 3–6 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 — [ ] Follow‑ups (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 — 3–6 sentences capturing the main opportunity and where the group aligned.
|
||||
2) Topic sections — Group ideas into natural clusters with a brief rationale; highlight high‑signal 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 note‑taker. Produce a crisp Markdown report in the SAME language as the transcript.
|
||||
|
||||
Always include:
|
||||
1) Overview — 3–6 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 — [ ] Follow‑ups with owners.
|
||||
|
||||
Be objective and concise.
|
||||
|
||||
IMPORTANT: If the transcript is empty or invalid, return an empty string.`,
|
||||
},
|
||||
standup: {
|
||||
key: "standup",
|
||||
label: "Daily stand‑up",
|
||||
prompt: `Create a brief Markdown stand‑up report in the SAME language as the transcript.
|
||||
|
||||
Always include:
|
||||
1) Overview — 2–4 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 follow‑ups.
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
453
settings.ts
453
settings.ts
|
|
@ -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 you’re done, you’ll 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 auto‑find.")
|
||||
.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("Auto‑resolved 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.5‑pro, 2.5‑flash, 2.5‑pro.")
|
||||
.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 {}
|
||||
});
|
||||
}
|
||||
}
|
||||
78
src/application/OrderedSegmentQueue.ts
Normal file
78
src/application/OrderedSegmentQueue.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
export interface SegmentDescriptor {
|
||||
index: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface SegmentQueueStats {
|
||||
nextExpectedIndex: number;
|
||||
queuedIndexes: number[];
|
||||
inFlightIndex: number | null;
|
||||
}
|
||||
|
||||
type CommitHandler = (segment: SegmentDescriptor) => Promise<void>;
|
||||
|
||||
export class OrderedSegmentQueue {
|
||||
private readonly pending = new Map<number, SegmentDescriptor>();
|
||||
private processing = false;
|
||||
private inFlightIndex: number | null = null;
|
||||
private readonly idleResolvers = new Set<() => void>();
|
||||
private failure: Error | null = null;
|
||||
|
||||
constructor(private readonly commit: CommitHandler, private nextExpectedIndex = 0) {}
|
||||
|
||||
enqueue(segments: SegmentDescriptor[]) {
|
||||
for (const segment of segments) {
|
||||
if (segment.index < this.nextExpectedIndex) continue;
|
||||
if (segment.index === this.inFlightIndex) continue;
|
||||
if (this.pending.has(segment.index)) continue;
|
||||
this.pending.set(segment.index, segment);
|
||||
}
|
||||
void this.process();
|
||||
}
|
||||
|
||||
getStats(): SegmentQueueStats {
|
||||
return {
|
||||
nextExpectedIndex: this.nextExpectedIndex,
|
||||
queuedIndexes: [...this.pending.keys()].sort((left, right) => left - right),
|
||||
inFlightIndex: this.inFlightIndex,
|
||||
};
|
||||
}
|
||||
|
||||
async whenIdle(): Promise<void> {
|
||||
if (!this.processing && this.pending.size === 0) {
|
||||
if (this.failure) throw this.failure;
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
this.idleResolvers.add(resolve);
|
||||
});
|
||||
|
||||
if (this.failure) throw this.failure;
|
||||
}
|
||||
|
||||
private async process() {
|
||||
if (this.processing) return;
|
||||
this.processing = true;
|
||||
try {
|
||||
while (this.pending.has(this.nextExpectedIndex)) {
|
||||
const segment = this.pending.get(this.nextExpectedIndex);
|
||||
if (!segment) break;
|
||||
this.pending.delete(this.nextExpectedIndex);
|
||||
this.inFlightIndex = segment.index;
|
||||
await this.commit(segment);
|
||||
this.inFlightIndex = null;
|
||||
this.nextExpectedIndex += 1;
|
||||
}
|
||||
} catch (error) {
|
||||
this.failure = error instanceof Error ? error : new Error(String(error));
|
||||
} finally {
|
||||
this.processing = false;
|
||||
this.inFlightIndex = null;
|
||||
if (this.pending.size === 0 || this.failure) {
|
||||
for (const resolve of this.idleResolvers) resolve();
|
||||
this.idleResolvers.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
521
src/application/SessionController.ts
Normal file
521
src/application/SessionController.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
158
src/application/dashboard.ts
Normal file
158
src/application/dashboard.ts
Normal 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
33
src/domain/dashboard.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { DiagnosticsReport, DiagnosticCheck } from "./diagnostics";
|
||||
import type { SessionListItem, SessionRuntimeSnapshot } from "./session";
|
||||
|
||||
export interface DiagnosticsGroups {
|
||||
blocking: DiagnosticCheck[];
|
||||
warnings: DiagnosticCheck[];
|
||||
healthy: DiagnosticCheck[];
|
||||
}
|
||||
|
||||
export interface DashboardPrimaryAction {
|
||||
intent: "start" | "stop" | "blocked" | "busy";
|
||||
label: string;
|
||||
disabled: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface DashboardHealthState {
|
||||
badge: "healthy" | "warning" | "failed";
|
||||
summary: string;
|
||||
blockingCount: number;
|
||||
warningCount: number;
|
||||
groups: DiagnosticsGroups;
|
||||
report?: DiagnosticsReport;
|
||||
}
|
||||
|
||||
export interface DashboardSnapshot {
|
||||
generatedAt: string;
|
||||
runtime: SessionRuntimeSnapshot;
|
||||
primaryAction: DashboardPrimaryAction;
|
||||
health: DashboardHealthState;
|
||||
recentSessions: SessionListItem[];
|
||||
canOpenDiagnostics: boolean;
|
||||
}
|
||||
22
src/domain/diagnostics.ts
Normal file
22
src/domain/diagnostics.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type { SummaryProviderId } from "./providers";
|
||||
|
||||
export type DiagnosticSeverity = "ok" | "warning" | "error";
|
||||
|
||||
export interface DiagnosticCheck {
|
||||
id: string;
|
||||
label: string;
|
||||
severity: DiagnosticSeverity;
|
||||
detail: string;
|
||||
remediation?: string;
|
||||
}
|
||||
|
||||
export interface DiagnosticsReport {
|
||||
checkedAt: string;
|
||||
provider: SummaryProviderId;
|
||||
capture: "web-audio";
|
||||
checks: DiagnosticCheck[];
|
||||
blockingIssueIds: string[];
|
||||
warningIds: string[];
|
||||
isHealthy: boolean;
|
||||
summary: string;
|
||||
}
|
||||
79
src/domain/providers.ts
Normal file
79
src/domain/providers.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
export type SummaryProviderId = "ollama" | "gemini" | "openai" | "anthropic";
|
||||
|
||||
export interface ProviderCapabilities {
|
||||
id: SummaryProviderId;
|
||||
label: string;
|
||||
tier: "tier1" | "experimental";
|
||||
kind: "local" | "cloud";
|
||||
requiresApiKey: boolean;
|
||||
requiresEndpoint: boolean;
|
||||
defaultModel: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const PROVIDER_CAPABILITIES: Record<SummaryProviderId, ProviderCapabilities> = {
|
||||
ollama: {
|
||||
id: "ollama",
|
||||
label: "Ollama",
|
||||
tier: "tier1",
|
||||
kind: "local",
|
||||
requiresApiKey: false,
|
||||
requiresEndpoint: true,
|
||||
defaultModel: "gemma3",
|
||||
description: "Default local-first summary provider for Resonance.",
|
||||
},
|
||||
gemini: {
|
||||
id: "gemini",
|
||||
label: "Gemini",
|
||||
tier: "experimental",
|
||||
kind: "cloud",
|
||||
requiresApiKey: true,
|
||||
requiresEndpoint: false,
|
||||
defaultModel: "gemini-2.5-flash",
|
||||
description: "Optional cloud provider.",
|
||||
},
|
||||
openai: {
|
||||
id: "openai",
|
||||
label: "OpenAI",
|
||||
tier: "experimental",
|
||||
kind: "cloud",
|
||||
requiresApiKey: true,
|
||||
requiresEndpoint: false,
|
||||
defaultModel: "gpt-4o-mini",
|
||||
description: "Optional cloud provider.",
|
||||
},
|
||||
anthropic: {
|
||||
id: "anthropic",
|
||||
label: "Anthropic",
|
||||
tier: "experimental",
|
||||
kind: "cloud",
|
||||
requiresApiKey: true,
|
||||
requiresEndpoint: false,
|
||||
defaultModel: "claude-3-5-sonnet-latest",
|
||||
description: "Optional cloud provider.",
|
||||
},
|
||||
};
|
||||
|
||||
export function getProviderCapabilities(provider: SummaryProviderId | undefined): ProviderCapabilities {
|
||||
return PROVIDER_CAPABILITIES[provider ?? "ollama"];
|
||||
}
|
||||
|
||||
export function getSelectedSummaryModel(summary: {
|
||||
provider: SummaryProviderId;
|
||||
ollamaModel: string;
|
||||
geminiModel: string;
|
||||
openaiModel: string;
|
||||
anthropicModel: string;
|
||||
}): string {
|
||||
switch (summary.provider) {
|
||||
case "gemini":
|
||||
return summary.geminiModel || PROVIDER_CAPABILITIES.gemini.defaultModel;
|
||||
case "openai":
|
||||
return summary.openaiModel || PROVIDER_CAPABILITIES.openai.defaultModel;
|
||||
case "anthropic":
|
||||
return summary.anthropicModel || PROVIDER_CAPABILITIES.anthropic.defaultModel;
|
||||
case "ollama":
|
||||
default:
|
||||
return summary.ollamaModel || PROVIDER_CAPABILITIES.ollama.defaultModel;
|
||||
}
|
||||
}
|
||||
109
src/domain/scenarios.ts
Normal file
109
src/domain/scenarios.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
export interface ScenarioTemplate {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
notePrefix: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SCENARIO_KEY = "work_meeting";
|
||||
|
||||
export const SCENARIOS: ScenarioTemplate[] = [
|
||||
{
|
||||
key: "work_meeting",
|
||||
label: "Meeting",
|
||||
description: "Decision log, topics, action items, owners.",
|
||||
notePrefix: "Meeting",
|
||||
prompt: `You are an expert meeting note-taker writing for a human reader.
|
||||
|
||||
Write plain Markdown in the SAME language as the transcript.
|
||||
|
||||
Rules:
|
||||
- Return raw Markdown only. No code fences. No preamble. No "markdown" label.
|
||||
- Do not add a document title. Start directly with the first section.
|
||||
- Use natural section titles in the output language. Never mix two languages in one heading.
|
||||
- Use only transcript facts. If something is uncertain, leave it out.
|
||||
- Omit empty sections.
|
||||
- Ignore obvious ASR noise, repeated filler, and bracketed cues when they add no value.
|
||||
|
||||
Structure:
|
||||
- Opening summary: 2 to 4 concise sentences with context, goal, and outcome.
|
||||
- Main topics: 2 to 5 short sections named after the real discussion topics.
|
||||
- Decisions: bullet list only if explicit decisions were made.
|
||||
- Action items: checklist only if real next steps were mentioned. Include owner or due date only if explicit.`,
|
||||
},
|
||||
{
|
||||
key: "brainstorming",
|
||||
label: "Brainstorming",
|
||||
description: "Idea clusters, assumptions, next experiments.",
|
||||
notePrefix: "Brainstorming",
|
||||
prompt: `You are a concise facilitator turning a brainstorming transcript into usable notes.
|
||||
|
||||
Write plain Markdown in the SAME language as the transcript.
|
||||
|
||||
Rules:
|
||||
- Return raw Markdown only. No code fences. No preamble. No document title.
|
||||
- Use natural section titles in the output language. Never mix two languages in one heading.
|
||||
- Use only transcript facts. If something is weak or unclear, leave it out.
|
||||
- Prefer tight bullets to long paragraphs.
|
||||
- Omit empty sections.
|
||||
- Ignore obvious ASR noise, repeated filler, and bracketed cues when they add no value.
|
||||
|
||||
Structure:
|
||||
- Opening summary: 2 to 4 sentences with the opportunity, direction, or key idea.
|
||||
- Idea clusters: group related ideas, tradeoffs, risks, and assumptions.
|
||||
- Open questions: include only if the transcript contains them.
|
||||
- Next experiments: checklist only if concrete validations or follow-ups were mentioned.`,
|
||||
},
|
||||
{
|
||||
key: "lecture",
|
||||
label: "Lecture",
|
||||
description: "Study notes, definitions, examples, follow-ups.",
|
||||
notePrefix: "Lecture",
|
||||
prompt: `You are an expert study note-taker turning a lecture transcript into clear notes.
|
||||
|
||||
Write plain Markdown in the SAME language as the transcript.
|
||||
|
||||
Rules:
|
||||
- Return raw Markdown only. No code fences. No preamble. No document title.
|
||||
- Use natural section titles in the output language. Never mix two languages in one heading.
|
||||
- Be didactic but concise.
|
||||
- Use only transcript facts. Do not add external knowledge.
|
||||
- Omit empty sections.
|
||||
- Ignore obvious ASR noise, repeated filler, and bracketed cues when they add no value.
|
||||
|
||||
Structure:
|
||||
- Opening summary: 2 to 4 sentences with the core lesson and main takeaways.
|
||||
- Topic sections: organize concepts, definitions, examples, and formulas only if they were actually mentioned.
|
||||
- Key takeaways: bullet list if the transcript naturally supports it.
|
||||
- Follow-ups: exercises, readings, or next study steps only if mentioned.`,
|
||||
},
|
||||
{
|
||||
key: "interview",
|
||||
label: "Interview",
|
||||
description: "Profile, themes, follow-ups, quotable moments.",
|
||||
notePrefix: "Interview",
|
||||
prompt: `You are an interview note-taker writing clean notes for a human reader.
|
||||
|
||||
Write plain Markdown in the SAME language as the transcript.
|
||||
|
||||
Rules:
|
||||
- Return raw Markdown only. No code fences. No preamble. No document title.
|
||||
- Use natural section titles in the output language. Never mix two languages in one heading.
|
||||
- Stay objective and avoid exaggerated tone.
|
||||
- Use only transcript facts. If something is uncertain, leave it out.
|
||||
- Omit empty sections.
|
||||
- Use short quotes only when they materially help.
|
||||
- Ignore obvious ASR noise, repeated filler, and bracketed cues when they add no value.
|
||||
|
||||
Structure:
|
||||
- Opening summary: 2 to 4 sentences with who the subject is, what was discussed, and the main takeaways.
|
||||
- Theme sections: organize the interview into short thematic sections.
|
||||
- Notable quotes: include only if brief and genuinely useful.
|
||||
- Follow-ups: checklist only if actual next steps were mentioned.`,
|
||||
},
|
||||
];
|
||||
|
||||
export function getScenario(key: string | undefined): ScenarioTemplate {
|
||||
return SCENARIOS.find((scenario) => scenario.key === key) ?? SCENARIOS[0];
|
||||
}
|
||||
166
src/domain/session.ts
Normal file
166
src/domain/session.ts
Normal 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
289
src/domain/settings.ts
Normal 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;
|
||||
}
|
||||
222
src/infrastructure/adapters/SummaryAdapter.ts
Normal file
222
src/infrastructure/adapters/SummaryAdapter.ts
Normal 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;
|
||||
}
|
||||
175
src/infrastructure/adapters/TranscriptionAdapter.ts
Normal file
175
src/infrastructure/adapters/TranscriptionAdapter.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
99
src/infrastructure/adapters/VaultAdapter.ts
Normal file
99
src/infrastructure/adapters/VaultAdapter.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
362
src/infrastructure/adapters/WebCaptureAdapter.ts
Normal file
362
src/infrastructure/adapters/WebCaptureAdapter.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
86
src/infrastructure/adapters/wavWriter.ts
Normal file
86
src/infrastructure/adapters/wavWriter.ts
Normal 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;
|
||||
}
|
||||
26
src/infrastructure/node.ts
Normal file
26
src/infrastructure/node.ts
Normal 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;
|
||||
}
|
||||
49
src/infrastructure/obsidianDesktop.ts
Normal file
49
src/infrastructure/obsidianDesktop.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import type { App, Plugin } from "obsidian";
|
||||
|
||||
interface PluginManagerBridge {
|
||||
getPlugin?: (pluginId: string) => Plugin | null | undefined;
|
||||
}
|
||||
|
||||
interface SettingsBridge {
|
||||
open?: () => void;
|
||||
openTabById?: (pluginId: string) => void;
|
||||
}
|
||||
|
||||
interface AppDesktopBridge {
|
||||
plugins?: PluginManagerBridge;
|
||||
setting?: SettingsBridge;
|
||||
}
|
||||
|
||||
type ToggleableElement = HTMLElement & {
|
||||
show?: () => void;
|
||||
hide?: () => void;
|
||||
};
|
||||
|
||||
export function getPluginInstance(app: App, pluginId: string): Plugin | null {
|
||||
const desktop = app as unknown as App & AppDesktopBridge;
|
||||
return desktop.plugins?.getPlugin?.(pluginId) ?? null;
|
||||
}
|
||||
|
||||
export function openPluginSettings(app: App, pluginId: string): void {
|
||||
const desktop = app as unknown as App & AppDesktopBridge;
|
||||
desktop.setting?.open?.();
|
||||
desktop.setting?.openTabById?.(pluginId);
|
||||
}
|
||||
|
||||
export function setElementVisibility(element: HTMLElement, visible: boolean): void {
|
||||
const toggleable = element as ToggleableElement;
|
||||
if (visible) {
|
||||
if (toggleable.show) {
|
||||
toggleable.show();
|
||||
return;
|
||||
}
|
||||
element.removeClass("rxn-hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
if (toggleable.hide) {
|
||||
toggleable.hide();
|
||||
return;
|
||||
}
|
||||
element.addClass("rxn-hidden");
|
||||
}
|
||||
418
src/infrastructure/storage/SessionStore.ts
Normal file
418
src/infrastructure/storage/SessionStore.ts
Normal 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 : "",
|
||||
};
|
||||
}
|
||||
374
src/infrastructure/system/DiagnosticsService.ts
Normal file
374
src/infrastructure/system/DiagnosticsService.ts
Normal 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.";
|
||||
}
|
||||
}
|
||||
260
src/infrastructure/system/autoDetect.ts
Normal file
260
src/infrastructure/system/autoDetect.ts
Normal 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;
|
||||
}
|
||||
95
src/infrastructure/system/webAudio.ts
Normal file
95
src/infrastructure/system/webAudio.ts
Normal 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
202
src/main.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import { Notice, Plugin } from "obsidian";
|
||||
import { SessionController } from "./application/SessionController";
|
||||
import { DEFAULT_SCENARIO_KEY } from "./domain/scenarios";
|
||||
import { isCoreConfigured, 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
1675
src/ui/SettingsTab.ts
Normal file
File diff suppressed because it is too large
Load diff
83
src/ui/copy.ts
Normal file
83
src/ui/copy.ts
Normal 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;
|
||||
65
src/ui/modals/ConfirmationModal.ts
Normal file
65
src/ui/modals/ConfirmationModal.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
303
src/ui/modals/RecordingModal.ts
Normal file
303
src/ui/modals/RecordingModal.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
30
src/ui/modals/TextPreviewModal.ts
Normal file
30
src/ui/modals/TextPreviewModal.ts
Normal 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
33
src/utils/format.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
export function slugifyForPath(value: string): string {
|
||||
return value.replace(/[\\/:*?"<>|]/g, "-").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
export function formatDateForFile(input: string | Date): string {
|
||||
const date = typeof input === "string" ? new Date(input) : input;
|
||||
const yyyy = String(date.getFullYear());
|
||||
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(date.getDate()).padStart(2, "0");
|
||||
const hh = String(date.getHours()).padStart(2, "0");
|
||||
const min = String(date.getMinutes()).padStart(2, "0");
|
||||
const ss = String(date.getSeconds()).padStart(2, "0");
|
||||
return `${yyyy}-${mm}-${dd} ${hh}-${min}-${ss}`;
|
||||
}
|
||||
|
||||
export function formatDuration(totalSeconds: number): string {
|
||||
const safe = Math.max(0, Math.floor(totalSeconds));
|
||||
const minutes = String(Math.floor(safe / 60)).padStart(2, "0");
|
||||
const seconds = String(safe % 60).padStart(2, "0");
|
||||
return `${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
||||
}
|
||||
127
src/utils/markdown.ts
Normal file
127
src/utils/markdown.ts
Normal 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
1260
styles.css
File diff suppressed because it is too large
Load diff
24
tests/autoDetect.test.ts
Normal file
24
tests/autoDetect.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { getPreferredWhisperModelBasenames, inferWhisperRepoPath } from "../src/infrastructure/system/autoDetect";
|
||||
|
||||
test("inferWhisperRepoPath resolves repo root from whisper-cli path", () => {
|
||||
assert.equal(
|
||||
inferWhisperRepoPath("/Users/test/whisper.cpp/build/bin/whisper-cli"),
|
||||
"/Users/test/whisper.cpp"
|
||||
);
|
||||
assert.equal(
|
||||
inferWhisperRepoPath("C:\\dev\\whisper.cpp\\build\\bin\\Release\\whisper-cli.exe"),
|
||||
"C:/dev/whisper.cpp"
|
||||
);
|
||||
});
|
||||
|
||||
test("getPreferredWhisperModelBasenames prioritizes the selected preset first", () => {
|
||||
const names = getPreferredWhisperModelBasenames("large");
|
||||
assert.deepEqual(names.slice(0, 4), [
|
||||
"ggml-large.bin",
|
||||
"ggml-large.en.bin",
|
||||
"ggml-base.bin",
|
||||
"ggml-base.en.bin",
|
||||
]);
|
||||
});
|
||||
232
tests/dashboard.test.ts
Normal file
232
tests/dashboard.test.ts
Normal 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
31
tests/markdown.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { formatLiveTranscriptNote, formatTranscriptChunkMarkdown, sanitizeSummary } from "../src/utils/markdown";
|
||||
|
||||
test("formatTranscriptChunkMarkdown emits plain transcript text without segment headings", () => {
|
||||
assert.equal(
|
||||
formatTranscriptChunkMarkdown(3, "Ciao.\n\nCome va?"),
|
||||
"Ciao.\n\nCome va?"
|
||||
);
|
||||
});
|
||||
|
||||
test("formatLiveTranscriptNote builds a user-facing transcript note", () => {
|
||||
const note = formatLiveTranscriptNote("Meeting — Transcript", "Prima riga.\n\nSeconda riga.");
|
||||
assert.match(note, /^# Meeting — Transcript/);
|
||||
assert.match(note, /> Live draft while recording/);
|
||||
assert.ok(!note.includes("Segment 0000"));
|
||||
});
|
||||
|
||||
test("formatTranscriptChunkMarkdown removes standalone annotation lines in brackets", () => {
|
||||
assert.equal(
|
||||
formatTranscriptChunkMarkdown(1, "[Musica]\n\nCiao a tutti.\n[Sottotitoli e rispondenti del mio canale]\nCome va?"),
|
||||
"Ciao a tutti.\nCome va?"
|
||||
);
|
||||
});
|
||||
|
||||
test("sanitizeSummary removes a wrapping markdown code fence", () => {
|
||||
assert.equal(
|
||||
sanitizeSummary("```markdown\n## Riassunto\n\nTesto.\n```"),
|
||||
"## Riassunto\n\nTesto."
|
||||
);
|
||||
});
|
||||
49
tests/orderedSegmentQueue.test.ts
Normal file
49
tests/orderedSegmentQueue.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { OrderedSegmentQueue } from "../src/application/OrderedSegmentQueue";
|
||||
|
||||
test("OrderedSegmentQueue commits strictly in segment order", async () => {
|
||||
const committed: number[] = [];
|
||||
const queue = new OrderedSegmentQueue(async (segment) => {
|
||||
committed.push(segment.index);
|
||||
});
|
||||
|
||||
queue.enqueue([
|
||||
{ index: 2, path: "segment-0002.mp3" },
|
||||
{ index: 0, path: "segment-0000.mp3" },
|
||||
{ index: 1, path: "segment-0001.mp3" },
|
||||
]);
|
||||
|
||||
await queue.whenIdle();
|
||||
assert.deepEqual(committed, [0, 1, 2]);
|
||||
});
|
||||
|
||||
test("OrderedSegmentQueue ignores already committed segments", async () => {
|
||||
const committed: number[] = [];
|
||||
const queue = new OrderedSegmentQueue(async (segment) => {
|
||||
committed.push(segment.index);
|
||||
}, 1);
|
||||
|
||||
queue.enqueue([
|
||||
{ index: 0, path: "segment-0000.mp3" },
|
||||
{ index: 1, path: "segment-0001.mp3" },
|
||||
]);
|
||||
|
||||
await queue.whenIdle();
|
||||
assert.deepEqual(committed, [1]);
|
||||
});
|
||||
|
||||
test("OrderedSegmentQueue resumes from the next expected segment index", async () => {
|
||||
const committed: number[] = [];
|
||||
const queue = new OrderedSegmentQueue(async (segment) => {
|
||||
committed.push(segment.index);
|
||||
}, 1);
|
||||
|
||||
queue.enqueue([
|
||||
{ index: 1, path: "segment-0001.mp3" },
|
||||
{ index: 2, path: "segment-0002.mp3" },
|
||||
]);
|
||||
|
||||
await queue.whenIdle();
|
||||
assert.deepEqual(committed, [1, 2]);
|
||||
});
|
||||
9
tests/sessionSchema.test.ts
Normal file
9
tests/sessionSchema.test.ts
Normal 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
111
tests/sessionStore.test.ts
Normal 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
104
tests/settings.test.ts
Normal 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
64
tests/wavWriter.test.ts
Normal 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
60
tests/webAudio.test.ts
Normal 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
312
tests/webCapture.test.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
|
|
@ -6,11 +6,13 @@
|
|||
"lib": ["ES2020", "DOM"],
|
||||
"allowJs": false,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noEmit": true,
|
||||
"types": ["node"],
|
||||
"baseUrl": ".",
|
||||
"paths": {}
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["node_modules", "main.js", "main.js.map"]
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts", "vite.config.ts"],
|
||||
"exclude": ["node_modules", "main.js", "main.js.map", "dist", ".test-dist"]
|
||||
}
|
||||
|
|
|
|||
22
tsconfig.tests.json
Normal file
22
tsconfig.tests.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"types": ["node"],
|
||||
"outDir": ".test-dist",
|
||||
"rootDir": ".",
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": [
|
||||
"src/domain/**/*.ts",
|
||||
"src/application/OrderedSegmentQueue.ts",
|
||||
"src/application/dashboard.ts",
|
||||
"src/infrastructure/system/autoDetect.ts",
|
||||
"src/infrastructure/system/deviceScanner.ts",
|
||||
"tests/**/*.ts"
|
||||
]
|
||||
}
|
||||
|
|
@ -4,10 +4,10 @@ import { builtinModules } from 'module';
|
|||
export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: 'main.ts',
|
||||
entry: 'src/main.ts',
|
||||
formats: ['cjs'],
|
||||
fileName: () => 'main',
|
||||
name: 'resonance',
|
||||
name: 'resonanceNext',
|
||||
},
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
|
|
|
|||
Loading…
Reference in a new issue