From 47d1f10e680d5b4943aea977282e86f736744dbf Mon Sep 17 00:00:00 2001 From: BadStorm Date: Thu, 10 Sep 2026 20:43:33 +0200 Subject: [PATCH] feat: add OmniVoice TTS container, upgrade whisper model, tune qwentts tone --- containers/omnivoice/build-container.sh | 22 +++++ containers/omnivoice/entrypoint.sh | 8 ++ containers/omnivoice/omnivoice.Containerfile | 42 +++++++++ containers/omnivoice/omnivoice.caddy | 13 +++ containers/omnivoice/omnivoice.container | 36 ++++++++ containers/omnivoice/server.py | 91 ++++++++++++++++++++ containers/qwentts/qwentts.container | 2 +- containers/whisper/whisper.container | 6 +- 8 files changed, 216 insertions(+), 4 deletions(-) create mode 100755 containers/omnivoice/build-container.sh create mode 100755 containers/omnivoice/entrypoint.sh create mode 100644 containers/omnivoice/omnivoice.Containerfile create mode 100644 containers/omnivoice/omnivoice.caddy create mode 100644 containers/omnivoice/omnivoice.container create mode 100644 containers/omnivoice/server.py diff --git a/containers/omnivoice/build-container.sh b/containers/omnivoice/build-container.sh new file mode 100755 index 0000000..1e6267c --- /dev/null +++ b/containers/omnivoice/build-container.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +### Build the omnivoice image. USAGE: ./build-container.sh [cpu|rocm] (default: cpu) +set -euo pipefail + +DEVICE="${1:-cpu}" + +case "$DEVICE" in + cpu|rocm) ;; + *) + echo "Usage: $0 [cpu|rocm]" >&2 + exit 1 + ;; +esac + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +podman build \ + --build-arg DEVICE="$DEVICE" \ + -t "localhost/omnivoice:$DEVICE" \ + -t localhost/omnivoice:latest \ + -f "$SCRIPT_DIR/omnivoice.Containerfile" \ + "$SCRIPT_DIR" diff --git a/containers/omnivoice/entrypoint.sh b/containers/omnivoice/entrypoint.sh new file mode 100755 index 0000000..8e5f4cb --- /dev/null +++ b/containers/omnivoice/entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# Starts the FastAPI server; model weights download on first request into /app/models +set -e + +echo "=== OmniVoice Server ===" +echo + +exec uvicorn server:app --host 0.0.0.0 --port 8000 --app-dir /app "$@" diff --git a/containers/omnivoice/omnivoice.Containerfile b/containers/omnivoice/omnivoice.Containerfile new file mode 100644 index 0000000..4626593 --- /dev/null +++ b/containers/omnivoice/omnivoice.Containerfile @@ -0,0 +1,42 @@ +### OmniVoice Container (https://github.com/k2-fsa/OmniVoice) — build with ./build-container.sh [cpu|rocm] +ARG DEVICE=cpu +ARG BASE_IMAGE_CPU=python:3.12-slim +ARG BASE_IMAGE_ROCM=docker.io/rocm/pytorch-nightly + +FROM ${BASE_IMAGE_CPU} AS base-cpu +FROM ${BASE_IMAGE_ROCM} AS base-rocm + +FROM base-${DEVICE} +ARG DEVICE + +USER root +EXPOSE 8000 + +# sox/ffmpeg: needed at runtime for reference-audio format handling +RUN apt-get update \ + && apt-get install -y --no-install-recommends sox ffmpeg curl \ + && apt-get autoremove -y \ + && apt-get clean -y \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# CPU-only PyTorch (ROCm base image already ships its own build) +RUN if [ "$DEVICE" = "cpu" ]; then \ + pip install --no-cache-dir torch torchaudio --index-url https://download.pytorch.org/whl/cpu; \ + fi + +# Install omnivoice and a minimal API server +RUN pip install --no-cache-dir omnivoice fastapi uvicorn python-multipart soundfile + +# Models download at runtime here — mount as a volume to persist them +RUN mkdir -p /app/models +ENV HF_HOME=/app/models + +# Copy entrypoint / server script +COPY entrypoint.sh /app/entrypoint.sh +COPY server.py /app/server.py +RUN chmod +x /app/entrypoint.sh + +ENTRYPOINT ["/app/entrypoint.sh"] +CMD [] diff --git a/containers/omnivoice/omnivoice.caddy b/containers/omnivoice/omnivoice.caddy new file mode 100644 index 0000000..096699a --- /dev/null +++ b/containers/omnivoice/omnivoice.caddy @@ -0,0 +1,13 @@ +omnivoice.example.com { + import gateway_error + + request_body { + max_size 100MB + } + + reverse_proxy omnivoice:8000 + + log { + output file /var/log/caddy/omnivoice_access.log + } +} diff --git a/containers/omnivoice/omnivoice.container b/containers/omnivoice/omnivoice.container new file mode 100644 index 0000000..47982ae --- /dev/null +++ b/containers/omnivoice/omnivoice.container @@ -0,0 +1,36 @@ +[Unit] +Description=OmniVoice TTS Server +After=network-online.target +Wants=network-online.target + +[Container] +Image=localhost/omnivoice:latest +ContainerName=omnivoice + +# CPU-only container +#Memory=8g +#CPUs=4 + +# ROCm +AddDevice=/dev/kfd +AddDevice=/dev/dri/renderD128 +PodmanArgs=--group-add=keep-groups --ipc=host --pids-limit=-1 --security-opt label=disable +SecurityLabelType=container_runtime_t + +# HTTP API +PublishPort=8001:8000 + +# Persist downloaded model weights across restarts (adjust host path as needed) +Volume=/srv/containers/omnivoice/models:/app/models:Z + +# Optional: override the default checkpoint +#Environment=OMNIVOICE_MODEL=k2-fsa/OmniVoice + + +[Service] +Restart=on-failure +RestartSec=5 +TimeoutStartSec=300 + +[Install] +WantedBy=default.target diff --git a/containers/omnivoice/server.py b/containers/omnivoice/server.py new file mode 100644 index 0000000..67a0b5a --- /dev/null +++ b/containers/omnivoice/server.py @@ -0,0 +1,91 @@ +"""Minimal FastAPI server exposing OmniVoice zero-shot voice cloning (/speech/clone only). https://github.com/k2-fsa/OmniVoice""" +import io +import os +import tempfile + +import soundfile as sf +import torch +from fastapi import FastAPI, File, Form, HTTPException, UploadFile +from fastapi.responses import StreamingResponse +from omnivoice import OmniVoice + +MODEL_NAME = os.environ.get("OMNIVOICE_MODEL", "k2-fsa/OmniVoice") + +_forced_device = os.environ.get("OMNIVOICE_DEVICE", "").strip() +if _forced_device: + DEVICE = _forced_device +elif torch.cuda.is_available(): # ROCm builds expose the GPU via the CUDA API too + DEVICE = "cuda:0" +elif getattr(torch, "xpu", None) is not None and torch.xpu.is_available(): + DEVICE = "xpu" +else: + DEVICE = "cpu" +# float16 is unsupported on CPU and broken on some XPU/oneDNN SDPA builds. +DTYPE = torch.float16 if DEVICE.startswith("cuda") else torch.float32 + +# OmniVoice hardcodes fp16 for its internal ASR sub-model on cuda/xpu, which breaks there; keep it on CPU by default. +ASR_DEVICE = os.environ.get("OMNIVOICE_ASR_DEVICE", "cpu") + +app = FastAPI(title="OmniVoice server") + +model: OmniVoice | None = None + + +@app.on_event("startup") +def load_model(): + global model + print(f"Loading OmniVoice model {MODEL_NAME} on {DEVICE}...") + model = OmniVoice.from_pretrained( + MODEL_NAME, + device_map=DEVICE, + dtype=DTYPE, + asr_device=ASR_DEVICE, + ) + print(f"OmniVoice model loaded. ASR (ref_text auto-transcribe) on {ASR_DEVICE}.") + + +@app.get("/health") +def health(): + return { + "status": "ok", + "model": MODEL_NAME, + "device": DEVICE, + } + + +@app.post("/speech/clone") +def speech_clone( + text: str = Form(...), + language: str = Form(""), + ref_text: str = Form(""), + instruct: str = Form(""), + speed: float = Form(1.0), + ref_audio: UploadFile = File(...), +): + """Clone a voice from ref_audio (ref_text auto-transcribed via Whisper if omitted) and synthesize text with it.""" + if model is None: + raise HTTPException(status_code=503, detail="Model not loaded yet") + + ref_bytes = ref_audio.file.read() + + suffix = os.path.splitext(ref_audio.filename or "")[1] or ".wav" # generate() needs a file path, not bytes + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: + tmp.write(ref_bytes) + tmp_path = tmp.name + + try: + audio = model.generate( + text=text, + language=language or None, + ref_audio=tmp_path, + ref_text=ref_text or None, + instruct=instruct or None, + speed=speed, + ) + finally: + os.remove(tmp_path) + + buf = io.BytesIO() + sf.write(buf, audio[0], model.sampling_rate, format="WAV") + buf.seek(0) + return StreamingResponse(buf, media_type="audio/wav") diff --git a/containers/qwentts/qwentts.container b/containers/qwentts/qwentts.container index 4a03f04..e80dda6 100644 --- a/containers/qwentts/qwentts.container +++ b/containers/qwentts/qwentts.container @@ -36,7 +36,7 @@ Volume=/srv/containers/qwen-tts/models:/app/models:Z # `instruct` (Qwen3-TTS has no numeric speed/style knob, only natural-language # steering). Only applies to /speech (CustomVoice); /speech/clone has no # instruct support. -#Environment=QWEN_TTS_DEFAULT_INSTRUCT=This is a voice message between two close friends on WhatsApp. Speak in a casual, warm, friendly tone, not formal or professional-sounding. +Environment=QWEN_TTS_DEFAULT_INSTRUCT=This is a voice message between two close friends on WhatsApp. Speak calmly and naturally at a steady, even pace, like ordinary spoken conversation. Keep the tone warm and casual but restrained: avoid dramatic emphasis, exaggerated excitement, or theatrical intonation. Use natural, evenly spaced pauses between sentences, not rushed or drawn out. [Service] diff --git a/containers/whisper/whisper.container b/containers/whisper/whisper.container index 8b798a8..e3c0db1 100644 --- a/containers/whisper/whisper.container +++ b/containers/whisper/whisper.container @@ -15,11 +15,11 @@ PodmanArgs=--group-add=keep-groups --ipc=host SecurityLabelType=container_runtime_t # Whisper configuration -Environment=WHISPER_MODEL_NAME=small -Environment=WHISPER_MODEL_FILE=ggml-small.bin +Environment=WHISPER_MODEL_NAME=large-v3-turbo +Environment=WHISPER_MODEL_FILE=ggml-large-v3-turbo.bin Environment=WHISPER_LANGUAGE=auto Environment=WHISPER_TRANSLATE=false -Environment=WHISPER_DTW=large.v3 +#Environment=WHISPER_DTW=large.v3 Environment=WHISPER_DETECT_LANGUAGE=false Environment=HF_HOME=/app/models/.huggingface