mirror of
https://github.com/blackajiro/Resonance.git
synced 2026-07-22 06:51:15 +00:00
359 lines
14 KiB
TypeScript
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));
|
|
}
|
|
|
|
|