migliorati settings, library, recording

This commit is contained in:
Michael Gorini 2025-08-29 15:55:07 +02:00
parent 04db924513
commit e8628b0cf1
13 changed files with 1069 additions and 306 deletions

View file

@ -22,7 +22,7 @@ export async function checkDependencies(options: {
const missingMessages: string[] = [];
const hasApiKey = !!apiKey && apiKey.trim().length > 0;
if (!hasApiKey) missingMessages.push("API Key mancante");
if (!hasApiKey) missingMessages.push("Missing API Key");
let ffmpegOk = false;
let whisperOk = false;
@ -40,9 +40,9 @@ export async function checkDependencies(options: {
resolve();
});
});
if (!ffmpegOk) missingMessages.push("FFmpeg non trovato o non eseguibile");
if (!ffmpegOk) missingMessages.push("FFmpeg not found or not executable");
} else {
missingMessages.push("Percorso FFmpeg non impostato");
missingMessages.push("FFmpeg path not set");
}
if (whisperMainPath) {
@ -52,9 +52,9 @@ export async function checkDependencies(options: {
resolve();
});
});
if (!whisperOk) missingMessages.push("whisper.cpp (main) non trovato o non eseguibile");
if (!whisperOk) missingMessages.push("whisper.cpp (main) not found or not executable");
} else {
missingMessages.push("Percorso whisper.cpp (main) non impostato");
missingMessages.push("whisper.cpp (main) path not set");
}
if (whisperModelPath) {
@ -64,16 +64,16 @@ export async function checkDependencies(options: {
resolve();
});
});
if (!modelOk) missingMessages.push("Modello Whisper non letto/trovato");
if (!modelOk) missingMessages.push("Whisper model not readable/found");
} else {
missingMessages.push("Percorso modello Whisper non impostato");
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("Impossibile verificare FFmpeg (restrizioni runtime)");
if (!whisperOk) missingMessages.push("Impossibile verificare whisper.cpp (restrizioni runtime)");
if (!modelOk) missingMessages.push("Impossibile verificare il modello Whisper (restrizioni runtime)");
if (!ffmpegOk) missingMessages.push("Unable to verify FFmpeg (runtime restrictions)");
if (!whisperOk) missingMessages.push("Unable to verify whisper.cpp (runtime restrictions)");
if (!modelOk) missingMessages.push("Unable to verify Whisper model (runtime restrictions)");
}
return { hasApiKey, ffmpegOk, whisperOk, modelOk, missingMessages };

View file

@ -1,15 +1,15 @@
// DeviceScanner: utility per interrogare FFmpeg e ricavare i nomi dei dispositivi
// Funziona invocando ffmpeg con i parametri di list dei dispositivi per i backend supportati.
// 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; // stringa da usare come spec ffmpeg (es. audio=Microphone (...) per dshow; :0 per avfoundation audio)
label: string; // etichetta leggibile
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('Percorso FFmpeg non configurato');
if (!ffmpegPath) throw new Error('FFmpeg path not configured');
const { spawn } = (window as any).require('child_process');
const args = backend === 'dshow'

View file

