feat: add multimodal audio analysis
This commit is contained in:
@@ -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
|
||||
// =========================================================================
|
||||
|
||||
Reference in New Issue
Block a user