From 8eece4506ab020e1e33183e6cf914d9c23ea7bf9 Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 9 Aug 2026 22:43:19 +0200 Subject: [PATCH] Aggiunto TTS via Gemini API: tool agy_tts, comando /agy:speak, notifiche vocali nel flusso F12 --- extensions/index.ts | 122 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/extensions/index.ts b/extensions/index.ts index 7b51129..a020b45 100644 --- a/extensions/index.ts +++ b/extensions/index.ts @@ -386,6 +386,74 @@ function getConversationContext(ctx: any, maxEntries = 8): string { } } +// --------------------------------------------------------------------------- +// TTS via Gemini API (nessun engine esterno) +// --------------------------------------------------------------------------- +async function ttsSpeak(text: string, outputDir?: string): Promise { + let key = process.env.GEMINI_API_KEY ?? ""; + if (!key) { + try { + key = fs.readFileSync(path.join(AGY_CHAT_DIR, "gemini-key"), "utf8").trim(); + } catch { + /* ignora */ + } + } + if (!key) return null; + + const model = process.env.AGY_TTS_MODEL ?? "gemini-2.5-flash-preview-tts"; + try { + const body = { + contents: [{ parts: [{ text }] }], + generationConfig: { responseModalities: ["AUDIO"] }, + }; + const res = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${key}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(60_000), + }, + ); + if (!res.ok) return null; + const data: any = await res.json(); + const parts = data?.candidates?.[0]?.content?.parts ?? []; + const audioPart = parts.find((p: any) => p.inlineData); + if (!audioPart) return null; + + // salva PCM raw (16-bit, 24kHz) e converti in WAV + const pcmFile = path.join(os.tmpdir(), `tts-${Date.now()}.pcm`); + fs.writeFileSync(pcmFile, Buffer.from(audioPart.inlineData.data, "base64")); + const wavFile = pcmFile.replace(".pcm", ".wav"); + await execFileAsync( + "ffmpeg", + ["-y", "-f", "s16le", "-ar", "24000", "-ac", "1", "-i", pcmFile, wavFile], + { timeout: 30_000 }, + ); + fs.rmSync(pcmFile, { force: true }); + + // copia in outputDir se richiesto + let finalFile = wavFile; + if (outputDir) { + try { + fs.mkdirSync(outputDir, { recursive: true }); + const dest = path.join(outputDir, `tts-${Date.now()}.wav`); + fs.copyFileSync(wavFile, dest); + finalFile = dest; + } catch { + /* ignora */ + } + } + + // riproduci (fire-and-forget) + execFileAsync("paplay", [finalFile], { timeout: 60_000 }).catch(() => {}); + + return finalFile; + } catch { + return null; + } +} + // --------------------------------------------------------------------------- // Estensione // --------------------------------------------------------------------------- @@ -827,6 +895,40 @@ export default function agyExtension(pi: ExtensionAPI) { }, }); + // ========================================================================= + // TOOL: agy_tts — text-to-speech via Gemini API + // ========================================================================= + pi.registerTool({ + name: "agy_tts", + label: "agy TTS (text to speech)", + description: + "Converte un testo in audio (TTS) usando la Gemini API (gemini-2.5-flash-preview-tts) e lo riproduce. " + + "Utile per notifiche vocali, sintesi parlate e aggiornamenti di stato. " + + "Richiede la key Gemini in ~/.agy-chat/gemini-key o GEMINI_API_KEY.", + parameters: Type.Object({ + text: Type.String({ description: "Il testo da pronunciare." }), + play: Type.Optional(Type.Boolean({ description: "true per riprodurre l'audio (default: true)." })), + outputDir: Type.Optional(Type.String({ description: "Cartella dove salvare il file audio." })), + }), + async execute(toolCallId, params, signal, onUpdate, ctx) { + const p = params as any; + onUpdate?.("agy_tts: sintesi vocale..."); + return ttsSpeak(p.text, p.outputDir).then((file) => { + if (!file) { + return { + content: [{ type: "text", text: "TTS fallito: key Gemini mancante o errore API." }], + details: {}, + isError: true, + }; + } + return { + content: [{ type: "text", text: `Audio TTS generato: ${file}` }], + details: { audioFile: file }, + }; + }); + }, + }); + // ========================================================================= // TOOL: agy_models — elenca i modelli disponibili // ========================================================================= @@ -986,6 +1088,11 @@ export default function agyExtension(pi: ExtensionAPI) { pi.sendUserMessage(finalText, { deliverAs: "followUp" }); } ctx.ui.notify("✅ Trascrizione inviata come prompt a pi", "info"); + + // notifica vocale (disattivabile con AGY_TTS_NOTIFY=0) + if (process.env.AGY_TTS_NOTIFY !== "0") { + ttsSpeak("Trascrizione completata e inviata.").catch(() => {}); + } } pi.registerShortcut("f12", { @@ -1012,4 +1119,19 @@ export default function agyExtension(pi: ExtensionAPI) { await handleRecordToggle(ctx); }, }); + + pi.registerCommand("agy:speak", { + description: "Pronuncia un testo con TTS Gemini. Uso: /agy:speak ", + handler: async (args, ctx) => { + if (!args?.trim()) { + ctx.ui.notify("Uso: /agy:speak ", "error"); + return; + } + ctx.ui.setStatus("agy-tts", "🔊 sintesi vocale..."); + const file = await ttsSpeak(args.trim()); + ctx.ui.setStatus("agy-tts", ""); + if (file) ctx.ui.notify(`🔊 Audio: ${file}`, "info"); + else ctx.ui.notify("TTS fallito (key Gemini mancante?)", "error"); + }, + }); }