diff --git a/README.md b/README.md index 86f1a4d..3bda119 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,10 @@ Estensione per [pi](https://github.com/badlogic/pi-mono) che integra il client O - generazione, editing, composizione e analisi immagini; - analisi video; - trascrizione audio e input vocale F12 tramite Antigravity; +- analisi qualitativa di effetti audio con waveform e metadati locali; - catalogo modelli e stato conversazione Antigravity. -Strumenti principali: `agy`, `agy_generate`, `agy_edit`, `agy_inpaint`, `agy_style_transfer`, `agy_compose`, `agy_character`, `agy_analyze`, `agy_transcribe`, `agy_video`, `agy_models`, `agy_conversation`, `antigravity_chat`. +Strumenti principali: `agy`, `agy_generate`, `agy_edit`, `agy_inpaint`, `agy_style_transfer`, `agy_compose`, `agy_character`, `agy_analyze`, `agy_transcribe`, `agy_analyze_audio`, `agy_video`, `agy_models`, `agy_conversation`, `antigravity_chat`. Non include TTS interno né generazione immagini verificata iterativa. @@ -18,6 +19,8 @@ Non include TTS interno né generazione immagini verificata iterativa. Premi **F12** per avviare/fermare la registrazione. L'audio viene ottimizzato, convertito in Opus/OGG e inviato al modello `voiceModel` tramite il gateway Antigravity OAuth. Il workflow genera trascrizione e prompt pulito; con `vocalPlanningMode=true` richiede una conferma nell'overlay prima dell'invio a pi. +`agy_analyze_audio` aggiunge un percorso separato per gli effetti non vocali: misura il file con `ffprobe`, genera una waveform con `ffmpeg` e invia audio, immagine e contesto locale insieme a Gemini tramite `inlineData`. Non richiede una chiave Gemini esterna e non assume che l'audio contenga parlato. + `agy_transcribe` usa la stessa pipeline Antigravity. Non esistono fallback a servizi con chiave API esterna. I suoni di start/stop/done sono effetti locali, non sintesi vocale. ## Configurazione diff --git a/docs/architecture.md b/docs/architecture.md index 2412b0c..a767da5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -11,6 +11,7 @@ pi → extension index.ts → Antigravity OAuth / agy CLI → modelli disponibil - **CLI wrapper**: esegue `agy` per chat, immagini e video. - **Client Antigravity diretto**: usa OAuth e gli endpoint cloudcode-pa per provider, modelli, audio F12 e `antigravity_chat`. - **Pipeline audio**: `ffmpeg` registra/ottimizza, poi invia `inlineData` audio ad Antigravity. +- **Analisi audio**: `ffprobe` misura il file, `ffmpeg` genera la waveform PNG, quindi `agy_analyze_audio` invia audio + immagine + metadati a Gemini via OAuth Antigravity. - **Context injection**: aggiunge ambiente, stato sessione e memoria locale. - **Persistenza**: configurazione in `~/.config/agy-pi/config.json`; conversazioni e token sono gestiti dal client Antigravity. diff --git a/docs/tools.md b/docs/tools.md index 7342659..7aaaf7f 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -11,6 +11,7 @@ | `agy_character` | Consistenza di personaggio/oggetto. | | `agy_analyze` | Analisi immagine. | | `agy_transcribe` | Trascrizione audio tramite Antigravity OAuth. | +| `agy_analyze_audio` | Analisi audio multimodale: WAV/audio + waveform PNG + metadati locali tramite Antigravity OAuth. | | `agy_video` | Analisi di video. | | `agy_models` | Elenco modelli Antigravity disponibili. | | `agy_conversation` | Stato della conversazione agy. | @@ -20,6 +21,12 @@ Accetta `filePath` e, opzionalmente, `language`. Converte WAV in Opus/OGG quando utile e invia l'audio al modello configurato in `voiceModel` tramite Antigravity. Non usa chiavi API esterne né fallback a provider diversi. +## `agy_analyze_audio` + +Accetta un file audio e, opzionalmente, una domanda, un modello Gemini e una cartella di output. Usa `ffprobe` per estrarre durata, codec, campionamento, canali, bit depth, peak/RMS e intervalli di silenzio; usa `ffmpeg` per generare una waveform PNG. Invia audio, waveform e metadati insieme al modello Gemini tramite OAuth Antigravity come `inlineData`. Non richiede parlato e non usa la Gemini Files API né chiavi API esterne. + +Con `outputDir` conserva la waveform e un report JSON; l'audio originale non viene copiato. Il limite del file inviato è venticinque megabyte. + ## Contesto `agy` può ricevere un blocco con ambiente, stato della sessione e memoria locale tramite `injectContext`. La ricerca web non è svolta dall'estensione: per informazioni aggiornate usa gli strumenti web dell'agente principale. \ No newline at end of file diff --git a/extensions/index.ts b/extensions/index.ts index 2b0bee9..3afa035 100644 --- a/extensions/index.ts +++ b/extensions/index.ts @@ -42,6 +42,8 @@ const CONV_DIR = path.join(os.homedir(), ".gemini", "antigravity-cli", "conversa const DEFAULT_TIMEOUT_MS = 180_000; // 3 min const IMAGE_TIMEOUT_MS = 300_000; // 5 min const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".svg"]); +const AUDIO_EXTENSIONS = new Set([".wav", ".mp3", ".m4a", ".aac", ".flac", ".ogg", ".opus", ".webm"]); +const AUDIO_MAX_BYTES = 25 * 1024 * 1024; // --------------------------------------------------------------------------- // Configurazione persistente (~/.config/agy-pi/config.json) @@ -983,6 +985,155 @@ function toolUpdate(onUpdate: any, text: string) { onUpdate?.({ content: [{ type: "text", text }] }); } +interface AudioLocalMetadata { + fileName: string; + durationSeconds: number | null; + codec: string | null; + container: string | null; + sampleRateHz: number | null; + channels: number | null; + bitDepth: number | null; + peakDbfs: number | null; + meanDbfs: number | null; + silenceIntervals: Array<{ startSeconds: number; endSeconds?: number }>; +} + +function audioMimeType(file: string): string { + const ext = path.extname(file).toLowerCase(); + return { + ".wav": "audio/wav", + ".mp3": "audio/mpeg", + ".m4a": "audio/mp4", + ".aac": "audio/aac", + ".flac": "audio/flac", + ".ogg": "audio/ogg", + ".opus": "audio/opus", + ".webm": "audio/webm", + }[ext] ?? "application/octet-stream"; +} + +async function inspectAudioLocally(file: string): Promise { + const probe = await execFileAsync( + "ffprobe", + [ + "-v", "error", + "-show_entries", + "format=format_name,duration:stream=codec_name,codec_type,sample_rate,channels,bits_per_sample", + "-of", "json", + file, + ], + { timeout: 30_000, maxBuffer: 1_000_000 }, + ); + const parsed = JSON.parse(probe.stdout || "{}"); + const stream = (parsed.streams ?? []).find((item: any) => item.codec_type === "audio") ?? parsed.streams?.[0] ?? {}; + const numberOrNull = (value: unknown): number | null => { + const number = Number(value); + return Number.isFinite(number) ? number : null; + }; + + let peakDbfs: number | null = null; + let meanDbfs: number | null = null; + let silenceIntervals: Array<{ startSeconds: number; endSeconds?: number }> = []; + try { + const measured = await execFileAsync( + "ffmpeg", + ["-hide_banner", "-i", file, "-af", "volumedetect,silencedetect=noise=-50dB:d=0.02", "-f", "null", "-"], + { timeout: 60_000, maxBuffer: 2_000_000 }, + ); + const stderr = measured.stderr || ""; + const peakMatch = stderr.match(/max_volume:\s*(-?[0-9.]+|-inf)\s*dB/); + const meanMatch = stderr.match(/mean_volume:\s*(-?[0-9.]+|-inf)\s*dB/); + if (peakMatch && peakMatch[1] !== "-inf") peakDbfs = Number(peakMatch[1]); + if (meanMatch && meanMatch[1] !== "-inf") meanDbfs = Number(meanMatch[1]); + const starts = [...stderr.matchAll(/silence_start:\s*([0-9.]+)/g)].map((match) => Number(match[1])); + const ends = [...stderr.matchAll(/silence_end:\s*([0-9.]+)/g)].map((match) => Number(match[1])); + silenceIntervals = starts.map((start, index) => ({ startSeconds: start, endSeconds: ends[index] })); + } catch { + // ffprobe è la misura minima; l'analisi Gemini può comunque procedere. + } + + return { + fileName: path.basename(file), + durationSeconds: numberOrNull(parsed.format?.duration), + codec: stream.codec_name ?? null, + container: parsed.format?.format_name ?? null, + sampleRateHz: numberOrNull(stream.sample_rate), + channels: numberOrNull(stream.channels), + bitDepth: numberOrNull(stream.bits_per_sample), + peakDbfs, + meanDbfs, + silenceIntervals, + }; +} + +async function analyzeAudioWithAntigravity( + file: string, + question: string | undefined, + modelName: string | undefined, + outputDir: string | undefined, + signal?: AbortSignal, +): Promise<{ text: string; metadata: AudioLocalMetadata; waveformPath?: string; reportPath?: string; model: string }> { + const requestedModel = modelName || getConfig("voiceModel") || ANTG_DEFAULT_MODEL; + if (!requestedModel.startsWith("gemini-")) { + throw new Error("L'analisi audio multimodale richiede un modello Gemini Antigravity, non un modello testuale alternativo."); + } + if (signal?.aborted) throw new Error("Analisi audio annullata"); + const size = fs.statSync(file).size; + if (size > AUDIO_MAX_BYTES) throw new Error(`File audio troppo grande: ${size} byte (limite ${AUDIO_MAX_BYTES})`); + + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "agy-audio-analysis-")); + try { + const metadata = await inspectAudioLocally(file); + const waveform = path.join(workDir, "waveform.png"); + await execFileAsync( + "ffmpeg", + [ + "-hide_banner", "-loglevel", "error", "-y", "-i", file, + "-filter_complex", "showwavespic=s=1600x420:split_channels=0:colors=0x4f7cff", + "-frames:v", "1", waveform, + ], + { timeout: 60_000, maxBuffer: 1_000_000 }, + ); + if (signal?.aborted) throw new Error("Analisi audio annullata"); + + const instructions = + "Analizza ascoltando il file audio e osservando anche la waveform PNG allegata. " + + "Rispondi in italiano con una descrizione percettiva prudente, timbro/materiale probabile, " + + "attacco, transienti, corpo, decadimento, silenzi, clipping e artefatti. " + + "Se l'utente chiede una valutazione, separa ciò che osservi dai metadati locali da ciò che inferisci dall'ascolto. " + + "Non inventare parlato o dettagli non udibili. " + + (question ? `Richiesta specifica: ${question}\n\n` : "") + + `Metadati misurati localmente:\n${JSON.stringify(metadata, null, 2)}`; + const response = await antgGenerate({ + parts: [ + { text: instructions }, + { inlineData: { mimeType: audioMimeType(file), data: fs.readFileSync(file).toString("base64") } }, + { inlineData: { mimeType: "image/png", data: fs.readFileSync(waveform).toString("base64") } }, + ], + model: requestedModel, + maxOutputTokens: 5000, + temperature: 0.2, + thinking: "low", + stream: true, + }); + + let waveformPath: string | undefined; + let reportPath: string | undefined; + if (outputDir) { + const destination = path.resolve(outputDir); + fs.mkdirSync(destination, { recursive: true }); + const stem = path.basename(file, path.extname(file)).replace(/[^a-zA-Z0-9._-]+/g, "_"); + waveformPath = path.join(destination, `${stem}.waveform.png`); + reportPath = path.join(destination, `${stem}.analysis.json`); + fs.copyFileSync(waveform, waveformPath); + fs.writeFileSync(reportPath, JSON.stringify({ filePath: file, model: requestedModel, metadata, analysis: response.text }, null, 2)); + } + return { text: response.text, metadata, waveformPath, reportPath, model: requestedModel }; + } finally { + try { fs.rmSync(workDir, { recursive: true, force: true }); } catch { /* ignora */ } + } +} + // Trascrizione audio esclusivamente tramite il gateway Antigravity OAuth. // La CLI agy non accetta file audio, ma cloudcode-pa supporta inlineData // audio con i modelli Gemini multimodali dell'account Antigravity. @@ -2250,6 +2401,45 @@ export default function agyExtension(pi: ExtensionAPI) { }, }); + // ========================================================================= + // TOOL: agy_analyze_audio — audio + waveform + metadati + Gemini + // ========================================================================= + pi.registerTool({ + name: "agy_analyze_audio", + label: "agy analyze audio multimodale", + description: + "Analyze a local audio file together with a generated waveform and measured metadata through Gemini over Antigravity OAuth. " + + "Use for sound effects, music, recordings, timbre, transients, silence and clipping. " + + "It does not assume speech and does not use an external Gemini API key.", + parameters: Type.Object({ + filePath: Type.String({ description: "Percorso del file audio (WAV, MP3, M4A, FLAC, OGG, OPUS o WEBM)." }), + question: Type.Optional(Type.String({ description: "Domanda specifica sull'audio o sul suo uso." })), + model: Type.Optional(Type.String({ description: "Modello Gemini Antigravity (default: voiceModel configurato)." })), + outputDir: Type.Optional(Type.String({ description: "Cartella opzionale dove conservare waveform PNG e report JSON; l'audio non viene copiato." })), + }), + async execute(toolCallId, params, signal, onUpdate, ctx) { + const p = params as any; + const filePath = path.resolve(ctx.cwd, String(p.filePath ?? "").replace(/^@/, "")); + const ext = path.extname(filePath).toLowerCase(); + if (!AUDIO_EXTENSIONS.has(ext)) throw new Error(`Formato audio non supportato: ${ext || "senza estensione"}`); + if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) throw new Error(`File audio non trovato: ${filePath}`); + toolUpdate(onUpdate, "agy_analyze_audio: waveform e metadati locali..."); + const result = await analyzeAudioWithAntigravity(filePath, p.question, p.model, p.outputDir, signal); + toolUpdate(onUpdate, "agy_analyze_audio: analisi Gemini completata"); + const local = JSON.stringify(result.metadata, null, 2); + return { + content: [{ type: "text", text: `${result.text}\n\nMetadati misurati localmente:\n${local}` }], + details: { + filePath, + model: result.model, + metadata: result.metadata, + waveformPath: result.waveformPath, + reportPath: result.reportPath, + }, + }; + }, + }); + // ========================================================================= // TOOL: agy_video — analisi video // =========================================================================