nex-speech: Sprachdienste für NEX (whisper.cpp CUDA 12 + Piper) als Compose und Ansible-Rolle
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:
Robert Rapp
2026-10-12 20:41:11 +02:00
co-authored by Claude Opus 5.5
parent b66c4c37cc
commit afc5fa0360
13 changed files with 643 additions and 1 deletions
+21
View File
@@ -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"]
+268
View File
@@ -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 "$@"