initial_prompt personale + glossario: POST /transcribe accetta prompt e glossary (normalizzazione alias->canonical post-ASR, raw_text/segments[].raw), GET/PUT /glossary con GLOSSARY_FILE, glossary.example.json, --prompt/--carry-initial-prompt in transcribe.py
This commit is contained in:
@@ -61,6 +61,7 @@ ENV WHISPER_CLI=/app/whisper-cli
|
||||
ENV MODELS_DIR=/models
|
||||
ENV STT_PYTHON=python3
|
||||
ENV HF_HOME=/hf-cache
|
||||
ENV GLOSSARY_FILE=/glossary/glossary.json
|
||||
|
||||
EXPOSE 8883
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
|
||||
@@ -33,9 +33,11 @@ services:
|
||||
HF_HOME: /hf-cache
|
||||
HF_TOKEN: ${HF_TOKEN:-}
|
||||
PORT: "8883"
|
||||
GLOSSARY_FILE: /glossary/glossary.json
|
||||
volumes:
|
||||
- "${MODELS_DIR:-/home/enne2/dev/whisper.cpp/models}:/models:ro"
|
||||
- "${HF_CACHE:-/home/enne2/.cache/huggingface}:/hf-cache:ro"
|
||||
- "${GLOSSARY_FILE:-/home/enne2/dev/stt/glossary.json}:/glossary/glossary.json:ro"
|
||||
ports:
|
||||
- "${PORT:-8883}:8883"
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"people": [
|
||||
{
|
||||
"canonical": "Giulia Bianchi",
|
||||
"aliases": ["Giulia Bianci", "Julia Bianchi", "Giulia Bianche"],
|
||||
"domain": "persone"
|
||||
},
|
||||
{
|
||||
"canonical": "Pietrolli",
|
||||
"aliases": ["Pietro Lli", "Pietrolli"],
|
||||
"domain": "persone"
|
||||
}
|
||||
],
|
||||
"organizations": [
|
||||
{
|
||||
"canonical": "Perplexity AI",
|
||||
"aliases": ["Perplexiti", "Perplexity"],
|
||||
"domain": "aziende"
|
||||
}
|
||||
],
|
||||
"terms": [
|
||||
{
|
||||
"canonical": "retrieval-augmented generation",
|
||||
"aliases": ["rag", "retriaval augmented generation"],
|
||||
"domain": "AI"
|
||||
},
|
||||
{
|
||||
"canonical": "fine-tuning",
|
||||
"aliases": ["fine tuning", "fain tuning"],
|
||||
"domain": "AI"
|
||||
},
|
||||
{
|
||||
"canonical": "stt-server",
|
||||
"aliases": ["stt server", "esteti server", "siti server"],
|
||||
"domain": "infrastruttura"
|
||||
}
|
||||
],
|
||||
"custom": []
|
||||
}
|
||||
@@ -4,13 +4,18 @@ ASR: whisper.cpp (Vulkan) | Diarization opzionale: pyannote Community-1 (CPU)
|
||||
|
||||
Endpoints:
|
||||
GET /health → {"status": "ok", "model": ...}
|
||||
POST /transcribe → multipart: file=<audio> + form: lang, model, diarize
|
||||
diarize=false (default): ASR only → {"text": "...", "segments": [...]}
|
||||
GET /glossary → glossario personale (se GLOSSARY_FILE configurato)
|
||||
PUT /glossary → salva glossario (se GLOSSARY_FILE configurato)
|
||||
POST /transcribe → multipart: file=<audio> + form:
|
||||
lang, model, diarize, prompt (initial_prompt personale),
|
||||
glossary (JSON string con persone/termini → normalizzazione
|
||||
deterministica alias→canonical; il testo originale resta in 'raw')
|
||||
diarize=false (default): ASR only → {"text": ..., "raw_text": ...}
|
||||
diarize=true: ASR + diarization → segments con speaker
|
||||
|
||||
Uso: uvicorn server:app --host 0.0.0.0 --port 8883
|
||||
"""
|
||||
import asyncio, os, tempfile, time
|
||||
import asyncio, json, os, tempfile, time
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, File, Form, UploadFile
|
||||
@@ -18,7 +23,7 @@ from fastapi.responses import JSONResponse
|
||||
|
||||
import transcribe
|
||||
|
||||
app = FastAPI(title="STT Server", version="1.0")
|
||||
app = FastAPI(title="STT Server", version="1.1")
|
||||
|
||||
# Semaforo: whisper-cli Vulkan e pyannote non sono thread-safe per uso concorrente
|
||||
_lock = asyncio.Lock()
|
||||
@@ -26,18 +31,21 @@ _lock = asyncio.Lock()
|
||||
# Cache dei modelli già caricati (pyannote pipeline è costosa da caricare)
|
||||
_pipeline_cache = {}
|
||||
|
||||
# Glossario server-side condiviso (opzionale): env GLOSSARY_FILE
|
||||
_glossary_file = os.environ.get("GLOSSARY_FILE") or ""
|
||||
|
||||
def _asr_only(audio_path: str, lang: str, model: str) -> list[dict]:
|
||||
|
||||
def _asr_only(audio_path: str, lang: str, model: str, prompt: str | None) -> list[dict]:
|
||||
"""Path veloce: solo ASR (whisper.cpp Vulkan), niente diarization."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
segs = transcribe.asr(audio_path, model, lang, tmp)
|
||||
segs = transcribe.asr(audio_path, model, lang, tmp, prompt=prompt)
|
||||
return segs
|
||||
|
||||
|
||||
def _full_pipeline(audio_path: str, lang: str, model: str) -> list[dict]:
|
||||
def _full_pipeline(audio_path: str, lang: str, model: str, prompt: str | None) -> list[dict]:
|
||||
"""Path completo: ASR + diarization pyannote."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
segs = transcribe.asr(audio_path, model, lang, tmp)
|
||||
segs = transcribe.asr(audio_path, model, lang, tmp, prompt=prompt)
|
||||
turns = transcribe.diarize(audio_path, tmp)
|
||||
segs = transcribe.merge(segs, turns)
|
||||
return segs
|
||||
@@ -45,7 +53,27 @@ def _full_pipeline(audio_path: str, lang: str, model: str) -> list[dict]:
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "service": "stt", "model": "large-v3-turbo"}
|
||||
return {"status": "ok", "service": "stt", "model": "large-v3-turbo",
|
||||
"version": "1.1"}
|
||||
|
||||
|
||||
@app.get("/glossary")
|
||||
async def get_glossary():
|
||||
if not _glossary_file or not os.path.exists(_glossary_file):
|
||||
return JSONResponse({"error": "glossario non configurato"}, status_code=404)
|
||||
with open(_glossary_file, encoding="utf-8") as f:
|
||||
return JSONResponse(json.load(f))
|
||||
|
||||
|
||||
@app.put("/glossary")
|
||||
async def put_glossary(body: dict):
|
||||
if not _glossary_file:
|
||||
return JSONResponse({"error": "glossario non configurato (GLOSSARY_FILE)"},
|
||||
status_code=404)
|
||||
with open(_glossary_file, "w", encoding="utf-8") as f:
|
||||
json.dump(body, f, ensure_ascii=False, indent=2)
|
||||
return {"status": "ok", "entries": sum(len(body.get(g) or []) for g in
|
||||
("people", "organizations", "terms", "custom"))}
|
||||
|
||||
|
||||
@app.post("/transcribe")
|
||||
@@ -54,6 +82,8 @@ async def transcribe_endpoint(
|
||||
lang: str = Form("it"),
|
||||
model: str = Form("large-v3-turbo"),
|
||||
diarize: bool = Form(False),
|
||||
prompt: str | None = Form(None),
|
||||
glossary: str | None = Form(None),
|
||||
):
|
||||
t0 = time.time()
|
||||
# Salva l'upload in un file temporaneo
|
||||
@@ -61,26 +91,34 @@ async def transcribe_endpoint(
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
||||
tmp.write(await file.read())
|
||||
audio_path = tmp.name
|
||||
flat_map = transcribe.load_glossary(glossary) if glossary else {}
|
||||
try:
|
||||
async with _lock:
|
||||
loop = asyncio.get_running_loop()
|
||||
if diarize:
|
||||
segs = await loop.run_in_executor(None, _full_pipeline, audio_path, lang, model)
|
||||
segs = await loop.run_in_executor(None, _full_pipeline, audio_path, lang, model, prompt)
|
||||
else:
|
||||
segs = await loop.run_in_executor(None, _asr_only, audio_path, lang, model)
|
||||
segs = await loop.run_in_executor(None, _asr_only, audio_path, lang, model, prompt)
|
||||
finally:
|
||||
os.unlink(audio_path)
|
||||
|
||||
raw_text = " ".join(s["text"] for s in segs).strip()
|
||||
if flat_map:
|
||||
segs = transcribe.apply_glossary(segs, flat_map)
|
||||
text = " ".join(s["text"] for s in segs).strip()
|
||||
elapsed = round(time.time() - t0, 2)
|
||||
return JSONResponse({
|
||||
resp = {
|
||||
"text": text,
|
||||
"segments": segs,
|
||||
"diarize": diarize,
|
||||
"lang": lang,
|
||||
"model": model,
|
||||
"elapsed_s": elapsed,
|
||||
})
|
||||
}
|
||||
# raw_text solo quando c'è stato un intervento (prompt/glossario)
|
||||
if flat_map or prompt:
|
||||
resp["raw_text"] = raw_text
|
||||
return JSONResponse(resp)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+107
-12
@@ -2,26 +2,101 @@
|
||||
"""STT + Speaker Diarization pipeline (frigate.vpn).
|
||||
ASR: whisper.cpp (Vulkan) | Diarization: pyannote Community-1 (CPU)
|
||||
Uso: transcribe.py <audio> [--lang it] [--model large-v3-turbo] [--out base]
|
||||
[--prompt \"frase personale\"] [--glossary glossario.json]
|
||||
"""
|
||||
import argparse, json, os, subprocess, sys, tempfile
|
||||
import argparse, json, os, re, subprocess, sys, tempfile
|
||||
|
||||
WHISPER_CLI = os.environ.get('WHISPER_CLI', os.path.expanduser('~/dev/whisper.cpp/build/bin/whisper-cli'))
|
||||
MODELS_DIR = os.environ.get('MODELS_DIR', os.path.expanduser('~/dev/whisper.cpp/models'))
|
||||
PYTHON = os.environ.get('STT_PYTHON', os.path.expanduser('~/dev/stt-venv/bin/python'))
|
||||
|
||||
def asr(audio, model, lang, tmp):
|
||||
# ---------------------------------------------------------------------------
|
||||
# Glossario personale: estensione vocabolario + normalizzazione deterministica
|
||||
# ---------------------------------------------------------------------------
|
||||
# Formato (JSON):
|
||||
# {
|
||||
# "people": [{"canonical": "Giulia Bianchi", "aliases": ["Giulia Bianci", ...]}],
|
||||
# "organizations": [{"canonical": "...", "aliases": [...]}],
|
||||
# "terms": [{"canonical": "fine-tuning", "aliases": ["fine tuning", "fain tuning"]}]
|
||||
# }
|
||||
# La normalizzazione sostituisce gli alias con la forma canonica SOLO su match
|
||||
# esatto (case-insensitive, confini di parola). Le sostituzioni sono annotate
|
||||
# nel campo "raw" del segmento per audit/rollback.
|
||||
|
||||
def load_glossary(path_or_json):
|
||||
"""Accetta un path JSON o una stringa JSON; restituisce la mappa
|
||||
alias->canonical (alias in lowercase). Invalido/vuoto -> {}."""
|
||||
if not path_or_json:
|
||||
return {}
|
||||
try:
|
||||
data = path_or_json
|
||||
if isinstance(data, str):
|
||||
stripped = data.strip()
|
||||
if stripped.startswith('{'):
|
||||
data = json.loads(stripped) # stringa JSON
|
||||
else:
|
||||
with open(stripped, encoding='utf-8') as f: # path
|
||||
data = json.load(f)
|
||||
flat = {}
|
||||
for group in ('people', 'organizations', 'terms', 'custom'):
|
||||
for entry in (data.get(group) or []):
|
||||
canon = str(entry.get('canonical') or '').strip()
|
||||
if not canon:
|
||||
continue
|
||||
for alias in (entry.get('aliases') or []):
|
||||
alias = str(alias).strip()
|
||||
if alias and alias.lower() != canon.lower():
|
||||
flat[alias.lower()] = canon
|
||||
# Formato alternativo semplice: {"alias": "canonical"}
|
||||
if not flat:
|
||||
for alias, canon in data.items():
|
||||
if isinstance(canon, str) and isinstance(alias, str):
|
||||
flat[alias.lower()] = canon
|
||||
return flat
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def normalize_text(text, flat_map):
|
||||
"""Sostituisce alias (case-insensitive, confini parola) con la forma
|
||||
canonica. Le sostituzioni avvengono su segnaposto univoci: un alias non
|
||||
può ri-matchare testo generato da un'altra sostituzione (no cascate).
|
||||
Restituisce (nuovo_testo, sostituzioni_fatte)."""
|
||||
if not flat_map or not text:
|
||||
return text, 0
|
||||
out, count = text, 0
|
||||
placeholders = []
|
||||
# Passo 1: alias -> segnaposto (alias più lunghi prima, evita match parziali)
|
||||
for alias in sorted(flat_map, key=len, reverse=True):
|
||||
canon = flat_map[alias]
|
||||
pattern = r'(?<!\w)' + re.escape(alias) + r'(?!\w)'
|
||||
ph = f'\x00PH{len(placeholders)}\x00'
|
||||
new_out, n = re.subn(pattern, ph, out, flags=re.IGNORECASE)
|
||||
if n:
|
||||
placeholders.append((ph, canon))
|
||||
count += n
|
||||
out = new_out
|
||||
# Passo 2: segnaposto -> forma canonica
|
||||
for ph, canon in placeholders:
|
||||
out = out.replace(ph, canon)
|
||||
return out, count
|
||||
|
||||
|
||||
def asr(audio, model, lang, tmp, prompt=None):
|
||||
model_path = os.path.join(MODELS_DIR, f'ggml-{model}.bin')
|
||||
out_json = os.path.join(tmp, 'asr.json')
|
||||
vad_model = os.path.join(MODELS_DIR, 'ggml-silero-v6.2.0.bin')
|
||||
# VAD (silero): salta il silenzio/rumore → elimina le allucinazioni
|
||||
# tipo "Grazie a tutti" su audio senza parlato. --suppress-regex come
|
||||
# rete di sicurezza per frasi allucinate residue.
|
||||
subprocess.run([WHISPER_CLI, '-m', model_path, '-f', audio, '-l', lang,
|
||||
'-oj', '-of', os.path.join(tmp, 'asr'), '--no-prints',
|
||||
'-vm', vad_model, '--vad',
|
||||
'--suppress-regex',
|
||||
r'(Grazie a tutti|Grazie per l.attenzione|Thank you|Thanks for watching|Sottotitoli creati|Sottotitoli)'],
|
||||
check=True, capture_output=True)
|
||||
cmd = [WHISPER_CLI, '-m', model_path, '-f', audio, '-l', lang,
|
||||
'-oj', '-of', os.path.join(tmp, 'asr'), '--no-prints',
|
||||
'-vm', vad_model, '--vad',
|
||||
'--suppress-regex',
|
||||
r'(Grazie a tutti|Grazie per l.attenzione|Thank you|Thanks for watching|Sottotitoli creati|Sottotitoli)']
|
||||
# Initial prompt (bias vocabolario personale, nomi, gergo): boost
|
||||
# zero-training. --carry-initial-prompt lo riapplica su ogni finestra
|
||||
# (necessario per audio lunghi; verificare con whisper-cli -h).
|
||||
if prompt:
|
||||
cmd += ['--prompt', prompt, '--carry-initial-prompt']
|
||||
subprocess.run(cmd, check=True, capture_output=True)
|
||||
data = json.load(open(out_json))
|
||||
segs = []
|
||||
for s in data['transcription']:
|
||||
@@ -37,6 +112,18 @@ def asr(audio, model, lang, tmp):
|
||||
'text': text})
|
||||
return segs
|
||||
|
||||
|
||||
def apply_glossary(segs, flat_map):
|
||||
"""Normalizza i testi dei segmenti con il glossario. I segmenti modificati
|
||||
ricevono 'raw' = testo ASR originale (audit/rollback)."""
|
||||
for seg in segs:
|
||||
new_text, count = normalize_text(seg['text'], flat_map)
|
||||
if count:
|
||||
seg['raw'] = seg['text']
|
||||
seg['text'] = new_text
|
||||
return segs
|
||||
|
||||
|
||||
def diarize(audio, tmp):
|
||||
script = f'''
|
||||
import json, sys
|
||||
@@ -85,12 +172,20 @@ def main():
|
||||
ap.add_argument('--lang', default='it')
|
||||
ap.add_argument('--model', default='large-v3-turbo')
|
||||
ap.add_argument('--out', default=None)
|
||||
ap.add_argument('--prompt', default=None,
|
||||
help='initial prompt personale (nomi, termini, gergo)')
|
||||
ap.add_argument('--glossary', default=None,
|
||||
help='path a glossario JSON (alias -> forma canonica)')
|
||||
args = ap.parse_args()
|
||||
base = args.out or os.path.splitext(args.audio)[0]
|
||||
flat_map = load_glossary(args.glossary)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
print(f'[1/3] ASR whisper.cpp ({args.model}, {args.lang})...', file=sys.stderr)
|
||||
segs = asr(args.audio, args.model, args.lang, tmp)
|
||||
segs = asr(args.audio, args.model, args.lang, tmp, prompt=args.prompt)
|
||||
print(f' {len(segs)} segmenti', file=sys.stderr)
|
||||
if flat_map:
|
||||
segs = apply_glossary(segs, flat_map)
|
||||
print(f' glossario: {len(flat_map)} alias applicati', file=sys.stderr)
|
||||
print('[2/3] Diarization pyannote Community-1...', file=sys.stderr)
|
||||
turns = diarize(args.audio, tmp)
|
||||
print(f' {len(turns)} turni parlante', file=sys.stderr)
|
||||
|
||||
Reference in New Issue
Block a user