@ -16,11 +16,11 @@ export class HelpModal extends Modal {
contentEl.addClass('resonance-modal');
const title = {
ffmpeg: 'Guida installazione FFmpeg',
whisper: 'Guida installazione Whisper.cpp',
llm: 'Guida configurazione LLM (Gemini)',
devices: 'Guida dispositivi audio (FFmpeg)',
obsidian: 'Guida configurazione Obsidian',
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 });
@ -32,8 +32,24 @@ export class HelpModal extends Modal {
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');
code.innerText = sec.code.trim();
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);
}
});
}
});
}
@ -42,36 +58,58 @@ export class HelpModal extends Modal {
switch (topic) {
case 'ffmpeg':
return [
{ title: 'Cosè FFmpeg', paragraphs: ['Utility usata per acquisire/registrare audio. È richiesta per registrare riunioni.'] },
{ title: 'macOS', paragraphs: ['Installa Homebrew, poi FFmpeg, e rileva il percorso.'], code: 'xcode-select --install\nbrew install ffmpeg' },
{ title: 'Windows', paragraphs: ['Scarica la build statica da ffmpeg.org, estrai e punta a ffmpeg.exe.'], code: 'C:/ffmpeg/bin/ffmpeg.exe' },
{ title: 'Linux', paragraphs: ['Installa dal gestore pacchetti del sistema (apt, yum, pacman).'], code: 'sudo apt install ffmpeg' },
{ title: 'What is FFmpeg', paragraphs: [
'FFmpeg is a crossplatform tool to capture, convert and process audio/video.',
'Resonance uses FFmpeg to record the microphone and (optionally) system audio.'
]},
{ title: 'macOS', paragraphs: [
'Install Xcode Command Line Tools and Homebrew, then install FFmpeg with the following commands.',
], code: 'brew install ffmpeg' },
{ title: 'Windows', paragraphs: [
'Download a static build from the official website, extract it, then point to ffmpeg.exe.',
'Alternatively, if you use Chocolatey: choco install ffmpeg.',
], code: 'C:/ffmpeg/bin/ffmpeg.exe\n:: or\nchoco install ffmpeg' },
{ title: 'Linux', paragraphs: [
'Install FFmpeg from your package manager (Debian/Ubuntu, Fedora, Arch examples).',
], code: 'sudo apt update && sudo apt install -y ffmpeg\n# Fedora/RHEL\nsudo dnf install -y ffmpeg\n# Arch\nsudo pacman -S ffmpeg' },
];
case 'whisper':
return [
{ title: 'Cosè whisper.cpp', paragraphs: ['Trascrizione locale. Usa modelli generici multilingua (.bin). Seleziona una lingua nelle impostazioni oppure lascia Automatico.'] },
{ title: 'Repo locale', paragraphs: ['Imposta la cartella root del repo whisper.cpp nelle impostazioni. Il plugin proverà a trovare automaticamente whisper-cli dentro build/bin/.'] },
{ title: 'Compilazione', paragraphs: ['Compila il progetto (make su macOS/Linux o CMake/Visual Studio su Windows).'], code: 'git clone https://github.com/ggerganov/whisper.cpp\ncd whisper.cpp\n# macOS/Linux\nmake -j\n# Windows\ncmake -B build -S .\ncmake --build build --config Release -j' },
{ title: 'Scaricare un modello', paragraphs: ['Usa la voce Modello (preset) nelle impostazioni per scaricare small/medium/large in whisper.cpp/models/. Oppure esegui lo script del repo.'], code: './models/download-ggml-model.sh medium' },
{ title: 'Esecuzione di test', paragraphs: ['Esempio con lingua italiana:'], code: './build/bin/whisper-cli -m ./models/ggml-medium.bin -f ./samples/jfk.wav -l it' },
{ title: 'What is whisper.cpp', paragraphs: [
'whisper.cpp is a local speechtotext engine; Resonance runs it on your machine.',
'You must set the repo path and choose/download a model (.bin).'
]},
{ title: 'macOS', paragraphs: [
'Clone the repo and build with make; models go into whisper.cpp/models/.',
'In Settings → Resonance, set the whisper.cpp repo path and the whisper-cli executable.',
'In Settings → Resonance, select and download the model you prefer.'
], code: 'git clone https://github.com/ggerganov/whisper.cpp\ncd whisper.cpp\nmake -j' },
{ title: 'Windows', paragraphs: [
'Clone the repo; build with CMake/Visual Studio in Release; models in whisper.cpp\\models\\.',
'In Settings → Resonance, set the whisper.cpp repo path and the whisper-cli executable.',
'In Settings → Resonance, select and download the model you prefer.'
], code: 'git clone https://github.com/ggerganov/whisper.cpp\ncd whisper.cpp\ncmake -B build -S .\ncmake --build build --config Release -j\n# example model\npowershell -Command "Invoke-WebRequest https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin -OutFile models/ggml-medium.bin"' },
{ title: 'Linux', paragraphs: [
'Build with make; install build tools if needed; download a model.',
'In Settings → Resonance, set the whisper.cpp repo path and the whisper-cli executable.',
'In Settings → Resonance, select and download the model you prefer.'
], code: 'sudo apt update && sudo apt install -y build-essential git\ngit clone https://github.com/ggerganov/whisper.cpp\ncd whisper.cpp && make -j\n./models/download-ggml-model.sh medium' },
{ title: 'Test run', paragraphs: ['Example with Italian language:'], code: './build/bin/whisper-cli -m ./models/ggml-medium.bin -f ./samples/jfk.wav -l it' },
];
case 'llm':
return [
{ title: 'Google Gemini', paragraphs: ['Crea lAPI Key in Google AI Studio e incollala nelle impostazioni. Scegli il modello (flash/pro/exp).'] },
{ title: 'Privacy', paragraphs: ['Solo la trascrizione testuale viene inviata al servizio per il riassunto.'] },
{ 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: 'Selezione dispositivi', paragraphs: ['Clicca Scansiona per popolare i menu di Microfono e Audio di sistema.'] },
{ title: 'Windows (dshow)', paragraphs: ['I nomi appaiono come audio=… Mic / Stereo Mix.'] },
{ title: 'macOS (avfoundation)', paragraphs: ['I dispositivi sono indicizzati. Laudio di sistema può richiedere un dispositivo virtuale (es. BlackHole).'] },
{ title: 'Linux (pulse/alsa)', paragraphs: ['Spesso il mic è default. Laudio di sistema richiede un loopback.'] },
];
case 'obsidian':
return [
{ title: 'Cartella note', paragraphs: ['Imposta la cartella del vault dove salvare le note generate. Se vuoto: root del vault.'] },
{ title: 'Ricaricare il plugin', paragraphs: ['Dopo una build, disattiva/riattiva il plugin o riavvia Obsidian.'] },
{ 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 Normal file
View file

@ -0,0 +1,400 @@
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;
}

124
README.md
View file

@ -1,55 +1,99 @@
# Resonance (Obsidian Plugin)
<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>
Registra riunioni, trascrive localmente con whisper.cpp, riassume con Google Gemini e crea automaticamente una nota in Obsidian.
<div align="center">
<h2>Resonance</h2>
<p>
Record → Transcribe → Summarize → Create note. <br/>
A localfirst recording & meeting notes workflow for Obsidian.
</p>
<p>
<a href="#installation">Installation</a> ·
<a href="#usage">Usage</a> ·
<a href="#settings">Settings</a> ·
<a href="#troubleshooting">Troubleshooting</a>
</p>
</div>
## Perché Vite
Vite è un bundler moderno che compila TypeScript in JavaScript in modo rapido e con DX eccellente.
- Build velocissima (watch in ms) → feedback immediato durante lo sviluppo.
- Configurazione semplice: produce `dist/main.js` in formato CommonJS richiesto da Obsidian.
- Mappa sorgenti (`.map`) utile per il debug.
## What it does
Script:
```bash
npm run dev # build in watch (rigenera dist/main.js ad ogni modifica)
npm run build # build una tantum per rilasciare (copia anche manifest.json e styles.css in dist/)
```
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.
## Features
- 🎙️ Record microphone and optionally system audio (depends on OS setup)
- 🧠 Local transcription via whisper.cpp
- ✨ AI summary via Google Gemini
- ⏱️ Statusbar timer and ribbon shortcuts
- 📚 Library to review, play, download or delete recordings/transcripts
## Requirements
## Requisiti
- Obsidian Desktop ≥ 1.5
- Node.js ≥ 18 (per build)
- FFmpeg installato localmente (per la registrazione audio)
- whisper.cpp compilato localmente (per la trascrizione)
- Chiave API Google Gemini (per il riassunto)
- FFmpeg installed locally
- whisper.cpp built locally (binary and model .bin)
- Google Gemini API Key (for summaries)
## Installazione sviluppo
## Installation
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.
From source (developers):
```bash
npm install
npm run build
npm run dev # watch build
npm run build # production build (outputs to dist/)
```
Troverai gli artefatti in `dist/`:
- `dist/manifest.json`
- `dist/main.js`
- `dist/styles.css`
Artifacts are emitted to `dist/`.
### Installazione nel Vault
Copia l'intera cartella `dist/` dentro `<Vault>/.obsidian/plugins/resonance/` (o copia i 3 file elencati sopra mantenendo i nomi). Riavvia/ricarica Obsidian e abilita il plugin.
## Usage
## UX avanzate incluse
- Scansione dispositivi FFmpeg (Windows/macOS): pulsante "Scansiona dispositivi audio" nelle impostazioni.
- Dropdown per scegliere rapidamente microfono e (opzionalmente) audio di sistema.
- Test rapido FFmpeg (3 secondi) per verificare il dispositivo.
- Pulsante "Annulla" durante Trascrizione/Riassunto.
- Scelta del modello Gemini.
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.
## Configurazione
Impostazioni → Resonance: API Key/model, FFmpeg + backend e device, whisper `main` e modello `.bin`, cartella note.
## Settings
## Build e ship
- Sviluppo: `npm run dev` e symlink/copia `dist/` nel vault
- Rilascio: `npm run build`, zippa il contenuto di `dist/`
- **FFmpeg**: executable path; backend (auto/dshow/avfoundation/pulse/alsa); device scan; 3second test.
- **Whisper**: repo path, whispercli path, model picker or autodownload; transcription language (auto or ISO code).
- **LLM**: Gemini API Key and model.
- **Obsidian**: output folder for generated notes.
- **Library & retention**: maximum recordings to keep (0 = infinite).
## How it works (overview)
1) FFmpeg writes an `.mp3` to `<vault>/.obsidian/plugins/resonance/recordings/`
2) whisper.cpp transcribes locally and writes a `.txt` transcript
3) Gemini summarizes the transcript
4) Resonance creates a Markdown note in your chosen folder
## Privacy
- Audio never leaves your machine.
- Only the text transcript is sent to Gemini for summarization.
- The API Key is stored locally in your vault.
## Troubleshooting
- "Configurazione incompleta" → imposta API Key, FFmpeg/whisper/model.
- Audio mancante → backend/device; usa scansione e test 3s.
- Trascrizione vuota → controlla `.mp3` e modello `.bin`.
- Errore Gemini → API Key e modello.
- “Incomplete configuration”: set FFmpeg path, whisper main, model, and API key.
- No audio: verify backend/device; use Scan and the 3second Test.
- Empty transcription: check the `.mp3` file and the model path.
- Gemini error: verify API key and selected model.
## Contributing
Issues and PRs are welcome. If youd like to help with docs or UX, please open an issue to coordinate.
---
Built with Vite. This plugin is desktoponly.

