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.
*/
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 os from "node:os";
import * as path from "node:path";
@@ -329,22 +329,46 @@ const MAX_RECORD_MS = 120_000; // 2 min
interface Recording {
proc: ReturnType<typeof spawn>;
file: string;
startTime: number;
timer?: NodeJS.Timeout;
}
let recording: Recording | null = null;
function startRecording(): string {
function startRecording(ctx?: any): string {
const maxDur = Number(getConfig("sttMaxDuration") ?? 120);
const file = path.join(os.tmpdir(), `agy-rec-${Date.now()}.wav`);
const proc = spawn(
"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" },
);
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
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;
}
@@ -352,6 +376,7 @@ function startRecording(): string {
function stopRecording(): Promise<string | null> {
return new Promise((resolve) => {
if (!recording) return resolve(null);
if (recording.timer) clearInterval(recording.timer);
const { proc, file } = recording;
recording = null;
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
async function optimizeAudio(input: string): Promise<string> {
const output = input.replace(/\.wav$/, "-opt.wav");
@@ -374,6 +423,9 @@ async function optimizeAudio(input: string): Promise<string> {
await execFileAsync(
"ffmpeg",
[
"-hide_banner",
"-loglevel",
"error",
"-y",
"-i",
input,
@@ -399,13 +451,14 @@ async function optimizeAudio(input: string): Promise<string> {
// Ritorna true se c'è segnale, false se è silenzio
function audioHasSpeech(file: string): boolean {
try {
const { stdout } = execFileSync(
const res = spawnSync(
"ffmpeg",
["-i", file, "-af", "volumedetect", "-f", "null", "-"],
["-hide_banner", "-i", file, "-af", "volumedetect", "-f", "null", "-"],
{ timeout: 30_000, encoding: "utf8" },
);
const maxMatch = stdout.match(/max_volume: ([\-0-9.]+) dB/);
const meanMatch = stdout.match(/mean_volume: ([\-0-9.]+) dB/);
const stderr = res.stderr || "";
const maxMatch = stderr.match(/max_volume: ([\-0-9.]+) dB/);
const meanMatch = stderr.match(/mean_volume: ([\-0-9.]+) dB/);
if (maxMatch) {
const max = parseFloat(maxMatch[1]);
// max < -35dB ≈ silenzio quasi totale
@@ -457,7 +510,7 @@ async function transcribeWithGeminiAPI(file: string): Promise<TranscriptResult>
try {
await execFileAsync(
"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 },
);
audioFile = ogg;
@@ -569,7 +622,7 @@ async function transcribeWithEnne2(file: string): Promise<TranscriptResult> {
try {
await execFileAsync(
"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 },
);
audioFile = mp3;
@@ -661,7 +714,7 @@ async function ttsSpeak(text: string, outputDir?: string): Promise<string | null
const wavFile = pcmFile.replace(".pcm", ".wav");
await execFileAsync(
"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 },
);
fs.rmSync(pcmFile, { force: true });
@@ -1282,9 +1335,8 @@ export default function agyExtension(pi: ExtensionAPI) {
// =========================================================================
async function handleRecordToggle(ctx: any) {
if (!recording) {
startRecording();
ctx.ui.notify("🎙️ Registrazione avviata (F12 per fermare, max 2 min)", "info");
ctx.ui.setStatus("agy-rec", "🔴 REGISTRAZIONE...");
startRecording(ctx);
ctx.ui.notify("🎙️ Registrazione avviata (F12 per fermare, Esc per annullare)", "info");
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", {
description: "Avvia/ferma registrazione microfono (come F12)",
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", {
description: "Pronuncia un testo con TTS Gemini. Uso: /agy:speak <testo>",
handler: async (args, ctx) => {