registrazione e riassunto funzionanti

This commit is contained in:
Michael Gorini 2025-08-29 11:31:51 +02:00
parent b4862b8aad
commit 04db924513
4 changed files with 451 additions and 12 deletions

319
RecorderService.ts Normal file
View file

@ -0,0 +1,319 @@
import { App, Notice, TFile } from "obsidian";
import type { ResonanceSettings } from "./settings";
export type RecorderPhase = "idle" | "recording" | "transcribing" | "summarizing" | "error" | "done";
export class RecorderService {
private app: App;
private settings: ResonanceSettings;
private pluginId: string;
private saveSettings: (partial: Partial<ResonanceSettings>) => Promise<void>;
private phase: RecorderPhase = "idle";
private startTs: number | null = null;
private elapsedIntervalId: number | null = null;
private ffmpegChild: any | null = null;
private stopRecordingFn: (() => Promise<void>) | null = null;
private audioDir: string = "";
private audioPath: string = "";
private transcriptPath: string = "";
private logPath: string = "";
onPhaseChange?: (phase: RecorderPhase) => void;
onElapsed?: (seconds: number) => void;
onError?: (message: string) => void;
onInfo?: (message: string) => void;
constructor(app: App, settings: ResonanceSettings, pluginId: string, saveSettings: (partial: Partial<ResonanceSettings>) => Promise<void>) {
this.app = app;
this.settings = settings;
this.pluginId = pluginId;
this.saveSettings = saveSettings;
}
getPhase(): RecorderPhase {
return this.phase;
}
private setPhase(p: RecorderPhase) {
this.phase = p;
this.onPhaseChange?.(p);
}
private startElapsedTimer() {
this.startTs = Date.now();
this.stopElapsedTimer();
this.elapsedIntervalId = window.setInterval(() => {
if (!this.startTs) return;
const sec = Math.floor((Date.now() - this.startTs) / 1000);
this.onElapsed?.(sec);
}, 500);
}
private stopElapsedTimer() {
if (this.elapsedIntervalId) {
window.clearInterval(this.elapsedIntervalId);
this.elapsedIntervalId = null;
}
}
async start() {
if (this.phase !== "idle" && this.phase !== "error" && this.phase !== "done") return;
try {
await this.prepareAudioPath();
await this.beginRecording();
} catch (e: any) {
const msg = String(e?.message ?? e);
this.onError?.(msg);
this.setPhase("error");
}
}
async stop() {
if (this.phase !== "recording") return;
try {
if (this.stopRecordingFn) await this.stopRecordingFn();
this.stopRecordingFn = null;
this.stopElapsedTimer();
await waitMs(500);
try {
const fs = (window as any).require("fs");
const stat = fs.statSync(this.audioPath);
this.appendLog(`Registrazione terminata. File: ${this.audioPath} (${stat.size} bytes)`);
} catch { this.appendLog(`Registrazione terminata. File: ${this.audioPath}`); }
this.onInfo?.(`File audio salvato in: ${this.audioPath}`);
await this.transcribe();
} catch (e: any) {
const msg = String(e?.message ?? e);
this.onError?.(msg);
this.setPhase("error");
}
}
private async prepareAudioPath() {
const os = (window as any).require("os");
const path = (window as any).require("path");
const fs = (window as any).require("fs");
// Determina la cartella del plugin nel 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)");
const configDir: string = (this.app.vault as any).configDir ?? ".obsidian";
const pluginDir = path.join(basePath, configDir, "plugins", this.pluginId);
const recDir = path.join(pluginDir, "recordings");
try { if (!fs.existsSync(pluginDir)) fs.mkdirSync(pluginDir, { recursive: true }); } catch {}
try { if (!fs.existsSync(recDir)) fs.mkdirSync(recDir, { recursive: true }); } catch {}
const stamp = new Date();
const name = `registrazione_${
stamp.getFullYear()
}-${String(stamp.getMonth() + 1).padStart(2, "0")}-${String(stamp.getDate()).padStart(2, "0")}_${String(stamp.getHours()).padStart(2, "0")}-${String(stamp.getMinutes()).padStart(2, "0")}-${String(stamp.getSeconds()).padStart(2, "0")}.mp3`;
this.audioDir = recDir;
this.audioPath = path.join(recDir, name);
const base = name.replace(/\.mp3$/i, "");
this.transcriptPath = path.join(recDir, `${base}.txt`);
this.logPath = path.join(recDir, `${base}.log`);
try { fs.appendFileSync(this.logPath, `[${new Date().toISOString()}] Sessione iniziata\n`); } catch {}
}
private resolveFfmpegInput(): { format: string; micSpec?: string; systemSpec?: string } {
const platform = process.platform;
let format = this.settings.ffmpegInputFormat;
if (format === "auto") {
format = platform === "darwin" ? "avfoundation" : platform === "win32" ? "dshow" : "pulse";
}
const mic = (this.settings.ffmpegMicDevice || "").trim();
const sys = (this.settings.ffmpegSystemDevice || "").trim();
// macOS: audio-only -> ":index" (non "index:")
if (format === "avfoundation") return { format, micSpec: mic || ":0", systemSpec: sys || "" };
if (format === "dshow") return { format, micSpec: mic || "audio=Microphone (default)", systemSpec: sys || "" };
return { format, micSpec: mic || "default", systemSpec: sys || "" };
}
private async beginRecording() {
const { ffmpegPath } = this.settings;
if (!ffmpegPath) throw new Error("Percorso FFmpeg non configurato");
const { spawn } = (window as any).require("child_process");
const { format, micSpec, systemSpec } = this.resolveFfmpegInput();
const inputs: string[] = [];
if (micSpec) inputs.push("-f", format, "-i", micSpec);
if (systemSpec) inputs.push("-f", format, "-i", systemSpec);
if (inputs.length === 0) throw new Error("Nessun dispositivo di input FFmpeg configurato. Imposta almeno il microfono.");
const shouldMix = Boolean(micSpec && systemSpec);
const mixArgs = shouldMix ? ["-filter_complex", "[0:a][1:a]amix=inputs=2:duration=longest"] : [];
const outputArgs = ["-acodec", "libmp3lame", "-ab", "192k", this.audioPath];
this.setPhase("recording");
this.startElapsedTimer();
const args = ["-y", ...inputs, ...mixArgs, ...outputArgs];
this.ffmpegChild = spawn(ffmpegPath, args, { detached: false });
this.appendLog(`FFmpeg avviato: ${ffmpegPath} ${args.join(" ")}`);
// Se FFmpeg termina subito con errore, notifichiamo
let ffErr = "";
this.ffmpegChild.stderr?.on("data", (d: Buffer) => { ffErr += d.toString(); });
this.ffmpegChild.on("close", (code: number) => {
if (this.phase === "recording" && code !== 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.setPhase("error");
}
});
this.stopRecordingFn = async () => {
try {
const child = this.ffmpegChild;
if (!child) return;
if (process.platform === "win32") {
try { child.kill("SIGINT"); } catch {}
await waitMs(900);
try { child.kill("SIGTERM"); } catch {}
await waitMs(700);
if (!child.killed) {
const { spawn: sp } = (window as any).require("child_process");
try { sp("taskkill", ["/pid", String(child.pid), "/t", "/f"], { windowsHide: true }); } catch {}
await waitMs(600);
}
} else {
try { child.kill("SIGINT"); } catch {}
await waitMs(900);
try { child.kill("SIGTERM"); } catch {}
await waitMs(700);
try { child.kill("SIGKILL"); } catch {}
}
} catch {}
};
}
private async transcribe() {
this.setPhase("transcribing");
const { whisperMainPath, whisperModelPath, whisperLanguage } = this.settings;
if (!whisperMainPath) throw new Error("Percorso whisper-cli non configurato");
if (!whisperModelPath) throw new Error("Percorso modello Whisper non configurato");
const { spawn } = (window as any).require("child_process");
const fs = (window as any).require("fs");
const args = ["-m", whisperModelPath, "-f", this.audioPath];
const lang = (whisperLanguage || "auto").trim();
if (lang && lang !== "auto") { args.push("-l", lang); }
this.appendLog(`Trascrizione: ${whisperMainPath} ${args.join(" ")}`);
const stdoutBuf: string[] = [];
let stderrBuf = "";
await new Promise<void>((resolve, reject) => {
const child = spawn(whisperMainPath, args, { cwd: this.audioDir });
child.stdout?.on("data", (d: Buffer) => stdoutBuf.push(d.toString()));
child.stderr?.on("data", (d: Buffer) => { stderrBuf += d.toString(); });
child.on("error", (err: any) => reject(err));
child.on("close", (code: number) => {
if (code === 0) resolve(); else reject(new Error(`whisper-cli uscita con codice ${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}`);
await this.summarize(transcript);
}
private async summarize(transcript: string) {
this.setPhase("summarizing");
const { geminiApiKey, geminiModel } = this.settings;
if (!geminiApiKey) throw new Error("API Key Gemini non configurata");
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 model = (geminiModel || "gemini-1.5-pro").trim();
const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`;
const body = {
contents: [
{
role: "user",
parts: [
{ text: prompt },
{ text: `\n\nTrascrizione:\n${transcript}` },
],
},
],
} as any;
const res = await fetch(`${url}?key=${encodeURIComponent(geminiApiKey)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const errTxt = await res.text().catch(() => "");
throw new Error(`Errore API Gemini: ${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)`);
await this.createNote(summary);
}
private async createNote(markdown: string) {
const fileName = `Riunione ${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}`);
this.setPhase("done");
const leaf = this.app.workspace.getLeaf(true);
await leaf.openFile(tfile as TFile);
}
private appendLog(message: string) {
try {
if (!this.logPath) return;
const fs = (window as any).require("fs");
fs.appendFileSync(this.logPath, `[${new Date().toISOString()}] ${message}\n`);
} catch {}
}
}
function extractMarkdownFromGemini(json: any): string | null {
try {
const text = json?.candidates?.[0]?.content?.parts?.map((p: any) => p?.text ?? "").join("\n");
if (text && typeof text === "string") return text.trim();
} catch {}
return null;
}
function waitMs(ms: number) {
return new Promise<void>(resolve => setTimeout(resolve, ms));
}

View file

@ -198,7 +198,8 @@ export class RecordingModal extends Modal {
throw new Error("Nessun dispositivo di input FFmpeg configurato. Imposta almeno il microfono.");
}
const mixArgs = inputs.length === 4
const shouldMix = Boolean(micSpec && systemSpec);
const mixArgs = shouldMix
? ["-filter_complex", "[0:a][1:a]amix=inputs=2:duration=longest"]
: [];
@ -247,8 +248,8 @@ export class RecordingModal extends Modal {
const sys = (this.settings.ffmpegSystemDevice || "").trim();
if (format === "avfoundation") {
// macOS: device es. '0:' (mic) e ':1' (system). Usa direttamente il valore impostato.
return { format, micSpec: mic || "0:", systemSpec: sys || "" };
// macOS: audio-only -> ":index" (non "index:")
return { format, micSpec: mic || ":0", systemSpec: sys || "" };
}
if (format === "dshow") {
// Windows: serve 'audio=Nome Dispositivo'

118
main.ts
View file

@ -1,7 +1,8 @@
import { App, Notice, Plugin } from "obsidian";
import { App, Modal, Notice, Plugin } from "obsidian";
import { ResonanceSettings, DEFAULT_SETTINGS, ResonanceSettingTab } from "./settings";
import { checkDependencies } from "./DependencyChecker";
import { RecordingModal } from "./RecordingModal";
// @ts-expect-error: risoluzione modulo a runtime via bundler
import { RecorderService, type RecorderPhase } from "./RecorderService";
declare global {
interface Window {
@ -13,31 +14,61 @@ declare global {
export default class ResonancePlugin extends Plugin {
settings!: ResonanceSettings;
private recorder!: RecorderService;
private ribbonIconEl!: HTMLElement;
private statusBarEl!: HTMLElement;
private statusTimerId: number | null = null;
async onload() {
await this.loadSettings();
const ribbonIconEl = this.addRibbonIcon("mic", "Resonance: Registratore Riunione", async () => {
await this.startFlow();
this.recorder = new RecorderService(this.app, this.settings, this.manifest.id, async (partial: Partial<ResonanceSettings>) => {
await this.saveSettings(partial);
});
const ribbonIconEl = this.addRibbonIcon("mic", "Resonance: Registratore Riunione", async () => {
await this.toggleFromRibbon();
});
this.ribbonIconEl = ribbonIconEl;
ribbonIconEl.addClass("resonance-ribbon");
// Status bar per timer/stati
this.statusBarEl = this.addStatusBarItem();
this.statusBarEl.addClass("resonance-statusbar");
this.statusBarEl.setText("");
(this.statusBarEl as any).hide?.();
this.addCommand({
id: "resonance-open-recorder",
name: "Avvia registrazione riunione (Resonance)",
name: "Avvia/ferma registrazione (Resonance)",
callback: async () => {
await this.startFlow();
await this.toggleFromRibbon();
},
});
this.addSettingTab(new ResonanceSettingTab(this.app, this.settings, async (partial) => {
await this.saveSettings(partial);
}));
// Collegamento eventi servizio → UI
this.recorder.onPhaseChange = (phase: RecorderPhase) => {
this.updateRibbonState(phase);
this.updateStatusBarState(phase);
};
this.recorder.onElapsed = (sec: number) => {
this.updateStatusBarTimer(sec);
};
this.recorder.onError = (message: string) => {
new Notice(`Errore Resonance: ${message}`);
};
this.recorder.onInfo = (message: string) => {
new Notice(message);
};
}
async onunload() {}
private async startFlow() {
private async ensureDepsOk(): Promise<boolean> {
const deps = await checkDependencies({
apiKey: this.settings.geminiApiKey,
ffmpegPath: this.settings.ffmpegPath,
@ -47,10 +78,79 @@ 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.");
return;
return false;
}
return true;
}
new RecordingModal(this.app, this.settings).open();
private async toggleFromRibbon() {
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();
} else if (phase === "recording") {
await this.recorder.stop();
} else {
new Notice("Elaborazione in corso… attendi il completamento.");
}
}
private updateRibbonState(phase: RecorderPhase) {
if (!this.ribbonIconEl) return;
this.ribbonIconEl.removeClass("recording");
this.ribbonIconEl.removeClass("processing");
if (phase === "recording") this.ribbonIconEl.addClass("recording");
if (phase === "transcribing" || phase === "summarizing") this.ribbonIconEl.addClass("processing");
}
private updateStatusBarState(phase: RecorderPhase) {
const el = this.statusBarEl;
if (!el) return;
if (phase === "recording") {
(el as any).show?.();
el.setText("Rec 00:00");
} else if (phase === "transcribing") {
(el as any).show?.();
el.setText("Trascrizione…");
} else if (phase === "summarizing") {
(el as any).show?.();
el.setText("Riassunto…");
} else {
el.setText("");
(el as any).hide?.();
}
}
private updateStatusBarTimer(sec: number) {
if (!this.statusBarEl) return;
const mm = String(Math.floor(sec / 60)).padStart(2, "0");
const ss = String(sec % 60).padStart(2, "0");
this.statusBarEl.setText(`Rec ${mm}:${ss}`);
}
private confirmStart(): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const modal = new (class extends Modal {
constructor(app: App, private onResult: (ok: boolean) => 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?" });
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(); });
}
onClose(): void { this.onResult(false); }
})(this.app, (ok) => resolve(ok));
modal.open();
});
}
async loadSettings() {

View file

@ -133,3 +133,22 @@
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
}
/* Ribbon states */
.workspace-ribbon .resonance-ribbon.recording {
color: #e03131 !important;
}
.workspace-ribbon .resonance-ribbon.processing {
color: var(--interactive-accent) !important;
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
}
/* Status bar */
.status-bar-item.resonance-statusbar {
color: var(--text-normal);
}