View file

@ -15,11 +15,13 @@ export class RecorderService {
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;
@ -71,9 +73,16 @@ export class RecorderService {
}
}
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();
@ -81,9 +90,10 @@ export class RecorderService {
try {
const fs = (window as any).require("fs");
const stat = fs.statSync(this.audioPath);
this.appendLog(`Registrazione terminata. File: ${this.audioPath} (${stat.size} bytes)`);
} catch { this.appendLog(`Registrazione terminata. File: ${this.audioPath}`); }
this.onInfo?.(`File audio salvato in: ${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);
@ -97,10 +107,10 @@ export class RecorderService {
const path = (window as any).require("path");
const fs = (window as any).require("fs");
// Determina la cartella del plugin nel vault: <vault>/.obsidian/plugins/<pluginId>/recordings
// 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("Impossibile determinare il percorso del vault (solo Desktop)");
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");
@ -109,7 +119,7 @@ export class RecorderService {
try { if (!fs.existsSync(recDir)) fs.mkdirSync(recDir, { recursive: true }); } catch {}
const stamp = new Date();
const name = `registrazione_${
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;
@ -121,6 +131,36 @@ export class RecorderService {
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;
@ -129,7 +169,7 @@ export class RecorderService {
}
const mic = (this.settings.ffmpegMicDevice || "").trim();
const sys = (this.settings.ffmpegSystemDevice || "").trim();
// macOS: audio-only -> ":index" (non "index:")
// 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 || "" };
@ -137,7 +177,7 @@ export class RecorderService {
private async beginRecording() {
const { ffmpegPath } = this.settings;
if (!ffmpegPath) throw new Error("Percorso FFmpeg non configurato");
if (!ffmpegPath) throw new Error("FFmpeg path not configured");
const { spawn } = (window as any).require("child_process");
@ -145,7 +185,7 @@ export class RecorderService {
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("Nessun dispositivo di input FFmpeg configurato. Imposta almeno il microfono.");
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"] : [];
@ -156,18 +196,27 @@ export class RecorderService {
const args = ["-y", ...inputs, ...mixArgs, ...outputArgs];
this.ffmpegChild = spawn(ffmpegPath, args, { detached: false });
this.appendLog(`FFmpeg avviato: ${ffmpegPath} ${args.join(" ")}`);
this.appendLog(`FFmpeg started: ${ffmpegPath} ${args.join(" ")}`);
// Se FFmpeg termina subito con errore, notifichiamo
// 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) => {
if (this.phase === "recording" && code !== 0) {
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 terminato con codice ${code}.\n${tail}`);
this.appendLog(`FFmpeg errore (${code}):\n${tail}`);
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 () => {
@ -198,8 +247,8 @@ export class RecorderService {
private async transcribe() {
this.setPhase("transcribing");
const { whisperMainPath, whisperModelPath, whisperLanguage } = this.settings;
if (!whisperMainPath) throw new Error("Percorso whisper-cli non configurato");
if (!whisperModelPath) throw new Error("Percorso modello Whisper non configurato");
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");
@ -207,7 +256,7 @@ export class RecorderService {
const args = ["-m", whisperModelPath, "-f", this.audioPath];
const lang = (whisperLanguage || "auto").trim();
if (lang && lang !== "auto") { args.push("-l", lang); }
this.appendLog(`Trascrizione: ${whisperMainPath} ${args.join(" ")}`);
this.appendLog(`Transcription: ${whisperMainPath} ${args.join(" ")}`);
const stdoutBuf: string[] = [];
let stderrBuf = "";
@ -218,14 +267,14 @@ export class RecorderService {
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 uscita con codice ${code}: ${stderrBuf}`));
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("Trascrizione vuota o non trovata");
try { fs.writeFileSync(this.transcriptPath, transcript, { encoding: "utf8" }); this.appendLog(`Trascrizione salvata: ${this.transcriptPath} (${transcript.length} chars)`); } catch {}
this.onInfo?.(`Trascrizione salvata: ${this.transcriptPath}`);
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);
}
@ -233,20 +282,11 @@ export class RecorderService {
private async summarize(transcript: string) {
this.setPhase("summarizing");
const { geminiApiKey, geminiModel } = this.settings;
if (!geminiApiKey) throw new Error("API Key Gemini non configurata");
if (!geminiApiKey) throw new Error("Gemini API Key not configured");
const prompt = `Sei un assistente AI d'élite, specializzato nell'estrarre l'essenza da dialoghi professionali. Analizza la seguente trascrizione di una riunione e distilla le informazioni in un report conciso e strutturato in formato Markdown. Il tuo output deve essere immediatamente utilizzabile.
## Punti Salienti
- Un elenco puntato che cattura le conclusioni, le intuizioni e i temi principali emersi dalla discussione.
## Decisioni Formalizzate
- Un elenco chiaro e diretto delle decisioni approvate. Se non ci sono decisioni esplicite, scrivi 'Nessuna decisione formale presa'.
## Piano d'Azione
- Una checklist di task da completare, formattata come: - [ ] Descrizione chiara del task @Proprietario (se identificabile)
Se una sezione risulta vuota, omettila elegantemente dal report finale. Concentrati sulla chiarezza e la sintesi.`;
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`;
@ -271,24 +311,24 @@ Se una sezione risulta vuota, omettila elegantemente dal report finale. Concentr
if (!res.ok) {
const errTxt = await res.text().catch(() => "");
throw new Error(`Errore API Gemini: ${res.status} ${errTxt}`);
throw new Error(`Gemini API error: ${res.status} ${errTxt}`);
}
const json = await res.json();
const summary = extractMarkdownFromGemini(json) || "";
if (!summary) throw new Error("Riassunto vuoto dalla risposta di Gemini");
this.appendLog(`Riassunto generato (${summary.length} chars)`);
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 = `Riunione ${window.moment().format("YYYY-MM-DD HH-mm")}.md`;
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("Nota creata!");
this.appendLog(`Nota creata: ${fullPath}`);
new Notice("Note created!");
this.appendLog(`Note created: ${fullPath}`);
this.setPhase("done");
const leaf = this.app.workspace.getLeaf(true);

View file

@ -1,9 +1,9 @@
import { App, Modal, Notice, TFile, moment } from "obsidian";
import { ResonanceSettings } from "./settings";
// Nota importante sull'esecuzione di processi esterni:
// Obsidian Desktop consente l'accesso a Node.js (Electron) in plugin community.
// Usiamo child_process per invocare ffmpeg e whisper.cpp in modo controllato.
// 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";
@ -21,7 +21,7 @@ export class RecordingModal extends Modal {
private ffmpegChild: any | null = null;
private stopRecordingFn: (() => Promise<void>) | null = null;
// Elementi UI di riferimento per aggiornamenti dinamici
// UI element refs for dynamic updates
private statusEl!: HTMLElement;
private timerEl!: HTMLElement;
private controlBtn!: HTMLButtonElement;
@ -38,11 +38,11 @@ export class RecordingModal extends Modal {
contentEl.empty();
contentEl.addClass("resonance-modal");
contentEl.createEl("h2", { text: "Registratore Riunione" });
contentEl.createEl("h2", { text: "Meeting Recorder" });
const status = contentEl.createEl("div", { cls: "resonance-status" });
this.statusEl = status;
this.setStatus("Pronto per registrare");
this.setStatus("Ready to record");
const timer = contentEl.createEl("div", { cls: "resonance-timer", text: "00:00" });
this.timerEl = timer;
@ -53,13 +53,13 @@ export class RecordingModal extends Modal {
const controls = contentEl.createEl("div", { cls: "resonance-controls" });
const btn = controls.createEl("button", { cls: "resonance-btn primary" });
btn.appendChild(createIcon("microphone"));
btn.appendText(" Avvia Registrazione");
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(" Annulla");
cancel.appendText(" Cancel");
cancel.addEventListener("click", () => this.cancelFlow());
this.cancelBtn = cancel as HTMLButtonElement;
this.cancelBtn.hide();
@ -77,36 +77,36 @@ export class RecordingModal extends Modal {
this.state.phase = phase;
switch (phase) {
case "idle":
this.setStatus("Pronto per registrare");
this.updateButton("primary", "microphone", "Avvia Registrazione", true);
this.setStatus("Ready to record");
this.updateButton("primary", "microphone", "Start Recording", true);
this.cancelBtn.hide();
this.waveEl.removeClass("active");
break;
case "recording":
this.setStatus("Registrazione...");
this.updateButton("danger", "square", "Ferma Registrazione", true);
this.setStatus("Recording...");
this.updateButton("danger", "square", "Stop Recording", true);
this.cancelBtn.hide();
this.waveEl.addClass("active");
break;
case "transcribing":
this.setStatus("Trascrizione...");
this.updateButton("disabled", "loader", "Elaborazione...", false, true);
this.setStatus("Transcribing...");
this.updateButton("disabled", "loader", "Processing...", false, true);
this.cancelBtn.show();
this.waveEl.removeClass("active");
break;
case "summarizing":
this.setStatus("Riassunto...");
this.updateButton("disabled", "loader", "Elaborazione...", false, true);
this.setStatus("Summarizing...");
this.updateButton("disabled", "loader", "Processing...", false, true);
this.cancelBtn.show();
this.waveEl.removeClass("active");
break;
case "error":
this.updateButton("primary", "microphone", "Riprova", true);
this.updateButton("primary", "microphone", "Retry", true);
this.cancelBtn.hide();
this.waveEl.removeClass("active");
break;
case "done":
this.updateButton("primary", "microphone", "Nuova Registrazione", true);
this.updateButton("primary", "microphone", "New Recording", true);
this.cancelBtn.hide();
this.waveEl.removeClass("active");
break;
@ -153,7 +153,7 @@ export class RecordingModal extends Modal {
await this.stopRecordingFlow();
}
} catch (e: any) {
new Notice(`Errore: ${e?.message ?? e}`);
new Notice(`Error: ${e?.message ?? e}`);
this.state.errorMessage = String(e?.message ?? e);
this.setPhase("error");
await this.cleanupTemp();
@ -171,10 +171,10 @@ export class RecordingModal extends Modal {
}
}
// Fase 1: Registrazione Audio con ffmpeg (crossplatform)
// Phase 1: Audio recording with ffmpeg (crossplatform)
private async beginRecordingFlow() {
const { ffmpegPath } = this.settings;
if (!ffmpegPath) throw new Error("Percorso FFmpeg non configurato");
if (!ffmpegPath) throw new Error("FFmpeg path not configured");
const os = (window as any).require("os");
const path = (window as any).require("path");
@ -182,7 +182,7 @@ export class RecordingModal extends Modal {
const { spawn } = (window as any).require("child_process");
this.tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "resonance-"));
this.tempAudio = path.join(this.tempDir, "registrazione.mp3");
this.tempAudio = path.join(this.tempDir, "recording.mp3");
const { format, micSpec, systemSpec } = this.resolveFfmpegInput();
@ -194,9 +194,7 @@ export class RecordingModal extends Modal {
inputs.push("-f", format, "-i", systemSpec);
}
if (inputs.length === 0) {
throw new Error("Nessun dispositivo di input FFmpeg configurato. Imposta almeno il microfono.");
}
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
@ -211,7 +209,7 @@ export class RecordingModal extends Modal {
const args = [...inputs, ...mixArgs, ...outputArgs];
this.ffmpegChild = spawn(ffmpegPath, args, { detached: false });
// Riferimento per stop (crossplatform)
// Stop reference (crossplatform)
this.stopRecordingFn = async () => {
try {
const child = this.ffmpegChild;
@ -248,14 +246,14 @@ export class RecordingModal extends Modal {
const sys = (this.settings.ffmpegSystemDevice || "").trim();
if (format === "avfoundation") {
// macOS: audio-only -> ":index" (non "index:")
// macOS: audio-only -> ":index" (not "index:")
return { format, micSpec: mic || ":0", systemSpec: sys || "" };
}
if (format === "dshow") {
// Windows: serve 'audio=Nome Dispositivo'
// Windows: requires 'audio=Device Name'
return { format, micSpec: mic || "audio=Microphone (default)", systemSpec: sys || "" };
}
// Linux (pulse/alsa): spesso 'default' funziona per mic; system audio richiede setup loopback
// Linux (pulse/alsa): 'default' often works for mic; system audio requires loopback setup
return { format, micSpec: mic || "default", systemSpec: sys || "" };
}
@ -265,16 +263,16 @@ export class RecordingModal extends Modal {
this.stopRecordingFn = null;
}
this.clearTimer();
await waitMs(500); // tempo per chiusura file
await waitMs(500); // allow file to be closed
await this.transcribeFlow();
}
// Fase 2: Trascrizione con whisper.cpp
// Phase 2: Transcription with whisper.cpp
private async transcribeFlow() {
this.setPhase("transcribing");
const { whisperMainPath, whisperModelPath, whisperLanguage } = this.settings;
if (!whisperMainPath) throw new Error("Percorso whisper-cli non configurato");
if (!whisperModelPath) throw new Error("Percorso modello Whisper non configurato");
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");
@ -292,35 +290,35 @@ export class RecordingModal extends Modal {
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 uscita con codice ${code}: ${stderrBuf}`));
if (code === 0) resolve(); else reject(new Error(`whisper-cli exited with code ${code}: ${stderrBuf}`));
});
});
// whisper-cli stampa la trascrizione su stdout; usiamo quella.
// whisper-cli prints the transcript to stdout; use that.
const transcript = stdoutBuf.join("").trim();
if (!transcript) throw new Error("Trascrizione vuota o non trovata");
if (!transcript) throw new Error("Empty transcription");
await this.summarizeFlow(transcript);
}
// Fase 3: Riassunto con Google Gemini
// Phase 3: Summarization with Google Gemini
private async summarizeFlow(transcript: string) {
this.setPhase("summarizing");
const { geminiApiKey, geminiModel } = this.settings;
if (!geminiApiKey) throw new Error("API Key Gemini non configurata");
if (!geminiApiKey) throw new Error("Gemini API Key not configured");
const prompt = `Sei un assistente AI d'élite, specializzato nell'estrarre l'essenza da dialoghi professionali. Analizza la seguente trascrizione di una riunione e distilla le informazioni in un report conciso e strutturato in formato Markdown. Il tuo output deve essere immediatamente utilizzabile.
const prompt = `You are a senior AI assistant specialized in distilling meetings into actionable notes. Read the transcript and produce a concise, wellstructured Markdown report.
## Punti Salienti
- Un elenco puntato che cattura le conclusioni, le intuizioni e i temi principali emersi dalla discussione.
## Highlights
- Bulleted list of key outcomes, insights, and themes.
## Decisioni Formalizzate
- Un elenco chiaro e diretto delle decisioni approvate. Se non ci sono decisioni esplicite, scrivi 'Nessuna decisione formale presa'.
## Decisions
- Bullet list of confirmed decisions. If none, write 'No explicit decisions.'
## Piano d'Azione
- Una checklist di task da completare, formattata come: - [ ] Descrizione chiara del task @Proprietario (se identificabile)
## Action Items
- Checklist of tasks: - [ ] Clear task description @Owner (if known)
Se una sezione risulta vuota, omettila elegantemente dal report finale. Concentrati sulla chiarezza e la sintesi.`;
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`;
@ -331,7 +329,7 @@ Se una sezione risulta vuota, omettila elegantemente dal report finale. Concentr
role: "user",
parts: [
{ text: prompt },
{ text: `\n\nTrascrizione:\n${transcript}` },
{ text: `\n\nTranscript:\n${transcript}` },
],
},
],
@ -345,27 +343,27 @@ Se una sezione risulta vuota, omettila elegantemente dal report finale. Concentr
if (!res.ok) {
const errTxt = await res.text().catch(() => "");
throw new Error(`Errore API Gemini: ${res.status} ${errTxt}`);
throw new Error(`Gemini API error: ${res.status} ${errTxt}`);
}
const json = await res.json();
const summary = extractMarkdownFromGemini(json) || "";
if (!summary) throw new Error("Riassunto vuoto dalla risposta di Gemini");
if (!summary) throw new Error("Empty summary from Gemini response");
await this.createNoteAndFinish(summary);
}
// Fase 4: Creazione nota
// Phase 4: Note creation
private async createNoteAndFinish(markdown: string) {
const date = window.moment().format("YYYY-MM-DD HH-mm");
const fileName = `Riunione ${date}.md`;
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("Nota creata!");
new Notice("Note created!");
this.setPhase("done");
await this.cleanupTemp();

View file

@ -6,7 +6,7 @@ export class SetupWizard extends Modal {
private settings: ResonanceSettings;
private save: (partial: Partial<ResonanceSettings>) => Promise<void>;
private step = 0;
private steps = ["API", "FFmpeg", "Dispositivi", "Whisper", "Test", "Fine"];
private steps = ["API", "FFmpeg", "Devices", "Whisper", "Test", "Finish"];
private content!: HTMLElement;
private scanResults: ListedDevice[] = [];
@ -21,7 +21,7 @@ export class SetupWizard extends Modal {
contentEl.empty();
contentEl.addClass("resonance-modal");
const title = contentEl.createEl("h2", { text: "Benvenuto in Resonance Configurazione Guidata" });
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 });
@ -31,8 +31,8 @@ export class SetupWizard extends Modal {
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: "Indietro" });
const next = controls.createEl("button", { cls: "resonance-btn primary", text: "Avanti" });
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());
@ -76,34 +76,34 @@ export class SetupWizard extends Modal {
}
private renderApiStep() {
this.content.createEl("h3", { text: "1) Chiave API Gemini" });
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: "La chiave resta locale in Obsidian. Modello selezionabile nelle impostazioni." });
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: "Specifica o rileva automaticamente il percorso a 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: "Rileva" });
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('Nessun FFmpeg rilevato');
else new Notice('No FFmpeg found');
});
}
private renderDevicesStep() {
this.content.createEl("h3", { text: "3) Dispositivi Audio" });
const scanBtn = this.content.createEl("button", { cls: "resonance-btn secondary", text: "Scansiona dispositivi" });
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 = '(nessuno)'; sysSel.appendChild(none);
const none = document.createElement('option'); none.value = ''; none.text = '(none)'; sysSel.appendChild(none);
scanBtn.addEventListener('click', async () => {
try {
@ -115,7 +115,7 @@ export class SetupWizard extends Modal {
const o2 = document.createElement('option'); o2.value = d.name; o2.text = d.label; sysSel.appendChild(o2);
});
} catch (e: any) {
new Notice(`Errore scansione: ${e?.message ?? e}`);
new Notice(`Scan error: ${e?.message ?? e}`);
}
});
@ -131,10 +131,10 @@ export class SetupWizard extends Modal {
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: "Rileva" });
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('Nessun whisper main rilevato');
if (guess) mainInput.value = guess; else new Notice('No whisper main found');
});
const modelInput = this.content.createEl("input");
@ -143,19 +143,19 @@ export class SetupWizard extends Modal {
}
private renderTestStep() {
this.content.createEl("h3", { text: "5) Test rapido" });
const p = this.content.createEl("p", { text: "Esegue una registrazione di 3s col microfono selezionato per verificare FFmpeg." });
const btn = this.content.createEl("button", { cls: "resonance-btn primary", text: "Esegui Test 3s" });
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 completato'); else new Notice('Test fallito');
if (ok) new Notice('Test completed'); else new Notice('Test failed');
});
}
private renderFinishStep() {
this.content.createEl("h3", { text: "6) Fatto" });
this.content.createEl("p", { text: "Configurazione di base completata. Puoi modificare opzioni avanzate in Impostazioni → Resonance." });
const close = this.content.createEl("button", { cls: "resonance-btn primary", text: "Chiudi" });
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();
@ -166,19 +166,19 @@ export class SetupWizard extends Modal {
if (this.step === 0) {
const val = (this.content.querySelector('input') as HTMLInputElement)?.value?.trim() || '';
await this.save({ geminiApiKey: val });
if (!val) { new Notice('Inserisci la API Key'); return false; }
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('Imposta FFmpeg'); return false; }
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('Imposta whisper main e modello'); return false; }
if (!main || !model) { new Notice('Set whisper main and model'); return false; }
}
return true;
}

