nex-speech: Sprachdienste für NEX (whisper.cpp CUDA 12 + Piper) als Compose und Ansible-Rolle
ci / test (push) Successful in 29s
ci / test (push) Successful in 29s
- deploy/compose/nex-speech: whisper-server v1.9.4 (sm_52/sm_61, Port 8178, -l de -bs 5 --convert, tmpfs, Aufwärmen mit 1 s Stille) und OpenAI-kompatibler Piper-Wrapper (Port 8179, mp3/opus/aac/flac/pcm gestreamt, wav, PCM 24 kHz mit Rate-Header) - Host-Netz, damit ufw greift; keine Texte in Logs, kein Access-Log - Rolle nex-speech + Playbook (nur gpu-pve52): ufw, Modelle mit sha256, Compose bauen/starten - compose-check um nex-speech erweitert, community.docker in requirements.yml Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b66c4c37cc
commit
afc5fa0360
@@ -0,0 +1,21 @@
|
||||
# NEX-Sprachdienste (deploy/compose/nex-speech). Keine Geheimnisse nötig, kein API-Key.
|
||||
MODELS_DIR=/srv/models
|
||||
# Erkennung (whisper.cpp, GPU)
|
||||
STT_PORT=8178
|
||||
STT_GPU=0
|
||||
WHISPER_TAG=v1.9.4
|
||||
WHISPER_COMMIT=927cfce34f31707e17f2bff35c349632fb9e2c3a
|
||||
CUDA_ARCHS=52;61
|
||||
WHISPER_MODEL_FILE=ggml-large-v3-turbo.bin
|
||||
WHISPER_BEAM_SIZE=5
|
||||
# z. B. -nfa (Flash-Attention aus)
|
||||
WHISPER_EXTRA_ARGS=
|
||||
# Ausgabe (Piper, CPU)
|
||||
TTS_PORT=8179
|
||||
PIPER_TTS_VERSION=1.8.0
|
||||
PIPER_VOICES=de_DE-thorsten-high,de_DE-kerstin-low
|
||||
PIPER_DEFAULT_VOICE=de_DE-thorsten-high
|
||||
# 0 = native Rate der Stimme (thorsten-high 22050 Hz, kerstin-low 16000 Hz)
|
||||
PCM_SAMPLE_RATE=24000
|
||||
TTS_MAX_CHARS=5000
|
||||
TTS_PARALLEL=2
|
||||
@@ -0,0 +1,76 @@
|
||||
# NEX-Sprachdienste (Task 6.2, Auftrag docs/auftraege/2026-10-12-sprachmodelle-nex.md)
|
||||
# nex-stt Erkennung: whisper.cpp whisper-server (CUDA 12, sm_52/sm_61), Port 8178
|
||||
# nex-tts Ausgabe: Piper (CPU) hinter OpenAI-kompatiblem Wrapper, Port 8179
|
||||
# Beide im Host-Netz: Veröffentlichte Docker-Ports laufen über DNAT/FORWARD und umgehen ufw.
|
||||
# So gelten die ufw-Regeln der VM (8178/8179 nur von 192.168.100.29), siehe Ansible-Rolle nex-speech.
|
||||
# Datenschutz: kein Access-Log, keine Texte im Log, Temp-Dateien nur im tmpfs.
|
||||
name: nex-speech
|
||||
|
||||
x-logging: &logging
|
||||
driver: local
|
||||
options: {max-size: "5m", max-file: "2"}
|
||||
|
||||
services:
|
||||
nex-stt:
|
||||
build:
|
||||
context: ./whisper
|
||||
args:
|
||||
WHISPER_TAG: ${WHISPER_TAG:-v1.9.4}
|
||||
WHISPER_COMMIT: ${WHISPER_COMMIT:-927cfce34f31707e17f2bff35c349632fb9e2c3a}
|
||||
CUDA_ARCHS: ${CUDA_ARCHS:-52;61}
|
||||
image: knaster/nex-whisper:${WHISPER_TAG:-v1.9.4}-cu12.6
|
||||
container_name: nex-stt
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
read_only: true
|
||||
environment:
|
||||
WHISPER_PORT: ${STT_PORT:-8178}
|
||||
WHISPER_MODEL: /models/whisper/${WHISPER_MODEL_FILE:-ggml-large-v3-turbo.bin}
|
||||
WHISPER_LANGUAGE: de
|
||||
WHISPER_BEAM_SIZE: ${WHISPER_BEAM_SIZE:-5}
|
||||
WHISPER_EXTRA_ARGS: ${WHISPER_EXTRA_ARGS:-}
|
||||
volumes:
|
||||
- ${MODELS_DIR:-/srv/models}/whisper:/models/whisper:ro
|
||||
tmpfs:
|
||||
- /run/nex-whisper:size=512m,mode=0700
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices: [{driver: nvidia, device_ids: ["${STT_GPU:-0}"], capabilities: [gpu]}]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:$${WHISPER_PORT}/health >/dev/null || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 300s
|
||||
logging: *logging
|
||||
|
||||
nex-tts:
|
||||
build: ./piper
|
||||
image: knaster/nex-tts-piper:${PIPER_TTS_VERSION:-1.8.0}
|
||||
container_name: nex-tts
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
read_only: true
|
||||
environment:
|
||||
TTS_PORT: ${TTS_PORT:-8179}
|
||||
PIPER_VOICES_DIR: /voices
|
||||
PIPER_VOICES: ${PIPER_VOICES:-de_DE-thorsten-high,de_DE-kerstin-low}
|
||||
PIPER_DEFAULT_VOICE: ${PIPER_DEFAULT_VOICE:-de_DE-thorsten-high}
|
||||
PCM_SAMPLE_RATE: ${PCM_SAMPLE_RATE:-24000}
|
||||
TTS_MAX_CHARS: ${TTS_MAX_CHARS:-5000}
|
||||
TTS_PARALLEL: ${TTS_PARALLEL:-2}
|
||||
volumes:
|
||||
- ${MODELS_DIR:-/srv/models}/piper:/voices:ro
|
||||
tmpfs:
|
||||
- /tmp:size=64m
|
||||
deploy:
|
||||
resources:
|
||||
limits: {cpus: "3", memory: 2G} # ein Kern bleibt für whisper-server und das System
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import os, urllib.request; urllib.request.urlopen(f\"http://127.0.0.1:{os.environ.get('TTS_PORT', '8179')}/health\", timeout=4)"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
logging: *logging
|
||||
@@ -0,0 +1,14 @@
|
||||
# NEX-Sprachausgabe: Piper (CPU) hinter einem OpenAI-kompatiblen Wrapper (app.py).
|
||||
# Basis per Digest gepinnt; ffmpeg aus Debian (libmp3lame, libopus).
|
||||
FROM python:3.12-slim-bookworm@sha256:34386ef0cb081344d7ec1c103ba398e6e9f64e9ab3a1509accc92a4e24a07258
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir --disable-pip-version-check -r /app/requirements.txt
|
||||
COPY app.py /app/app.py
|
||||
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
|
||||
USER nobody
|
||||
WORKDIR /app
|
||||
# Kein Access-Log (Datenschutz), nur Warnungen und Fehler
|
||||
CMD ["sh", "-c", "exec uvicorn app:app --host ${TTS_HOST:-0.0.0.0} --port ${TTS_PORT:-8179} --no-access-log --log-level warning --timeout-graceful-shutdown 5"]
|
||||
@@ -0,0 +1,268 @@
|
||||
"""OpenAI-kompatible Sprachausgabe für NEX: POST /v1/audio/speech mit Piper auf der CPU.
|
||||
|
||||
Schlanker Wrapper um piper-tts (Python-API) und ffmpeg:
|
||||
- Formate: mp3 (Standard), opus (Ogg-Container), aac, flac, pcm (int16 LE mono) und wav.
|
||||
- mp3/opus/aac/flac/pcm werden satzweise gestreamt (chunked), wav kommt vollständig mit
|
||||
korrektem Header (kompatibel mit `knaster bench tts` und dem Canary-Prober).
|
||||
- pcm wird auf PCM_SAMPLE_RATE umgerechnet (Standard 24000 Hz wie bei OpenAI); die Rate steht in
|
||||
`X-Audio-Sample-Rate` und in `Content-Type: audio/pcm; rate=<Hz>`.
|
||||
- Datenschutz: Text und Audio werden weder gespeichert noch geloggt; alles läuft über Pipes im RAM.
|
||||
|
||||
Umgebung:
|
||||
PIPER_VOICES_DIR Verzeichnis mit <stimme>.onnx und <stimme>.onnx.json (Standard /voices)
|
||||
PIPER_VOICES Kommagetrennte Stimmen-IDs (Standard de_DE-thorsten-high,de_DE-kerstin-low)
|
||||
PIPER_DEFAULT_VOICE Standardstimme (Standard: erste aus PIPER_VOICES)
|
||||
PCM_SAMPLE_RATE Rate für response_format=pcm, 0 = native Rate der Stimme (Standard 24000)
|
||||
TTS_MAX_CHARS Höchstlänge von input in Zeichen (Standard 5000)
|
||||
TTS_PARALLEL Gleichzeitige Synthesen (Standard 2)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import wave
|
||||
from collections.abc import AsyncGenerator, AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
||||
from piper import PiperVoice, SynthesisConfig
|
||||
|
||||
log = logging.getLogger("nex-tts")
|
||||
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "WARNING").upper(),
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
|
||||
VOICES_DIR = Path(os.environ.get("PIPER_VOICES_DIR", "/voices"))
|
||||
VOICE_IDS = [v.strip() for v in os.environ.get("PIPER_VOICES", "de_DE-thorsten-high,de_DE-kerstin-low").split(",")
|
||||
if v.strip()]
|
||||
DEFAULT_VOICE = os.environ.get("PIPER_DEFAULT_VOICE") or VOICE_IDS[0]
|
||||
PCM_RATE = int(os.environ.get("PCM_SAMPLE_RATE", "24000"))
|
||||
MAX_CHARS = int(os.environ.get("TTS_MAX_CHARS", "5000"))
|
||||
PARALLEL = threading.Semaphore(int(os.environ.get("TTS_PARALLEL", "2"))) # CPU nicht überbuchen
|
||||
|
||||
# ffmpeg-Ausgabeparameter je Format: (Argumente, Content-Type)
|
||||
ENCODERS: dict[str, tuple[list[str], str]] = {
|
||||
"mp3": (["-c:a", "libmp3lame", "-b:a", "64k", "-id3v2_version", "0", "-write_xing", "0", "-f", "mp3"], "audio/mpeg"),
|
||||
# Ogg-Seiten klein halten (20 ms), sonst puffert der Muxer bis zu 1 s
|
||||
"opus": (["-ar", "48000", "-c:a", "libopus", "-b:a", "32k", "-f", "ogg", "-page_duration", "20000"], "audio/ogg"),
|
||||
"aac": (["-c:a", "aac", "-b:a", "64k", "-f", "adts"], "audio/aac"),
|
||||
"flac": (["-c:a", "flac", "-f", "flac"], "audio/flac"),
|
||||
}
|
||||
FORMATS = {*ENCODERS, "pcm", "wav"}
|
||||
|
||||
voices: dict[str, PiperVoice] = {}
|
||||
aliases: dict[str, str] = {}
|
||||
|
||||
|
||||
def load_voices() -> None:
|
||||
for vid in VOICE_IDS:
|
||||
model = VOICES_DIR / f"{vid}.onnx"
|
||||
voice = PiperVoice.load(str(model), config_path=str(model) + ".json", use_cuda=False)
|
||||
voices[vid] = voice
|
||||
# Kurzname: de_DE-thorsten-high → thorsten-high und thorsten
|
||||
short = vid.split("-", 1)[1] if "-" in vid else vid
|
||||
aliases.setdefault(short, vid)
|
||||
aliases.setdefault(short.rsplit("-", 1)[0], vid)
|
||||
# Aufwärmen, damit der erste echte Aufruf nicht die ONNX-Initialisierung bezahlt
|
||||
for _ in voice.synthesize("Hallo."):
|
||||
pass
|
||||
if DEFAULT_VOICE not in voices:
|
||||
raise RuntimeError(f"Standardstimme {DEFAULT_VOICE} ist nicht geladen")
|
||||
|
||||
|
||||
def resolve_voice(name: str | None) -> str:
|
||||
if not name or name == "default":
|
||||
return DEFAULT_VOICE
|
||||
if name in voices:
|
||||
return name
|
||||
if name in aliases:
|
||||
return aliases[name]
|
||||
# OpenAI-Clients schicken oft "alloy" o. Ä.: nicht scheitern, sondern Standardstimme nehmen
|
||||
log.warning("Stimme %r unbekannt, nutze %s", name[:40], DEFAULT_VOICE)
|
||||
return DEFAULT_VOICE
|
||||
|
||||
|
||||
async def synth_pcm(voice: PiperVoice, text: str, cfg: SynthesisConfig) -> AsyncIterator[bytes]:
|
||||
"""Piper satzweise in einem Thread rechnen und die int16-Blöcke asynchron weiterreichen."""
|
||||
loop = asyncio.get_running_loop()
|
||||
queue: asyncio.Queue[bytes | BaseException | None] = asyncio.Queue()
|
||||
stop = threading.Event()
|
||||
|
||||
def work() -> None:
|
||||
try:
|
||||
with PARALLEL:
|
||||
for chunk in voice.synthesize(text, syn_config=cfg):
|
||||
if stop.is_set():
|
||||
break
|
||||
loop.call_soon_threadsafe(queue.put_nowait, chunk.audio_int16_bytes)
|
||||
except BaseException as exc: # noqa: BLE001 – an den Aufrufer weitergeben
|
||||
loop.call_soon_threadsafe(queue.put_nowait, exc)
|
||||
finally:
|
||||
loop.call_soon_threadsafe(queue.put_nowait, None)
|
||||
|
||||
loop.run_in_executor(None, work)
|
||||
try:
|
||||
while True:
|
||||
item = await queue.get()
|
||||
if item is None:
|
||||
return
|
||||
if isinstance(item, BaseException):
|
||||
raise item
|
||||
yield item
|
||||
finally:
|
||||
stop.set() # Client weg oder fertig: Thread bricht nach dem laufenden Satz ab
|
||||
|
||||
|
||||
async def encode(pcm: AsyncIterator[bytes], rate: int, out_args: list[str]) -> AsyncIterator[bytes]:
|
||||
"""Rohes PCM per ffmpeg (stdin → stdout) umwandeln und dabei weiterstreamen."""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error",
|
||||
"-f", "s16le", "-ar", str(rate), "-ac", "1", "-i", "pipe:0",
|
||||
*out_args, "-flush_packets", "1", "pipe:1",
|
||||
stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
|
||||
assert proc.stdin is not None and proc.stdout is not None
|
||||
|
||||
async def feed() -> None:
|
||||
try:
|
||||
async for block in pcm:
|
||||
proc.stdin.write(block)
|
||||
await proc.stdin.drain()
|
||||
finally:
|
||||
proc.stdin.close()
|
||||
|
||||
feeder = asyncio.create_task(feed())
|
||||
try:
|
||||
while data := await proc.stdout.read(8192):
|
||||
yield data
|
||||
await feeder
|
||||
if await proc.wait() != 0:
|
||||
raise RuntimeError(f"ffmpeg beendet mit {proc.returncode}")
|
||||
finally:
|
||||
if not feeder.done():
|
||||
feeder.cancel()
|
||||
if proc.returncode is None:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
|
||||
|
||||
async def primed(stream: AsyncGenerator[bytes, None]) -> AsyncIterator[bytes]:
|
||||
"""Erst den ersten Audioblock abwarten, dann die Header senden.
|
||||
|
||||
So misst die Zeit bis zum ersten Byte echtes Audio, und ein Fehler beim Start wird noch
|
||||
als HTTP-Fehler gemeldet. Fehler mitten im Stream werden nur ohne Text protokolliert.
|
||||
"""
|
||||
try:
|
||||
first = await anext(stream)
|
||||
except StopAsyncIteration:
|
||||
first = b""
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.error("Synthese fehlgeschlagen: %s", type(exc).__name__)
|
||||
raise HTTPException(500, "Synthese fehlgeschlagen") from None
|
||||
|
||||
async def rest() -> AsyncIterator[bytes]:
|
||||
try:
|
||||
if first:
|
||||
yield first
|
||||
async for data in stream:
|
||||
yield data
|
||||
except Exception as exc:
|
||||
log.error("Synthese abgebrochen: %s", type(exc).__name__)
|
||||
raise
|
||||
finally:
|
||||
await stream.aclose()
|
||||
|
||||
return rest()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
load_voices()
|
||||
log.warning("nex-tts bereit: Stimmen %s, Standard %s, PCM %s Hz", ", ".join(voices), DEFAULT_VOICE,
|
||||
PCM_RATE or "nativ")
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="nex-tts (Piper)", docs_url=None, redoc_url=None, openapi_url=None, lifespan=lifespan)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict:
|
||||
return {"status": "ok", "voices": list(voices), "default": DEFAULT_VOICE}
|
||||
|
||||
|
||||
@app.get("/v1/models")
|
||||
def models() -> dict:
|
||||
return {"object": "list", "data": [{"id": "piper", "object": "model", "owned_by": "knaster"}]}
|
||||
|
||||
|
||||
@app.get("/v1/audio/voices")
|
||||
def list_voices() -> dict:
|
||||
return {"voices": [{"id": vid, "sample_rate": v.config.sample_rate} for vid, v in voices.items()],
|
||||
"default": DEFAULT_VOICE}
|
||||
|
||||
|
||||
@app.post("/v1/audio/speech")
|
||||
async def speech(request: Request) -> Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
except ValueError:
|
||||
raise HTTPException(400, "Body muss JSON sein") from None
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(400, "Body muss ein JSON-Objekt sein")
|
||||
# Unbekannte Felder (model, instructions, stream_format, …) werden ignoriert.
|
||||
text = body.get("input")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise HTTPException(400, "Feld input fehlt oder ist leer")
|
||||
if len(text) > MAX_CHARS:
|
||||
raise HTTPException(400, f"input ist länger als {MAX_CHARS} Zeichen")
|
||||
fmt = str(body.get("response_format") or "mp3").lower()
|
||||
if fmt not in FORMATS:
|
||||
raise HTTPException(400, f"response_format {fmt[:20]!r} nicht unterstützt ({', '.join(sorted(FORMATS))})")
|
||||
try:
|
||||
speed = float(body.get("speed") or 1.0)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "speed muss eine Zahl sein") from None
|
||||
speed = min(max(speed, 0.25), 4.0)
|
||||
|
||||
vid = resolve_voice(body.get("voice") if isinstance(body.get("voice"), str) else None)
|
||||
voice = voices[vid]
|
||||
rate = voice.config.sample_rate
|
||||
cfg = SynthesisConfig(length_scale=(voice.config.length_scale or 1.0) / speed)
|
||||
headers = {"X-Voice": vid, "Cache-Control": "no-store"}
|
||||
|
||||
if fmt == "wav":
|
||||
# Vollständig mit korrektem Header (kurze Texte; für Streaming pcm/mp3/opus nehmen)
|
||||
buf = io.BytesIO()
|
||||
with wave.open(buf, "wb") as w:
|
||||
w.setnchannels(1)
|
||||
w.setsampwidth(2)
|
||||
w.setframerate(rate)
|
||||
async for block in synth_pcm(voice, text, cfg):
|
||||
w.writeframes(block)
|
||||
return Response(buf.getvalue(), media_type="audio/wav", headers=headers)
|
||||
|
||||
if fmt == "pcm":
|
||||
out_rate = PCM_RATE or rate
|
||||
headers |= {"X-Audio-Sample-Rate": str(out_rate), "X-Audio-Channels": "1", "X-Audio-Format": "s16le"}
|
||||
media = f"audio/pcm; rate={out_rate}"
|
||||
if out_rate == rate:
|
||||
stream = synth_pcm(voice, text, cfg)
|
||||
else:
|
||||
stream = encode(synth_pcm(voice, text, cfg), rate, ["-ar", str(out_rate), "-f", "s16le"])
|
||||
return StreamingResponse(await primed(stream), media_type=media, headers=headers)
|
||||
|
||||
out_args, media = ENCODERS[fmt]
|
||||
stream = encode(synth_pcm(voice, text, cfg), rate, out_args)
|
||||
return StreamingResponse(await primed(stream), media_type=media, headers=headers)
|
||||
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def _http_error(_: Request, exc: HTTPException) -> JSONResponse:
|
||||
# OpenAI-Fehlerformat, ohne den Eingabetext zu wiederholen
|
||||
return JSONResponse({"error": {"message": exc.detail, "type": "invalid_request_error"}},
|
||||
status_code=exc.status_code)
|
||||
@@ -0,0 +1,6 @@
|
||||
# Feste Versionen für den NEX-Sprachausgabe-Wrapper (Stand 12.10.2026)
|
||||
piper-tts==1.8.0 # GPL-3.0-or-later, eigener Dienst (siehe catalog/models.yaml)
|
||||
onnxruntime==1.30.0
|
||||
fastapi==0.141.1
|
||||
pydantic==2.13.5
|
||||
uvicorn==0.53.0
|
||||
@@ -0,0 +1,39 @@
|
||||
# whisper.cpp whisper-server für Maxwell (sm_52, Quadro M4000) und Pascal (sm_61, Quadro P2000).
|
||||
# CUDA 12 ist Pflicht: CUDA 13 unterstützt Maxwell/Pascal nicht mehr. Treiber R580 in der VM.
|
||||
# Version und Commit sind fest; ein Wechsel ist ein bewusster Schritt (Tag und Commit gemeinsam ändern).
|
||||
ARG CUDA_DEVEL=nvidia/cuda:12.6.3-devel-ubuntu24.04@sha256:392c0df7b577ecae17a17f6ba7f2009c217bb4422f8431c053ae9af61a8c148a
|
||||
ARG CUDA_RUNTIME=nvidia/cuda:12.6.3-runtime-ubuntu24.04@sha256:92906d87596d638d35015c6353053121bd299d25943b875763321653884ba924
|
||||
|
||||
FROM ${CUDA_DEVEL} AS build
|
||||
ARG WHISPER_TAG=v1.9.4
|
||||
ARG WHISPER_COMMIT=927cfce34f31707e17f2bff35c349632fb9e2c3a
|
||||
ARG CUDA_ARCHS="52;61"
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git cmake build-essential ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /src
|
||||
RUN git clone --depth 1 --branch "${WHISPER_TAG}" https://github.com/ggml-org/whisper.cpp.git . \
|
||||
&& test "$(git rev-parse HEAD)" = "${WHISPER_COMMIT}"
|
||||
# Statisch gegen ggml/whisper linken: eine einzelne Binary, keine .so-Kopien in der Laufzeit-Stufe.
|
||||
# libcuda kommt zur Laufzeit vom Treiber; beim Bauen reicht der Stub.
|
||||
RUN cmake -B build \
|
||||
-DGGML_CUDA=ON \
|
||||
-DCMAKE_CUDA_ARCHITECTURES="${CUDA_ARCHS}" \
|
||||
-DBUILD_SHARED_LIBS=OFF \
|
||||
-DWHISPER_BUILD_TESTS=OFF \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_EXE_LINKER_FLAGS="-L/usr/local/cuda/lib64/stubs" \
|
||||
&& cmake --build build --target whisper-server -j"$(nproc)" \
|
||||
&& strip build/bin/whisper-server
|
||||
|
||||
FROM ${CUDA_RUNTIME}
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg curl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=build /src/build/bin/whisper-server /usr/local/bin/whisper-server
|
||||
# ffmpeg-Hülle: whisper-server ruft "ffmpeg … 2>&1" auf und schriebe sonst Banner und
|
||||
# Container-Metadaten des Uploads ins Log. Die Hülle liegt vor /usr/bin im PATH.
|
||||
COPY ffmpeg-quiet.sh /usr/local/bin/ffmpeg
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod 0755 /usr/local/bin/ffmpeg /usr/local/bin/entrypoint.sh
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/bin/sh
|
||||
# Start von whisper-server für NEX (Spracherkennung, OpenAI-kompatibel).
|
||||
# - Parameter laut Auftrag: -l de -bs 5 --convert, ohne --print-realtime (kein Text im Log).
|
||||
# - Temp-Dateien nur im tmpfs; Reste (Fehlerpfad von whisper-server) werden regelmäßig gelöscht.
|
||||
# - Nach dem Start einmal mit 1 s Stille aufwärmen.
|
||||
set -eu
|
||||
|
||||
MODEL="${WHISPER_MODEL:-/models/whisper/ggml-large-v3-turbo.bin}"
|
||||
PORT="${WHISPER_PORT:-8178}"
|
||||
HOST="${WHISPER_HOST:-0.0.0.0}"
|
||||
TMP_DIR="${WHISPER_TMP_DIR:-/run/nex-whisper}"
|
||||
BEAM="${WHISPER_BEAM_SIZE:-5}"
|
||||
LANGUAGE="${WHISPER_LANGUAGE:-de}"
|
||||
# Zusätzliche Schalter, z. B. "-nfa" (Flash-Attention aus) oder "-t 4"
|
||||
EXTRA="${WHISPER_EXTRA_ARGS:-}"
|
||||
|
||||
mkdir -p "$TMP_DIR"
|
||||
chmod 0700 "$TMP_DIR"
|
||||
|
||||
# Aufräumer: Dateien älter als 10 Minuten im tmpfs löschen
|
||||
( while sleep 300; do find "$TMP_DIR" -type f -mmin +10 -delete 2>/dev/null || true; done ) &
|
||||
cleaner=$!
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
whisper-server -m "$MODEL" -l "$LANGUAGE" -bs "$BEAM" --convert --tmp-dir "$TMP_DIR" \
|
||||
--inference-path /v1/audio/transcriptions --host "$HOST" --port "$PORT" $EXTRA &
|
||||
pid=$!
|
||||
|
||||
stop() { kill -TERM "$pid" 2>/dev/null || true; kill "$cleaner" 2>/dev/null || true; }
|
||||
trap stop TERM INT
|
||||
|
||||
# Warten, bis das Modell geladen ist (/health liefert bis dahin 503)
|
||||
i=0
|
||||
until curl -fsS "http://127.0.0.1:${PORT}/health" >/dev/null 2>&1; do
|
||||
kill -0 "$pid" 2>/dev/null || { echo "whisper-server beendet sich beim Start" >&2; wait "$pid" || exit $?; exit 1; }
|
||||
i=$((i + 1))
|
||||
[ "$i" -le 600 ] || { echo "whisper-server nach 600 s nicht bereit" >&2; stop; exit 1; }
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Aufwärmen: 1 s Stille, Ergebnis wird verworfen
|
||||
warm="$TMP_DIR/warmup.wav"
|
||||
if ffmpeg -f lavfi -i anullsrc=r=16000:cl=mono -t 1 -c:a pcm_s16le -y "$warm" \
|
||||
&& curl -fsS -o /dev/null -F "file=@${warm}" -F response_format=json \
|
||||
"http://127.0.0.1:${PORT}/v1/audio/transcriptions"; then
|
||||
echo "whisper-server bereit und aufgewärmt (Port ${PORT})"
|
||||
else
|
||||
echo "Aufwärmen fehlgeschlagen, Dienst läuft trotzdem" >&2
|
||||
fi
|
||||
rm -f "$warm"
|
||||
|
||||
# Auf whisper-server warten; nach einem Signal erneut warten, bis er wirklich beendet ist
|
||||
rc=0
|
||||
wait "$pid" || rc=$?
|
||||
while kill -0 "$pid" 2>/dev/null; do wait "$pid" || rc=$?; done
|
||||
kill "$cleaner" 2>/dev/null || true
|
||||
exit "$rc"
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
# Leise ffmpeg-Hülle für whisper-server (--convert): kein Banner, keine Metadaten, nur Fehler.
|
||||
exec /usr/bin/ffmpeg -hide_banner -nostdin -loglevel error "$@"
|
||||
Reference in New Issue
Block a user