blackajiro_Resonance/RecorderService.ts
2025-08-29 15:55:07 +02:00

359 lines
14 KiB
TypeScript

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