From 04db924513fa78f1d42842943966d82d0577815d Mon Sep 17 00:00:00 2001 From: Michael Gorini Date: Fri, 29 Aug 2025 11:31:51 +0200 Subject: [PATCH] registrazione e riassunto funzionanti --- RecorderService.ts | 319 +++++++++++++++++++++++++++++++++++++++++++++ RecordingModal.ts | 7 +- main.ts | 118 +++++++++++++++-- styles.css | 19 +++ 4 files changed, 451 insertions(+), 12 deletions(-) create mode 100644 RecorderService.ts diff --git a/RecorderService.ts b/RecorderService.ts new file mode 100644 index 0000000..0d0a3e9 --- /dev/null +++ b/RecorderService.ts @@ -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) => Promise; + + private phase: RecorderPhase = "idle"; + private startTs: number | null = null; + private elapsedIntervalId: number | null = null; + + private ffmpegChild: any | null = null; + private stopRecordingFn: (() => Promise) | 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) => Promise) { + 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: /.obsidian/plugins//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((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(resolve => setTimeout(resolve, ms)); +} + + diff --git a/RecordingModal.ts b/RecordingModal.ts index dd698eb..e038efc 100644 --- a/RecordingModal.ts +++ b/RecordingModal.ts @@ -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' diff --git a/main.ts b/main.ts index ccbab07..e987059 100644 --- a/main.ts +++ b/main.ts @@ -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) => { + 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 { 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 { + return new Promise((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() { diff --git a/styles.css b/styles.css index 3613207..2933388 100644 --- a/styles.css +++ b/styles.css @@ -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); +}