ASR local com faster-whisper (proxy LiteLLM nao aceita audio) + integracao judge/transcribe
parent
3472640649
commit
432a691e38
@ -0,0 +1,5 @@
|
|||||||
|
# ASR local via faster-whisper (Python 3.12 — onde o pacote esta instalado).
|
||||||
|
# O proxy LiteLLM nao aceita partes de audio (omni devolve 400), entao a transcricao
|
||||||
|
# roda na maquina e o LLM so julga o texto. Ver tools/probe-audio.mjs.
|
||||||
|
ASR_PYTHON=C:\Users\henrique\AppData\Local\Programs\Python\Python312\python.exe
|
||||||
|
ASR_MODEL=small
|
||||||
@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* ASR local via faster-whisper (Python).
|
||||||
|
* O proxy LiteLLM NÃO aceita partes de áudio (qwen omni devolve 400),
|
||||||
|
* então a transcrição roda local com faster-whisper.
|
||||||
|
*/
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
// lib/asr.ts -> server/src/lib -> repo root = ../../..
|
||||||
|
const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
|
||||||
|
|
||||||
|
export interface TranscriptionResult {
|
||||||
|
text: string;
|
||||||
|
language: string;
|
||||||
|
probability: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detecta formato do áudio pelos magic bytes iniciais.
|
||||||
|
* Retorna extensão adequada para o arquivo temporário.
|
||||||
|
*/
|
||||||
|
function detectAudioExtension(buffer: Buffer, mimeHint?: string): string {
|
||||||
|
// Magic bytes detection
|
||||||
|
if (buffer.length >= 4) {
|
||||||
|
const b0 = buffer[0]!;
|
||||||
|
const b1 = buffer[1]!;
|
||||||
|
const b2 = buffer[2]!;
|
||||||
|
const b3 = buffer[3]!;
|
||||||
|
|
||||||
|
// EBML (WebM/MKV): 1A 45 DF A3
|
||||||
|
if (b0 === 0x1a && b1 === 0x45 && b2 === 0xdf && b3 === 0xa3) {
|
||||||
|
return ".webm";
|
||||||
|
}
|
||||||
|
// ID3 (MP3 com tag): 49 44 33
|
||||||
|
if (b0 === 0x49 && b1 === 0x44 && b2 === 0x33) {
|
||||||
|
return ".mp3";
|
||||||
|
}
|
||||||
|
// MP3 frame sync: FF FB ou FF F3 ou FF F2
|
||||||
|
if (b0 === 0xff && (b1 & 0xe0) === 0xe0) {
|
||||||
|
return ".mp3";
|
||||||
|
}
|
||||||
|
// RIFF (WAV): 52 49 46 46 ... 57 41 56 45
|
||||||
|
if (b0 === 0x52 && b1 === 0x49 && b2 === 0x46 && b3 === 0x46) {
|
||||||
|
if (buffer.length >= 12 && buffer[8] === 0x57 && buffer[9] === 0x41 && buffer[10] === 0x56 && buffer[11] === 0x45) {
|
||||||
|
return ".wav";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// OGG: 4F 67 67 53
|
||||||
|
if (b0 === 0x4f && b1 === 0x67 && b2 === 0x67 && b3 === 0x53) {
|
||||||
|
return ".ogg";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback para mime hint
|
||||||
|
if (mimeHint) {
|
||||||
|
const mimeMap: Record<string, string> = {
|
||||||
|
"audio/webm": ".webm",
|
||||||
|
"audio/mp3": ".mp3",
|
||||||
|
"audio/mpeg": ".mp3",
|
||||||
|
"audio/wav": ".wav",
|
||||||
|
"audio/ogg": ".ogg",
|
||||||
|
"audio/mp4": ".mp4",
|
||||||
|
};
|
||||||
|
const extFromMime = mimeMap[mimeHint];
|
||||||
|
if (extFromMime) {
|
||||||
|
return extFromMime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default seguro
|
||||||
|
return ".webm";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transcreve áudio usando faster-whisper local.
|
||||||
|
* @param buffer - Buffer do áudio
|
||||||
|
* @param mimeHint - MIME type opcional para ajudar na detecção de formato
|
||||||
|
*/
|
||||||
|
export async function transcribeAudio(
|
||||||
|
buffer: Buffer,
|
||||||
|
mimeHint?: string,
|
||||||
|
): Promise<TranscriptionResult> {
|
||||||
|
const asrPython = process.env.ASR_PYTHON || "python";
|
||||||
|
const asrModel = process.env.ASR_MODEL || "small";
|
||||||
|
const scriptPath = path.join(REPO_ROOT, "tools", "asr_whisper.py");
|
||||||
|
|
||||||
|
// Verificar se o script existe
|
||||||
|
try {
|
||||||
|
await fs.access(scriptPath);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Script ASR não encontrado: ${scriptPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Criar arquivo temporário com extensão adequada
|
||||||
|
const ext = detectAudioExtension(buffer, mimeHint);
|
||||||
|
const tmpDir = os.tmpdir();
|
||||||
|
const tmpFile = path.join(tmpDir, `asr-${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.writeFile(tmpFile, buffer);
|
||||||
|
|
||||||
|
// Executar o script Python
|
||||||
|
const result = await new Promise<string>((resolve, reject) => {
|
||||||
|
const proc = spawn(asrPython, [scriptPath, tmpFile, "--model", asrModel, "--json"], {
|
||||||
|
timeout: 60000,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
|
||||||
|
proc.stdout.on("data", (chunk: Buffer) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.stderr.on("data", (chunk: Buffer) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on("error", (err) => {
|
||||||
|
reject(new Error(`Falha ao executar ASR: ${err.message}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on("close", (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`ASR falhou (código ${code}): ${stderr.trim() || "sem detalhes"}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(stdout);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parsear a última linha como JSON (o script imprime logs antes)
|
||||||
|
const lines = result.trim().split("\n");
|
||||||
|
const lastLine = lines[lines.length - 1];
|
||||||
|
|
||||||
|
if (!lastLine) {
|
||||||
|
throw new Error("ASR não retornou saída");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(lastLine) as {
|
||||||
|
text?: string;
|
||||||
|
language?: string;
|
||||||
|
language_probability?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: (parsed.text ?? "").trim(),
|
||||||
|
language: parsed.language ?? "unknown",
|
||||||
|
probability: parsed.language_probability ?? 0,
|
||||||
|
};
|
||||||
|
} catch (parseErr) {
|
||||||
|
throw new Error(`Saída ASR inválida: ${(parseErr as Error).message}. Raw: ${lastLine.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Limpar arquivo temporário
|
||||||
|
try {
|
||||||
|
await fs.unlink(tmpFile);
|
||||||
|
} catch {
|
||||||
|
// Ignorar erros de limpeza
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""ASR local para o Italiano Vivo via faster-whisper.
|
||||||
|
|
||||||
|
O proxy LiteLLM (10.8.0.1:4000) nao aceita partes de audio no chat/completions
|
||||||
|
(qwen omni devolve 400 "Invalid video file" / "provided URL does not appear to be valid"),
|
||||||
|
entao a transcricao roda aqui e o server Node repassa so o texto para o juiz LLM.
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
python asr_whisper.py <audio_path> [--model small] [--json]
|
||||||
|
|
||||||
|
Imprime o texto transcrito (ou JSON com segments/timing com --json).
|
||||||
|
Idempotente: baixa o modelo uma vez e usa cache local do HuggingFace.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="Transcricao IT/PT com faster-whisper")
|
||||||
|
ap.add_argument("audio", help="caminho do arquivo de audio (mp3/wav/webm)")
|
||||||
|
ap.add_argument("--model", default="small", help="tiny|base|small|medium (default small)")
|
||||||
|
ap.add_argument("--lang", default=None, help="forcar idioma, ex.: it")
|
||||||
|
ap.add_argument("--json", action="store_true", dest="as_json")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
from faster_whisper import WhisperModel
|
||||||
|
|
||||||
|
model = WhisperModel(args.model, device="cpu", compute_type="int8")
|
||||||
|
segments, info = model.transcribe(
|
||||||
|
args.audio, language=args.lang, beam_size=5, vad_filter=True
|
||||||
|
)
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
seg_list = []
|
||||||
|
for s in segments:
|
||||||
|
parts.append(s.text.strip())
|
||||||
|
seg_list.append({"start": round(s.start, 2), "end": round(s.end, 2), "text": s.text.strip()})
|
||||||
|
|
||||||
|
text = " ".join(p for p in parts if p).strip()
|
||||||
|
|
||||||
|
if args.as_json:
|
||||||
|
print(json.dumps({
|
||||||
|
"text": text,
|
||||||
|
"language": info.language,
|
||||||
|
"language_probability": round(float(info.language_probability), 3),
|
||||||
|
"segments": seg_list,
|
||||||
|
}, ensure_ascii=False))
|
||||||
|
else:
|
||||||
|
print(text)
|
||||||
|
return 0 if text else 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
// Probe: qual formato de audio o proxy LiteLLM aceita para transcricao (omni)
|
||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
const key = process.env.K;
|
||||||
|
const b64 = fs.readFileSync(new URL('../audio/b1-chunk-01.mp3', import.meta.url)).toString('base64');
|
||||||
|
|
||||||
|
async function tryOne(model, part) {
|
||||||
|
const r = await fetch('http://10.8.0.1:4000/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
messages: [{ role: 'user', content: [{ type: 'text', text: 'Transcreva exatamente a fala em italiano, sem comentarios.' }, part] }],
|
||||||
|
max_tokens: 120,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const t = await r.text();
|
||||||
|
console.log('===', model, JSON.stringify(Object.keys(part)), r.status);
|
||||||
|
console.log(t.slice(0, 300).replace(/\n/g, ' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
await tryOne('qwen3.5-omni-flash', { type: 'video_url', video_url: { url: 'data:audio/mpeg;base64,' + b64 } });
|
||||||
|
await tryOne('qwen3-omni-flash', { type: 'input_audio', input_audio: { data: b64, format: 'mp3' } });
|
||||||
|
await tryOne('qwen3.5-plus', { type: 'input_audio', input_audio: { data: b64, format: 'mp3' } });
|
||||||
Loading…
Reference in New Issue