diff --git a/DependencyChecker.ts b/DependencyChecker.ts index d558473..dbfbfbc 100644 --- a/DependencyChecker.ts +++ b/DependencyChecker.ts @@ -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 }; diff --git a/DeviceScanner.ts b/DeviceScanner.ts index eebd84b..886f4bc 100644 --- a/DeviceScanner.ts +++ b/DeviceScanner.ts @@ -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 { - 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' diff --git a/HelpModal.ts b/HelpModal.ts index 9d8100b..8d4e664 100644 --- a/HelpModal.ts +++ b/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 []; } } } diff --git a/LibraryModal.ts b/LibraryModal.ts new file mode 100644 index 0000000..9558ceb --- /dev/null +++ b/LibraryModal.ts @@ -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 = 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 { + 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 { + 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 { + 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 { + 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: " + 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' ? '' : + name === 'file-text' ? '' : + name === 'download' ? '' : + name === 'folder' ? '' : + name === 'note' ? '' : + name === 'trash' ? '' : + /* refresh */ ''; + return span; +} + + diff --git a/README.md b/README.md index 8d51afb..e48d698 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,99 @@ -# Resonance (Obsidian Plugin) +

+ + Resonance + + Desktop only + Requires FFmpeg + Transcription via whisper.cpp +

-Registra riunioni, trascrive localmente con whisper.cpp, riassume con Google Gemini e crea automaticamente una nota in Obsidian. +
+

Resonance

+

+ Record → Transcribe → Summarize → Create note.
+ A local‑first recording & meeting notes workflow for Obsidian. +

+

+ Installation · + Usage · + Settings · + Troubleshooting +

+
-## 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 `/.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 `/.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 `/.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. \ No newline at end of file diff --git a/RecorderService.ts b/RecorderService.ts index 0d0a3e9..db19903 100644 --- a/RecorderService.ts +++ b/RecorderService.ts @@ -15,11 +15,13 @@ export class RecorderService { private ffmpegChild: any | null = null; private stopRecordingFn: (() => Promise) | 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: /.obsidian/plugins//recordings + // Determine plugin folder inside the vault: /.obsidian/plugins//recordings const adapter = (this.app.vault as any).adapter; const basePath: string = adapter?.getBasePath?.() ?? adapter?.basePath ?? ""; - if (!basePath) throw new Error("Impossibile determinare il percorso del vault (solo Desktop)"); + 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); diff --git a/RecordingModal.ts b/RecordingModal.ts index e038efc..145147c 100644 --- a/RecordingModal.ts +++ b/RecordingModal.ts @@ -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) | 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(); diff --git a/SetupWizard.ts b/SetupWizard.ts index d791538..9ac6f5f 100644 --- a/SetupWizard.ts +++ b/SetupWizard.ts @@ -6,7 +6,7 @@ export class SetupWizard extends Modal { private settings: ResonanceSettings; private save: (partial: Partial) => Promise; 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; } diff --git a/main.ts b/main.ts index e987059..fa471b8 100644 --- a/main.ts +++ b/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 { - return new Promise((resolve) => { + private selectPreset(defaultKey: string): Promise { + return new Promise((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(); }); } diff --git a/manifest.json b/manifest.json index c3e990a..b73733e 100644 --- a/manifest.json +++ b/manifest.json @@ -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 diff --git a/prompts.ts b/prompts.ts new file mode 100644 index 0000000..a326ae1 --- /dev/null +++ b/prompts.ts @@ -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 = { + 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); +} + + diff --git a/settings.ts b/settings.ts index 6866e9e..08672ba 100644 --- a/settings.ts +++ b/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) => Promise; private lastScan: ListedDevice[] = []; + private pluginId: string; constructor(app: App, settings: ResonanceSettings, save: (settings: Partial) => Promise) { 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 = { - "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 = { "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 {} }); } diff --git a/styles.css b/styles.css index 2933388..07342e1 100644 --- a/styles.css +++ b/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 {