diff --git a/Makefile b/Makefile index 06bb438..bd89bd0 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ validate: $(VENV)/bin/knaster placement --inventory sites/knaster/inventory.yaml --catalog catalog > /dev/null compose-check: - @for d in infra gpu-vllm gpu-llamacpp; do \ + @for d in infra gpu-vllm gpu-llamacpp nex-speech; do \ (cd deploy/compose/$$d && cp .env.example .env.ci && \ POSTGRES_PASSWORD=ci VLLM_MODEL=ci SLOT_ALIAS=ci docker compose --env-file .env.ci \ --profile embed --profile chat --profile dashboards --profile proxmox config -q; \ diff --git a/deploy/compose/nex-speech/.env.example b/deploy/compose/nex-speech/.env.example new file mode 100644 index 0000000..032c83a --- /dev/null +++ b/deploy/compose/nex-speech/.env.example @@ -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 diff --git a/deploy/compose/nex-speech/docker-compose.yml b/deploy/compose/nex-speech/docker-compose.yml new file mode 100644 index 0000000..9bbb642 --- /dev/null +++ b/deploy/compose/nex-speech/docker-compose.yml @@ -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 diff --git a/deploy/compose/nex-speech/piper/Dockerfile b/deploy/compose/nex-speech/piper/Dockerfile new file mode 100644 index 0000000..aa6868a --- /dev/null +++ b/deploy/compose/nex-speech/piper/Dockerfile @@ -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"] diff --git a/deploy/compose/nex-speech/piper/app.py b/deploy/compose/nex-speech/piper/app.py new file mode 100644 index 0000000..7852a0e --- /dev/null +++ b/deploy/compose/nex-speech/piper/app.py @@ -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=`. +- Datenschutz: Text und Audio werden weder gespeichert noch geloggt; alles läuft über Pipes im RAM. + +Umgebung: + PIPER_VOICES_DIR Verzeichnis mit .onnx und .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) diff --git a/deploy/compose/nex-speech/piper/requirements.txt b/deploy/compose/nex-speech/piper/requirements.txt new file mode 100644 index 0000000..584d82f --- /dev/null +++ b/deploy/compose/nex-speech/piper/requirements.txt @@ -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 diff --git a/deploy/compose/nex-speech/whisper/Dockerfile b/deploy/compose/nex-speech/whisper/Dockerfile new file mode 100644 index 0000000..7cdd419 --- /dev/null +++ b/deploy/compose/nex-speech/whisper/Dockerfile @@ -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"] diff --git a/deploy/compose/nex-speech/whisper/entrypoint.sh b/deploy/compose/nex-speech/whisper/entrypoint.sh new file mode 100644 index 0000000..f105076 --- /dev/null +++ b/deploy/compose/nex-speech/whisper/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" diff --git a/deploy/compose/nex-speech/whisper/ffmpeg-quiet.sh b/deploy/compose/nex-speech/whisper/ffmpeg-quiet.sh new file mode 100644 index 0000000..58e69a1 --- /dev/null +++ b/deploy/compose/nex-speech/whisper/ffmpeg-quiet.sh @@ -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 "$@" diff --git a/platform/proxmox/ansible/playbooks/nex-speech.yml b/platform/proxmox/ansible/playbooks/nex-speech.yml new file mode 100644 index 0000000..5e7dd3a --- /dev/null +++ b/platform/proxmox/ansible/playbooks/nex-speech.yml @@ -0,0 +1,6 @@ +# NEX-Sprachdienste (Task 6.2): Erkennung (whisper.cpp, M4000) und Ausgabe (Piper, CPU) in gpu-pve52. +# Aufruf auf ops: ansible-playbook playbooks/nex-speech.yml +# Vorher Zugang auffrischen (DHCP): bash ../scripts/refresh-vm-access.sh +- name: NEX-Sprachdienste + hosts: "{{ nex_speech_hosts | default('gpu-pve52') }}" + roles: [nex-speech] diff --git a/platform/proxmox/ansible/requirements.yml b/platform/proxmox/ansible/requirements.yml index 73966e9..9c77bb2 100644 --- a/platform/proxmox/ansible/requirements.yml +++ b/platform/proxmox/ansible/requirements.yml @@ -1,3 +1,4 @@ collections: - name: ansible.posix - name: community.general + - name: community.docker diff --git a/platform/proxmox/ansible/roles/nex-speech/defaults/main.yml b/platform/proxmox/ansible/roles/nex-speech/defaults/main.yml new file mode 100644 index 0000000..346f57d --- /dev/null +++ b/platform/proxmox/ansible/roles/nex-speech/defaults/main.yml @@ -0,0 +1,49 @@ +# NEX-Sprachdienste (Task 6.2): whisper-server (GPU) und Piper (CPU) als Docker Compose. +# Compose-Dateien kommen aus deploy/compose/nex-speech, Modelle nach /srv/models (Modellplatte). +nex_speech_dir: /opt/nex-speech +nex_speech_models_dir: /srv/models + +# Firewall nur in der VM (ufw). Die Proxmox-Firewall bleibt aus. +nex_speech_allow_from: 192.168.100.29 # appsrv08 (LiteLLM, Bridge-Netz erscheint mit der Host-IP) +nex_speech_ssh_from: 192.168.100.0/24 # ops hat DHCP, deshalb das ganze Netz für SSH +nex_speech_stt_port: 8178 +nex_speech_tts_port: 8179 + +# Werte für .env (siehe deploy/compose/nex-speech/.env.example) +nex_speech_env: + MODELS_DIR: "{{ nex_speech_models_dir }}" + STT_PORT: "{{ nex_speech_stt_port }}" + 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" + WHISPER_EXTRA_ARGS: "" + TTS_PORT: "{{ nex_speech_tts_port }}" + PIPER_TTS_VERSION: 1.8.0 + PIPER_VOICES: de_DE-thorsten-high,de_DE-kerstin-low + PIPER_DEFAULT_VOICE: de_DE-thorsten-high + PCM_SAMPLE_RATE: "24000" + TTS_MAX_CHARS: "5000" + TTS_PARALLEL: "2" + +# Modelle mit fester Revision und Prüfsumme (sha256 laut Hugging Face, LFS-OID bzw. nachgerechnet) +_hf_whisper: https://huggingface.co/ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1 +_hf_piper: https://huggingface.co/rhasspy/piper-voices/resolve/c10ece1aade47bb51c153c893d14e5bf8e5b7117/de/de_DE +nex_speech_models: + - dest: whisper/ggml-large-v3-turbo.bin + url: "{{ _hf_whisper }}/ggml-large-v3-turbo.bin" + checksum: sha256:1fc70f774d38eb169993ac391eea357ef47c88757ef72ee5943879b7e8e2bc69 + - dest: piper/de_DE-thorsten-high.onnx + url: "{{ _hf_piper }}/thorsten/high/de_DE-thorsten-high.onnx" + checksum: sha256:9df1c43c61149ef9b39e618e2b861fbe41e1fcea9390b2dac62e8761573ea4f1 + - dest: piper/de_DE-thorsten-high.onnx.json + url: "{{ _hf_piper }}/thorsten/high/de_DE-thorsten-high.onnx.json" + checksum: sha256:6de734444e4c3f9e33b7ebe2746dbc19b71e85f613e79c65acf623200b99a76a + - dest: piper/de_DE-kerstin-low.onnx + url: "{{ _hf_piper }}/kerstin/low/de_DE-kerstin-low.onnx" + checksum: sha256:d352a7641892cebf2903859af94e9ba81a141110215fe3943bcda7f7da401b7a + - dest: piper/de_DE-kerstin-low.onnx.json + url: "{{ _hf_piper }}/kerstin/low/de_DE-kerstin-low.onnx.json" + checksum: sha256:56e708556b7b9b7a53c4f8957e021421e69f11a600962bba554cffbe72cf2d47 diff --git a/platform/proxmox/ansible/roles/nex-speech/tasks/main.yml b/platform/proxmox/ansible/roles/nex-speech/tasks/main.yml new file mode 100644 index 0000000..af81870 --- /dev/null +++ b/platform/proxmox/ansible/roles/nex-speech/tasks/main.yml @@ -0,0 +1,102 @@ +# --- Firewall in der VM: SSH aus dem LAN, Sprachdienste nur von appsrv08, sonst nichts --- +# Reihenfolge wichtig: SSH-Regel vor dem Einschalten, sonst sperrt sich Ansible aus. +- name: ufw installieren + ansible.builtin.apt: + name: ufw + state: present + +- name: ufw – SSH aus dem LAN erlauben + community.general.ufw: + rule: allow + from_ip: "{{ nex_speech_ssh_from }}" + port: "22" + proto: tcp + comment: ssh-lan + +- name: ufw – Sprachdienste nur von appsrv08 + community.general.ufw: + rule: allow + from_ip: "{{ nex_speech_allow_from }}" + port: "{{ item.port | string }}" + proto: tcp + comment: "{{ item.name }}" + loop: + - {name: nex-stt, port: "{{ nex_speech_stt_port }}"} + - {name: nex-tts, port: "{{ nex_speech_tts_port }}"} + +- name: ufw – eingehend sonst ablehnen, ausgehend erlauben + community.general.ufw: + direction: "{{ item.direction }}" + default: "{{ item.policy }}" + loop: + - {direction: incoming, policy: deny} + - {direction: outgoing, policy: allow} + +- name: ufw einschalten + community.general.ufw: + state: enabled + +# Compose baut nur mit buildx (BuildKit); der Altbau-Builder ist in Docker 29 veraltet +- name: docker-buildx installieren + ansible.builtin.apt: + name: docker-buildx + state: present + +# --- Modelle --- +- name: Modellverzeichnisse + ansible.builtin.file: + path: "{{ nex_speech_models_dir }}/{{ item }}" + state: directory + mode: "0755" + loop: [whisper, piper] + +- name: Modelle laden (Prüfsumme gegen Hugging Face) + ansible.builtin.get_url: + url: "{{ item.url }}" + dest: "{{ nex_speech_models_dir }}/{{ item.dest }}" + checksum: "{{ item.checksum }}" + mode: "0644" + timeout: 120 + loop: "{{ nex_speech_models }}" + loop_control: + label: "{{ item.dest }}" + +# --- Compose-Projekt --- +- name: Compose-Dateien kopieren + ansible.builtin.copy: + src: "{{ playbook_dir }}/../../../../deploy/compose/nex-speech/" + dest: "{{ nex_speech_dir }}/" + mode: preserve + directory_mode: "0755" + register: nex_speech_files + +- name: .env schreiben (keine Geheimnisse) + ansible.builtin.copy: + dest: "{{ nex_speech_dir }}/.env" + mode: "0644" + content: | + # Verwaltet von Ansible (Rolle nex-speech) + {% for k, v in nex_speech_env.items() %} + {{ k }}={{ v }} + {% endfor %} + register: nex_speech_envfile + +# Bauen dauert beim ersten Mal lange (whisper.cpp mit CUDA auf 4 Kernen, ca. 20–40 min) +- name: Images bauen und Dienste starten + community.docker.docker_compose_v2: + project_src: "{{ nex_speech_dir }}" + build: "{{ 'always' if (nex_speech_files.changed or nex_speech_envfile.changed) else 'policy' }}" + state: present + wait: false + async: 3600 + poll: 30 + +- name: Warten, bis beide Dienste gesund sind + ansible.builtin.uri: + url: "http://127.0.0.1:{{ item }}/health" + status_code: 200 + register: nex_speech_health + until: nex_speech_health.status == 200 + retries: 60 + delay: 10 + loop: ["{{ nex_speech_stt_port }}", "{{ nex_speech_tts_port }}"]