feat: add multimodal audio analysis

This commit is contained in:
enne2
2026-09-08 11:11:06 +02:00
parent 4a88084b2b
commit 4920c4c0ba
4 changed files with 202 additions and 1 deletions
+4 -1
View File
@@ -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
+1
View File
@@ -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.
+7
View File
@@ -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.
+190
View File
@@ -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<AudioLocalMetadata> {
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
// =========================================================================