Initial voice-assistant-stack: open-webui + spotify-voice-assistant submodules, asr/tts sidecars
- open-webui: submodule, LAN OIDC/Authentik-fronted, GPU-passthrough ollama, ollama-auth proxy, asr/tts wired via docker-compose.audio.yaml - spotify-voice-assistant: submodule, Home Assistant custom integration - tts-server / asr-server: plain directories, built as sidecars by open-webui/docker-stack.sh (no separate git history)
This commit is contained in:
commit
c2b92d96cb
14 files changed
+307
No files matched your search
@@ -0,0 +1 @@
|
|||||||
|
tts-server/audio-files/
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[submodule "open-webui"]
|
||||||
|
path = open-webui
|
||||||
|
url = ssh://git@192.168.50.224:30009/bhetherman/open-webui.git
|
||||||
|
[submodule "spotify-voice-assistant"]
|
||||||
|
path = spotify-voice-assistant
|
||||||
|
url = https://git.hetherman.cloud/bhetherman/spotify-voice-assistant.git
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libsndfile1 ffmpeg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
# The Titan Xp (Pascal, sm_61) needs a torch build old enough to still ship
|
||||||
|
# Pascal kernels; recent default PyPI wheels dropped sm_60/sm_61 entirely.
|
||||||
|
ENV PIP_EXTRA_INDEX_URL=https://download.pytorch.org/whl/cu124
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app.py .
|
||||||
|
|
||||||
|
EXPOSE 8020
|
||||||
|
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8020"]
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import hmac
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import torch
|
||||||
|
from fastapi import Depends, FastAPI, File, Form, HTTPException, UploadFile
|
||||||
|
from fastapi.responses import JSONResponse, PlainTextResponse
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from transformers import AutoModelForRNNT, AutoProcessor
|
||||||
|
from transformers.audio_utils import load_audio
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
log = logging.getLogger("asr-server")
|
||||||
|
|
||||||
|
AUTH_TOKEN = os.environ["ASR_AUTH_TOKEN"]
|
||||||
|
bearer_scheme = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
|
def require_auth(creds: HTTPAuthorizationCredentials = Depends(bearer_scheme)) -> None:
|
||||||
|
if not hmac.compare_digest(creds.credentials, AUTH_TOKEN):
|
||||||
|
raise HTTPException(status_code=401, detail="invalid token")
|
||||||
|
|
||||||
|
|
||||||
|
MODEL_ID = os.environ.get("ASR_MODEL_ID", "nvidia/nemotron-3.5-asr-streaming-0.6b")
|
||||||
|
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
DTYPE = torch.float16 if DEVICE == "cuda" else torch.float32
|
||||||
|
|
||||||
|
# The RNN-T decoder's LSTM layers hit cuDNN's fused RNN kernel on `.to(device)`,
|
||||||
|
# which requires SM >= 7.5 in recent cuDNN builds. This GPU (Pascal, SM 6.1) is
|
||||||
|
# below that floor, so disable cuDNN and fall back to PyTorch's generic CUDA
|
||||||
|
# RNN kernels instead.
|
||||||
|
torch.backends.cudnn.enabled = False
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
log.info("Loading %s on %s (%s)", MODEL_ID, DEVICE, DTYPE)
|
||||||
|
processor = AutoProcessor.from_pretrained(MODEL_ID)
|
||||||
|
model = AutoModelForRNNT.from_pretrained(MODEL_ID, dtype=DTYPE).to(DEVICE).eval()
|
||||||
|
SAMPLING_RATE = processor.feature_extractor.sampling_rate
|
||||||
|
log.info("Model ready (sampling_rate=%s)", SAMPLING_RATE)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok", "device": DEVICE}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v1/audio/transcriptions", dependencies=[Depends(require_auth)])
|
||||||
|
async def transcribe(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
model_name: str = Form("nemotron-3.5-asr-streaming-0.6b", alias="model"),
|
||||||
|
language: str | None = Form(None),
|
||||||
|
response_format: str = Form("json"),
|
||||||
|
):
|
||||||
|
suffix = os.path.splitext(file.filename or "")[1] or ".wav"
|
||||||
|
data = await file.read()
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=suffix) as tmp:
|
||||||
|
tmp.write(data)
|
||||||
|
tmp.flush()
|
||||||
|
try:
|
||||||
|
audio = load_audio(tmp.name, sampling_rate=SAMPLING_RATE)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=f"could not decode audio: {exc}") from exc
|
||||||
|
|
||||||
|
lang = language or "auto"
|
||||||
|
inputs = processor(audio, sampling_rate=SAMPLING_RATE, language=lang)
|
||||||
|
inputs = inputs.to(DEVICE, dtype=DTYPE)
|
||||||
|
|
||||||
|
with torch.inference_mode():
|
||||||
|
output = model.generate(**inputs, return_dict_in_generate=True)
|
||||||
|
|
||||||
|
text = processor.decode(output.sequences, skip_special_tokens=True)
|
||||||
|
if isinstance(text, list):
|
||||||
|
text = text[0] if text else ""
|
||||||
|
|
||||||
|
if response_format == "text":
|
||||||
|
return PlainTextResponse(text)
|
||||||
|
return JSONResponse({"text": text})
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fastapi==0.115.*
|
||||||
|
uvicorn[standard]==0.32.*
|
||||||
|
python-multipart==0.0.*
|
||||||
|
torch==2.6.0
|
||||||
|
transformers>=5.13.0
|
||||||
|
soundfile
|
||||||
|
librosa
|
||||||
|
accelerate
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "open-webui"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".venv"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "tts-server"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "asr-server"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "spotify-voice-assistant"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {}
|
||||||
|
}
|
||||||
Submodule
+1
Submodule open-webui added at 7eff527d16.
Submodule
+1
Submodule spotify-voice-assistant added at 7249919006.
@@ -0,0 +1,17 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libsndfile1 ffmpeg espeak-ng \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
# The Titan Xp (Pascal, sm_61) needs a torch build old enough to still ship
|
||||||
|
# Pascal kernels; recent default PyPI wheels dropped sm_60/sm_61 entirely.
|
||||||
|
ENV PIP_EXTRA_INDEX_URL=https://download.pytorch.org/whl/cu121
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app.py .
|
||||||
|
|
||||||
|
EXPOSE 8010
|
||||||
|
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8010"]
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import asyncio
|
||||||
|
import hmac
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import parselmouth
|
||||||
|
import soundfile as sf
|
||||||
|
import torch
|
||||||
|
from fastapi import Depends, FastAPI, HTTPException
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from kokoro import KPipeline
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sopro import SoproTTS
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
log = logging.getLogger("tts-server")
|
||||||
|
|
||||||
|
AUTH_TOKEN = os.environ["TTS_AUTH_TOKEN"]
|
||||||
|
bearer_scheme = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
|
def require_auth(creds: HTTPAuthorizationCredentials = Depends(bearer_scheme)) -> None:
|
||||||
|
if not hmac.compare_digest(creds.credentials, AUTH_TOKEN):
|
||||||
|
raise HTTPException(status_code=401, detail="invalid token")
|
||||||
|
|
||||||
|
|
||||||
|
# 'a' = American English. Kokoro's voices are fixed pretrained presets (not
|
||||||
|
# zero-shot cloning), so there's no per-request drift to work around like
|
||||||
|
# Audio8 needed -- picking a voice is just picking an ID.
|
||||||
|
LANG_CODE = os.environ.get("TTS_LANG_CODE", "a")
|
||||||
|
DEFAULT_VOICE = os.environ.get("TTS_DEFAULT_VOICE", "af_heart")
|
||||||
|
KOKORO_SAMPLE_RATE = 24000
|
||||||
|
|
||||||
|
# Sopro is zero-shot voice cloning, not preset voices -- a "voice" here is
|
||||||
|
# just a reference clip dropped into this directory as <name>.wav, picked by
|
||||||
|
# the `voice` request field.
|
||||||
|
VOICES_DIR = Path(os.environ.get("TTS_VOICES_DIR", "/app/voices"))
|
||||||
|
DEFAULT_SOPRO_VOICE = os.environ.get("TTS_SOPRO_DEFAULT_VOICE")
|
||||||
|
|
||||||
|
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
log.info("Loading Kokoro (lang_code=%s) on %s", LANG_CODE, DEVICE)
|
||||||
|
kokoro_pipeline = KPipeline(lang_code=LANG_CODE, device=DEVICE)
|
||||||
|
log.info("Kokoro ready")
|
||||||
|
|
||||||
|
log.info("Loading Sopro on %s", DEVICE)
|
||||||
|
sopro_model = SoproTTS.from_pretrained("samuel-vitorino/sopro-v2-turbo", device=DEVICE)
|
||||||
|
log.info("Sopro ready")
|
||||||
|
|
||||||
|
# References are just resampled/cropped tensors of the voice clip -- cheap to
|
||||||
|
# keep around per voice name instead of redoing that work every request.
|
||||||
|
_sopro_ref_cache = {}
|
||||||
|
|
||||||
|
# Sopro caches a CUDA graph per decode length (sopro/nn/decode.py) captured on
|
||||||
|
# whichever thread first hits that length. Replaying -- or sampling after
|
||||||
|
# replaying -- from a different thread breaks torch's CUDA RNG bookkeeping
|
||||||
|
# ("Offset increment outside graph capture encountered unexpectedly"), so
|
||||||
|
# every Sopro call has to run on this same dedicated thread.
|
||||||
|
_sopro_executor = ThreadPoolExecutor(max_workers=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_numpy(audio) -> np.ndarray:
|
||||||
|
if isinstance(audio, torch.Tensor):
|
||||||
|
return audio.detach().cpu().numpy()
|
||||||
|
return audio
|
||||||
|
|
||||||
|
|
||||||
|
def _time_stretch(audio: np.ndarray, sample_rate: int, speed: float) -> np.ndarray:
|
||||||
|
# Sopro has no native speed/duration control, unlike Kokoro's duration
|
||||||
|
# predictor. PSOLA (pitch-synchronous overlap-add) changes tempo without
|
||||||
|
# pitch-shifting, and handles speech transients/consonants far more
|
||||||
|
# cleanly than a generic STFT phase vocoder does.
|
||||||
|
snd = parselmouth.Sound(audio.astype(np.float64), sampling_frequency=sample_rate)
|
||||||
|
stretched = parselmouth.praat.call(snd, "Lengthen (overlap-add)", 75, 600, 1.0 / speed)
|
||||||
|
return stretched.values[0].astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def _sopro_reference(name: str):
|
||||||
|
if name not in _sopro_ref_cache:
|
||||||
|
wav_path = VOICES_DIR / f"{name}.wav"
|
||||||
|
if not wav_path.exists():
|
||||||
|
raise HTTPException(status_code=400, detail=f"unknown sopro voice '{name}' (expected {wav_path})")
|
||||||
|
_sopro_ref_cache[name] = sopro_model.prepare_reference(ref_audio_path=str(wav_path))
|
||||||
|
return _sopro_ref_cache[name]
|
||||||
|
|
||||||
|
|
||||||
|
def _synthesize_kokoro(text: str, voice: str, speed: float) -> tuple[np.ndarray, int]:
|
||||||
|
# KPipeline yields one Result per chunk it splits the input into -- no
|
||||||
|
# manual chunking needed here, unlike the autoregressive model this
|
||||||
|
# replaced.
|
||||||
|
chunks = [_to_numpy(result.audio) for result in kokoro_pipeline(text, voice=voice, speed=speed)]
|
||||||
|
audio = np.concatenate(chunks) if len(chunks) > 1 else chunks[0]
|
||||||
|
return audio, KOKORO_SAMPLE_RATE
|
||||||
|
|
||||||
|
|
||||||
|
def _synthesize_sopro(text: str, voice: str | None, speed: float) -> tuple[np.ndarray, int]:
|
||||||
|
voice = voice or DEFAULT_SOPRO_VOICE
|
||||||
|
if not voice:
|
||||||
|
raise HTTPException(status_code=400, detail="no voice given and TTS_SOPRO_DEFAULT_VOICE is unset")
|
||||||
|
ref = _sopro_reference(voice)
|
||||||
|
audio = _to_numpy(sopro_model.synthesize(text, ref=ref))
|
||||||
|
if speed != 1.0:
|
||||||
|
audio = _time_stretch(audio, sopro_model.sample_rate, speed)
|
||||||
|
return audio, sopro_model.sample_rate
|
||||||
|
|
||||||
|
|
||||||
|
class SpeechRequest(BaseModel):
|
||||||
|
model: str | None = None
|
||||||
|
input: str
|
||||||
|
voice: str | None = None
|
||||||
|
response_format: str = "wav"
|
||||||
|
speed: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok", "device": DEVICE}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v1/audio/speech", dependencies=[Depends(require_auth)])
|
||||||
|
async def synthesize(req: SpeechRequest):
|
||||||
|
if (req.model or "").lower().startswith("sopro"):
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
audio, sample_rate = await loop.run_in_executor(
|
||||||
|
_sopro_executor, _synthesize_sopro, req.input, req.voice, req.speed or 1.0
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
audio, sample_rate = await asyncio.to_thread(
|
||||||
|
_synthesize_kokoro, req.input, req.voice or DEFAULT_VOICE, req.speed or 1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fmt = "WAV" if req.response_format in ("wav", None) else req.response_format.upper()
|
||||||
|
sf.write(buf, audio, sample_rate, format=fmt)
|
||||||
|
buf.seek(0)
|
||||||
|
|
||||||
|
media_type = "audio/wav" if fmt == "WAV" else f"audio/{req.response_format}"
|
||||||
|
return Response(content=buf.read(), media_type=media_type)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fastapi==0.115.*
|
||||||
|
uvicorn[standard]==0.32.*
|
||||||
|
torch==2.5.1
|
||||||
|
# Pinned to match torch==2.5.1+cu121 above -- an unpinned install pulls a
|
||||||
|
# newer torchaudio built against a CUDA runtime the Titan Xp's pinned torch
|
||||||
|
# doesn't ship, which fails with "libcudart.so.13: cannot open shared object".
|
||||||
|
torchaudio==2.5.1
|
||||||
|
kokoro>=0.9.2
|
||||||
|
sopro
|
||||||
|
soundfile
|
||||||
|
praat-parselmouth
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in new issue
Block a user