stt-server: FastAPI whisper.cpp Vulkan ASR + pyannote diarization, containerizzato
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# ============================================================================
|
||||
# stt-server containerizzato (whisper.cpp Vulkan ASR + pyannote diarization)
|
||||
# ----------------------------------------------------------------------------
|
||||
# GPU: AMD/Intel via Mesa RADV (/dev/dri/renderD128). Il driver kernel resta
|
||||
# sull'host; il container monta solo il device node.
|
||||
#
|
||||
# Build: docker build -t stt-server:vulkan .
|
||||
# Run: docker compose up -d (vedi docker-compose.yml)
|
||||
# ============================================================================
|
||||
|
||||
# ------------------------------------------------------------- Vulkan build
|
||||
# glslc (shader compiler) è impacchettato solo dal repo LunarG su Ubuntu 22.04
|
||||
FROM ubuntu:22.04 AS build-vulkan
|
||||
|
||||
RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends \
|
||||
git ca-certificates cmake g++ make wget gnupg \
|
||||
> /dev/null && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN wget -qO- https://packages.lunarg.com/lunarg-signing-key-pub.asc | gpg --dearmor -o /usr/share/keyrings/lunarg.gpg && \
|
||||
echo "deb [signed-by=/usr/share/keyrings/lunarg.gpg] https://packages.lunarg.com/vulkan/1.3.296 jammy main" \
|
||||
> /etc/apt/sources.list.d/lunarg-vulkan.list && \
|
||||
apt-get update -qq && apt-get install -y -qq --no-install-recommends vulkan-sdk \
|
||||
> /dev/null && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# whisper.cpp: commit verificato con build Vulkan su Radeon 780M
|
||||
RUN git clone https://github.com/ggerganov/whisper.cpp.git /build/whisper.cpp && \
|
||||
cd /build/whisper.cpp && git checkout 592feef
|
||||
|
||||
WORKDIR /build/whisper.cpp
|
||||
# GGML_NATIVE=OFF: binario portabile tra CPU diverse
|
||||
RUN cmake -B build -DGGML_VULKAN=ON -DGGML_NATIVE=OFF -DCMAKE_BUILD_TYPE=Release && \
|
||||
cmake --build build --config Release -j"$(nproc)" --target whisper-cli
|
||||
|
||||
# ------------------------------------------------------------------ runtime
|
||||
FROM python:3.11-slim AS runtime
|
||||
|
||||
RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends \
|
||||
libgomp1 libvulkan1 mesa-vulkan-drivers libsndfile1 curl ca-certificates \
|
||||
> /dev/null && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build-vulkan /build/whisper.cpp/build/bin/whisper-cli /build/whisper.cpp/build/bin/*.so* ./
|
||||
|
||||
# torch CPU (niente CUDA) prima di requirements, per evitare il wheel CUDA
|
||||
RUN pip install --no-cache-dir torch==2.9.1 --index-url https://download.pytorch.org/whl/cpu
|
||||
|
||||
COPY requirements.txt ./requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY server.py transcribe.py entrypoint.sh ./
|
||||
RUN chmod +x ./entrypoint.sh
|
||||
|
||||
# Le librerie ggml copiate fuori dall'albero di build hanno RPATH interno:
|
||||
# serve il path esplicito.
|
||||
ENV LD_LIBRARY_PATH=/app
|
||||
ENV GGML_BACKEND=Vulkan0
|
||||
ENV WHISPER_CLI=/app/whisper-cli
|
||||
ENV MODELS_DIR=/models
|
||||
ENV STT_PYTHON=python3
|
||||
ENV HF_HOME=/hf-cache
|
||||
|
||||
EXPOSE 8883
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
@@ -0,0 +1,41 @@
|
||||
# ============================================================================
|
||||
# stt-server (whisper.cpp Vulkan + pyannote) — docker compose
|
||||
# ----------------------------------------------------------------------------
|
||||
# Uso:
|
||||
# export RENDER_GID=$(getent group render | cut -d: -f3) # GID gruppo render
|
||||
# docker compose up -d --build
|
||||
#
|
||||
# GPU: AMD/Intel via Mesa RADV (/dev/dri/renderD128). Su host dove il node
|
||||
# è world-writable (es. Fedora) group_add non è necessario ma innocuo.
|
||||
#
|
||||
# Variabili utili:
|
||||
# MODELS_DIR : dir con ggml-large-v3-turbo.bin + ggml-silero-v6.2.0.bin
|
||||
# HF_CACHE : cache HuggingFace (pyannote Community-1, gated)
|
||||
# HF_TOKEN : token HF (solo se la cache non contiene i modelli)
|
||||
# PORT : porta host (default 8883)
|
||||
# ============================================================================
|
||||
services:
|
||||
stt-server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: stt-server:vulkan
|
||||
container_name: stt-server
|
||||
devices:
|
||||
- /dev/dri/renderD128:/dev/dri/renderD128
|
||||
group_add:
|
||||
- "${RENDER_GID:-44}"
|
||||
environment:
|
||||
GGML_BACKEND: Vulkan0
|
||||
WHISPER_CLI: /app/whisper-cli
|
||||
MODELS_DIR: /models
|
||||
STT_PYTHON: python3
|
||||
HF_HOME: /hf-cache
|
||||
HF_TOKEN: ${HF_TOKEN:-}
|
||||
PORT: "8883"
|
||||
volumes:
|
||||
- "${MODELS_DIR:-/home/enne2/dev/whisper.cpp/models}:/models:ro"
|
||||
- "${HF_CACHE:-/home/enne2/.cache/huggingface}:/hf-cache:ro"
|
||||
ports:
|
||||
- "${PORT:-8883}:8883"
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
# Entrypoint stt-server: avvia il server FastAPI (uvicorn via server.py).
|
||||
# Tutti i parametri sono configurabili via variabili d'ambiente.
|
||||
set -e
|
||||
|
||||
echo "[stt] avvio stt-server (whisper-cli=$WHISPER_CLI models=$MODELS_DIR port=$PORT)"
|
||||
exec python3 /app/server.py
|
||||
@@ -0,0 +1,7 @@
|
||||
fastapi==0.128.0
|
||||
uvicorn>=0.30
|
||||
python-multipart==0.0.21
|
||||
pyannote.audio==4.0.7
|
||||
numpy==2.3.5
|
||||
soundfile==0.14.0
|
||||
huggingface_hub==1.15.0
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
"""STT Server persistente (frigate.vpn:8883).
|
||||
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": [...]}
|
||||
diarize=true: ASR + diarization → segments con speaker
|
||||
|
||||
Uso: uvicorn server:app --host 0.0.0.0 --port 8883
|
||||
"""
|
||||
import asyncio, os, tempfile, time
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, File, Form, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
import transcribe
|
||||
|
||||
app = FastAPI(title="STT Server", version="1.0")
|
||||
|
||||
# Semaforo: whisper-cli Vulkan e pyannote non sono thread-safe per uso concorrente
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
# Cache dei modelli già caricati (pyannote pipeline è costosa da caricare)
|
||||
_pipeline_cache = {}
|
||||
|
||||
|
||||
def _asr_only(audio_path: str, lang: str, model: str) -> list[dict]:
|
||||
"""Path veloce: solo ASR (whisper.cpp Vulkan), niente diarization."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
segs = transcribe.asr(audio_path, model, lang, tmp)
|
||||
return segs
|
||||
|
||||
|
||||
def _full_pipeline(audio_path: str, lang: str, model: str) -> list[dict]:
|
||||
"""Path completo: ASR + diarization pyannote."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
segs = transcribe.asr(audio_path, model, lang, tmp)
|
||||
turns = transcribe.diarize(audio_path, tmp)
|
||||
segs = transcribe.merge(segs, turns)
|
||||
return segs
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "service": "stt", "model": "large-v3-turbo"}
|
||||
|
||||
|
||||
@app.post("/transcribe")
|
||||
async def transcribe_endpoint(
|
||||
file: UploadFile = File(...),
|
||||
lang: str = Form("it"),
|
||||
model: str = Form("large-v3-turbo"),
|
||||
diarize: bool = Form(False),
|
||||
):
|
||||
t0 = time.time()
|
||||
# Salva l'upload in un file temporaneo
|
||||
suffix = Path(file.filename or "audio.wav").suffix or ".wav"
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
||||
tmp.write(await file.read())
|
||||
audio_path = tmp.name
|
||||
try:
|
||||
async with _lock:
|
||||
loop = asyncio.get_running_loop()
|
||||
if diarize:
|
||||
segs = await loop.run_in_executor(None, _full_pipeline, audio_path, lang, model)
|
||||
else:
|
||||
segs = await loop.run_in_executor(None, _asr_only, audio_path, lang, model)
|
||||
finally:
|
||||
os.unlink(audio_path)
|
||||
|
||||
text = " ".join(s["text"] for s in segs).strip()
|
||||
elapsed = round(time.time() - t0, 2)
|
||||
return JSONResponse({
|
||||
"text": text,
|
||||
"segments": segs,
|
||||
"diarize": diarize,
|
||||
"lang": lang,
|
||||
"model": model,
|
||||
"elapsed_s": elapsed,
|
||||
})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "8883")))
|
||||
Executable
+108
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
"""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]
|
||||
"""
|
||||
import argparse, json, os, 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):
|
||||
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)
|
||||
data = json.load(open(out_json))
|
||||
segs = []
|
||||
for s in data['transcription']:
|
||||
text = s['text'].strip()
|
||||
# Filtro finale: scarta segmenti che sono solo frasi allucinate
|
||||
# (rete di sicurezza oltre a VAD e suppress-regex)
|
||||
if text.lower() in {'grazie', 'grazie a tutti', 'thank you', 'thanks',
|
||||
"grazie per l'attenzione", 'sottotitoli',
|
||||
'sottotitoli creati', 'sottotitoli creati da'}:
|
||||
continue
|
||||
segs.append({'start': s['offsets']['from']/1000.0,
|
||||
'end': s['offsets']['to']/1000.0,
|
||||
'text': text})
|
||||
return segs
|
||||
|
||||
def diarize(audio, tmp):
|
||||
script = f'''
|
||||
import json, sys
|
||||
import numpy as np, soundfile as sf, torch
|
||||
from pyannote.audio import Pipeline
|
||||
data, sr = sf.read({audio!r}, dtype='float32')
|
||||
wav = torch.from_numpy(data.T if data.ndim > 1 else data[None, :]).float()
|
||||
p = Pipeline.from_pretrained('pyannote/speaker-diarization-community-1')
|
||||
out = p({{'waveform': wav, 'sample_rate': sr}})
|
||||
diar = out.speaker_diarization if hasattr(out, 'speaker_diarization') else out
|
||||
turns = [{{'start': t.start, 'end': t.end, 'speaker': sp}}
|
||||
for t, _, sp in diar.itertracks(yield_label=True)]
|
||||
json.dump(turns, open({os.path.join(tmp, 'diar.json')!r}, 'w'))
|
||||
'''
|
||||
sp = os.path.join(tmp, 'diar.py')
|
||||
open(sp, 'w').write(script)
|
||||
subprocess.run([PYTHON, sp], check=True)
|
||||
return json.load(open(os.path.join(tmp, 'diar.json')))
|
||||
|
||||
def overlap(a, b):
|
||||
return max(0.0, min(a['end'], b['end']) - max(a['start'], b['start']))
|
||||
|
||||
def merge(segs, turns):
|
||||
for s in segs:
|
||||
best, best_ov = None, 0.0
|
||||
for t in turns:
|
||||
ov = overlap(s, t)
|
||||
if ov > best_ov:
|
||||
best, best_ov = t['speaker'], ov
|
||||
s['speaker'] = best if best_ov > 0.05 else 'UNKNOWN'
|
||||
return segs
|
||||
|
||||
def to_srt(segs, path):
|
||||
def ts(x):
|
||||
h, m = int(x//3600), int(x%3600//60)
|
||||
s, ms = int(x%60), int((x-int(x))*1000)
|
||||
return f'{h:02d}:{m:02d}:{s:02d},{ms:03d}'
|
||||
with open(path, 'w') as f:
|
||||
for i, s in enumerate(segs, 1):
|
||||
f.write(f'{i}\n{ts(s["start"])} --> {ts(s["end"])}\n')
|
||||
f.write(f'[{s["speaker"]}] {s["text"]}\n\n')
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('audio')
|
||||
ap.add_argument('--lang', default='it')
|
||||
ap.add_argument('--model', default='large-v3-turbo')
|
||||
ap.add_argument('--out', default=None)
|
||||
args = ap.parse_args()
|
||||
base = args.out or os.path.splitext(args.audio)[0]
|
||||
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)
|
||||
print(f' {len(segs)} segmenti', 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)
|
||||
print('[3/3] Merge ASR + diarization...', file=sys.stderr)
|
||||
segs = merge(segs, turns)
|
||||
out_json = base + '.json'
|
||||
out_srt = base + '.srt'
|
||||
json.dump({'segments': segs}, open(out_json, 'w'), ensure_ascii=False, indent=2)
|
||||
to_srt(segs, out_srt)
|
||||
print(f'OK: {out_json} + {out_srt}', file=sys.stderr)
|
||||
for s in segs:
|
||||
print(f'{s["start"]:6.2f}-{s["end"]:6.2f} [{s["speaker"]}] {s["text"]}')
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user