mirror of
https://github.com/blackajiro/Resonance.git
synced 2026-07-22 06:51:15 +00:00
migliorati settings, library, recording
This commit is contained in:
parent
04db924513
commit
e8628b0cf1
13 changed files with 1069 additions and 306 deletions
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
90
HelpModal.ts
90
HelpModal.ts
|
|
@ -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 cross‑platform 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 speech‑to‑text 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 l’API 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. L’audio di sistema può richiedere un dispositivo virtuale (es. BlackHole).'] },
|
||||
{ title: 'Linux (pulse/alsa)', paragraphs: ['Spesso il mic è default. L’audio 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
400
LibraryModal.ts
Normal 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
124
README.md
|
|
@ -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 local‑first 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
|
||||
- ⏱️ Status‑bar 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; 3‑second test.
|
||||
- **Whisper**: repo path, whisper‑cli path, model picker or auto‑download; 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 3‑second 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 you’d like to help with docs or UX, please open an issue to coordinate.
|
||||
|
||||
---
|
||||
|
||||
Built with Vite. This plugin is desktop‑only.
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 (cross‑platform)
|
||||
// Phase 1: Audio recording with ffmpeg (cross‑platform)
|
||||
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 (cross‑platform)
|
||||
// Stop reference (cross‑platform)
|
||||
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, well‑structured 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();
|
||||
|
|
|
|||
|
|
@ -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
103
main.ts
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
87
prompts.ts
Normal 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 note‑taker. Produce a clean, skimmable Markdown report in the SAME language as the transcript.
|
||||
|
||||
Always include, in this order:
|
||||
1) Overview — 3–6 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 note‑taker. Produce clear Markdown notes in the SAME language as the transcript.
|
||||
|
||||
Always include:
|
||||
1) Overview — 3–6 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 — [ ] Follow‑ups (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 — 3–6 sentences capturing the main opportunity and where the group aligned.
|
||||
2) Topic sections — Group ideas into natural clusters with a brief rationale; highlight high‑signal 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 note‑taker. Produce a crisp Markdown report in the SAME language as the transcript.
|
||||
|
||||
Always include:
|
||||
1) Overview — 3–6 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 — [ ] Follow‑ups with owners.
|
||||
|
||||
Be objective and concise.
|
||||
|
||||
IMPORTANT: If the transcript is empty or invalid, return an empty string.`,
|
||||
},
|
||||
standup: {
|
||||
key: "standup",
|
||||
label: "Daily stand‑up",
|
||||
prompt: `Create a brief Markdown stand‑up report in the SAME language as the transcript.
|
||||
|
||||
Always include:
|
||||
1) Overview — 2–4 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 follow‑ups.
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
211
settings.ts
211
settings.ts
|
|
@ -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 you’re done, you’ll 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 auto‑find.")
|
||||
.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("Auto‑resolved 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.5‑pro, 2.5‑flash, 2.5‑pro.")
|
||||
.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 {}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
52
styles.css
52
styles.css
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue