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.
|
* 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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user