103
main.ts
View file

@ -1,8 +1,9 @@
import { App, Modal, Notice, Plugin } from "obsidian";
import { ResonanceSettings, DEFAULT_SETTINGS, ResonanceSettingTab } from "./settings";
import { checkDependencies } from "./DependencyChecker";
// @ts-expect-error: risoluzione modulo a runtime via bundler
// @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 {
@ -26,13 +27,35 @@ export default class ResonancePlugin extends Plugin {
await this.saveSettings(partial);
});
const ribbonIconEl = this.addRibbonIcon("mic", "Resonance: Registratore Riunione", async () => {
await this.toggleFromRibbon();
// 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 per timer/stati
// Status bar for timer/status
this.statusBarEl = this.addStatusBarItem();
this.statusBarEl.addClass("resonance-statusbar");
this.statusBarEl.setText("");
@ -40,9 +63,18 @@ export default class ResonancePlugin extends Plugin {
this.addCommand({
id: "resonance-open-recorder",
name: "Avvia/ferma registrazione (Resonance)",
name: "Start/stop recording (Resonance)",
callback: async () => {
await this.toggleFromRibbon();
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();
},
});
@ -50,7 +82,7 @@ export default class ResonancePlugin extends Plugin {
await this.saveSettings(partial);
}));
// Collegamento eventi servizio → UI
// Wire up service events → UI
this.recorder.onPhaseChange = (phase: RecorderPhase) => {
this.updateRibbonState(phase);
this.updateStatusBarState(phase);
@ -59,7 +91,7 @@ export default class ResonancePlugin extends Plugin {
this.updateStatusBarTimer(sec);
};
this.recorder.onError = (message: string) => {
new Notice(`Errore Resonance: ${message}`);
new Notice(`Resonance error: ${message}`);
};
this.recorder.onInfo = (message: string) => {
new Notice(message);
@ -77,24 +109,25 @@ export default class ResonancePlugin extends Plugin {
});
if (!deps.hasApiKey || !deps.ffmpegOk || !deps.whisperOk || !deps.modelOk) {
new Notice("Configurazione incompleta. Vai a Impostazioni → Resonance per completare i campi richiesti.");
new Notice("Incomplete configuration. Go to Settings → Resonance to fill in the required fields.");
return false;
}
return true;
}
private async toggleFromRibbon() {
private async toggleFromRibbonWithPreset() {
if (!(await this.ensureDepsOk())) return;
const phase = this.recorder.getPhase();
if (phase === "idle" || phase === "error" || phase === "done") {
const ok = await this.confirmStart();
if (!ok) return;
await this.recorder.start();
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("Elaborazione in corso… attendi il completamento.");
new Notice("Processing… please wait.");
}
}
@ -106,6 +139,13 @@ export default class ResonancePlugin extends Plugin {
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;
@ -114,10 +154,10 @@ export default class ResonancePlugin extends Plugin {
el.setText("Rec 00:00");
} else if (phase === "transcribing") {
(el as any).show?.();
el.setText("Trascrizione…");
el.setText("Transcribing…");
} else if (phase === "summarizing") {
(el as any).show?.();
el.setText("Riassunto…");
el.setText("Summarizing…");
} else {
el.setText("");
(el as any).hide?.();
@ -131,24 +171,33 @@ export default class ResonancePlugin extends Plugin {
this.statusBarEl.setText(`Rec ${mm}:${ss}`);
}
private confirmStart(): Promise<boolean> {
return new Promise<boolean>((resolve) => {
private selectPreset(defaultKey: string): Promise<string | null> {
return new Promise<string | null>((resolve) => {
const modal = new (class extends Modal {
constructor(app: App, private onResult: (ok: boolean) => void) { super(app); }
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: "Avvia registrazione?" });
contentEl.createEl("p", { text: "Vuoi avviare una registrazione con Resonance?" });
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: "Avvia" });
okBtn.addEventListener("click", () => { this.onResult(true); this.close(); });
const cancelBtn = controls.createEl("button", { cls: "resonance-btn secondary", text: "Annulla" });
cancelBtn.addEventListener("click", () => { this.onResult(false); this.close(); });
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 { this.onResult(false); }
})(this.app, (ok) => resolve(ok));
onClose(): void { /* keep last selection on close */ }
})(this.app, (key) => resolve(key));
modal.open();
});
}

View file

@ -1,9 +1,9 @@
{
"id": "resonance",
"name": "Resonance",
"version": "1.0.0",
"version": "0.0.1",
"minAppVersion": "1.5.0",
"description": "Registra riunioni, trascrive localmente con whisper.cpp, riassume con Gemini e crea note in Obsidian.",
"description": "Record meetings, lessons, interviews, and more. Transcribe locally, summarize with AI, and create notes in Obsidian. Free and open source.",
"author": "Michael Gorini",
"authorUrl": "",
"isDesktopOnly": true

87
prompts.ts Normal file
View file

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

View file

@ -1,6 +1,7 @@
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;
@ -9,12 +10,14 @@ export interface ResonanceSettings {
ffmpegInputFormat: "auto" | "avfoundation" | "dshow" | "pulse" | "alsa";
ffmpegMicDevice: string;
ffmpegSystemDevice: string;
whisperRepoPath: string; // nuovo: path root repo whisper.cpp
whisperMainPath: string; // whisper-cli risolto automaticamente dal repo
whisperRepoPath: string; // repo root path for whisper.cpp
whisperMainPath: string; // whisper-cli resolved automatically from repo
whisperModelPath: string;
whisperModelPreset: "small" | "medium" | "large"; // scelte rapide
whisperLanguage: string; // iso code or 'auto'
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 = {
@ -30,96 +33,123 @@ export const DEFAULT_SETTINGS: ResonanceSettings = {
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("h2", { text: "Resonance Impostazioni" });
containerEl.createEl("h3", { text: "Resonance" });
containerEl.createEl("p", {
text: "Thanks for using Resonance :)"
});
containerEl.createEl("p", {
text: "I know, setup can be a bit of a hassle... but once youre done, youll be recording and transcribing like a pro! Take a few minutes to complete all the steps,future you will thank you."
});
containerEl.createEl("p", {
text: "After you've completed the setup, use the microphone icon in the ribbon to start/stop, select a scenario from the menu that appears, and watch the timer in the status bar. "
});
containerEl.createEl("p", {
text: "When finished, a note will be created in the Notes folder you've selected. You can also open the Library (audio file icon) to listen, copy the transcription, or manage recordings."
});
// STEP 1: FFmpeg
containerEl.createEl("h3", { text: "1) FFmpeg" });
containerEl.createEl("p", { text: "FFmpeg è l'utility che useremo per registrare l'audio. Installazione consigliata: macOS con Homebrew (brew install ffmpeg); Windows scarica la build statica da ffmpeg.org e punta a ffmpeg.exe; Linux usa il gestore pacchetti (apt/yum/pacman)." });
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("Percorso FFmpeg")
.setDesc("Scegli il percorso all'eseguibile ffmpeg e verifica con Rileva.")
.setName("FFmpeg path")
.setDesc("Choose the ffmpeg executable or use Detect to autofind.")
.addText(text =>
text
.setPlaceholder("/opt/homebrew/bin/ffmpeg o C:/ffmpeg/bin/ffmpeg.exe")
.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("Rileva").onClick(async () => {
ffmpegSetting.addButton((btn) => btn.setButtonText("Detect").onClick(async () => {
const guess = await this.autoDetectFfmpeg();
if (guess) { await this.save({ ffmpegPath: guess }); new Notice('FFmpeg rilevato'); this.display(); }
else new Notice('Nessun FFmpeg rilevato');
if (guess) { await this.save({ ffmpegPath: guess }); new Notice('FFmpeg detected'); this.display(); }
else new Notice('No FFmpeg found');
}));
ffmpegSetting.addButton((btn)=> btn.setButtonText("Guida").onClick(()=> new HelpModal(this.app, 'ffmpeg').open()));
// STEP 2: Whisper
containerEl.createEl("h3", { text: "2) Whisper (trascrizione locale)" });
containerEl.createEl("p", { text: "Indica la cartella del repository whisper.cpp. Troveremo automaticamente l'eseguibile whisper-cli. Poi scegli un modello (small/medium/large) e scarichiamolo nella cartella models/ del repo." });
containerEl.createEl("h3", { text: "Whisper" });
containerEl.createEl("p", { text: "Used to transcribe audio locally." });
const repoSetting = new Setting(containerEl)
.setName("Percorso repo whisper.cpp")
.setDesc("Cartella root del repo (es: /path/whisper.cpp)")
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() }); })
);
repoSetting.addButton((btn)=> btn.setButtonText("Guida").onClick(()=> new HelpModal(this.app, 'whisper').open()));
const whisperSetting = new Setting(containerEl)
.setName("Eseguibile whisper-cli")
.setDesc("Risolto automaticamente dalla cartella repo; puoi modificarlo se necessario.")
.setName("whisper-cli executable")
.setDesc("Autoresolved from repo; you can override it.")
.addText(text =>
text
.setPlaceholder(process.platform === 'win32' ? 'C:/whisper/build/bin/whisper-cli.exe' : '/path/whisper.cpp/build/bin/whisper-cli')
.setValue(this.settings.whisperMainPath)
.onChange(async (value) => { await this.save({ whisperMainPath: value.trim() }); })
);
whisperSetting.addButton((btn)=> btn.setButtonText("Trova dal repo").onClick(async ()=>{
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 trovato'); this.display(); }
else new Notice('whisper-cli non trovato. Compila il repo (cmake/make).');
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("Modello (preset)")
.setDesc("Scegli la dimensione del modello. Verrà scaricato in whisper.cpp/models se non presente.")
.setName("Model")
.setDesc("Choose the model size. It will be downloaded automatically if missing.")
.addDropdown(drop => {
drop.addOption('small','small (veloce)');
drop.addOption('medium','medium (bilanciato)');
drop.addOption('large','large (qualità)');
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("Scarica modello").onClick(async ()=>{
modelPreset.addButton((btn)=> btn.setButtonText("Download model").onClick(async ()=>{
try {
const file = await this.downloadModelPreset();
if (file) { await this.save({ whisperModelPath: file }); new Notice('Modello pronto: ' + file); this.display(); }
else new Notice('Download modello non riuscito');
} catch (e: any) { new Notice('Errore download: ' + (e?.message ?? e)); }
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("Modello Whisper (.bin)")
.setDesc("Percorso completo del modello. Se hai usato 'Scarica modello' è gia impostato.")
.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')
@ -128,11 +158,11 @@ export class ResonanceSettingTab extends PluginSettingTab {
);
new Setting(containerEl)
.setName("Lingua trascrizione")
.setDesc("Scegli la lingua attesa nella registrazione oppure lascia Automatico.")
.setName("Transcription language")
.setDesc("Choose expected language or leave Automatic.")
.addDropdown(drop => {
const opts: [string, string][] = [
['auto','Automatico'],
['auto','Automatic'],
['it','Italiano'],
['en','English'],
['es','Español'],
@ -146,12 +176,17 @@ export class ResonanceSettingTab extends PluginSettingTab {
});
// STEP 3: LLM (Gemini)
containerEl.createEl("h3", { text: "3) LLM (riassunto)" });
containerEl.createEl("p", { text: "Per generare il riassunto usiamo l'API di Google Gemini. Crea una API Key nella console Google AI Studio e incollala qui. Puoi scegliere il modello da usare." });
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("La chiave resta memorizzata localmente nel vault.")
.setDesc("Key is stored locally in your vault.")
.addText(text =>
text
.setPlaceholder("gai-...")
@ -159,31 +194,32 @@ export class ResonanceSettingTab extends PluginSettingTab {
.onChange(async (value) => { await this.save({ geminiApiKey: value }); })
);
apiSetting.settingEl.querySelector("input")?.setAttribute("type", "password");
apiSetting.addButton((btn)=> btn.setButtonText("Guida").onClick(()=> new HelpModal(this.app, 'llm').open()));
new Setting(containerEl)
.setName("Modello Gemini")
.setDesc("Scegli il modello: flash (più veloce), pro (migliore qualità), exp (sperimentale).")
.setName("Gemini model")
.setDesc("Available: 1.5pro, 2.5flash, 2.5pro.")
.addDropdown(drop => {
const options: Record<string, string> = {
"gemini-1.5-flash": "gemini-1.5-flash",
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-1.5-pro-exp": "gemini-1.5-pro-exp",
"gemini-2.5-flash": "gemini-2.5-flash",
"gemini-2.5-pro": "gemini-2.5-pro",
};
Object.entries(options).forEach(([value, label]) => drop.addOption(value, label));
drop.setValue(this.settings.geminiModel || "gemini-1.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: Dispositivi audio
containerEl.createEl("h3", { text: "4) Dispositivi audio" });
containerEl.createEl("p", { text: "Seleziona il backend e scegli i dispositivi dall'elenco. Usa Scansiona per popolare automaticamente. Non è necessario scrivere nulla a mano." });
// STEP 4: Audio devices
containerEl.createEl("h3", { text: "Audio devices" });
containerEl.createEl("p", { text: "Select backend and choose devices." });
const backendSetting = new Setting(containerEl)
.setName("Backend FFmpeg")
.setDesc("Automatico prova a scegliere in base al sistema. In caso di problemi seleziona manualmente.")
new Setting(containerEl)
.setName("FFmpeg backend")
.setDesc("Automatic picks based on OS, choose manually if needed.")
.addDropdown(drop => {
drop.addOption("auto", "Automatico");
drop.addOption("auto", "Automatic");
drop.addOption("avfoundation", "avfoundation (macOS)");
drop.addOption("dshow", "dshow (Windows)");
drop.addOption("pulse", "pulse (Linux)");
@ -191,36 +227,57 @@ export class ResonanceSettingTab extends PluginSettingTab {
drop.setValue(this.settings.ffmpegInputFormat || "auto");
drop.onChange(async (value) => { await this.save({ ffmpegInputFormat: value as ResonanceSettings["ffmpegInputFormat"] }); });
});
backendSetting.addButton((btn)=> btn.setButtonText("Guida").onClick(()=> new HelpModal(this.app, 'devices').open()));
const micSetting = new Setting(containerEl).setName("Microfono").setDesc("Scegli dall'elenco dopo la scansione.");
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("Audio di sistema (opzionale)").setDesc("Scegli dall'elenco dopo la scansione. Se non disponibile lascia vuoto.");
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='(nessuno)'; sysSelect.appendChild(none);
const none = document.createElement('option'); none.value=''; none.text='(none)'; sysSelect.appendChild(none);
new Setting(containerEl)
.setName("Strumenti dispositivi")
.setDesc("Scansiona e poi seleziona i dispositivi.")
.addButton((btn) => btn.setButtonText("Scansiona").onClick(async () => { await this.performScanAndPopulate(micSelect, sysSelect); }))
.addButton((btn) => btn.setButtonText("Test rapido 3s").onClick(async () => { await this.quickTestRecording(); }));
.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: "5) Obsidian" });
containerEl.createEl("p", { text: "Scegli la cartella del tuo vault in cui salvare le note generate. Se lasci vuoto, verranno create nella root." });
containerEl.createEl("h3", { text: "Obsidian" });
containerEl.createEl("p", { text: "Choose the folder where generated notes will be saved." });
const obs = new Setting(containerEl)
.setName("Cartella per le note")
.setDesc("Esempio: Meeting Notes")
.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() }); }));
obs.addButton((btn)=> btn.setButtonText("Guida").onClick(()=> new HelpModal(this.app, 'obsidian').open()));
// 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' {
@ -250,7 +307,7 @@ export class ResonanceSettingTab extends PluginSettingTab {
} else if (micSelect.options.length > 0) {
micSelect.selectedIndex = 0;
await this.save({ ffmpegMicDevice: micSelect.value });
new Notice(`Microfono impostato automaticamente su: ${micSelect.options[micSelect.selectedIndex].text}`);
new Notice(`Microphone auto-selected: ${micSelect.options[micSelect.selectedIndex].text}`);
}
const availableSysValues = new Set(Array.from(sysSelect.options).map(o => o.value));
@ -338,7 +395,7 @@ export class ResonanceSettingTab extends PluginSettingTab {
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('Imposta prima la cartella repo o un percorso modello');
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());
@ -361,10 +418,10 @@ export class ResonanceSettingTab extends PluginSettingTab {
private async quickTestRecording() {
const ffmpeg = this.settings.ffmpegPath.trim();
if (!ffmpeg) { new Notice('Imposta prima FFmpeg'); return; }
if (!ffmpeg) { new Notice('Set FFmpeg first'); return; }
const backend = this.resolveBackend();
const mic = this.settings.ffmpegMicDevice.trim();
if (!mic) { new Notice('Seleziona un microfono'); return; }
if (!mic) { new Notice('Select a microphone'); return; }
const { spawn } = (window as any).require('child_process');
const os = (window as any).require('os');
@ -381,15 +438,15 @@ export class ResonanceSettingTab extends PluginSettingTab {
child.stderr?.on('data', (d: Buffer) => { stderr += d.toString(); });
child.on('close', (code: number) => {
if (code === 0) new Notice('Test completato: registrazione di 3s creata');
if (code === 0) new Notice('Test completed: 3s recording created');
else {
const hint = stderr.split(/\r?\n/).slice(-6).join('\n');
new Notice(`Test fallito (codice ${code}).\n${hint}`);
new Notice(`Test failed (code ${code}).\n${hint}`);
}
try { fs.unlinkSync(out); fs.rmdirSync(tmpDir); } catch {}
});
child.on('error', (e: any) => {
new Notice(`Errore test FFmpeg: ${e?.message ?? e}`);
new Notice(`FFmpeg test error: ${e?.message ?? e}`);
try { fs.rmdirSync(tmpDir); } catch {}
});
}

View file

@ -1,6 +1,53 @@
.resonance-modal {
padding: 12px 16px;
}
.modal-container .resonance-wide .modal {
width: 900px;
max-width: 92vw;
}
.resonance-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin: 6px 0 10px 0;
}
.resonance-toolbar .date { flex: 0 1 auto; }
.resonance-toolbar .search { flex: 1 1 220px; }
.resonance-toolbar input[type="text"],
.resonance-toolbar select {
width: 100%;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--background-modifier-border);
background: var(--background-secondary);
color: var(--text-normal);
}
.resonance-toolbar .spacer { flex: 1 1 auto; }
.resonance-list { display: flex; flex-direction: column; gap: 8px; }
.resonance-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--background-modifier-border);
border-radius: 10px;
background: var(--background-secondary);
}
.resonance-meta { display: flex; align-items: center; gap: 10px; }
.resonance-left { display: flex; align-items: center; gap: 10px; }
.resonance-title { font-weight: 600; }
.resonance-sub { color: var(--text-muted); font-size: 12px; }
.resonance-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.resonance-audio-wrap { margin: 6px 0 12px 28px; }
.resonance-modal h2 {
margin-top: 4px;
@ -113,7 +160,8 @@
/* Select inline nelle impostazioni */
.resonance-inline-select {
margin-left: 8px;
margin-left: 0px;
margin-bottom: 10px;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid var(--background-modifier-border);
@ -128,11 +176,13 @@
border-radius: 8px;
padding: 8px 10px;
overflow: auto;
position: relative;
}
.resonance-help code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
}
.resonance-btn.small { padding: 4px 8px; font-size: 12px; border-radius: 6px; }
/* Ribbon states */
.workspace-ribbon .resonance-ribbon.recording {