feat: timer registrazione, tasto Esc per annullare e nascondi output ffmpeg
This commit is contained in:
+102
-15
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user