feat: timer registrazione, tasto Esc per annullare e nascondi output ffmpeg

This commit is contained in:
2026-08-10 13:25:18 +02:00
parent f0f1719a65
commit c7388dc91d
+102 -15
View File
@@ -10,7 +10,7 @@
* L'agente (pi) decide quale strumento usare in base al task. * L'agente (pi) decide quale strumento usare in base al task.
*/ */
import { execFile, execFileSync, spawn } from "node:child_process"; import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
@@ -329,22 +329,46 @@ const MAX_RECORD_MS = 120_000; // 2 min
interface Recording { interface Recording {
proc: ReturnType<typeof spawn>; proc: ReturnType<typeof spawn>;
file: string; file: string;
startTime: number;
timer?: NodeJS.Timeout;
} }
let recording: Recording | null = null; let recording: Recording | null = null;
function startRecording(): string { function startRecording(ctx?: any): string {
const maxDur = Number(getConfig("sttMaxDuration") ?? 120); const maxDur = Number(getConfig("sttMaxDuration") ?? 120);
const file = path.join(os.tmpdir(), `agy-rec-${Date.now()}.wav`); const file = path.join(os.tmpdir(), `agy-rec-${Date.now()}.wav`);
const proc = spawn( const proc = spawn(
"ffmpeg", "ffmpeg",
["-y", "-f", "pulse", "-i", "default", "-ac", "1", "-ar", "16000", "-t", String(maxDur), file], ["-hide_banner", "-loglevel", "error", "-y", "-f", "pulse", "-i", "default", "-ac", "1", "-ar", "16000", "-t", String(maxDur), file],
{ stdio: "ignore" }, { stdio: "ignore" },
); );
recording = { proc, file }; const startTime = Date.now();
let timer: NodeJS.Timeout | undefined;
if (ctx) {
const updateStatus = () => {
const elapsedSec = Math.floor((Date.now() - startTime) / 1000);
const elapsedMinStr = String(Math.floor(elapsedSec / 60)).padStart(2, "0");
const elapsedSecStr = String(elapsedSec % 60).padStart(2, "0");
const maxMinStr = String(Math.floor(maxDur / 60)).padStart(2, "0");
const maxSecStr = String(maxDur % 60).padStart(2, "0");
ctx.ui.setStatus(
"agy-rec",
`🔴 REGISTRAZIONE... ${elapsedMinStr}:${elapsedSecStr} / ${maxMinStr}:${maxSecStr} (Esc per annullare)`,
);
};
updateStatus();
timer = setInterval(updateStatus, 500);
}
recording = { proc, file, startTime, timer };
// se ffmpeg termina da solo (timeout 2 min), azzera lo stato // se ffmpeg termina da solo (timeout 2 min), azzera lo stato
proc.on("exit", () => { proc.on("exit", () => {
if (recording && recording.proc === proc) recording = null; if (recording && recording.proc === proc) {
if (recording.timer) clearInterval(recording.timer);
recording = null;
}
}); });
return file; return file;
} }
@@ -352,6 +376,7 @@ function startRecording(): string {
function stopRecording(): Promise<string | null> { function stopRecording(): Promise<string | null> {
return new Promise((resolve) => { return new Promise((resolve) => {
if (!recording) return resolve(null); if (!recording) return resolve(null);
if (recording.timer) clearInterval(recording.timer);
const { proc, file } = recording; const { proc, file } = recording;
recording = null; recording = null;
let done = false; let done = false;
@@ -367,6 +392,30 @@ function stopRecording(): Promise<string | null> {
}); });
} }
function cancelRecording(): Promise<void> {
return new Promise((resolve) => {
if (!recording) return resolve();
if (recording.timer) clearInterval(recording.timer);
const { proc, file } = recording;
recording = null;
let done = false;
const cleanup = () => {
if (!done) {
done = true;
try {
if (fs.existsSync(file)) fs.unlinkSync(file);
} catch {
/* ignora */
}
resolve();
}
};
proc.on("exit", cleanup);
proc.kill("SIGKILL");
setTimeout(cleanup, 1000);
});
}
// Ottimizza l'audio: taglia il silenzio iniziale/finale e converte in WAV 16kHz mono // Ottimizza l'audio: taglia il silenzio iniziale/finale e converte in WAV 16kHz mono
async function optimizeAudio(input: string): Promise<string> { async function optimizeAudio(input: string): Promise<string> {
const output = input.replace(/\.wav$/, "-opt.wav"); const output = input.replace(/\.wav$/, "-opt.wav");
@@ -374,6 +423,9 @@ async function optimizeAudio(input: string): Promise<string> {
await execFileAsync( await execFileAsync(
"ffmpeg", "ffmpeg",
[ [
"-hide_banner",
"-loglevel",
"error",
"-y", "-y",
"-i", "-i",
input, input,
@@ -399,13 +451,14 @@ async function optimizeAudio(input: string): Promise<string> {
// Ritorna true se c'è segnale, false se è silenzio // Ritorna true se c'è segnale, false se è silenzio
function audioHasSpeech(file: string): boolean { function audioHasSpeech(file: string): boolean {
try { try {
const { stdout } = execFileSync( const res = spawnSync(
"ffmpeg", "ffmpeg",
["-i", file, "-af", "volumedetect", "-f", "null", "-"], ["-hide_banner", "-i", file, "-af", "volumedetect", "-f", "null", "-"],
{ timeout: 30_000, encoding: "utf8" }, { timeout: 30_000, encoding: "utf8" },
); );
const maxMatch = stdout.match(/max_volume: ([\-0-9.]+) dB/); const stderr = res.stderr || "";
const meanMatch = stdout.match(/mean_volume: ([\-0-9.]+) dB/); const maxMatch = stderr.match(/max_volume: ([\-0-9.]+) dB/);
const meanMatch = stderr.match(/mean_volume: ([\-0-9.]+) dB/);
if (maxMatch) { if (maxMatch) {
const max = parseFloat(maxMatch[1]); const max = parseFloat(maxMatch[1]);
// max < -35dB ≈ silenzio quasi totale // max < -35dB ≈ silenzio quasi totale
@@ -457,7 +510,7 @@ async function transcribeWithGeminiAPI(file: string): Promise<TranscriptResult>
try { try {
await execFileAsync( await execFileAsync(
"ffmpeg", "ffmpeg",
["-y", "-i", file, "-ac", "1", "-ar", "16000", "-c:a", "libopus", "-b:a", "16k", ogg], ["-hide_banner", "-loglevel", "error", "-y", "-i", file, "-ac", "1", "-ar", "16000", "-c:a", "libopus", "-b:a", "16k", ogg],
{ timeout: 60_000 }, { timeout: 60_000 },
); );
audioFile = ogg; audioFile = ogg;
@@ -569,7 +622,7 @@ async function transcribeWithEnne2(file: string): Promise<TranscriptResult> {
try { try {
await execFileAsync( await execFileAsync(
"ffmpeg", "ffmpeg",
["-y", "-i", file, "-ac", "1", "-ar", "16000", "-c:a", "libmp3lame", "-q:a", "5", mp3], ["-hide_banner", "-loglevel", "error", "-y", "-i", file, "-ac", "1", "-ar", "16000", "-c:a", "libmp3lame", "-q:a", "5", mp3],
{ timeout: 60_000 }, { timeout: 60_000 },
); );
audioFile = mp3; audioFile = mp3;
@@ -661,7 +714,7 @@ async function ttsSpeak(text: string, outputDir?: string): Promise<string | null
const wavFile = pcmFile.replace(".pcm", ".wav"); const wavFile = pcmFile.replace(".pcm", ".wav");
await execFileAsync( await execFileAsync(
"ffmpeg", "ffmpeg",
["-y", "-f", "s16le", "-ar", "24000", "-ac", "1", "-i", pcmFile, wavFile], ["-hide_banner", "-loglevel", "error", "-y", "-f", "s16le", "-ar", "24000", "-ac", "1", "-i", pcmFile, wavFile],
{ timeout: 30_000 }, { timeout: 30_000 },
); );
fs.rmSync(pcmFile, { force: true }); fs.rmSync(pcmFile, { force: true });
@@ -1282,9 +1335,8 @@ export default function agyExtension(pi: ExtensionAPI) {
// ========================================================================= // =========================================================================
async function handleRecordToggle(ctx: any) { async function handleRecordToggle(ctx: any) {
if (!recording) { if (!recording) {
startRecording(); startRecording(ctx);
ctx.ui.notify("🎙️ Registrazione avviata (F12 per fermare, max 2 min)", "info"); ctx.ui.notify("🎙️ Registrazione avviata (F12 per fermare, Esc per annullare)", "info");
ctx.ui.setStatus("agy-rec", "🔴 REGISTRAZIONE...");
return; return;
} }
@@ -1346,6 +1398,28 @@ export default function agyExtension(pi: ExtensionAPI) {
}, },
}); });
pi.registerShortcut("escape", {
description: "Annulla la registrazione microfono in corso",
handler: async (ctx) => {
if (recording) {
await cancelRecording();
ctx.ui.setStatus("agy-rec", "");
ctx.ui.notify("❌ Registrazione annullata", "info");
}
},
});
pi.registerShortcut("esc", {
description: "Annulla la registrazione microfono in corso",
handler: async (ctx) => {
if (recording) {
await cancelRecording();
ctx.ui.setStatus("agy-rec", "");
ctx.ui.notify("❌ Registrazione annullata", "info");
}
},
});
pi.registerCommand("agy:record", { pi.registerCommand("agy:record", {
description: "Avvia/ferma registrazione microfono (come F12)", description: "Avvia/ferma registrazione microfono (come F12)",
handler: async (_args, ctx) => { handler: async (_args, ctx) => {
@@ -1364,6 +1438,19 @@ export default function agyExtension(pi: ExtensionAPI) {
}, },
}); });
pi.registerCommand("agy:record:cancel", {
description: "Annulla la registrazione microfono in corso",
handler: async (_args, ctx) => {
if (!recording) {
ctx.ui.notify("Nessuna registrazione in corso da annullare", "warning");
return;
}
await cancelRecording();
ctx.ui.setStatus("agy-rec", "");
ctx.ui.notify("❌ Registrazione annullata", "info");
},
});
pi.registerCommand("agy:speak", { pi.registerCommand("agy:speak", {
description: "Pronuncia un testo con TTS Gemini. Uso: /agy:speak <testo>", description: "Pronuncia un testo con TTS Gemini. Uso: /agy:speak <testo>",
handler: async (args, ctx) => { handler: async (args, ctx) => {