Upload folder using huggingface_hub
Browse files- .dockerignore +22 -0
- .env +14 -0
- .gitattributes +5 -0
- Dockerfile +41 -0
- README.md +32 -0
- api/__init__.py +0 -0
- api/src/__init__.py +0 -0
- api/src/core/__init__.py +0 -0
- api/src/core/config.py +70 -0
- api/src/core/model_config.py +216 -0
- api/src/core/paths.py +50 -0
- api/src/inference/__init__.py +0 -0
- api/src/inference/model_manager.py +411 -0
- api/src/inference/text_chunker.py +81 -0
- api/src/inference/voice_manager.py +156 -0
- api/src/main.py +136 -0
- api/src/routers/__init__.py +0 -0
- api/src/routers/debug.py +177 -0
- api/src/routers/health.py +18 -0
- api/src/routers/model_management.py +166 -0
- api/src/routers/openai_compatible.py +132 -0
- api/src/routers/voice_management.py +106 -0
- api/src/routers/websocket.py +149 -0
- api/src/services/__init__.py +0 -0
- api/src/services/audio_utils.py +64 -0
- api/src/services/streaming_audio_writer.py +134 -0
- api/src/services/temp_manager.py +27 -0
- api/src/services/tts_service.py +134 -0
- api/src/static/index.html +1693 -0
- api/src/structures/__init__.py +0 -0
- api/src/structures/schemas.py +177 -0
- api/src/structures/websocket_schemas.py +37 -0
- api/src/voices/builtin/dave.txt +1 -0
- api/src/voices/builtin/dave.wav +3 -0
- api/src/voices/builtin/dave_neuphonic_distill-neucodec.pt +3 -0
- api/src/voices/builtin/greta.txt +1 -0
- api/src/voices/builtin/greta.wav +3 -0
- api/src/voices/builtin/greta_neuphonic_distill-neucodec.pt +3 -0
- api/src/voices/builtin/jo.txt +1 -0
- api/src/voices/builtin/jo.wav +3 -0
- api/src/voices/builtin/jo_neuphonic_distill-neucodec.pt +3 -0
- api/src/voices/builtin/juliette.txt +1 -0
- api/src/voices/builtin/juliette.wav +3 -0
- api/src/voices/builtin/mateo.txt +1 -0
- api/src/voices/builtin/mateo.wav +3 -0
- api/src/voices/custom/.gitkeep +0 -0
- docker/scripts/download_models.py +48 -0
- entrypoint.sh +17 -0
- pyproject.toml +31 -0
.dockerignore
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
*.egg-info/
|
| 4 |
+
dist/
|
| 5 |
+
build/
|
| 6 |
+
.eggs/
|
| 7 |
+
.env
|
| 8 |
+
.git/
|
| 9 |
+
.gitignore
|
| 10 |
+
.gitattributes
|
| 11 |
+
*.wav
|
| 12 |
+
*.mp3
|
| 13 |
+
*.opus
|
| 14 |
+
*.aac
|
| 15 |
+
*.flac
|
| 16 |
+
*.pcm
|
| 17 |
+
*.pt
|
| 18 |
+
*.gguf
|
| 19 |
+
*.onnx
|
| 20 |
+
**/custom/*
|
| 21 |
+
.vscode/
|
| 22 |
+
.idea/
|
.env
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
NEUTTS_HOST=0.0.0.0
|
| 2 |
+
NEUTTS_PORT=7860
|
| 3 |
+
NEUTTS_DEFAULT_MODELS=neutts-nano-q4-gguf
|
| 4 |
+
NEUTTS_DEFAULT_CODEC=neuphonic/neucodec-onnx-decoder
|
| 5 |
+
NEUTTS_DEFAULT_BACKBONE_DEVICE=cpu
|
| 6 |
+
NEUTTS_DEFAULT_CODEC_DEVICE=cpu
|
| 7 |
+
NEUTTS_DEFAULT_VOICE=jo
|
| 8 |
+
NEUTTS_SAMPLE_RATE=24000
|
| 9 |
+
NEUTTS_DEFAULT_RESPONSE_FORMAT=mp3
|
| 10 |
+
NEUTTS_CORS_ENABLED=true
|
| 11 |
+
NEUTTS_CORS_ORIGINS=["*"]
|
| 12 |
+
NEUTTS_LOG_LEVEL=INFO
|
| 13 |
+
NEUTTS_MAX_INFERENCE_WORKERS=4
|
| 14 |
+
NEUTTS_ALLOW_VOICE_UPLOAD=true
|
.gitattributes
CHANGED
|
@@ -33,3 +33,8 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
api/src/voices/builtin/dave.wav filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
api/src/voices/builtin/greta.wav filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
api/src/voices/builtin/jo.wav filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
api/src/voices/builtin/juliette.wav filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
api/src/voices/builtin/mateo.wav filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONUNBUFFERED=1
|
| 4 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 5 |
+
|
| 6 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 7 |
+
espeak-ng libespeak-ng-dev \
|
| 8 |
+
ffmpeg libsndfile1 \
|
| 9 |
+
git curl \
|
| 10 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
+
|
| 12 |
+
ENV PHONEMIZER_ESPEAK_LIBRARY=/usr/lib/x86_64-linux-gnu/libespeak-ng.so.1
|
| 13 |
+
ENV ESPEAK_DATA_PATH=/usr/lib/x86_64-linux-gnu/espeak-ng-data
|
| 14 |
+
|
| 15 |
+
ENV HF_HOME=/data/huggingface
|
| 16 |
+
ENV NEUTTS_HOST=0.0.0.0
|
| 17 |
+
ENV NEUTTS_PORT=7860
|
| 18 |
+
ENV NEUTTS_DEFAULT_MODELS=neutts-nano-q4-gguf
|
| 19 |
+
ENV NEUTTS_DEFAULT_CODEC=neuphonic/neucodec-onnx-decoder
|
| 20 |
+
ENV NEUTTS_DEFAULT_BACKBONE_DEVICE=cpu
|
| 21 |
+
ENV NEUTTS_DEFAULT_CODEC_DEVICE=cpu
|
| 22 |
+
ENV NEUTTS_DEFAULT_VOICE=jo
|
| 23 |
+
ENV NEUTTS_LOG_LEVEL=INFO
|
| 24 |
+
ENV NEUTTS_CORS_ENABLED=true
|
| 25 |
+
ENV NEUTTS_CORS_ORIGINS=["*"]
|
| 26 |
+
|
| 27 |
+
RUN pip install --no-cache-dir uv
|
| 28 |
+
|
| 29 |
+
WORKDIR /app
|
| 30 |
+
|
| 31 |
+
COPY pyproject.toml .
|
| 32 |
+
|
| 33 |
+
RUN uv pip install --system --no-cache-dir ".[cpu]"
|
| 34 |
+
|
| 35 |
+
COPY . .
|
| 36 |
+
|
| 37 |
+
RUN mkdir -p /data/huggingface /app/api/src/voices/custom
|
| 38 |
+
|
| 39 |
+
EXPOSE 7860
|
| 40 |
+
|
| 41 |
+
ENTRYPOINT ["bash", "entrypoint.sh"]
|
README.md
CHANGED
|
@@ -7,4 +7,36 @@ sdk: docker
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
+
# NeuTTS API on Hugging Face Spaces
|
| 11 |
+
|
| 12 |
+
OpenAI-compatible Text-to-Speech API powered by NeuTTS.
|
| 13 |
+
|
| 14 |
+
## Environment Variables
|
| 15 |
+
|
| 16 |
+
| Variable | Default | Description |
|
| 17 |
+
|---|---|---|
|
| 18 |
+
| `NEUTTS_DEFAULT_MODELS` | `neutts-nano-q4-gguf` | Comma-separated model IDs |
|
| 19 |
+
| `NEUTTS_DEFAULT_CODEC` | `neuphonic/neucodec-onnx-decoder` | Codec model |
|
| 20 |
+
| `NEUTTS_DEFAULT_VOICE` | `jo` | Default voice name |
|
| 21 |
+
| `NEUTTS_DEFAULT_BACKBONE_DEVICE` | `cpu` | Device for backbone |
|
| 22 |
+
| `NEUTTS_DEFAULT_CODEC_DEVICE` | `cpu` | Device for codec |
|
| 23 |
+
| `NEUTTS_LOG_LEVEL` | `INFO` | Log level |
|
| 24 |
+
| `NEUTTS_CORS_ENABLED` | `true` | Enable CORS |
|
| 25 |
+
|
| 26 |
+
## API Usage
|
| 27 |
+
|
| 28 |
+
The API is OpenAI-compatible:
|
| 29 |
+
|
| 30 |
+
```bash
|
| 31 |
+
curl -X POST "https://{your-space}.hf.space/v1/audio/speech" \
|
| 32 |
+
-H "Content-Type: application/json" \
|
| 33 |
+
-d '{
|
| 34 |
+
"model": "neutts-nano-q4-gguf",
|
| 35 |
+
"input": "Hello, world!",
|
| 36 |
+
"voice": "jo",
|
| 37 |
+
"response_format": "mp3"
|
| 38 |
+
}' \
|
| 39 |
+
--output speech.mp3
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
api/__init__.py
ADDED
|
File without changes
|
api/src/__init__.py
ADDED
|
File without changes
|
api/src/core/__init__.py
ADDED
|
File without changes
|
api/src/core/config.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from typing import Literal
|
| 5 |
+
|
| 6 |
+
from pydantic import Field, field_validator
|
| 7 |
+
from pydantic_settings import BaseSettings
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class Settings(BaseSettings):
|
| 11 |
+
model_config = {"env_prefix": "NEUTTS_", "env_file": ".env", "extra": "ignore"}
|
| 12 |
+
|
| 13 |
+
# Server
|
| 14 |
+
host: str = "0.0.0.0"
|
| 15 |
+
port: int = 8880
|
| 16 |
+
|
| 17 |
+
# Models
|
| 18 |
+
default_models: str = "neutts-nano-q4-gguf"
|
| 19 |
+
default_codec: str = "neuphonic/neucodec-onnx-decoder"
|
| 20 |
+
default_backbone_device: Literal["auto", "cpu", "cuda"] = "auto"
|
| 21 |
+
default_codec_device: Literal["cpu", "cuda"] = "cpu"
|
| 22 |
+
|
| 23 |
+
# Voice
|
| 24 |
+
default_voice: str = "jo"
|
| 25 |
+
|
| 26 |
+
# Audio
|
| 27 |
+
sample_rate: int = 24000
|
| 28 |
+
default_response_format: Literal["mp3", "opus", "aac", "flac", "wav", "pcm"] = "mp3"
|
| 29 |
+
|
| 30 |
+
# CORS
|
| 31 |
+
cors_enabled: bool = True
|
| 32 |
+
cors_origins: str = '["*"]'
|
| 33 |
+
|
| 34 |
+
# Logging
|
| 35 |
+
log_level: str = "INFO"
|
| 36 |
+
|
| 37 |
+
# Performance
|
| 38 |
+
max_inference_workers: int = 4
|
| 39 |
+
|
| 40 |
+
# Voice Upload
|
| 41 |
+
allow_voice_upload: bool = True
|
| 42 |
+
|
| 43 |
+
@field_validator("cors_origins", mode="before")
|
| 44 |
+
@classmethod
|
| 45 |
+
def parse_cors_origins(cls, v: str | list) -> str:
|
| 46 |
+
if isinstance(v, list):
|
| 47 |
+
return json.dumps(v)
|
| 48 |
+
return v
|
| 49 |
+
|
| 50 |
+
@property
|
| 51 |
+
def cors_origins_list(self) -> list[str]:
|
| 52 |
+
return json.loads(self.cors_origins)
|
| 53 |
+
|
| 54 |
+
@property
|
| 55 |
+
def default_models_list(self) -> list[str]:
|
| 56 |
+
return [m.strip() for m in self.default_models.split(",") if m.strip()]
|
| 57 |
+
|
| 58 |
+
@property
|
| 59 |
+
def resolved_backbone_device(self) -> str:
|
| 60 |
+
if self.default_backbone_device == "auto":
|
| 61 |
+
try:
|
| 62 |
+
import torch
|
| 63 |
+
|
| 64 |
+
return "cuda" if torch.cuda.is_available() else "cpu"
|
| 65 |
+
except ImportError:
|
| 66 |
+
return "cpu"
|
| 67 |
+
return self.default_backbone_device
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
settings = Settings()
|
api/src/core/model_config.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from enum import Enum
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class BackendType(str, Enum):
|
| 8 |
+
TORCH = "torch"
|
| 9 |
+
GGUF = "gguf"
|
| 10 |
+
ONNX = "onnx"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@dataclass(frozen=True)
|
| 14 |
+
class BackboneModelInfo:
|
| 15 |
+
model_id: str
|
| 16 |
+
repo: str
|
| 17 |
+
language: str
|
| 18 |
+
backend: BackendType
|
| 19 |
+
supports_streaming: bool
|
| 20 |
+
description: str
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass(frozen=True)
|
| 24 |
+
class CodecModelInfo:
|
| 25 |
+
codec_id: str
|
| 26 |
+
repo: str
|
| 27 |
+
codec_type: str
|
| 28 |
+
description: str
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
BACKBONE_MODELS: dict[str, BackboneModelInfo] = {
|
| 32 |
+
# NeuTTS-Air (English)
|
| 33 |
+
"neutts-air": BackboneModelInfo(
|
| 34 |
+
model_id="neutts-air",
|
| 35 |
+
repo="neuphonic/neutts-air",
|
| 36 |
+
language="en-us",
|
| 37 |
+
backend=BackendType.TORCH,
|
| 38 |
+
supports_streaming=False,
|
| 39 |
+
description="NeuTTS Air ~748M params, PyTorch",
|
| 40 |
+
),
|
| 41 |
+
"neutts-air-q4-gguf": BackboneModelInfo(
|
| 42 |
+
model_id="neutts-air-q4-gguf",
|
| 43 |
+
repo="neuphonic/neutts-air-q4-gguf",
|
| 44 |
+
language="en-us",
|
| 45 |
+
backend=BackendType.GGUF,
|
| 46 |
+
supports_streaming=True,
|
| 47 |
+
description="NeuTTS Air Q4 quantized, GGUF",
|
| 48 |
+
),
|
| 49 |
+
"neutts-air-q8-gguf": BackboneModelInfo(
|
| 50 |
+
model_id="neutts-air-q8-gguf",
|
| 51 |
+
repo="neuphonic/neutts-air-q8-gguf",
|
| 52 |
+
language="en-us",
|
| 53 |
+
backend=BackendType.GGUF,
|
| 54 |
+
supports_streaming=True,
|
| 55 |
+
description="NeuTTS Air Q8 quantized, GGUF",
|
| 56 |
+
),
|
| 57 |
+
"neutts-air-onnx": BackboneModelInfo(
|
| 58 |
+
model_id="neutts-air-onnx",
|
| 59 |
+
repo="neuphonic/neutts-air-onnx",
|
| 60 |
+
language="en-us",
|
| 61 |
+
backend=BackendType.ONNX,
|
| 62 |
+
supports_streaming=False,
|
| 63 |
+
description="NeuTTS Air ONNX runtime",
|
| 64 |
+
),
|
| 65 |
+
# NeuTTS-Nano (English)
|
| 66 |
+
"neutts-nano": BackboneModelInfo(
|
| 67 |
+
model_id="neutts-nano",
|
| 68 |
+
repo="neuphonic/neutts-nano",
|
| 69 |
+
language="en-us",
|
| 70 |
+
backend=BackendType.TORCH,
|
| 71 |
+
supports_streaming=False,
|
| 72 |
+
description="NeuTTS Nano ~120M params, PyTorch",
|
| 73 |
+
),
|
| 74 |
+
"neutts-nano-q4-gguf": BackboneModelInfo(
|
| 75 |
+
model_id="neutts-nano-q4-gguf",
|
| 76 |
+
repo="neuphonic/neutts-nano-q4-gguf",
|
| 77 |
+
language="en-us",
|
| 78 |
+
backend=BackendType.GGUF,
|
| 79 |
+
supports_streaming=True,
|
| 80 |
+
description="NeuTTS Nano Q4 quantized, GGUF",
|
| 81 |
+
),
|
| 82 |
+
"neutts-nano-q8-gguf": BackboneModelInfo(
|
| 83 |
+
model_id="neutts-nano-q8-gguf",
|
| 84 |
+
repo="neuphonic/neutts-nano-q8-gguf",
|
| 85 |
+
language="en-us",
|
| 86 |
+
backend=BackendType.GGUF,
|
| 87 |
+
supports_streaming=True,
|
| 88 |
+
description="NeuTTS Nano Q8 quantized, GGUF",
|
| 89 |
+
),
|
| 90 |
+
# NeuTTS-Nano German
|
| 91 |
+
"neutts-nano-german": BackboneModelInfo(
|
| 92 |
+
model_id="neutts-nano-german",
|
| 93 |
+
repo="neuphonic/neutts-nano-german",
|
| 94 |
+
language="de",
|
| 95 |
+
backend=BackendType.TORCH,
|
| 96 |
+
supports_streaming=False,
|
| 97 |
+
description="NeuTTS Nano German, PyTorch",
|
| 98 |
+
),
|
| 99 |
+
"neutts-nano-german-q4-gguf": BackboneModelInfo(
|
| 100 |
+
model_id="neutts-nano-german-q4-gguf",
|
| 101 |
+
repo="neuphonic/neutts-nano-german-q4-gguf",
|
| 102 |
+
language="de",
|
| 103 |
+
backend=BackendType.GGUF,
|
| 104 |
+
supports_streaming=True,
|
| 105 |
+
description="NeuTTS Nano German Q4 quantized, GGUF",
|
| 106 |
+
),
|
| 107 |
+
"neutts-nano-german-q8-gguf": BackboneModelInfo(
|
| 108 |
+
model_id="neutts-nano-german-q8-gguf",
|
| 109 |
+
repo="neuphonic/neutts-nano-german-q8-gguf",
|
| 110 |
+
language="de",
|
| 111 |
+
backend=BackendType.GGUF,
|
| 112 |
+
supports_streaming=True,
|
| 113 |
+
description="NeuTTS Nano German Q8 quantized, GGUF",
|
| 114 |
+
),
|
| 115 |
+
# NeuTTS-Nano French
|
| 116 |
+
"neutts-nano-french": BackboneModelInfo(
|
| 117 |
+
model_id="neutts-nano-french",
|
| 118 |
+
repo="neuphonic/neutts-nano-french",
|
| 119 |
+
language="fr-fr",
|
| 120 |
+
backend=BackendType.TORCH,
|
| 121 |
+
supports_streaming=False,
|
| 122 |
+
description="NeuTTS Nano French, PyTorch",
|
| 123 |
+
),
|
| 124 |
+
"neutts-nano-french-q4-gguf": BackboneModelInfo(
|
| 125 |
+
model_id="neutts-nano-french-q4-gguf",
|
| 126 |
+
repo="neuphonic/neutts-nano-french-q4-gguf",
|
| 127 |
+
language="fr-fr",
|
| 128 |
+
backend=BackendType.GGUF,
|
| 129 |
+
supports_streaming=True,
|
| 130 |
+
description="NeuTTS Nano French Q4 quantized, GGUF",
|
| 131 |
+
),
|
| 132 |
+
"neutts-nano-french-q8-gguf": BackboneModelInfo(
|
| 133 |
+
model_id="neutts-nano-french-q8-gguf",
|
| 134 |
+
repo="neuphonic/neutts-nano-french-q8-gguf",
|
| 135 |
+
language="fr-fr",
|
| 136 |
+
backend=BackendType.GGUF,
|
| 137 |
+
supports_streaming=True,
|
| 138 |
+
description="NeuTTS Nano French Q8 quantized, GGUF",
|
| 139 |
+
),
|
| 140 |
+
# NeuTTS-Nano Spanish
|
| 141 |
+
"neutts-nano-spanish": BackboneModelInfo(
|
| 142 |
+
model_id="neutts-nano-spanish",
|
| 143 |
+
repo="neuphonic/neutts-nano-spanish",
|
| 144 |
+
language="es",
|
| 145 |
+
backend=BackendType.TORCH,
|
| 146 |
+
supports_streaming=False,
|
| 147 |
+
description="NeuTTS Nano Spanish, PyTorch",
|
| 148 |
+
),
|
| 149 |
+
"neutts-nano-spanish-q4-gguf": BackboneModelInfo(
|
| 150 |
+
model_id="neutts-nano-spanish-q4-gguf",
|
| 151 |
+
repo="neuphonic/neutts-nano-spanish-q4-gguf",
|
| 152 |
+
language="es",
|
| 153 |
+
backend=BackendType.GGUF,
|
| 154 |
+
supports_streaming=True,
|
| 155 |
+
description="NeuTTS Nano Spanish Q4 quantized, GGUF",
|
| 156 |
+
),
|
| 157 |
+
"neutts-nano-spanish-q8-gguf": BackboneModelInfo(
|
| 158 |
+
model_id="neutts-nano-spanish-q8-gguf",
|
| 159 |
+
repo="neuphonic/neutts-nano-spanish-q8-gguf",
|
| 160 |
+
language="es",
|
| 161 |
+
backend=BackendType.GGUF,
|
| 162 |
+
supports_streaming=True,
|
| 163 |
+
description="NeuTTS Nano Spanish Q8 quantized, GGUF",
|
| 164 |
+
),
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
CODEC_MODELS: dict[str, CodecModelInfo] = {
|
| 168 |
+
"neuphonic/neucodec": CodecModelInfo(
|
| 169 |
+
codec_id="neuphonic/neucodec",
|
| 170 |
+
repo="neuphonic/neucodec",
|
| 171 |
+
codec_type="pytorch",
|
| 172 |
+
description="NeuCodec PyTorch (cpu/cuda)",
|
| 173 |
+
),
|
| 174 |
+
"neuphonic/distill-neucodec": CodecModelInfo(
|
| 175 |
+
codec_id="neuphonic/distill-neucodec",
|
| 176 |
+
repo="neuphonic/distill-neucodec",
|
| 177 |
+
codec_type="pytorch",
|
| 178 |
+
description="Distilled NeuCodec PyTorch (cpu/cuda)",
|
| 179 |
+
),
|
| 180 |
+
"neuphonic/neucodec-onnx-decoder": CodecModelInfo(
|
| 181 |
+
codec_id="neuphonic/neucodec-onnx-decoder",
|
| 182 |
+
repo="neuphonic/neucodec-onnx-decoder",
|
| 183 |
+
codec_type="onnx",
|
| 184 |
+
description="NeuCodec ONNX decoder (cpu)",
|
| 185 |
+
),
|
| 186 |
+
"neuphonic/neucodec-onnx-decoder-int8": CodecModelInfo(
|
| 187 |
+
codec_id="neuphonic/neucodec-onnx-decoder-int8",
|
| 188 |
+
repo="neuphonic/neucodec-onnx-decoder-int8",
|
| 189 |
+
codec_type="onnx_int8",
|
| 190 |
+
description="NeuCodec ONNX INT8 decoder (cpu)",
|
| 191 |
+
),
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
BUILTIN_VOICES: dict[str, dict] = {
|
| 195 |
+
"dave": {"language": "en-us", "gender": "male", "description": "English male voice"},
|
| 196 |
+
"jo": {"language": "en-us", "gender": "female", "description": "English female voice"},
|
| 197 |
+
"greta": {"language": "de", "gender": "female", "description": "German female voice"},
|
| 198 |
+
"hans": {"language": "de", "gender": "male", "description": "German male voice"},
|
| 199 |
+
"mateo": {"language": "es", "gender": "male", "description": "Spanish male voice"},
|
| 200 |
+
"elena": {"language": "es", "gender": "female", "description": "Spanish female voice"},
|
| 201 |
+
"juliette": {"language": "fr-fr", "gender": "female", "description": "French female voice"},
|
| 202 |
+
"pierre": {"language": "fr-fr", "gender": "male", "description": "French male voice"},
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def get_backbone_info(model_id: str) -> BackboneModelInfo | None:
|
| 207 |
+
return BACKBONE_MODELS.get(model_id)
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def get_codec_info(codec_id: str) -> CodecModelInfo | None:
|
| 211 |
+
return CODEC_MODELS.get(codec_id)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def get_voice_language(voice_name: str) -> str | None:
|
| 215 |
+
info = BUILTIN_VOICES.get(voice_name)
|
| 216 |
+
return info["language"] if info else None
|
api/src/core/paths.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
| 6 |
+
API_ROOT = PROJECT_ROOT / "api"
|
| 7 |
+
SRC_ROOT = API_ROOT / "src"
|
| 8 |
+
VOICES_DIR = SRC_ROOT / "voices"
|
| 9 |
+
BUILTIN_VOICES_DIR = VOICES_DIR / "builtin"
|
| 10 |
+
CUSTOM_VOICES_DIR = VOICES_DIR / "custom"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def ensure_voice_dirs() -> None:
|
| 14 |
+
BUILTIN_VOICES_DIR.mkdir(parents=True, exist_ok=True)
|
| 15 |
+
CUSTOM_VOICES_DIR.mkdir(parents=True, exist_ok=True)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def get_voice_wav(voice_name: str) -> Path | None:
|
| 19 |
+
for base in (BUILTIN_VOICES_DIR, CUSTOM_VOICES_DIR):
|
| 20 |
+
wav = base / f"{voice_name}.wav"
|
| 21 |
+
if wav.exists():
|
| 22 |
+
return wav
|
| 23 |
+
return None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def get_voice_text(voice_name: str) -> Path | None:
|
| 27 |
+
for base in (BUILTIN_VOICES_DIR, CUSTOM_VOICES_DIR):
|
| 28 |
+
txt = base / f"{voice_name}.txt"
|
| 29 |
+
if txt.exists():
|
| 30 |
+
return txt
|
| 31 |
+
return None
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def get_voice_codes(voice_name: str, codec_id: str) -> Path | None:
|
| 35 |
+
codec_suffix = codec_id.replace("/", "_")
|
| 36 |
+
for base in (BUILTIN_VOICES_DIR, CUSTOM_VOICES_DIR):
|
| 37 |
+
pt = base / f"{voice_name}_{codec_suffix}.pt"
|
| 38 |
+
if pt.exists():
|
| 39 |
+
return pt
|
| 40 |
+
return None
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def voice_codes_path(voice_name: str, codec_id: str, custom: bool = False) -> Path:
|
| 44 |
+
codec_suffix = codec_id.replace("/", "_")
|
| 45 |
+
base = CUSTOM_VOICES_DIR if custom else BUILTIN_VOICES_DIR
|
| 46 |
+
return base / f"{voice_name}_{codec_suffix}.pt"
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def is_custom_voice(voice_name: str) -> bool:
|
| 50 |
+
return (CUSTOM_VOICES_DIR / f"{voice_name}.wav").exists()
|
api/src/inference/__init__.py
ADDED
|
File without changes
|
api/src/inference/model_manager.py
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import time
|
| 5 |
+
import uuid
|
| 6 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 7 |
+
from dataclasses import dataclass, field
|
| 8 |
+
from enum import Enum
|
| 9 |
+
from typing import AsyncGenerator
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
from loguru import logger
|
| 13 |
+
|
| 14 |
+
from api.src.core.config import settings
|
| 15 |
+
from api.src.core.model_config import (
|
| 16 |
+
BACKBONE_MODELS,
|
| 17 |
+
BackendType,
|
| 18 |
+
get_backbone_info,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class ModelLoadStatus(str, Enum):
|
| 23 |
+
PENDING = "pending"
|
| 24 |
+
DOWNLOADING = "downloading"
|
| 25 |
+
LOADING = "loading"
|
| 26 |
+
READY = "ready"
|
| 27 |
+
ERROR = "error"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass
|
| 31 |
+
class ModelLoadingTask:
|
| 32 |
+
task_id: str
|
| 33 |
+
model_id: str
|
| 34 |
+
status: ModelLoadStatus = ModelLoadStatus.PENDING
|
| 35 |
+
progress_message: str = ""
|
| 36 |
+
error_message: str = ""
|
| 37 |
+
started_at: float = 0.0
|
| 38 |
+
completed_at: float = 0.0
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@dataclass
|
| 42 |
+
class LoadedModel:
|
| 43 |
+
model_id: str
|
| 44 |
+
codec_id: str
|
| 45 |
+
tts_instance: object # NeuTTS instance
|
| 46 |
+
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
| 47 |
+
backbone_device: str = "cpu"
|
| 48 |
+
codec_device: str = "cpu"
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class ModelManager:
|
| 52 |
+
_instance: ModelManager | None = None
|
| 53 |
+
|
| 54 |
+
def __init__(self) -> None:
|
| 55 |
+
self._models: dict[str, LoadedModel] = {}
|
| 56 |
+
self._loading_tasks: dict[str, ModelLoadingTask] = {}
|
| 57 |
+
self._executor = ThreadPoolExecutor(max_workers=settings.max_inference_workers)
|
| 58 |
+
|
| 59 |
+
@classmethod
|
| 60 |
+
def get_instance(cls) -> ModelManager:
|
| 61 |
+
if cls._instance is None:
|
| 62 |
+
cls._instance = cls()
|
| 63 |
+
return cls._instance
|
| 64 |
+
|
| 65 |
+
@property
|
| 66 |
+
def loaded_models(self) -> dict[str, LoadedModel]:
|
| 67 |
+
return self._models
|
| 68 |
+
|
| 69 |
+
@property
|
| 70 |
+
def loading_tasks(self) -> dict[str, ModelLoadingTask]:
|
| 71 |
+
return self._loading_tasks
|
| 72 |
+
|
| 73 |
+
def is_loaded(self, model_id: str) -> bool:
|
| 74 |
+
return model_id in self._models
|
| 75 |
+
|
| 76 |
+
def get_task(self, task_id: str) -> ModelLoadingTask | None:
|
| 77 |
+
return self._loading_tasks.get(task_id)
|
| 78 |
+
|
| 79 |
+
async def load_model_async(
|
| 80 |
+
self,
|
| 81 |
+
model_id: str,
|
| 82 |
+
codec_id: str | None = None,
|
| 83 |
+
backbone_device: str | None = None,
|
| 84 |
+
codec_device: str | None = None,
|
| 85 |
+
) -> ModelLoadingTask:
|
| 86 |
+
"""Start loading a model in the background. Returns a task for polling."""
|
| 87 |
+
# Already loaded -> return READY task immediately
|
| 88 |
+
if model_id in self._models:
|
| 89 |
+
task = ModelLoadingTask(
|
| 90 |
+
task_id=str(uuid.uuid4()),
|
| 91 |
+
model_id=model_id,
|
| 92 |
+
status=ModelLoadStatus.READY,
|
| 93 |
+
progress_message="Already loaded",
|
| 94 |
+
started_at=time.time(),
|
| 95 |
+
completed_at=time.time(),
|
| 96 |
+
)
|
| 97 |
+
self._loading_tasks[task.task_id] = task
|
| 98 |
+
return task
|
| 99 |
+
|
| 100 |
+
# Already loading -> return existing task
|
| 101 |
+
for task in self._loading_tasks.values():
|
| 102 |
+
if task.model_id == model_id and task.status in (
|
| 103 |
+
ModelLoadStatus.PENDING,
|
| 104 |
+
ModelLoadStatus.DOWNLOADING,
|
| 105 |
+
ModelLoadStatus.LOADING,
|
| 106 |
+
):
|
| 107 |
+
return task
|
| 108 |
+
|
| 109 |
+
info = get_backbone_info(model_id)
|
| 110 |
+
if info is None:
|
| 111 |
+
raise ValueError(f"Unknown model: {model_id}. Available: {list(BACKBONE_MODELS.keys())}")
|
| 112 |
+
|
| 113 |
+
task = ModelLoadingTask(
|
| 114 |
+
task_id=str(uuid.uuid4()),
|
| 115 |
+
model_id=model_id,
|
| 116 |
+
status=ModelLoadStatus.PENDING,
|
| 117 |
+
progress_message="Queued",
|
| 118 |
+
started_at=time.time(),
|
| 119 |
+
)
|
| 120 |
+
self._loading_tasks[task.task_id] = task
|
| 121 |
+
|
| 122 |
+
asyncio.ensure_future(
|
| 123 |
+
self._background_load(task, codec_id, backbone_device, codec_device)
|
| 124 |
+
)
|
| 125 |
+
return task
|
| 126 |
+
|
| 127 |
+
async def _background_load(
|
| 128 |
+
self,
|
| 129 |
+
task: ModelLoadingTask,
|
| 130 |
+
codec_id: str | None,
|
| 131 |
+
backbone_device: str | None,
|
| 132 |
+
codec_device: str | None,
|
| 133 |
+
) -> None:
|
| 134 |
+
"""Background coroutine that loads a model and updates task status."""
|
| 135 |
+
try:
|
| 136 |
+
task.status = ModelLoadStatus.DOWNLOADING
|
| 137 |
+
task.progress_message = "Downloading / checking cache..."
|
| 138 |
+
|
| 139 |
+
info = get_backbone_info(task.model_id)
|
| 140 |
+
if info is None:
|
| 141 |
+
raise ValueError(f"Unknown model: {task.model_id}")
|
| 142 |
+
|
| 143 |
+
codec = codec_id or settings.default_codec
|
| 144 |
+
bb_device = backbone_device or settings.resolved_backbone_device
|
| 145 |
+
cc_device = codec_device or settings.default_codec_device
|
| 146 |
+
|
| 147 |
+
# GGUF models only support CPU (llama.cpp limitation)
|
| 148 |
+
if info.backend == BackendType.GGUF:
|
| 149 |
+
bb_device = "cpu"
|
| 150 |
+
|
| 151 |
+
logger.info(
|
| 152 |
+
f"[Task {task.task_id[:8]}] Loading {task.model_id} "
|
| 153 |
+
f"(backbone_device={bb_device}, codec_device={cc_device})"
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
# Schedule status transition after 3s (heuristic for download vs load)
|
| 157 |
+
async def _mark_loading() -> None:
|
| 158 |
+
await asyncio.sleep(3)
|
| 159 |
+
if task.status == ModelLoadStatus.DOWNLOADING:
|
| 160 |
+
task.status = ModelLoadStatus.LOADING
|
| 161 |
+
task.progress_message = "Initializing model..."
|
| 162 |
+
|
| 163 |
+
timer_task = asyncio.ensure_future(_mark_loading())
|
| 164 |
+
|
| 165 |
+
loop = asyncio.get_event_loop()
|
| 166 |
+
tts = await loop.run_in_executor(
|
| 167 |
+
self._executor,
|
| 168 |
+
self._create_tts_instance,
|
| 169 |
+
info.repo,
|
| 170 |
+
codec,
|
| 171 |
+
bb_device,
|
| 172 |
+
cc_device,
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
timer_task.cancel()
|
| 176 |
+
|
| 177 |
+
loaded = LoadedModel(
|
| 178 |
+
model_id=task.model_id,
|
| 179 |
+
codec_id=codec,
|
| 180 |
+
tts_instance=tts,
|
| 181 |
+
backbone_device=bb_device,
|
| 182 |
+
codec_device=cc_device,
|
| 183 |
+
)
|
| 184 |
+
self._models[task.model_id] = loaded
|
| 185 |
+
|
| 186 |
+
task.status = ModelLoadStatus.READY
|
| 187 |
+
task.progress_message = "Model ready"
|
| 188 |
+
task.completed_at = time.time()
|
| 189 |
+
logger.info(f"[Task {task.task_id[:8]}] {task.model_id} loaded successfully")
|
| 190 |
+
|
| 191 |
+
except Exception as e:
|
| 192 |
+
task.status = ModelLoadStatus.ERROR
|
| 193 |
+
task.error_message = str(e)
|
| 194 |
+
task.progress_message = "Failed"
|
| 195 |
+
task.completed_at = time.time()
|
| 196 |
+
logger.error(f"[Task {task.task_id[:8]}] Failed to load {task.model_id}: {e}")
|
| 197 |
+
|
| 198 |
+
async def load_model(
|
| 199 |
+
self,
|
| 200 |
+
model_id: str,
|
| 201 |
+
codec_id: str | None = None,
|
| 202 |
+
backbone_device: str | None = None,
|
| 203 |
+
codec_device: str | None = None,
|
| 204 |
+
) -> LoadedModel:
|
| 205 |
+
"""Synchronous load (blocks until done). Used by startup."""
|
| 206 |
+
if model_id in self._models:
|
| 207 |
+
logger.info(f"Model {model_id} already loaded")
|
| 208 |
+
return self._models[model_id]
|
| 209 |
+
|
| 210 |
+
info = get_backbone_info(model_id)
|
| 211 |
+
if info is None:
|
| 212 |
+
raise ValueError(f"Unknown model: {model_id}. Available: {list(BACKBONE_MODELS.keys())}")
|
| 213 |
+
|
| 214 |
+
codec = codec_id or settings.default_codec
|
| 215 |
+
bb_device = backbone_device or settings.resolved_backbone_device
|
| 216 |
+
cc_device = codec_device or settings.default_codec_device
|
| 217 |
+
|
| 218 |
+
if info.backend == BackendType.GGUF:
|
| 219 |
+
bb_device = "cpu"
|
| 220 |
+
|
| 221 |
+
logger.info(
|
| 222 |
+
f"Loading model {model_id} (repo={info.repo}, codec={codec}, "
|
| 223 |
+
f"backbone_device={bb_device}, codec_device={cc_device})"
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
loop = asyncio.get_event_loop()
|
| 227 |
+
tts = await loop.run_in_executor(
|
| 228 |
+
self._executor,
|
| 229 |
+
self._create_tts_instance,
|
| 230 |
+
info.repo,
|
| 231 |
+
codec,
|
| 232 |
+
bb_device,
|
| 233 |
+
cc_device,
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
loaded = LoadedModel(
|
| 237 |
+
model_id=model_id,
|
| 238 |
+
codec_id=codec,
|
| 239 |
+
tts_instance=tts,
|
| 240 |
+
backbone_device=bb_device,
|
| 241 |
+
codec_device=cc_device,
|
| 242 |
+
)
|
| 243 |
+
self._models[model_id] = loaded
|
| 244 |
+
logger.info(f"Model {model_id} loaded successfully")
|
| 245 |
+
return loaded
|
| 246 |
+
|
| 247 |
+
@staticmethod
|
| 248 |
+
def _create_tts_instance(
|
| 249 |
+
backbone_repo: str,
|
| 250 |
+
codec_repo: str,
|
| 251 |
+
backbone_device: str,
|
| 252 |
+
codec_device: str,
|
| 253 |
+
) -> object:
|
| 254 |
+
from neutts import NeuTTS
|
| 255 |
+
|
| 256 |
+
return NeuTTS(
|
| 257 |
+
backbone_repo=backbone_repo,
|
| 258 |
+
backbone_device=backbone_device,
|
| 259 |
+
codec_repo=codec_repo,
|
| 260 |
+
codec_device=codec_device,
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
async def unload_model(self, model_id: str) -> None:
|
| 264 |
+
if model_id not in self._models:
|
| 265 |
+
raise ValueError(f"Model {model_id} is not loaded")
|
| 266 |
+
|
| 267 |
+
loaded = self._models.pop(model_id)
|
| 268 |
+
async with loaded.lock:
|
| 269 |
+
del loaded.tts_instance
|
| 270 |
+
logger.info(f"Model {model_id} unloaded")
|
| 271 |
+
|
| 272 |
+
async def switch_device(
|
| 273 |
+
self,
|
| 274 |
+
model_id: str,
|
| 275 |
+
backbone_device: str | None = None,
|
| 276 |
+
codec_device: str | None = None,
|
| 277 |
+
) -> ModelLoadingTask:
|
| 278 |
+
"""Unload model and reload on a different device."""
|
| 279 |
+
if model_id not in self._models:
|
| 280 |
+
raise ValueError(f"Model {model_id} is not loaded")
|
| 281 |
+
|
| 282 |
+
loaded = self._models[model_id]
|
| 283 |
+
info = get_backbone_info(model_id)
|
| 284 |
+
|
| 285 |
+
if info and info.backend == BackendType.GGUF:
|
| 286 |
+
raise ValueError(
|
| 287 |
+
f"Model {model_id} is GGUF (llama.cpp) and only supports CPU. "
|
| 288 |
+
"Device switching is not available for GGUF models."
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
codec_id = loaded.codec_id
|
| 292 |
+
bb_device = backbone_device or loaded.backbone_device
|
| 293 |
+
cc_device = codec_device or loaded.codec_device
|
| 294 |
+
|
| 295 |
+
logger.info(f"Switching {model_id} device to backbone={bb_device}, codec={cc_device}")
|
| 296 |
+
await self.unload_model(model_id)
|
| 297 |
+
|
| 298 |
+
return await self.load_model_async(
|
| 299 |
+
model_id=model_id,
|
| 300 |
+
codec_id=codec_id,
|
| 301 |
+
backbone_device=bb_device,
|
| 302 |
+
codec_device=cc_device,
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
def cleanup_old_tasks(self, max_age_seconds: float = 3600) -> int:
|
| 306 |
+
"""Remove completed/errored tasks older than max_age_seconds."""
|
| 307 |
+
now = time.time()
|
| 308 |
+
to_remove = [
|
| 309 |
+
tid
|
| 310 |
+
for tid, t in self._loading_tasks.items()
|
| 311 |
+
if t.status in (ModelLoadStatus.READY, ModelLoadStatus.ERROR)
|
| 312 |
+
and t.completed_at > 0
|
| 313 |
+
and (now - t.completed_at) > max_age_seconds
|
| 314 |
+
]
|
| 315 |
+
for tid in to_remove:
|
| 316 |
+
del self._loading_tasks[tid]
|
| 317 |
+
return len(to_remove)
|
| 318 |
+
|
| 319 |
+
async def infer(
|
| 320 |
+
self,
|
| 321 |
+
model_id: str,
|
| 322 |
+
text: str,
|
| 323 |
+
ref_codes: object,
|
| 324 |
+
ref_text: str,
|
| 325 |
+
) -> np.ndarray:
|
| 326 |
+
loaded = self._get_loaded(model_id)
|
| 327 |
+
|
| 328 |
+
async with loaded.lock:
|
| 329 |
+
loop = asyncio.get_event_loop()
|
| 330 |
+
wav = await loop.run_in_executor(
|
| 331 |
+
self._executor,
|
| 332 |
+
loaded.tts_instance.infer,
|
| 333 |
+
text,
|
| 334 |
+
ref_codes,
|
| 335 |
+
ref_text,
|
| 336 |
+
)
|
| 337 |
+
return wav
|
| 338 |
+
|
| 339 |
+
async def infer_stream(
|
| 340 |
+
self,
|
| 341 |
+
model_id: str,
|
| 342 |
+
text: str,
|
| 343 |
+
ref_codes: object,
|
| 344 |
+
ref_text: str,
|
| 345 |
+
) -> AsyncGenerator[np.ndarray, None]:
|
| 346 |
+
loaded = self._get_loaded(model_id)
|
| 347 |
+
info = get_backbone_info(model_id)
|
| 348 |
+
|
| 349 |
+
if info is None or not info.supports_streaming:
|
| 350 |
+
raise ValueError(
|
| 351 |
+
f"Model {model_id} does not support streaming. "
|
| 352 |
+
"Only GGUF models support infer_stream()."
|
| 353 |
+
)
|
| 354 |
+
|
| 355 |
+
queue: asyncio.Queue[np.ndarray | None] = asyncio.Queue()
|
| 356 |
+
|
| 357 |
+
def _stream_worker() -> None:
|
| 358 |
+
try:
|
| 359 |
+
for chunk in loaded.tts_instance.infer_stream(text, ref_codes, ref_text):
|
| 360 |
+
queue.put_nowait(chunk)
|
| 361 |
+
except Exception as e:
|
| 362 |
+
logger.error(f"Streaming error for {model_id}: {e}")
|
| 363 |
+
finally:
|
| 364 |
+
queue.put_nowait(None)
|
| 365 |
+
|
| 366 |
+
async with loaded.lock:
|
| 367 |
+
loop = asyncio.get_event_loop()
|
| 368 |
+
loop.run_in_executor(self._executor, _stream_worker)
|
| 369 |
+
|
| 370 |
+
while True:
|
| 371 |
+
chunk = await queue.get()
|
| 372 |
+
if chunk is None:
|
| 373 |
+
break
|
| 374 |
+
yield chunk
|
| 375 |
+
|
| 376 |
+
async def encode_reference(self, model_id: str, audio_path: str) -> object:
|
| 377 |
+
loaded = self._get_loaded(model_id)
|
| 378 |
+
|
| 379 |
+
async with loaded.lock:
|
| 380 |
+
loop = asyncio.get_event_loop()
|
| 381 |
+
ref_codes = await loop.run_in_executor(
|
| 382 |
+
self._executor,
|
| 383 |
+
loaded.tts_instance.encode_reference,
|
| 384 |
+
audio_path,
|
| 385 |
+
)
|
| 386 |
+
return ref_codes
|
| 387 |
+
|
| 388 |
+
def _get_loaded(self, model_id: str) -> LoadedModel:
|
| 389 |
+
loaded = self._models.get(model_id)
|
| 390 |
+
if loaded is None:
|
| 391 |
+
raise ValueError(
|
| 392 |
+
f"Model {model_id} is not loaded. "
|
| 393 |
+
f"Loaded models: {list(self._models.keys())}"
|
| 394 |
+
)
|
| 395 |
+
return loaded
|
| 396 |
+
|
| 397 |
+
async def startup(self) -> None:
|
| 398 |
+
for model_id in settings.default_models_list:
|
| 399 |
+
try:
|
| 400 |
+
await self.load_model(model_id)
|
| 401 |
+
except Exception as e:
|
| 402 |
+
logger.error(f"Failed to load default model {model_id}: {e}")
|
| 403 |
+
|
| 404 |
+
async def shutdown(self) -> None:
|
| 405 |
+
model_ids = list(self._models.keys())
|
| 406 |
+
for model_id in model_ids:
|
| 407 |
+
try:
|
| 408 |
+
await self.unload_model(model_id)
|
| 409 |
+
except Exception:
|
| 410 |
+
pass
|
| 411 |
+
self._executor.shutdown(wait=False)
|
api/src/inference/text_chunker.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def split_into_sentences(text: str) -> list[str]:
|
| 7 |
+
"""Split text into sentences at sentence boundaries."""
|
| 8 |
+
# Split on sentence-ending punctuation followed by whitespace
|
| 9 |
+
parts = re.split(r'(?<=[.!?;])\s+', text.strip())
|
| 10 |
+
return [p.strip() for p in parts if p.strip()]
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def chunk_text(text: str, max_chars: int = 500) -> list[str]:
|
| 14 |
+
"""Split text into chunks suitable for TTS inference.
|
| 15 |
+
|
| 16 |
+
First splits by sentences, then groups sentences into chunks
|
| 17 |
+
that don't exceed max_chars. If a single sentence exceeds
|
| 18 |
+
max_chars, it's split at clause boundaries or word boundaries.
|
| 19 |
+
"""
|
| 20 |
+
sentences = split_into_sentences(text)
|
| 21 |
+
if not sentences:
|
| 22 |
+
return [text] if text.strip() else []
|
| 23 |
+
|
| 24 |
+
chunks: list[str] = []
|
| 25 |
+
current = ""
|
| 26 |
+
|
| 27 |
+
for sentence in sentences:
|
| 28 |
+
if len(sentence) > max_chars:
|
| 29 |
+
# Flush current
|
| 30 |
+
if current:
|
| 31 |
+
chunks.append(current)
|
| 32 |
+
current = ""
|
| 33 |
+
# Split long sentence at clause boundaries
|
| 34 |
+
sub_parts = _split_long_sentence(sentence, max_chars)
|
| 35 |
+
chunks.extend(sub_parts)
|
| 36 |
+
elif len(current) + len(sentence) + 1 > max_chars:
|
| 37 |
+
if current:
|
| 38 |
+
chunks.append(current)
|
| 39 |
+
current = sentence
|
| 40 |
+
else:
|
| 41 |
+
current = f"{current} {sentence}".strip() if current else sentence
|
| 42 |
+
|
| 43 |
+
if current:
|
| 44 |
+
chunks.append(current)
|
| 45 |
+
|
| 46 |
+
return chunks
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _split_long_sentence(sentence: str, max_chars: int) -> list[str]:
|
| 50 |
+
"""Split a long sentence at commas or word boundaries."""
|
| 51 |
+
# Try splitting at commas first
|
| 52 |
+
parts = re.split(r',\s*', sentence)
|
| 53 |
+
if len(parts) > 1:
|
| 54 |
+
result: list[str] = []
|
| 55 |
+
current = ""
|
| 56 |
+
for part in parts:
|
| 57 |
+
candidate = f"{current}, {part}".strip(", ") if current else part
|
| 58 |
+
if len(candidate) > max_chars and current:
|
| 59 |
+
result.append(current)
|
| 60 |
+
current = part
|
| 61 |
+
else:
|
| 62 |
+
current = candidate
|
| 63 |
+
if current:
|
| 64 |
+
result.append(current)
|
| 65 |
+
return result
|
| 66 |
+
|
| 67 |
+
# Fallback: split at word boundaries
|
| 68 |
+
words = sentence.split()
|
| 69 |
+
result = []
|
| 70 |
+
current = ""
|
| 71 |
+
for word in words:
|
| 72 |
+
candidate = f"{current} {word}".strip() if current else word
|
| 73 |
+
if len(candidate) > max_chars and current:
|
| 74 |
+
result.append(current)
|
| 75 |
+
current = word
|
| 76 |
+
else:
|
| 77 |
+
current = candidate
|
| 78 |
+
if current:
|
| 79 |
+
result.append(current)
|
| 80 |
+
|
| 81 |
+
return result
|
api/src/inference/voice_manager.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import shutil
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
from loguru import logger
|
| 8 |
+
|
| 9 |
+
from api.src.core.config import settings
|
| 10 |
+
from api.src.core.model_config import BUILTIN_VOICES
|
| 11 |
+
from api.src.core.paths import (
|
| 12 |
+
BUILTIN_VOICES_DIR,
|
| 13 |
+
CUSTOM_VOICES_DIR,
|
| 14 |
+
ensure_voice_dirs,
|
| 15 |
+
get_voice_codes,
|
| 16 |
+
get_voice_text,
|
| 17 |
+
get_voice_wav,
|
| 18 |
+
is_custom_voice,
|
| 19 |
+
voice_codes_path,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class VoiceManager:
|
| 24 |
+
_instance: VoiceManager | None = None
|
| 25 |
+
|
| 26 |
+
def __init__(self) -> None:
|
| 27 |
+
self._voices: dict[str, dict] = {}
|
| 28 |
+
|
| 29 |
+
@classmethod
|
| 30 |
+
def get_instance(cls) -> VoiceManager:
|
| 31 |
+
if cls._instance is None:
|
| 32 |
+
cls._instance = cls()
|
| 33 |
+
return cls._instance
|
| 34 |
+
|
| 35 |
+
def scan_voices(self) -> None:
|
| 36 |
+
ensure_voice_dirs()
|
| 37 |
+
self._voices.clear()
|
| 38 |
+
|
| 39 |
+
# Built-in voices
|
| 40 |
+
for name, info in BUILTIN_VOICES.items():
|
| 41 |
+
wav_exists = get_voice_wav(name) is not None
|
| 42 |
+
txt_exists = get_voice_text(name) is not None
|
| 43 |
+
self._voices[name] = {
|
| 44 |
+
"name": name,
|
| 45 |
+
"language": info["language"],
|
| 46 |
+
"gender": info["gender"],
|
| 47 |
+
"description": info["description"],
|
| 48 |
+
"custom": False,
|
| 49 |
+
"available": wav_exists and txt_exists,
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
# Custom voices: scan for .wav files
|
| 53 |
+
for wav in CUSTOM_VOICES_DIR.glob("*.wav"):
|
| 54 |
+
name = wav.stem
|
| 55 |
+
if name not in self._voices:
|
| 56 |
+
txt_exists = get_voice_text(name) is not None
|
| 57 |
+
self._voices[name] = {
|
| 58 |
+
"name": name,
|
| 59 |
+
"language": "unknown",
|
| 60 |
+
"gender": "unknown",
|
| 61 |
+
"description": "Custom uploaded voice",
|
| 62 |
+
"custom": True,
|
| 63 |
+
"available": txt_exists,
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
available = sum(1 for v in self._voices.values() if v.get("available", True))
|
| 67 |
+
logger.info(
|
| 68 |
+
f"Scanned {len(self._voices)} voices ({len(BUILTIN_VOICES)} builtin, {available} available)"
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
@property
|
| 72 |
+
def voices(self) -> dict[str, dict]:
|
| 73 |
+
return self._voices
|
| 74 |
+
|
| 75 |
+
def voice_exists(self, voice_name: str) -> bool:
|
| 76 |
+
return voice_name in self._voices
|
| 77 |
+
|
| 78 |
+
def get_ref_text(self, voice_name: str) -> str:
|
| 79 |
+
txt_path = get_voice_text(voice_name)
|
| 80 |
+
if txt_path is None:
|
| 81 |
+
raise FileNotFoundError(f"No reference text found for voice '{voice_name}'")
|
| 82 |
+
return txt_path.read_text(encoding="utf-8").strip()
|
| 83 |
+
|
| 84 |
+
def get_ref_codes(self, voice_name: str, codec_id: str) -> torch.Tensor | None:
|
| 85 |
+
codes_path = get_voice_codes(voice_name, codec_id)
|
| 86 |
+
if codes_path is None:
|
| 87 |
+
return None
|
| 88 |
+
return torch.load(codes_path, map_location="cpu", weights_only=True)
|
| 89 |
+
|
| 90 |
+
async def get_or_encode_ref_codes(
|
| 91 |
+
self,
|
| 92 |
+
voice_name: str,
|
| 93 |
+
codec_id: str,
|
| 94 |
+
model_manager: object,
|
| 95 |
+
model_id: str,
|
| 96 |
+
) -> object:
|
| 97 |
+
codes = self.get_ref_codes(voice_name, codec_id)
|
| 98 |
+
if codes is not None:
|
| 99 |
+
return codes
|
| 100 |
+
|
| 101 |
+
wav_path = get_voice_wav(voice_name)
|
| 102 |
+
if wav_path is None:
|
| 103 |
+
raise FileNotFoundError(f"No WAV file found for voice '{voice_name}'")
|
| 104 |
+
|
| 105 |
+
logger.info(f"Encoding reference for voice '{voice_name}' with codec '{codec_id}'")
|
| 106 |
+
ref_codes = await model_manager.encode_reference(model_id, str(wav_path))
|
| 107 |
+
|
| 108 |
+
# Cache the encoded reference
|
| 109 |
+
custom = is_custom_voice(voice_name)
|
| 110 |
+
save_path = voice_codes_path(voice_name, codec_id, custom=custom)
|
| 111 |
+
torch.save(ref_codes, save_path)
|
| 112 |
+
logger.info(f"Cached reference codes at {save_path}")
|
| 113 |
+
|
| 114 |
+
return ref_codes
|
| 115 |
+
|
| 116 |
+
def upload_voice(
|
| 117 |
+
self,
|
| 118 |
+
voice_name: str,
|
| 119 |
+
wav_data: bytes,
|
| 120 |
+
ref_text: str,
|
| 121 |
+
language: str = "unknown",
|
| 122 |
+
gender: str = "unknown",
|
| 123 |
+
) -> Path:
|
| 124 |
+
ensure_voice_dirs()
|
| 125 |
+
wav_path = CUSTOM_VOICES_DIR / f"{voice_name}.wav"
|
| 126 |
+
txt_path = CUSTOM_VOICES_DIR / f"{voice_name}.txt"
|
| 127 |
+
|
| 128 |
+
wav_path.write_bytes(wav_data)
|
| 129 |
+
txt_path.write_text(ref_text, encoding="utf-8")
|
| 130 |
+
|
| 131 |
+
self._voices[voice_name] = {
|
| 132 |
+
"name": voice_name,
|
| 133 |
+
"language": language,
|
| 134 |
+
"gender": gender,
|
| 135 |
+
"description": "Custom uploaded voice",
|
| 136 |
+
"custom": True,
|
| 137 |
+
"available": True,
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
logger.info(f"Uploaded custom voice '{voice_name}' (lang={language}, gender={gender})")
|
| 141 |
+
return wav_path
|
| 142 |
+
|
| 143 |
+
def delete_voice(self, voice_name: str) -> None:
|
| 144 |
+
if voice_name in BUILTIN_VOICES:
|
| 145 |
+
raise ValueError(f"Cannot delete built-in voice '{voice_name}'")
|
| 146 |
+
|
| 147 |
+
if voice_name not in self._voices:
|
| 148 |
+
raise ValueError(f"Voice '{voice_name}' not found")
|
| 149 |
+
|
| 150 |
+
# Remove all files for this voice
|
| 151 |
+
for pattern in (f"{voice_name}.wav", f"{voice_name}.txt", f"{voice_name}_*.pt"):
|
| 152 |
+
for f in CUSTOM_VOICES_DIR.glob(pattern):
|
| 153 |
+
f.unlink()
|
| 154 |
+
|
| 155 |
+
self._voices.pop(voice_name, None)
|
| 156 |
+
logger.info(f"Deleted custom voice '{voice_name}'")
|
api/src/main.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import subprocess
|
| 4 |
+
import sys
|
| 5 |
+
from contextlib import asynccontextmanager
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from fastapi import FastAPI
|
| 9 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
+
from fastapi.responses import FileResponse
|
| 11 |
+
from fastapi.staticfiles import StaticFiles
|
| 12 |
+
from loguru import logger
|
| 13 |
+
|
| 14 |
+
from api.src.core.config import settings
|
| 15 |
+
from api.src.inference.model_manager import ModelManager
|
| 16 |
+
from api.src.inference.voice_manager import VoiceManager
|
| 17 |
+
from api.src.services.temp_manager import cleanup_temp
|
| 18 |
+
|
| 19 |
+
# Configure loguru
|
| 20 |
+
logger.remove()
|
| 21 |
+
logger.add(
|
| 22 |
+
sys.stderr,
|
| 23 |
+
level=settings.log_level,
|
| 24 |
+
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan> - <level>{message}</level>",
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@asynccontextmanager
|
| 29 |
+
async def lifespan(app: FastAPI):
|
| 30 |
+
"""Application startup and shutdown events."""
|
| 31 |
+
logger.info("NeuTTS-FastAPI starting up...")
|
| 32 |
+
|
| 33 |
+
# Initialize voice manager
|
| 34 |
+
voice_manager = VoiceManager.get_instance()
|
| 35 |
+
voice_manager.scan_voices()
|
| 36 |
+
|
| 37 |
+
# GPU startup diagnostics
|
| 38 |
+
try:
|
| 39 |
+
import torch
|
| 40 |
+
|
| 41 |
+
torch_cuda_ok = torch.cuda.is_available()
|
| 42 |
+
if not torch_cuda_ok:
|
| 43 |
+
try:
|
| 44 |
+
result = subprocess.run(
|
| 45 |
+
["nvidia-smi", "--query-gpu=name,driver_version", "--format=csv,noheader,nounits"],
|
| 46 |
+
capture_output=True, text=True, timeout=5,
|
| 47 |
+
)
|
| 48 |
+
if result.returncode == 0 and result.stdout.strip():
|
| 49 |
+
from api.src.routers.debug import _build_gpu_fix_instructions
|
| 50 |
+
line = result.stdout.strip().split("\n")[0]
|
| 51 |
+
parts = [p.strip() for p in line.split(",")]
|
| 52 |
+
gpu_name = parts[0]
|
| 53 |
+
driver_ver = parts[1] if len(parts) > 1 else "unknown"
|
| 54 |
+
fix = _build_gpu_fix_instructions(
|
| 55 |
+
gpu_name, driver_ver, torch.__version__, torch.version.cuda,
|
| 56 |
+
)
|
| 57 |
+
logger.warning(f"GPU detected but unusable! Running on CPU only.\n{fix}")
|
| 58 |
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
| 59 |
+
pass
|
| 60 |
+
except ImportError:
|
| 61 |
+
pass
|
| 62 |
+
|
| 63 |
+
# Load default models
|
| 64 |
+
model_manager = ModelManager.get_instance()
|
| 65 |
+
await model_manager.startup()
|
| 66 |
+
|
| 67 |
+
logger.info(
|
| 68 |
+
f"Ready! Models: {list(model_manager.loaded_models.keys())}, "
|
| 69 |
+
f"Voices: {list(voice_manager.voices.keys())}"
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
yield
|
| 73 |
+
|
| 74 |
+
# Shutdown
|
| 75 |
+
logger.info("Shutting down...")
|
| 76 |
+
await model_manager.shutdown()
|
| 77 |
+
cleanup_temp()
|
| 78 |
+
logger.info("Shutdown complete")
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
app = FastAPI(
|
| 82 |
+
title="NeuTTS-FastAPI",
|
| 83 |
+
description="OpenAI-compatible Text-to-Speech API powered by NeuTTS",
|
| 84 |
+
version="0.1.0",
|
| 85 |
+
lifespan=lifespan,
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
# CORS
|
| 89 |
+
if settings.cors_enabled:
|
| 90 |
+
app.add_middleware(
|
| 91 |
+
CORSMiddleware,
|
| 92 |
+
allow_origins=settings.cors_origins_list,
|
| 93 |
+
allow_credentials=True,
|
| 94 |
+
allow_methods=["*"],
|
| 95 |
+
allow_headers=["*"],
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
# Register routers
|
| 99 |
+
from api.src.routers import (
|
| 100 |
+
debug,
|
| 101 |
+
health,
|
| 102 |
+
model_management,
|
| 103 |
+
openai_compatible,
|
| 104 |
+
voice_management,
|
| 105 |
+
websocket,
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
# model_management first so /v1/models/registry is matched before /v1/models/{model_id}
|
| 109 |
+
app.include_router(model_management.router)
|
| 110 |
+
app.include_router(openai_compatible.router)
|
| 111 |
+
app.include_router(voice_management.router)
|
| 112 |
+
app.include_router(websocket.router)
|
| 113 |
+
app.include_router(health.router)
|
| 114 |
+
app.include_router(debug.router)
|
| 115 |
+
|
| 116 |
+
# Serve Web UI
|
| 117 |
+
_static_dir = Path(__file__).parent / "static"
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
@app.get("/", include_in_schema=False)
|
| 121 |
+
async def web_ui():
|
| 122 |
+
return FileResponse(_static_dir / "index.html")
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
app.mount("/static", StaticFiles(directory=str(_static_dir)), name="static")
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
if __name__ == "__main__":
|
| 129 |
+
import uvicorn
|
| 130 |
+
|
| 131 |
+
uvicorn.run(
|
| 132 |
+
"api.src.main:app",
|
| 133 |
+
host=settings.host,
|
| 134 |
+
port=settings.port,
|
| 135 |
+
log_level=settings.log_level.lower(),
|
| 136 |
+
)
|
api/src/routers/__init__.py
ADDED
|
File without changes
|
api/src/routers/debug.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import platform
|
| 4 |
+
import subprocess
|
| 5 |
+
import sys
|
| 6 |
+
|
| 7 |
+
import psutil
|
| 8 |
+
from fastapi import APIRouter
|
| 9 |
+
|
| 10 |
+
from api.src.inference.model_manager import ModelManager
|
| 11 |
+
from api.src.inference.voice_manager import VoiceManager
|
| 12 |
+
from api.src.structures.schemas import SystemDebugResponse
|
| 13 |
+
|
| 14 |
+
router = APIRouter(prefix="/debug", tags=["Debug"])
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _nvidia_smi_info() -> dict | None:
|
| 18 |
+
"""Run nvidia-smi and return GPU name + driver version, or None."""
|
| 19 |
+
try:
|
| 20 |
+
result = subprocess.run(
|
| 21 |
+
["nvidia-smi", "--query-gpu=name,driver_version", "--format=csv,noheader,nounits"],
|
| 22 |
+
capture_output=True, text=True, timeout=5,
|
| 23 |
+
)
|
| 24 |
+
if result.returncode == 0 and result.stdout.strip():
|
| 25 |
+
line = result.stdout.strip().split("\n")[0]
|
| 26 |
+
parts = [p.strip() for p in line.split(",")]
|
| 27 |
+
return {"gpu_name": parts[0], "driver_version": parts[1] if len(parts) > 1 else "unknown"}
|
| 28 |
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
| 29 |
+
pass
|
| 30 |
+
return None
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _build_gpu_fix_instructions(
|
| 34 |
+
gpu_name: str,
|
| 35 |
+
driver_version: str,
|
| 36 |
+
torch_ver: str | None,
|
| 37 |
+
cuda_ver: str | None,
|
| 38 |
+
) -> str:
|
| 39 |
+
"""Build OS-specific GPU fix instructions."""
|
| 40 |
+
os_name = platform.system() # "Windows", "Linux", "Darwin"
|
| 41 |
+
is_docker = _is_running_in_docker()
|
| 42 |
+
python_ver = f"{sys.version_info.major}.{sys.version_info.minor}"
|
| 43 |
+
|
| 44 |
+
# Detect GPU generation from name
|
| 45 |
+
is_blackwell = any(x in gpu_name.upper() for x in ("RTX 50", "RTX50", "BLACKWELL", "GB2"))
|
| 46 |
+
is_ada = any(x in gpu_name.upper() for x in ("RTX 40", "RTX40", "ADA"))
|
| 47 |
+
needs_cu128 = is_blackwell
|
| 48 |
+
min_torch = "2.6.0" if is_blackwell else "2.1.0"
|
| 49 |
+
cu_tag = "cu128" if needs_cu128 else "cu124"
|
| 50 |
+
|
| 51 |
+
# Diagnosis
|
| 52 |
+
lines = [
|
| 53 |
+
f"GPU '{gpu_name}' detected (driver {driver_version}) but PyTorch cannot use it.",
|
| 54 |
+
f"Installed: torch=={torch_ver or 'not installed'}, CUDA=={cuda_ver or 'none'}.",
|
| 55 |
+
]
|
| 56 |
+
|
| 57 |
+
if torch_ver is None:
|
| 58 |
+
lines.append("PyTorch is not installed at all.")
|
| 59 |
+
elif cuda_ver is None:
|
| 60 |
+
lines.append("PyTorch is installed but was built without CUDA (CPU-only build).")
|
| 61 |
+
elif is_blackwell and cuda_ver and cuda_ver < "12.8":
|
| 62 |
+
lines.append(f"RTX 50xx (Blackwell) requires CUDA >= 12.8, but torch has CUDA {cuda_ver}.")
|
| 63 |
+
else:
|
| 64 |
+
lines.append("PyTorch CUDA version may not match your GPU architecture.")
|
| 65 |
+
|
| 66 |
+
lines.append("")
|
| 67 |
+
|
| 68 |
+
# OS-specific fix
|
| 69 |
+
if is_docker:
|
| 70 |
+
lines.append("Fix (Docker): Rebuild with the updated Dockerfile (CUDA 12.8.0 base image):")
|
| 71 |
+
lines.append(" docker build -f docker/gpu/Dockerfile -t neutts-gpu .")
|
| 72 |
+
elif os_name == "Windows":
|
| 73 |
+
lines.append(f"Fix (Windows, Python {python_ver}):")
|
| 74 |
+
lines.append(f" pip install torch>={min_torch} --index-url https://download.pytorch.org/whl/{cu_tag}")
|
| 75 |
+
lines.append("")
|
| 76 |
+
lines.append("Make sure you have the latest NVIDIA driver installed:")
|
| 77 |
+
lines.append(" https://www.nvidia.com/Download/index.aspx")
|
| 78 |
+
if is_blackwell:
|
| 79 |
+
lines.append(" RTX 50xx requires driver >= 572.16")
|
| 80 |
+
elif os_name == "Linux":
|
| 81 |
+
lines.append(f"Fix (Linux, Python {python_ver}):")
|
| 82 |
+
lines.append(f" pip install torch>={min_torch} --index-url https://download.pytorch.org/whl/{cu_tag}")
|
| 83 |
+
lines.append("")
|
| 84 |
+
lines.append("Or with conda:")
|
| 85 |
+
lines.append(f" conda install pytorch>={min_torch} pytorch-cuda=12.8 -c pytorch -c nvidia")
|
| 86 |
+
lines.append("")
|
| 87 |
+
lines.append("Verify NVIDIA driver: nvidia-smi")
|
| 88 |
+
if is_blackwell:
|
| 89 |
+
lines.append(" RTX 50xx requires driver >= 572.16")
|
| 90 |
+
else:
|
| 91 |
+
lines.append(f"Fix: pip install torch>={min_torch} --index-url https://download.pytorch.org/whl/{cu_tag}")
|
| 92 |
+
|
| 93 |
+
lines.append("")
|
| 94 |
+
lines.append("After installing, restart NeuTTS-FastAPI.")
|
| 95 |
+
|
| 96 |
+
return "\n".join(lines)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _is_running_in_docker() -> bool:
|
| 100 |
+
"""Check if we're running inside a Docker container."""
|
| 101 |
+
try:
|
| 102 |
+
with open("/proc/1/cgroup", "r") as f:
|
| 103 |
+
return "docker" in f.read()
|
| 104 |
+
except (FileNotFoundError, PermissionError):
|
| 105 |
+
pass
|
| 106 |
+
try:
|
| 107 |
+
from pathlib import Path
|
| 108 |
+
return Path("/.dockerenv").exists()
|
| 109 |
+
except Exception:
|
| 110 |
+
pass
|
| 111 |
+
return False
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
@router.get("/system", response_model=SystemDebugResponse)
|
| 115 |
+
async def system_info() -> SystemDebugResponse:
|
| 116 |
+
"""Return system resource usage and loaded model info."""
|
| 117 |
+
model_manager = ModelManager.get_instance()
|
| 118 |
+
voice_manager = VoiceManager.get_instance()
|
| 119 |
+
|
| 120 |
+
mem = psutil.virtual_memory()
|
| 121 |
+
|
| 122 |
+
gpu_available = False
|
| 123 |
+
gpu_info = None
|
| 124 |
+
torch_version = None
|
| 125 |
+
cuda_version = None
|
| 126 |
+
cuda_driver_version = None
|
| 127 |
+
gpu_detected_but_unusable = False
|
| 128 |
+
gpu_fix_instructions = None
|
| 129 |
+
|
| 130 |
+
try:
|
| 131 |
+
import torch
|
| 132 |
+
|
| 133 |
+
torch_version = torch.__version__
|
| 134 |
+
cuda_version = torch.version.cuda
|
| 135 |
+
|
| 136 |
+
if torch.cuda.is_available():
|
| 137 |
+
gpu_available = True
|
| 138 |
+
gpu_info = []
|
| 139 |
+
for i in range(torch.cuda.device_count()):
|
| 140 |
+
props = torch.cuda.get_device_properties(i)
|
| 141 |
+
allocated = torch.cuda.memory_allocated(i) / (1024**3)
|
| 142 |
+
total = props.total_mem / (1024**3)
|
| 143 |
+
gpu_info.append({
|
| 144 |
+
"index": i,
|
| 145 |
+
"name": props.name,
|
| 146 |
+
"total_gb": round(total, 2),
|
| 147 |
+
"allocated_gb": round(allocated, 2),
|
| 148 |
+
})
|
| 149 |
+
except ImportError:
|
| 150 |
+
pass
|
| 151 |
+
|
| 152 |
+
# Check nvidia-smi for GPU detection even if torch can't use it
|
| 153 |
+
smi = _nvidia_smi_info()
|
| 154 |
+
if smi:
|
| 155 |
+
cuda_driver_version = smi["driver_version"]
|
| 156 |
+
if not gpu_available:
|
| 157 |
+
gpu_detected_but_unusable = True
|
| 158 |
+
gpu_fix_instructions = _build_gpu_fix_instructions(
|
| 159 |
+
smi["gpu_name"], smi["driver_version"], torch_version, cuda_version,
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
return SystemDebugResponse(
|
| 163 |
+
cpu_count=psutil.cpu_count() or 0,
|
| 164 |
+
cpu_percent=psutil.cpu_percent(),
|
| 165 |
+
memory_total_gb=round(mem.total / (1024**3), 2),
|
| 166 |
+
memory_used_gb=round(mem.used / (1024**3), 2),
|
| 167 |
+
memory_percent=mem.percent,
|
| 168 |
+
gpu_available=gpu_available,
|
| 169 |
+
gpu_info=gpu_info,
|
| 170 |
+
torch_version=torch_version,
|
| 171 |
+
cuda_version=cuda_version,
|
| 172 |
+
cuda_driver_version=cuda_driver_version,
|
| 173 |
+
gpu_detected_but_unusable=gpu_detected_but_unusable,
|
| 174 |
+
gpu_fix_instructions=gpu_fix_instructions,
|
| 175 |
+
models_loaded=list(model_manager.loaded_models.keys()),
|
| 176 |
+
voices_available=len(voice_manager.voices),
|
| 177 |
+
)
|
api/src/routers/health.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter
|
| 4 |
+
|
| 5 |
+
from api.src.inference.model_manager import ModelManager
|
| 6 |
+
from api.src.structures.schemas import HealthResponse
|
| 7 |
+
|
| 8 |
+
router = APIRouter(tags=["Health"])
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@router.get("/health", response_model=HealthResponse)
|
| 12 |
+
async def health_check() -> HealthResponse:
|
| 13 |
+
model_manager = ModelManager.get_instance()
|
| 14 |
+
return HealthResponse(
|
| 15 |
+
status="ok",
|
| 16 |
+
version="0.1.0",
|
| 17 |
+
models_loaded=len(model_manager.loaded_models),
|
| 18 |
+
)
|
api/src/routers/model_management.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, HTTPException
|
| 6 |
+
from loguru import logger
|
| 7 |
+
|
| 8 |
+
from api.src.core.model_config import BACKBONE_MODELS, CODEC_MODELS, get_backbone_info
|
| 9 |
+
from api.src.inference.model_manager import ModelLoadingTask, ModelManager
|
| 10 |
+
from api.src.structures.schemas import (
|
| 11 |
+
LoadedModelInfo,
|
| 12 |
+
LoadedModelsResponse,
|
| 13 |
+
LoadModelRequest,
|
| 14 |
+
ModelLoadTaskResponse,
|
| 15 |
+
ModelRegistryResponse,
|
| 16 |
+
RegistryModelInfo,
|
| 17 |
+
SwitchDeviceRequest,
|
| 18 |
+
UnloadModelResponse,
|
| 19 |
+
make_error,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
router = APIRouter(prefix="/v1/models", tags=["Model Management"])
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _task_to_response(task: ModelLoadingTask) -> ModelLoadTaskResponse:
|
| 26 |
+
elapsed = 0.0
|
| 27 |
+
if task.started_at > 0:
|
| 28 |
+
end = task.completed_at if task.completed_at > 0 else time.time()
|
| 29 |
+
elapsed = round(end - task.started_at, 2)
|
| 30 |
+
return ModelLoadTaskResponse(
|
| 31 |
+
task_id=task.task_id,
|
| 32 |
+
model_id=task.model_id,
|
| 33 |
+
status=task.status.value,
|
| 34 |
+
progress_message=task.progress_message,
|
| 35 |
+
error_message=task.error_message,
|
| 36 |
+
elapsed_seconds=elapsed,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@router.post("/load", response_model=ModelLoadTaskResponse)
|
| 41 |
+
async def load_model(request: LoadModelRequest) -> ModelLoadTaskResponse:
|
| 42 |
+
"""Start loading a model (non-blocking). Returns a task for polling."""
|
| 43 |
+
model_manager = ModelManager.get_instance()
|
| 44 |
+
info = get_backbone_info(request.model_id)
|
| 45 |
+
|
| 46 |
+
if info is None:
|
| 47 |
+
raise HTTPException(
|
| 48 |
+
status_code=400,
|
| 49 |
+
detail=make_error(
|
| 50 |
+
f"Unknown model '{request.model_id}'. "
|
| 51 |
+
f"Available: {list(BACKBONE_MODELS.keys())}"
|
| 52 |
+
),
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
try:
|
| 56 |
+
task = await model_manager.load_model_async(
|
| 57 |
+
model_id=request.model_id,
|
| 58 |
+
codec_id=request.codec,
|
| 59 |
+
backbone_device=request.backbone_device,
|
| 60 |
+
codec_device=request.codec_device,
|
| 61 |
+
)
|
| 62 |
+
# Cleanup old tasks opportunistically
|
| 63 |
+
model_manager.cleanup_old_tasks()
|
| 64 |
+
return _task_to_response(task)
|
| 65 |
+
except Exception as e:
|
| 66 |
+
logger.error(f"Failed to start loading model {request.model_id}: {e}")
|
| 67 |
+
raise HTTPException(status_code=500, detail=make_error(str(e), "server_error", 500))
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@router.get("/load/{task_id}", response_model=ModelLoadTaskResponse)
|
| 71 |
+
async def get_load_status(task_id: str) -> ModelLoadTaskResponse:
|
| 72 |
+
"""Poll the status of a model loading task."""
|
| 73 |
+
model_manager = ModelManager.get_instance()
|
| 74 |
+
task = model_manager.get_task(task_id)
|
| 75 |
+
|
| 76 |
+
if task is None:
|
| 77 |
+
raise HTTPException(
|
| 78 |
+
status_code=404,
|
| 79 |
+
detail=make_error(f"Task '{task_id}' not found"),
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
return _task_to_response(task)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@router.get("/loaded", response_model=LoadedModelsResponse)
|
| 86 |
+
async def get_loaded_models() -> LoadedModelsResponse:
|
| 87 |
+
"""List all loaded models with device details."""
|
| 88 |
+
model_manager = ModelManager.get_instance()
|
| 89 |
+
models = []
|
| 90 |
+
|
| 91 |
+
for model_id, loaded in model_manager.loaded_models.items():
|
| 92 |
+
info = get_backbone_info(model_id)
|
| 93 |
+
models.append(LoadedModelInfo(
|
| 94 |
+
model_id=model_id,
|
| 95 |
+
codec=loaded.codec_id,
|
| 96 |
+
backbone_device=loaded.backbone_device,
|
| 97 |
+
codec_device=loaded.codec_device,
|
| 98 |
+
language=info.language if info else None,
|
| 99 |
+
backend=info.backend.value if info else None,
|
| 100 |
+
supports_streaming=info.supports_streaming if info else False,
|
| 101 |
+
))
|
| 102 |
+
|
| 103 |
+
return LoadedModelsResponse(models=models)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
@router.post("/{model_id}/switch-device", response_model=ModelLoadTaskResponse)
|
| 107 |
+
async def switch_device(model_id: str, request: SwitchDeviceRequest) -> ModelLoadTaskResponse:
|
| 108 |
+
"""Switch a loaded model to a different device (CPU <-> GPU)."""
|
| 109 |
+
model_manager = ModelManager.get_instance()
|
| 110 |
+
|
| 111 |
+
try:
|
| 112 |
+
task = await model_manager.switch_device(
|
| 113 |
+
model_id=model_id,
|
| 114 |
+
backbone_device=request.backbone_device,
|
| 115 |
+
codec_device=request.codec_device,
|
| 116 |
+
)
|
| 117 |
+
return _task_to_response(task)
|
| 118 |
+
except ValueError as e:
|
| 119 |
+
raise HTTPException(status_code=400, detail=make_error(str(e)))
|
| 120 |
+
except Exception as e:
|
| 121 |
+
logger.error(f"Failed to switch device for {model_id}: {e}")
|
| 122 |
+
raise HTTPException(status_code=500, detail=make_error(str(e), "server_error", 500))
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
@router.delete("/{model_id}", response_model=UnloadModelResponse)
|
| 126 |
+
async def unload_model(model_id: str) -> UnloadModelResponse:
|
| 127 |
+
"""Unload a model from memory."""
|
| 128 |
+
model_manager = ModelManager.get_instance()
|
| 129 |
+
|
| 130 |
+
try:
|
| 131 |
+
await model_manager.unload_model(model_id)
|
| 132 |
+
except ValueError as e:
|
| 133 |
+
raise HTTPException(status_code=400, detail=make_error(str(e)))
|
| 134 |
+
|
| 135 |
+
return UnloadModelResponse(model_id=model_id, status="unloaded")
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@router.get("/registry", response_model=ModelRegistryResponse)
|
| 139 |
+
async def get_registry() -> ModelRegistryResponse:
|
| 140 |
+
"""List all available models (not just loaded ones)."""
|
| 141 |
+
model_manager = ModelManager.get_instance()
|
| 142 |
+
|
| 143 |
+
backbones = [
|
| 144 |
+
RegistryModelInfo(
|
| 145 |
+
model_id=info.model_id,
|
| 146 |
+
repo=info.repo,
|
| 147 |
+
language=info.language,
|
| 148 |
+
backend=info.backend.value,
|
| 149 |
+
supports_streaming=info.supports_streaming,
|
| 150 |
+
description=info.description,
|
| 151 |
+
loaded=model_manager.is_loaded(info.model_id),
|
| 152 |
+
)
|
| 153 |
+
for info in BACKBONE_MODELS.values()
|
| 154 |
+
]
|
| 155 |
+
|
| 156 |
+
codecs = [
|
| 157 |
+
{
|
| 158 |
+
"codec_id": c.codec_id,
|
| 159 |
+
"repo": c.repo,
|
| 160 |
+
"type": c.codec_type,
|
| 161 |
+
"description": c.description,
|
| 162 |
+
}
|
| 163 |
+
for c in CODEC_MODELS.values()
|
| 164 |
+
]
|
| 165 |
+
|
| 166 |
+
return ModelRegistryResponse(backbones=backbones, codecs=codecs)
|
api/src/routers/openai_compatible.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, HTTPException
|
| 6 |
+
from fastapi.responses import Response, StreamingResponse
|
| 7 |
+
from loguru import logger
|
| 8 |
+
|
| 9 |
+
from api.src.core.model_config import get_backbone_info
|
| 10 |
+
from api.src.inference.model_manager import ModelManager
|
| 11 |
+
from api.src.inference.voice_manager import VoiceManager
|
| 12 |
+
from api.src.services.streaming_audio_writer import get_content_type
|
| 13 |
+
from api.src.services.tts_service import TTSService
|
| 14 |
+
from api.src.structures.schemas import (
|
| 15 |
+
ModelDetailResponse,
|
| 16 |
+
ModelInfo,
|
| 17 |
+
ModelListResponse,
|
| 18 |
+
OpenAISpeechRequest,
|
| 19 |
+
VoiceInfo,
|
| 20 |
+
VoiceListResponse,
|
| 21 |
+
make_error,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
router = APIRouter(prefix="/v1", tags=["OpenAI Compatible"])
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@router.post("/audio/speech")
|
| 28 |
+
async def create_speech(request: OpenAISpeechRequest) -> Response:
|
| 29 |
+
"""OpenAI-compatible TTS endpoint."""
|
| 30 |
+
model_manager = ModelManager.get_instance()
|
| 31 |
+
voice_manager = VoiceManager.get_instance()
|
| 32 |
+
tts_service = TTSService.get_instance()
|
| 33 |
+
|
| 34 |
+
# Validate model
|
| 35 |
+
if not model_manager.is_loaded(request.model):
|
| 36 |
+
raise HTTPException(status_code=400, detail=make_error(
|
| 37 |
+
f"Model '{request.model}' is not loaded. "
|
| 38 |
+
f"Available: {list(model_manager.loaded_models.keys())}"
|
| 39 |
+
))
|
| 40 |
+
|
| 41 |
+
# Validate voice
|
| 42 |
+
if not voice_manager.voice_exists(request.voice):
|
| 43 |
+
raise HTTPException(status_code=400, detail=make_error(
|
| 44 |
+
f"Voice '{request.voice}' not found. "
|
| 45 |
+
f"Available: {list(voice_manager.voices.keys())}"
|
| 46 |
+
))
|
| 47 |
+
|
| 48 |
+
content_type = get_content_type(request.response_format)
|
| 49 |
+
|
| 50 |
+
try:
|
| 51 |
+
if request.stream:
|
| 52 |
+
return StreamingResponse(
|
| 53 |
+
tts_service.stream_speech(request),
|
| 54 |
+
media_type=content_type,
|
| 55 |
+
headers={
|
| 56 |
+
"Content-Type": content_type,
|
| 57 |
+
"Transfer-Encoding": "chunked",
|
| 58 |
+
},
|
| 59 |
+
)
|
| 60 |
+
else:
|
| 61 |
+
start = time.perf_counter()
|
| 62 |
+
audio_data = await tts_service.generate_speech(request)
|
| 63 |
+
elapsed = time.perf_counter() - start
|
| 64 |
+
logger.info(
|
| 65 |
+
f"TTS: model={request.model} voice={request.voice} "
|
| 66 |
+
f"format={request.response_format} chars={len(request.input)} "
|
| 67 |
+
f"time={elapsed:.2f}s size={len(audio_data)} bytes"
|
| 68 |
+
)
|
| 69 |
+
return Response(
|
| 70 |
+
content=audio_data,
|
| 71 |
+
media_type=content_type,
|
| 72 |
+
headers={"Content-Type": content_type},
|
| 73 |
+
)
|
| 74 |
+
except Exception as e:
|
| 75 |
+
logger.error(f"TTS generation failed: {e}")
|
| 76 |
+
raise HTTPException(status_code=500, detail=make_error(str(e), "server_error", 500))
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@router.get("/audio/voices", response_model=VoiceListResponse)
|
| 80 |
+
async def list_voices() -> VoiceListResponse:
|
| 81 |
+
"""List available voices."""
|
| 82 |
+
voice_manager = VoiceManager.get_instance()
|
| 83 |
+
voices = [
|
| 84 |
+
VoiceInfo(
|
| 85 |
+
voice_id=name,
|
| 86 |
+
name=name,
|
| 87 |
+
language=info["language"],
|
| 88 |
+
gender=info["gender"],
|
| 89 |
+
custom=info.get("custom", False),
|
| 90 |
+
available=info.get("available", True),
|
| 91 |
+
)
|
| 92 |
+
for name, info in voice_manager.voices.items()
|
| 93 |
+
]
|
| 94 |
+
return VoiceListResponse(voices=voices)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@router.get("/models", response_model=ModelListResponse)
|
| 98 |
+
async def list_models() -> ModelListResponse:
|
| 99 |
+
"""List loaded models (OpenAI-compatible)."""
|
| 100 |
+
model_manager = ModelManager.get_instance()
|
| 101 |
+
models = []
|
| 102 |
+
for model_id, loaded in model_manager.loaded_models.items():
|
| 103 |
+
info = get_backbone_info(model_id)
|
| 104 |
+
models.append(ModelInfo(
|
| 105 |
+
id=model_id,
|
| 106 |
+
language=info.language if info else None,
|
| 107 |
+
backend=info.backend.value if info else None,
|
| 108 |
+
supports_streaming=info.supports_streaming if info else False,
|
| 109 |
+
backbone_device=loaded.backbone_device,
|
| 110 |
+
codec_device=loaded.codec_device,
|
| 111 |
+
))
|
| 112 |
+
return ModelListResponse(data=models)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@router.get("/models/{model_id}", response_model=ModelDetailResponse)
|
| 116 |
+
async def get_model(model_id: str) -> ModelDetailResponse:
|
| 117 |
+
"""Get details about a specific model."""
|
| 118 |
+
model_manager = ModelManager.get_instance()
|
| 119 |
+
info = get_backbone_info(model_id)
|
| 120 |
+
|
| 121 |
+
if info is None:
|
| 122 |
+
raise HTTPException(status_code=404, detail=make_error(f"Model '{model_id}' not found"))
|
| 123 |
+
|
| 124 |
+
loaded = model_manager.loaded_models.get(model_id)
|
| 125 |
+
return ModelDetailResponse(
|
| 126 |
+
id=model_id,
|
| 127 |
+
language=info.language,
|
| 128 |
+
backend=info.backend.value,
|
| 129 |
+
supports_streaming=info.supports_streaming,
|
| 130 |
+
loaded=loaded is not None,
|
| 131 |
+
codec=loaded.codec_id if loaded else None,
|
| 132 |
+
)
|
api/src/routers/voice_management.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
|
| 4 |
+
from loguru import logger
|
| 5 |
+
|
| 6 |
+
from api.src.core.config import settings
|
| 7 |
+
from api.src.inference.model_manager import ModelManager
|
| 8 |
+
from api.src.inference.voice_manager import VoiceManager
|
| 9 |
+
from api.src.services.audio_utils import validate_reference_audio
|
| 10 |
+
from api.src.structures.schemas import (
|
| 11 |
+
VoiceDeleteResponse,
|
| 12 |
+
VoiceEncodeRequest,
|
| 13 |
+
VoiceEncodeResponse,
|
| 14 |
+
VoiceUploadResponse,
|
| 15 |
+
make_error,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
router = APIRouter(prefix="/v1/audio/voices", tags=["Voice Management"])
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@router.post("/upload", response_model=VoiceUploadResponse)
|
| 22 |
+
async def upload_voice(
|
| 23 |
+
voice_id: str = Form(...),
|
| 24 |
+
ref_text: str = Form(...),
|
| 25 |
+
audio: UploadFile = File(...),
|
| 26 |
+
language: str = Form("unknown"),
|
| 27 |
+
gender: str = Form("unknown"),
|
| 28 |
+
) -> VoiceUploadResponse:
|
| 29 |
+
"""Upload a custom voice reference (WAV + transcription text)."""
|
| 30 |
+
if not settings.allow_voice_upload:
|
| 31 |
+
raise HTTPException(status_code=403, detail=make_error("Voice upload is disabled"))
|
| 32 |
+
|
| 33 |
+
voice_manager = VoiceManager.get_instance()
|
| 34 |
+
|
| 35 |
+
# Check name is valid
|
| 36 |
+
if not voice_id.isalnum() and not all(c.isalnum() or c in "-_" for c in voice_id):
|
| 37 |
+
raise HTTPException(
|
| 38 |
+
status_code=400,
|
| 39 |
+
detail=make_error("Voice ID must be alphanumeric (hyphens/underscores allowed)"),
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
if voice_manager.voice_exists(voice_id):
|
| 43 |
+
raise HTTPException(
|
| 44 |
+
status_code=409,
|
| 45 |
+
detail=make_error(f"Voice '{voice_id}' already exists"),
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
# Read and validate audio
|
| 49 |
+
wav_data = await audio.read()
|
| 50 |
+
try:
|
| 51 |
+
props = validate_reference_audio(wav_data)
|
| 52 |
+
except ValueError as e:
|
| 53 |
+
raise HTTPException(status_code=400, detail=make_error(str(e)))
|
| 54 |
+
|
| 55 |
+
voice_manager.upload_voice(voice_id, wav_data, ref_text, language=language, gender=gender)
|
| 56 |
+
|
| 57 |
+
return VoiceUploadResponse(
|
| 58 |
+
voice_id=voice_id,
|
| 59 |
+
status="uploaded",
|
| 60 |
+
message=f"Voice uploaded ({props['duration']:.1f}s, {props['sample_rate']}Hz)",
|
| 61 |
+
language=language,
|
| 62 |
+
gender=gender,
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@router.post("/{voice_id}/encode", response_model=VoiceEncodeResponse)
|
| 67 |
+
async def encode_voice(voice_id: str, request: VoiceEncodeRequest | None = None) -> VoiceEncodeResponse:
|
| 68 |
+
"""Pre-encode a voice reference for a specific codec."""
|
| 69 |
+
voice_manager = VoiceManager.get_instance()
|
| 70 |
+
model_manager = ModelManager.get_instance()
|
| 71 |
+
|
| 72 |
+
if not voice_manager.voice_exists(voice_id):
|
| 73 |
+
raise HTTPException(status_code=404, detail=make_error(f"Voice '{voice_id}' not found"))
|
| 74 |
+
|
| 75 |
+
# Use first loaded model to encode
|
| 76 |
+
if not model_manager.loaded_models:
|
| 77 |
+
raise HTTPException(
|
| 78 |
+
status_code=400,
|
| 79 |
+
detail=make_error("No models loaded. Load a model first."),
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
model_id = next(iter(model_manager.loaded_models))
|
| 83 |
+
loaded = model_manager.loaded_models[model_id]
|
| 84 |
+
codec = (request.codec if request and request.codec else loaded.codec_id)
|
| 85 |
+
|
| 86 |
+
try:
|
| 87 |
+
await voice_manager.get_or_encode_ref_codes(
|
| 88 |
+
voice_id, codec, model_manager, model_id
|
| 89 |
+
)
|
| 90 |
+
except Exception as e:
|
| 91 |
+
raise HTTPException(status_code=500, detail=make_error(str(e), "server_error", 500))
|
| 92 |
+
|
| 93 |
+
return VoiceEncodeResponse(voice_id=voice_id, codec=codec, status="encoded")
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@router.delete("/{voice_id}", response_model=VoiceDeleteResponse)
|
| 97 |
+
async def delete_voice(voice_id: str) -> VoiceDeleteResponse:
|
| 98 |
+
"""Delete a custom voice."""
|
| 99 |
+
voice_manager = VoiceManager.get_instance()
|
| 100 |
+
|
| 101 |
+
try:
|
| 102 |
+
voice_manager.delete_voice(voice_id)
|
| 103 |
+
except ValueError as e:
|
| 104 |
+
raise HTTPException(status_code=400, detail=make_error(str(e)))
|
| 105 |
+
|
| 106 |
+
return VoiceDeleteResponse(voice_id=voice_id, status="deleted")
|
api/src/routers/websocket.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import json
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
| 7 |
+
from loguru import logger
|
| 8 |
+
|
| 9 |
+
from api.src.core.config import settings
|
| 10 |
+
from api.src.inference.model_manager import ModelManager
|
| 11 |
+
from api.src.inference.voice_manager import VoiceManager
|
| 12 |
+
from api.src.services.streaming_audio_writer import StreamingAudioWriter
|
| 13 |
+
from api.src.structures.websocket_schemas import (
|
| 14 |
+
WSResponseMessage,
|
| 15 |
+
WSStartMessage,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
router = APIRouter(tags=["WebSocket"])
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@router.websocket("/v1/audio/speech/stream")
|
| 22 |
+
async def websocket_tts(ws: WebSocket) -> None:
|
| 23 |
+
"""WebSocket endpoint for real-time TTS streaming.
|
| 24 |
+
|
| 25 |
+
Protocol:
|
| 26 |
+
1. Client sends: {"type": "start", "model": "...", "voice": "...", "response_format": "pcm"}
|
| 27 |
+
2. Client sends: {"type": "text", "text": "Hello world"}
|
| 28 |
+
3. Server streams: {"type": "audio", "data": "<base64>", "format": "pcm"}
|
| 29 |
+
4. Server sends: {"type": "done"}
|
| 30 |
+
5. Client sends: {"type": "stop"} or more text messages
|
| 31 |
+
"""
|
| 32 |
+
await ws.accept()
|
| 33 |
+
logger.info("WebSocket connection accepted")
|
| 34 |
+
|
| 35 |
+
model_manager = ModelManager.get_instance()
|
| 36 |
+
voice_manager = VoiceManager.get_instance()
|
| 37 |
+
|
| 38 |
+
session_config: WSStartMessage | None = None
|
| 39 |
+
|
| 40 |
+
try:
|
| 41 |
+
while True:
|
| 42 |
+
raw = await ws.receive_text()
|
| 43 |
+
msg = json.loads(raw)
|
| 44 |
+
msg_type = msg.get("type")
|
| 45 |
+
|
| 46 |
+
if msg_type == "ping":
|
| 47 |
+
await ws.send_text(json.dumps({"type": "pong"}))
|
| 48 |
+
continue
|
| 49 |
+
|
| 50 |
+
if msg_type == "start":
|
| 51 |
+
session_config = WSStartMessage(**msg)
|
| 52 |
+
|
| 53 |
+
# Validate model
|
| 54 |
+
if not model_manager.is_loaded(session_config.model):
|
| 55 |
+
await _send_error(ws, f"Model '{session_config.model}' is not loaded")
|
| 56 |
+
continue
|
| 57 |
+
|
| 58 |
+
if not voice_manager.voice_exists(session_config.voice):
|
| 59 |
+
await _send_error(ws, f"Voice '{session_config.voice}' not found")
|
| 60 |
+
continue
|
| 61 |
+
|
| 62 |
+
logger.info(
|
| 63 |
+
f"WS session started: model={session_config.model} "
|
| 64 |
+
f"voice={session_config.voice} format={session_config.response_format}"
|
| 65 |
+
)
|
| 66 |
+
continue
|
| 67 |
+
|
| 68 |
+
if msg_type == "text":
|
| 69 |
+
if session_config is None:
|
| 70 |
+
await _send_error(ws, "Send a 'start' message first")
|
| 71 |
+
continue
|
| 72 |
+
|
| 73 |
+
text = msg.get("text", "").strip()
|
| 74 |
+
if not text:
|
| 75 |
+
await _send_error(ws, "Empty text")
|
| 76 |
+
continue
|
| 77 |
+
|
| 78 |
+
await _handle_text(ws, session_config, text, model_manager, voice_manager)
|
| 79 |
+
|
| 80 |
+
elif msg_type == "stop":
|
| 81 |
+
logger.info("WS session stopped by client")
|
| 82 |
+
break
|
| 83 |
+
|
| 84 |
+
except WebSocketDisconnect:
|
| 85 |
+
logger.info("WebSocket disconnected")
|
| 86 |
+
except Exception as e:
|
| 87 |
+
logger.error(f"WebSocket error: {e}")
|
| 88 |
+
try:
|
| 89 |
+
await _send_error(ws, str(e))
|
| 90 |
+
except Exception:
|
| 91 |
+
pass
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
async def _handle_text(
|
| 95 |
+
ws: WebSocket,
|
| 96 |
+
config: WSStartMessage,
|
| 97 |
+
text: str,
|
| 98 |
+
model_manager: ModelManager,
|
| 99 |
+
voice_manager: VoiceManager,
|
| 100 |
+
) -> None:
|
| 101 |
+
loaded = model_manager.loaded_models[config.model]
|
| 102 |
+
ref_codes = await voice_manager.get_or_encode_ref_codes(
|
| 103 |
+
config.voice, loaded.codec_id, model_manager, config.model
|
| 104 |
+
)
|
| 105 |
+
ref_text = voice_manager.get_ref_text(config.voice)
|
| 106 |
+
|
| 107 |
+
from api.src.core.model_config import get_backbone_info
|
| 108 |
+
|
| 109 |
+
info = get_backbone_info(config.model)
|
| 110 |
+
writer = StreamingAudioWriter(config.response_format, settings.sample_rate)
|
| 111 |
+
|
| 112 |
+
try:
|
| 113 |
+
if info and info.supports_streaming:
|
| 114 |
+
async for chunk in model_manager.infer_stream(
|
| 115 |
+
config.model, text, ref_codes, ref_text
|
| 116 |
+
):
|
| 117 |
+
encoded = writer.write_chunk(chunk)
|
| 118 |
+
if encoded:
|
| 119 |
+
await ws.send_text(json.dumps({
|
| 120 |
+
"type": "audio",
|
| 121 |
+
"data": base64.b64encode(encoded).decode(),
|
| 122 |
+
"format": config.response_format,
|
| 123 |
+
}))
|
| 124 |
+
else:
|
| 125 |
+
wav = await model_manager.infer(config.model, text, ref_codes, ref_text)
|
| 126 |
+
encoded = writer.write_chunk(wav)
|
| 127 |
+
if encoded:
|
| 128 |
+
await ws.send_text(json.dumps({
|
| 129 |
+
"type": "audio",
|
| 130 |
+
"data": base64.b64encode(encoded).decode(),
|
| 131 |
+
"format": config.response_format,
|
| 132 |
+
}))
|
| 133 |
+
|
| 134 |
+
final = writer.finalize()
|
| 135 |
+
if final:
|
| 136 |
+
await ws.send_text(json.dumps({
|
| 137 |
+
"type": "audio",
|
| 138 |
+
"data": base64.b64encode(final).decode(),
|
| 139 |
+
"format": config.response_format,
|
| 140 |
+
}))
|
| 141 |
+
|
| 142 |
+
await ws.send_text(json.dumps({"type": "done"}))
|
| 143 |
+
|
| 144 |
+
finally:
|
| 145 |
+
writer.close()
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
async def _send_error(ws: WebSocket, message: str) -> None:
|
| 149 |
+
await ws.send_text(json.dumps({"type": "error", "message": message}))
|
api/src/services/__init__.py
ADDED
|
File without changes
|
api/src/services/audio_utils.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import io
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import soundfile as sf
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def validate_wav(data: bytes) -> dict:
|
| 10 |
+
"""Validate a WAV file and return its properties."""
|
| 11 |
+
buf = io.BytesIO(data)
|
| 12 |
+
try:
|
| 13 |
+
info = sf.info(buf)
|
| 14 |
+
except Exception as e:
|
| 15 |
+
raise ValueError(f"Invalid WAV file: {e}") from e
|
| 16 |
+
|
| 17 |
+
return {
|
| 18 |
+
"sample_rate": info.samplerate,
|
| 19 |
+
"channels": info.channels,
|
| 20 |
+
"duration": info.duration,
|
| 21 |
+
"frames": info.frames,
|
| 22 |
+
"format": info.format,
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def validate_reference_audio(data: bytes) -> dict:
|
| 27 |
+
"""Validate reference audio for voice cloning.
|
| 28 |
+
|
| 29 |
+
Requirements:
|
| 30 |
+
- Mono channel
|
| 31 |
+
- 16-44 kHz sample rate
|
| 32 |
+
- 3-15 seconds duration
|
| 33 |
+
"""
|
| 34 |
+
props = validate_wav(data)
|
| 35 |
+
|
| 36 |
+
if props["channels"] != 1:
|
| 37 |
+
raise ValueError(
|
| 38 |
+
f"Reference audio must be mono (1 channel), got {props['channels']} channels"
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
if not (8000 <= props["sample_rate"] <= 48000):
|
| 42 |
+
raise ValueError(
|
| 43 |
+
f"Reference audio sample rate must be 8-48 kHz, got {props['sample_rate']} Hz"
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
if props["duration"] < 1.0:
|
| 47 |
+
raise ValueError(
|
| 48 |
+
f"Reference audio too short ({props['duration']:.1f}s), minimum 1 second"
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
if props["duration"] > 30.0:
|
| 52 |
+
raise ValueError(
|
| 53 |
+
f"Reference audio too long ({props['duration']:.1f}s), maximum 30 seconds"
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
return props
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def pcm_to_wav_bytes(pcm_data: np.ndarray, sample_rate: int = 24000) -> bytes:
|
| 60 |
+
"""Convert float32 PCM numpy array to WAV bytes."""
|
| 61 |
+
buf = io.BytesIO()
|
| 62 |
+
sf.write(buf, pcm_data, sample_rate, format="WAV", subtype="PCM_16")
|
| 63 |
+
buf.seek(0)
|
| 64 |
+
return buf.read()
|
api/src/services/streaming_audio_writer.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import io
|
| 4 |
+
from typing import Literal
|
| 5 |
+
|
| 6 |
+
import av
|
| 7 |
+
import numpy as np
|
| 8 |
+
|
| 9 |
+
AudioFormat = Literal["mp3", "opus", "aac", "flac", "wav", "pcm"]
|
| 10 |
+
|
| 11 |
+
# Mapping from our format names to PyAV codec/container names
|
| 12 |
+
FORMAT_CONFIG: dict[str, dict] = {
|
| 13 |
+
"mp3": {"codec": "mp3", "container": "mp3", "content_type": "audio/mpeg"},
|
| 14 |
+
"opus": {"codec": "libopus", "container": "ogg", "content_type": "audio/ogg"},
|
| 15 |
+
"aac": {"codec": "aac", "container": "adts", "content_type": "audio/aac"},
|
| 16 |
+
"flac": {"codec": "flac", "container": "flac", "content_type": "audio/flac"},
|
| 17 |
+
"wav": {"codec": "pcm_s16le", "container": "wav", "content_type": "audio/wav"},
|
| 18 |
+
"pcm": {"codec": None, "container": None, "content_type": "audio/pcm"},
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def get_content_type(fmt: AudioFormat) -> str:
|
| 23 |
+
return FORMAT_CONFIG[fmt]["content_type"]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class StreamingAudioWriter:
|
| 27 |
+
"""Encodes raw PCM audio (float32, mono) into various formats using PyAV."""
|
| 28 |
+
|
| 29 |
+
def __init__(self, fmt: AudioFormat, sample_rate: int = 24000) -> None:
|
| 30 |
+
self.format = fmt
|
| 31 |
+
self.sample_rate = sample_rate
|
| 32 |
+
self._buffer = io.BytesIO()
|
| 33 |
+
|
| 34 |
+
if fmt == "pcm":
|
| 35 |
+
# No encoding needed for raw PCM
|
| 36 |
+
self._container = None
|
| 37 |
+
self._stream = None
|
| 38 |
+
else:
|
| 39 |
+
config = FORMAT_CONFIG[fmt]
|
| 40 |
+
self._container = av.open(self._buffer, mode="w", format=config["container"])
|
| 41 |
+
self._stream = self._container.add_stream(config["codec"], rate=sample_rate)
|
| 42 |
+
self._stream.layout = "mono"
|
| 43 |
+
if fmt == "opus":
|
| 44 |
+
self._stream.rate = 48000 # Opus requires 48kHz
|
| 45 |
+
|
| 46 |
+
def write_chunk(self, pcm_data: np.ndarray) -> bytes:
|
| 47 |
+
"""Encode a chunk of float32 PCM audio and return the encoded bytes."""
|
| 48 |
+
if self.format == "pcm":
|
| 49 |
+
# Convert float32 to int16 PCM
|
| 50 |
+
pcm_int16 = (pcm_data * 32767).astype(np.int16)
|
| 51 |
+
return pcm_int16.tobytes()
|
| 52 |
+
|
| 53 |
+
# Convert float32 [-1.0, 1.0] to int16
|
| 54 |
+
pcm_int16 = (np.clip(pcm_data, -1.0, 1.0) * 32767).astype(np.int16)
|
| 55 |
+
|
| 56 |
+
frame = av.AudioFrame.from_ndarray(
|
| 57 |
+
pcm_int16.reshape(1, -1),
|
| 58 |
+
format="s16",
|
| 59 |
+
layout="mono",
|
| 60 |
+
)
|
| 61 |
+
frame.sample_rate = self.sample_rate
|
| 62 |
+
if self.format == "opus":
|
| 63 |
+
frame.sample_rate = 48000
|
| 64 |
+
|
| 65 |
+
start_pos = self._buffer.tell()
|
| 66 |
+
for packet in self._stream.encode(frame):
|
| 67 |
+
self._container.mux(packet)
|
| 68 |
+
|
| 69 |
+
# Read newly written bytes
|
| 70 |
+
self._buffer.seek(start_pos)
|
| 71 |
+
data = self._buffer.read()
|
| 72 |
+
return data
|
| 73 |
+
|
| 74 |
+
def finalize(self) -> bytes:
|
| 75 |
+
"""Flush remaining encoded data and close the container."""
|
| 76 |
+
if self.format == "pcm" or self._container is None:
|
| 77 |
+
return b""
|
| 78 |
+
|
| 79 |
+
start_pos = self._buffer.tell()
|
| 80 |
+
|
| 81 |
+
# Flush encoder
|
| 82 |
+
for packet in self._stream.encode(None):
|
| 83 |
+
self._container.mux(packet)
|
| 84 |
+
|
| 85 |
+
self._container.close()
|
| 86 |
+
|
| 87 |
+
self._buffer.seek(start_pos)
|
| 88 |
+
data = self._buffer.read()
|
| 89 |
+
return data
|
| 90 |
+
|
| 91 |
+
def close(self) -> None:
|
| 92 |
+
if self._container is not None:
|
| 93 |
+
try:
|
| 94 |
+
self._container.close()
|
| 95 |
+
except Exception:
|
| 96 |
+
pass
|
| 97 |
+
self._buffer.close()
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def encode_audio_complete(
|
| 101 |
+
pcm_data: np.ndarray,
|
| 102 |
+
fmt: AudioFormat,
|
| 103 |
+
sample_rate: int = 24000,
|
| 104 |
+
) -> bytes:
|
| 105 |
+
"""Encode a complete PCM float32 array to the specified audio format."""
|
| 106 |
+
if fmt == "pcm":
|
| 107 |
+
return (pcm_data * 32767).astype(np.int16).tobytes()
|
| 108 |
+
|
| 109 |
+
buf = io.BytesIO()
|
| 110 |
+
config = FORMAT_CONFIG[fmt]
|
| 111 |
+
container = av.open(buf, mode="w", format=config["container"])
|
| 112 |
+
stream = container.add_stream(config["codec"], rate=sample_rate)
|
| 113 |
+
stream.layout = "mono"
|
| 114 |
+
|
| 115 |
+
actual_rate = 48000 if fmt == "opus" else sample_rate
|
| 116 |
+
if fmt == "opus":
|
| 117 |
+
stream.rate = 48000
|
| 118 |
+
|
| 119 |
+
pcm_int16 = (np.clip(pcm_data, -1.0, 1.0) * 32767).astype(np.int16)
|
| 120 |
+
frame = av.AudioFrame.from_ndarray(
|
| 121 |
+
pcm_int16.reshape(1, -1),
|
| 122 |
+
format="s16",
|
| 123 |
+
layout="mono",
|
| 124 |
+
)
|
| 125 |
+
frame.sample_rate = actual_rate
|
| 126 |
+
|
| 127 |
+
for packet in stream.encode(frame):
|
| 128 |
+
container.mux(packet)
|
| 129 |
+
for packet in stream.encode(None):
|
| 130 |
+
container.mux(packet)
|
| 131 |
+
container.close()
|
| 132 |
+
|
| 133 |
+
buf.seek(0)
|
| 134 |
+
return buf.read()
|
api/src/services/temp_manager.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import tempfile
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from loguru import logger
|
| 7 |
+
|
| 8 |
+
_temp_dir: Path | None = None
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def get_temp_dir() -> Path:
|
| 12 |
+
global _temp_dir
|
| 13 |
+
if _temp_dir is None:
|
| 14 |
+
_temp_dir = Path(tempfile.mkdtemp(prefix="neutts_"))
|
| 15 |
+
logger.info(f"Created temp directory: {_temp_dir}")
|
| 16 |
+
_temp_dir.mkdir(parents=True, exist_ok=True)
|
| 17 |
+
return _temp_dir
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def cleanup_temp() -> None:
|
| 21 |
+
global _temp_dir
|
| 22 |
+
if _temp_dir is not None and _temp_dir.exists():
|
| 23 |
+
import shutil
|
| 24 |
+
|
| 25 |
+
shutil.rmtree(_temp_dir, ignore_errors=True)
|
| 26 |
+
logger.info(f"Cleaned up temp directory: {_temp_dir}")
|
| 27 |
+
_temp_dir = None
|
api/src/services/tts_service.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import AsyncGenerator
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
from loguru import logger
|
| 7 |
+
|
| 8 |
+
from api.src.core.config import settings
|
| 9 |
+
from api.src.core.model_config import BackendType, get_backbone_info
|
| 10 |
+
from api.src.inference.model_manager import ModelManager
|
| 11 |
+
from api.src.inference.text_chunker import chunk_text
|
| 12 |
+
from api.src.inference.voice_manager import VoiceManager
|
| 13 |
+
from api.src.services.streaming_audio_writer import (
|
| 14 |
+
AudioFormat,
|
| 15 |
+
StreamingAudioWriter,
|
| 16 |
+
encode_audio_complete,
|
| 17 |
+
)
|
| 18 |
+
from api.src.structures.schemas import OpenAISpeechRequest
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _apply_speed(wav: np.ndarray, speed: float) -> np.ndarray:
|
| 22 |
+
"""Adjust audio playback speed via resampling."""
|
| 23 |
+
if speed == 1.0:
|
| 24 |
+
return wav
|
| 25 |
+
# Resample: fewer samples = faster, more samples = slower
|
| 26 |
+
new_length = int(len(wav) / speed)
|
| 27 |
+
indices = np.linspace(0, len(wav) - 1, new_length)
|
| 28 |
+
return np.interp(indices, np.arange(len(wav)), wav).astype(np.float32)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class TTSService:
|
| 32 |
+
_instance: TTSService | None = None
|
| 33 |
+
|
| 34 |
+
def __init__(self) -> None:
|
| 35 |
+
self._model_manager = ModelManager.get_instance()
|
| 36 |
+
self._voice_manager = VoiceManager.get_instance()
|
| 37 |
+
|
| 38 |
+
@classmethod
|
| 39 |
+
def get_instance(cls) -> TTSService:
|
| 40 |
+
if cls._instance is None:
|
| 41 |
+
cls._instance = cls()
|
| 42 |
+
return cls._instance
|
| 43 |
+
|
| 44 |
+
async def generate_speech(self, request: OpenAISpeechRequest) -> bytes:
|
| 45 |
+
"""Generate complete audio from text."""
|
| 46 |
+
model_id = request.model
|
| 47 |
+
voice = request.voice
|
| 48 |
+
fmt: AudioFormat = request.response_format
|
| 49 |
+
|
| 50 |
+
# Ensure model is loaded
|
| 51 |
+
if not self._model_manager.is_loaded(model_id):
|
| 52 |
+
raise ValueError(f"Model '{model_id}' is not loaded")
|
| 53 |
+
|
| 54 |
+
loaded = self._model_manager.loaded_models[model_id]
|
| 55 |
+
|
| 56 |
+
# Get voice reference
|
| 57 |
+
ref_codes = await self._voice_manager.get_or_encode_ref_codes(
|
| 58 |
+
voice, loaded.codec_id, self._model_manager, model_id
|
| 59 |
+
)
|
| 60 |
+
ref_text = self._voice_manager.get_ref_text(voice)
|
| 61 |
+
|
| 62 |
+
# For short text, single inference
|
| 63 |
+
text = request.input.strip()
|
| 64 |
+
info = get_backbone_info(model_id)
|
| 65 |
+
|
| 66 |
+
if len(text) <= 500 or info is None:
|
| 67 |
+
wav = await self._model_manager.infer(model_id, text, ref_codes, ref_text)
|
| 68 |
+
wav = _apply_speed(wav, request.speed)
|
| 69 |
+
return encode_audio_complete(wav, fmt, settings.sample_rate)
|
| 70 |
+
|
| 71 |
+
# For long text, chunk and concatenate
|
| 72 |
+
chunks = chunk_text(text)
|
| 73 |
+
wav_parts: list[np.ndarray] = []
|
| 74 |
+
|
| 75 |
+
for chunk in chunks:
|
| 76 |
+
wav = await self._model_manager.infer(model_id, chunk, ref_codes, ref_text)
|
| 77 |
+
wav_parts.append(wav)
|
| 78 |
+
|
| 79 |
+
full_wav = np.concatenate(wav_parts)
|
| 80 |
+
full_wav = _apply_speed(full_wav, request.speed)
|
| 81 |
+
return encode_audio_complete(full_wav, fmt, settings.sample_rate)
|
| 82 |
+
|
| 83 |
+
async def stream_speech(
|
| 84 |
+
self, request: OpenAISpeechRequest
|
| 85 |
+
) -> AsyncGenerator[bytes, None]:
|
| 86 |
+
"""Stream audio chunks as they are generated."""
|
| 87 |
+
model_id = request.model
|
| 88 |
+
voice = request.voice
|
| 89 |
+
fmt: AudioFormat = request.response_format
|
| 90 |
+
|
| 91 |
+
if not self._model_manager.is_loaded(model_id):
|
| 92 |
+
raise ValueError(f"Model '{model_id}' is not loaded")
|
| 93 |
+
|
| 94 |
+
loaded = self._model_manager.loaded_models[model_id]
|
| 95 |
+
info = get_backbone_info(model_id)
|
| 96 |
+
|
| 97 |
+
ref_codes = await self._voice_manager.get_or_encode_ref_codes(
|
| 98 |
+
voice, loaded.codec_id, self._model_manager, model_id
|
| 99 |
+
)
|
| 100 |
+
ref_text = self._voice_manager.get_ref_text(voice)
|
| 101 |
+
|
| 102 |
+
text = request.input.strip()
|
| 103 |
+
writer = StreamingAudioWriter(fmt, settings.sample_rate)
|
| 104 |
+
|
| 105 |
+
speed = request.speed
|
| 106 |
+
|
| 107 |
+
try:
|
| 108 |
+
if info and info.supports_streaming:
|
| 109 |
+
# Real streaming with GGUF models
|
| 110 |
+
async for chunk in self._model_manager.infer_stream(
|
| 111 |
+
model_id, text, ref_codes, ref_text
|
| 112 |
+
):
|
| 113 |
+
chunk = _apply_speed(chunk, speed)
|
| 114 |
+
encoded = writer.write_chunk(chunk)
|
| 115 |
+
if encoded:
|
| 116 |
+
yield encoded
|
| 117 |
+
else:
|
| 118 |
+
# Pseudo-streaming: chunk text, infer per chunk
|
| 119 |
+
chunks = chunk_text(text)
|
| 120 |
+
for chunk in chunks:
|
| 121 |
+
wav = await self._model_manager.infer(
|
| 122 |
+
model_id, chunk, ref_codes, ref_text
|
| 123 |
+
)
|
| 124 |
+
wav = _apply_speed(wav, speed)
|
| 125 |
+
encoded = writer.write_chunk(wav)
|
| 126 |
+
if encoded:
|
| 127 |
+
yield encoded
|
| 128 |
+
|
| 129 |
+
# Finalize
|
| 130 |
+
final = writer.finalize()
|
| 131 |
+
if final:
|
| 132 |
+
yield final
|
| 133 |
+
finally:
|
| 134 |
+
writer.close()
|
api/src/static/index.html
ADDED
|
@@ -0,0 +1,1693 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>NeuTTS-FastAPI</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
| 10 |
+
<style>
|
| 11 |
+
:root {
|
| 12 |
+
--bg: #0f172a;
|
| 13 |
+
--fg: #7c6aef;
|
| 14 |
+
--fg2: #a78bfa;
|
| 15 |
+
--surface: rgba(30, 41, 59, 1);
|
| 16 |
+
--surface2: rgba(15, 23, 42, 0.5);
|
| 17 |
+
--text: #f8fafc;
|
| 18 |
+
--text-dim: #94a3b8;
|
| 19 |
+
--border: rgba(148, 163, 184, 0.15);
|
| 20 |
+
--success: #4ade80;
|
| 21 |
+
--error: #f87171;
|
| 22 |
+
--warning: #fbbf24;
|
| 23 |
+
--radius: 12px;
|
| 24 |
+
--font: 'Inter', system-ui, -apple-system, sans-serif;
|
| 25 |
+
}
|
| 26 |
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
| 27 |
+
html { width: 100%; height: 100%; overflow-x: hidden; }
|
| 28 |
+
body {
|
| 29 |
+
font-family: var(--font);
|
| 30 |
+
background: var(--bg);
|
| 31 |
+
color: var(--text);
|
| 32 |
+
min-height: 100vh;
|
| 33 |
+
line-height: 1.5;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/* Background effects */
|
| 37 |
+
.bg-glow {
|
| 38 |
+
position: fixed; inset: 0; pointer-events: none; z-index: 0;
|
| 39 |
+
background: radial-gradient(ellipse at 20% 0%, rgba(124,106,239,0.15) 0%, transparent 60%),
|
| 40 |
+
radial-gradient(ellipse at 80% 100%, rgba(167,139,250,0.08) 0%, transparent 50%);
|
| 41 |
+
}
|
| 42 |
+
.bg-grid {
|
| 43 |
+
position: fixed; inset: 0; pointer-events: none; z-index: 0;
|
| 44 |
+
background-image:
|
| 45 |
+
repeating-linear-gradient(0deg, rgba(255,255,255,0.02) 0px, rgba(255,255,255,0.02) 1px, transparent 1px, transparent 24px),
|
| 46 |
+
repeating-linear-gradient(90deg, rgba(255,255,255,0.02) 0px, rgba(255,255,255,0.02) 1px, transparent 1px, transparent 24px);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
.app { position: relative; z-index: 1; max-width: 1100px; margin: 0 auto; padding: 2rem 1.5rem; }
|
| 50 |
+
|
| 51 |
+
/* Header */
|
| 52 |
+
header { text-align: center; margin-bottom: 2rem; }
|
| 53 |
+
.logo {
|
| 54 |
+
font-size: 2.2rem; font-weight: 700;
|
| 55 |
+
background: linear-gradient(135deg, var(--fg), var(--fg2), #c084fc);
|
| 56 |
+
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
| 57 |
+
}
|
| 58 |
+
.subtitle { color: var(--text-dim); font-size: 0.9rem; margin-top: 4px; }
|
| 59 |
+
.badges { display: flex; gap: 8px; justify-content: center; margin-top: 12px; flex-wrap: wrap; }
|
| 60 |
+
.badge {
|
| 61 |
+
display: flex; align-items: center; gap: 6px;
|
| 62 |
+
background: var(--surface); border: 1px solid var(--border);
|
| 63 |
+
border-radius: 20px; padding: 5px 12px; font-size: 0.75rem; color: var(--text-dim);
|
| 64 |
+
}
|
| 65 |
+
.dot { width: 7px; height: 7px; border-radius: 50%; }
|
| 66 |
+
.dot-ok { background: var(--success); box-shadow: 0 0 6px var(--success); }
|
| 67 |
+
.dot-off { background: var(--error); }
|
| 68 |
+
.dot-warn { background: var(--warning); }
|
| 69 |
+
|
| 70 |
+
/* Device badge */
|
| 71 |
+
.device-badge {
|
| 72 |
+
display: inline-flex; align-items: center; gap: 5px;
|
| 73 |
+
padding: 5px 12px; border-radius: 20px; font-size: 0.75rem; font-weight: 600;
|
| 74 |
+
background: var(--surface); border: 1px solid var(--border);
|
| 75 |
+
}
|
| 76 |
+
.device-badge.gpu {
|
| 77 |
+
color: #4ade80; border-color: rgba(74,222,128,0.3);
|
| 78 |
+
background: rgba(74,222,128,0.08);
|
| 79 |
+
}
|
| 80 |
+
.device-badge.cpu {
|
| 81 |
+
color: var(--text-dim); border-color: var(--border);
|
| 82 |
+
}
|
| 83 |
+
.device-badge svg { width: 14px; height: 14px; }
|
| 84 |
+
|
| 85 |
+
/* GPU Warning Banner */
|
| 86 |
+
.gpu-warning {
|
| 87 |
+
background: rgba(251,191,36,0.1); border: 1px solid rgba(251,191,36,0.3);
|
| 88 |
+
border-radius: var(--radius); padding: 14px 18px; margin-bottom: 1.5rem;
|
| 89 |
+
display: flex; align-items: flex-start; gap: 12px;
|
| 90 |
+
font-size: 0.85rem; color: var(--warning); line-height: 1.5;
|
| 91 |
+
}
|
| 92 |
+
.gpu-warning-icon { font-size: 1.2rem; flex-shrink: 0; margin-top: 1px; }
|
| 93 |
+
.gpu-warning-content { flex: 1; }
|
| 94 |
+
.gpu-warning-title { font-weight: 700; margin-bottom: 4px; }
|
| 95 |
+
.gpu-warning-text { font-size: 0.78rem; opacity: 0.9; white-space: pre-wrap; font-family: 'Consolas', 'Monaco', monospace; }
|
| 96 |
+
.gpu-warning-dismiss {
|
| 97 |
+
background: none; border: 1px solid rgba(251,191,36,0.3); border-radius: 4px;
|
| 98 |
+
color: var(--warning); cursor: pointer; padding: 2px 8px; font-size: 0.72rem;
|
| 99 |
+
font-family: var(--font); flex-shrink: 0; transition: all 0.2s;
|
| 100 |
+
}
|
| 101 |
+
.gpu-warning-dismiss:hover { background: rgba(251,191,36,0.15); }
|
| 102 |
+
|
| 103 |
+
/* Layout */
|
| 104 |
+
.main-grid {
|
| 105 |
+
display: grid;
|
| 106 |
+
grid-template-columns: 1fr 340px;
|
| 107 |
+
gap: 1.5rem;
|
| 108 |
+
align-items: start;
|
| 109 |
+
}
|
| 110 |
+
@media (max-width: 860px) {
|
| 111 |
+
.main-grid { grid-template-columns: 1fr; }
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
/* Cards */
|
| 115 |
+
.card {
|
| 116 |
+
background: var(--surface);
|
| 117 |
+
border: 1px solid var(--border);
|
| 118 |
+
border-radius: var(--radius);
|
| 119 |
+
padding: 1.25rem;
|
| 120 |
+
}
|
| 121 |
+
.card-label {
|
| 122 |
+
font-size: 0.72rem; font-weight: 600; text-transform: uppercase;
|
| 123 |
+
letter-spacing: 0.6px; color: var(--text-dim); margin-bottom: 12px;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
/* Text Editor */
|
| 127 |
+
.text-editor textarea {
|
| 128 |
+
width: 100%; min-height: 180px;
|
| 129 |
+
background: var(--surface2); border: 1px solid var(--border);
|
| 130 |
+
border-radius: 8px; padding: 14px; color: var(--text);
|
| 131 |
+
font-size: 0.95rem; font-family: var(--font); resize: vertical;
|
| 132 |
+
transition: border-color 0.2s;
|
| 133 |
+
}
|
| 134 |
+
.text-editor textarea:focus { outline: none; border-color: var(--fg); }
|
| 135 |
+
.text-meta {
|
| 136 |
+
display: flex; justify-content: space-between; align-items: center;
|
| 137 |
+
margin-top: 6px; font-size: 0.72rem; color: var(--text-dim);
|
| 138 |
+
}
|
| 139 |
+
.text-meta kbd {
|
| 140 |
+
background: var(--surface2); border: 1px solid var(--border);
|
| 141 |
+
border-radius: 4px; padding: 1px 5px; font-size: 0.68rem;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
/* Player */
|
| 145 |
+
.player { margin-top: 1.25rem; }
|
| 146 |
+
.player-bar {
|
| 147 |
+
display: flex; align-items: center; gap: 10px;
|
| 148 |
+
background: var(--surface2); border: 1px solid var(--border);
|
| 149 |
+
border-radius: 10px; padding: 8px 12px; height: 48px;
|
| 150 |
+
}
|
| 151 |
+
.player-btn {
|
| 152 |
+
width: 36px; height: 36px; border-radius: 50%;
|
| 153 |
+
background: var(--fg); border: none; cursor: pointer;
|
| 154 |
+
display: flex; align-items: center; justify-content: center;
|
| 155 |
+
transition: all 0.2s; flex-shrink: 0;
|
| 156 |
+
}
|
| 157 |
+
.player-btn:hover { transform: scale(1.08); box-shadow: 0 0 16px rgba(124,106,239,0.3); }
|
| 158 |
+
.player-btn svg { fill: white; width: 16px; height: 16px; }
|
| 159 |
+
.seek-wrap { flex: 1; min-width: 0; }
|
| 160 |
+
.seek-slider {
|
| 161 |
+
-webkit-appearance: none; width: 100%; height: 4px;
|
| 162 |
+
background: rgba(124,106,239,0.2); border-radius: 2px;
|
| 163 |
+
outline: none; cursor: pointer;
|
| 164 |
+
}
|
| 165 |
+
.seek-slider::-webkit-slider-thumb {
|
| 166 |
+
-webkit-appearance: none; width: 14px; height: 14px;
|
| 167 |
+
border-radius: 50%; background: var(--fg); cursor: pointer;
|
| 168 |
+
transition: transform 0.15s;
|
| 169 |
+
}
|
| 170 |
+
.seek-slider::-webkit-slider-thumb:hover { transform: scale(1.2); }
|
| 171 |
+
.seek-slider::-moz-range-thumb {
|
| 172 |
+
width: 14px; height: 14px; border: none;
|
| 173 |
+
border-radius: 50%; background: var(--fg); cursor: pointer;
|
| 174 |
+
}
|
| 175 |
+
.time-display {
|
| 176 |
+
font-size: 0.78rem; color: var(--text-dim); min-width: 80px;
|
| 177 |
+
text-align: center; font-variant-numeric: tabular-nums;
|
| 178 |
+
border-left: 1px solid var(--border); padding-left: 10px;
|
| 179 |
+
}
|
| 180 |
+
.vol-group {
|
| 181 |
+
display: flex; align-items: center; gap: 6px;
|
| 182 |
+
border-left: 1px solid var(--border); padding-left: 10px;
|
| 183 |
+
}
|
| 184 |
+
.vol-icon { color: var(--fg); opacity: 0.7; flex-shrink: 0; }
|
| 185 |
+
.vol-slider {
|
| 186 |
+
-webkit-appearance: none; width: 70px; height: 4px;
|
| 187 |
+
background: rgba(124,106,239,0.2); border-radius: 2px;
|
| 188 |
+
outline: none; cursor: pointer;
|
| 189 |
+
}
|
| 190 |
+
.vol-slider::-webkit-slider-thumb {
|
| 191 |
+
-webkit-appearance: none; width: 12px; height: 12px;
|
| 192 |
+
border-radius: 50%; background: var(--fg); cursor: pointer;
|
| 193 |
+
}
|
| 194 |
+
.vol-slider::-moz-range-thumb {
|
| 195 |
+
width: 12px; height: 12px; border: none;
|
| 196 |
+
border-radius: 50%; background: var(--fg); cursor: pointer;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
/* Wave visualizer */
|
| 200 |
+
.wave-box {
|
| 201 |
+
position: relative; width: 100%; height: 56px;
|
| 202 |
+
background: var(--surface2); border-radius: 8px;
|
| 203 |
+
overflow: hidden; margin-top: 8px;
|
| 204 |
+
}
|
| 205 |
+
.wave-box canvas { width: 100%; height: 100%; }
|
| 206 |
+
.gen-progress {
|
| 207 |
+
position: absolute; bottom: 0; left: 0; height: 3px;
|
| 208 |
+
background: var(--fg); border-radius: 2px; transition: width 0.3s;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
/* Download button */
|
| 212 |
+
.dl-wrap {
|
| 213 |
+
position: absolute; bottom: 8px; right: 8px; z-index: 5;
|
| 214 |
+
opacity: 0; pointer-events: none; transition: opacity 0.3s;
|
| 215 |
+
}
|
| 216 |
+
.dl-wrap.ready { opacity: 1; pointer-events: auto; }
|
| 217 |
+
.dl-btn {
|
| 218 |
+
width: 34px; height: 34px; border-radius: 6px;
|
| 219 |
+
background: var(--surface); border: 1px solid var(--border);
|
| 220 |
+
display: flex; align-items: center; justify-content: center;
|
| 221 |
+
cursor: pointer; color: var(--text); transition: all 0.2s;
|
| 222 |
+
position: relative; overflow: visible;
|
| 223 |
+
}
|
| 224 |
+
.dl-btn:hover { box-shadow: 0 0 12px rgba(124,106,239,0.3); transform: scale(1.05); }
|
| 225 |
+
.dl-glow {
|
| 226 |
+
position: absolute; inset: -4px; border-radius: 8px;
|
| 227 |
+
background: conic-gradient(from 0deg, var(--fg), #a78bfa, var(--fg));
|
| 228 |
+
animation: glow-spin 3s linear infinite; filter: blur(6px); opacity: 0.4; z-index: -1;
|
| 229 |
+
}
|
| 230 |
+
@keyframes glow-spin { to { transform: rotate(360deg); } }
|
| 231 |
+
|
| 232 |
+
/* Right panel controls */
|
| 233 |
+
.controls-panel { display: flex; flex-direction: column; gap: 1rem; }
|
| 234 |
+
|
| 235 |
+
/* Voice selector */
|
| 236 |
+
.voice-list { max-height: 160px; overflow-y: auto; }
|
| 237 |
+
.voice-item {
|
| 238 |
+
display: flex; align-items: center; gap: 8px;
|
| 239 |
+
padding: 8px 10px; border-radius: 6px; cursor: pointer;
|
| 240 |
+
transition: background 0.15s; font-size: 0.85rem;
|
| 241 |
+
}
|
| 242 |
+
.voice-item:hover { background: rgba(124,106,239,0.1); }
|
| 243 |
+
.voice-item.active { background: rgba(124,106,239,0.2); border: 1px solid rgba(124,106,239,0.3); }
|
| 244 |
+
.voice-item .v-name { font-weight: 500; }
|
| 245 |
+
.voice-item .v-meta { font-size: 0.72rem; color: var(--text-dim); margin-left: auto; }
|
| 246 |
+
.voice-item .v-lang {
|
| 247 |
+
font-size: 0.65rem; background: rgba(124,106,239,0.15);
|
| 248 |
+
color: var(--fg2); border-radius: 4px; padding: 1px 6px;
|
| 249 |
+
}
|
| 250 |
+
.voice-item .v-delete {
|
| 251 |
+
font-size: 0.72rem; color: var(--error); cursor: pointer; opacity: 0.5;
|
| 252 |
+
font-weight: 700; transition: opacity 0.2s; margin-left: 4px;
|
| 253 |
+
}
|
| 254 |
+
.voice-item .v-delete:hover { opacity: 1; }
|
| 255 |
+
.voice-item.voice-unavailable {
|
| 256 |
+
opacity: 0.4; cursor: not-allowed; pointer-events: none;
|
| 257 |
+
}
|
| 258 |
+
.voice-item.voice-unavailable .v-name::after {
|
| 259 |
+
content: ' (no audio)'; font-size: 0.65rem; font-weight: 400; color: var(--text-dim);
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
/* Voice Upload Panel */
|
| 263 |
+
.voice-upload-toggle {
|
| 264 |
+
width: 100%; padding: 10px; margin-top: 10px;
|
| 265 |
+
background: transparent; border: 2px dashed var(--border);
|
| 266 |
+
border-radius: 8px; color: var(--text-dim); font-size: 0.82rem;
|
| 267 |
+
cursor: pointer; font-family: var(--font); transition: all 0.2s;
|
| 268 |
+
}
|
| 269 |
+
.voice-upload-toggle:hover { border-color: var(--fg); color: var(--fg2); }
|
| 270 |
+
.voice-upload-panel {
|
| 271 |
+
display: none; margin-top: 10px; padding: 12px;
|
| 272 |
+
background: var(--surface2); border: 1px solid var(--border);
|
| 273 |
+
border-radius: 8px;
|
| 274 |
+
}
|
| 275 |
+
.voice-upload-panel.open { display: block; }
|
| 276 |
+
.voice-upload-panel input[type="text"],
|
| 277 |
+
.voice-upload-panel textarea,
|
| 278 |
+
.voice-upload-panel select {
|
| 279 |
+
width: 100%; padding: 8px 10px;
|
| 280 |
+
background: var(--bg); border: 1px solid var(--border);
|
| 281 |
+
border-radius: 6px; color: var(--text); font-size: 0.82rem;
|
| 282 |
+
font-family: var(--font); margin-bottom: 8px; transition: border-color 0.2s;
|
| 283 |
+
}
|
| 284 |
+
.voice-upload-panel input:focus,
|
| 285 |
+
.voice-upload-panel textarea:focus,
|
| 286 |
+
.voice-upload-panel select:focus { outline: none; border-color: var(--fg); }
|
| 287 |
+
.voice-upload-panel textarea { min-height: 60px; resize: vertical; }
|
| 288 |
+
.voice-upload-panel .field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px; }
|
| 289 |
+
|
| 290 |
+
.drop-zone {
|
| 291 |
+
border: 2px dashed var(--border); border-radius: 8px;
|
| 292 |
+
padding: 20px; text-align: center; cursor: pointer;
|
| 293 |
+
color: var(--text-dim); font-size: 0.8rem; transition: all 0.2s;
|
| 294 |
+
margin-bottom: 8px;
|
| 295 |
+
}
|
| 296 |
+
.drop-zone:hover, .drop-zone.dragover {
|
| 297 |
+
border-color: var(--fg); color: var(--fg2);
|
| 298 |
+
background: rgba(124,106,239,0.05);
|
| 299 |
+
}
|
| 300 |
+
.drop-zone.has-file {
|
| 301 |
+
border-color: var(--success); color: var(--success);
|
| 302 |
+
background: rgba(74,222,128,0.05);
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
.record-section {
|
| 306 |
+
text-align: center; margin: 8px 0;
|
| 307 |
+
padding: 8px 0; border-top: 1px solid var(--border);
|
| 308 |
+
}
|
| 309 |
+
.record-section-label { font-size: 0.7rem; color: var(--text-dim); margin-bottom: 6px; }
|
| 310 |
+
.record-btn {
|
| 311 |
+
width: 44px; height: 44px; border-radius: 50%;
|
| 312 |
+
background: rgba(248,113,113,0.15); border: 2px solid rgba(248,113,113,0.3);
|
| 313 |
+
color: var(--error); cursor: pointer; font-size: 0.7rem; font-weight: 600;
|
| 314 |
+
font-family: var(--font); transition: all 0.2s;
|
| 315 |
+
display: inline-flex; align-items: center; justify-content: center;
|
| 316 |
+
}
|
| 317 |
+
.record-btn:hover { background: rgba(248,113,113,0.25); transform: scale(1.05); }
|
| 318 |
+
.record-btn.recording {
|
| 319 |
+
background: var(--error); color: white; border-color: var(--error);
|
| 320 |
+
animation: record-pulse 1.2s ease-in-out infinite;
|
| 321 |
+
}
|
| 322 |
+
@keyframes record-pulse {
|
| 323 |
+
0%, 100% { box-shadow: 0 0 0 0 rgba(248,113,113,0.5); }
|
| 324 |
+
50% { box-shadow: 0 0 0 8px rgba(248,113,113,0); }
|
| 325 |
+
}
|
| 326 |
+
.record-timer {
|
| 327 |
+
display: inline-block; margin-left: 8px;
|
| 328 |
+
font-size: 0.82rem; font-variant-numeric: tabular-nums;
|
| 329 |
+
color: var(--text-dim); min-width: 36px;
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
.voice-upload-progress {
|
| 333 |
+
height: 4px; background: var(--border); border-radius: 2px;
|
| 334 |
+
overflow: hidden; margin-bottom: 8px; display: none;
|
| 335 |
+
}
|
| 336 |
+
.voice-upload-progress-bar {
|
| 337 |
+
height: 100%; background: linear-gradient(90deg, var(--fg), var(--fg2));
|
| 338 |
+
border-radius: 2px; width: 0%; transition: width 0.3s;
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
.btn-upload {
|
| 342 |
+
width: 100%; padding: 10px; border: none; border-radius: 6px;
|
| 343 |
+
background: linear-gradient(135deg, var(--fg), #6d5ce7);
|
| 344 |
+
color: white; font-size: 0.85rem; font-weight: 600;
|
| 345 |
+
cursor: pointer; font-family: var(--font); transition: all 0.2s;
|
| 346 |
+
}
|
| 347 |
+
.btn-upload:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(124,106,239,0.3); }
|
| 348 |
+
.btn-upload:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
|
| 349 |
+
|
| 350 |
+
.voice-upload-status {
|
| 351 |
+
margin-top: 8px; padding: 8px; border-radius: 6px;
|
| 352 |
+
font-size: 0.78rem; text-align: center; display: none;
|
| 353 |
+
}
|
| 354 |
+
.voice-upload-status.upload-success {
|
| 355 |
+
display: block; background: rgba(74,222,128,0.1);
|
| 356 |
+
border: 1px solid rgba(74,222,128,0.2); color: var(--success);
|
| 357 |
+
}
|
| 358 |
+
.voice-upload-status.upload-error {
|
| 359 |
+
display: block; background: rgba(248,113,113,0.1);
|
| 360 |
+
border: 1px solid rgba(248,113,113,0.2); color: var(--error);
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
/* Selects & inputs */
|
| 364 |
+
select, input[type="text"] {
|
| 365 |
+
width: 100%; padding: 9px 10px;
|
| 366 |
+
background: var(--surface2); border: 1px solid var(--border);
|
| 367 |
+
border-radius: 8px; color: var(--text); font-size: 0.85rem;
|
| 368 |
+
font-family: var(--font); cursor: pointer; transition: border-color 0.2s;
|
| 369 |
+
}
|
| 370 |
+
select:focus, input[type="text"]:focus { outline: none; border-color: var(--fg); }
|
| 371 |
+
|
| 372 |
+
.field { margin-bottom: 12px; }
|
| 373 |
+
.field-label {
|
| 374 |
+
font-size: 0.75rem; font-weight: 500; color: var(--text-dim);
|
| 375 |
+
margin-bottom: 5px; display: block;
|
| 376 |
+
}
|
| 377 |
+
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
| 378 |
+
|
| 379 |
+
/* Speed slider */
|
| 380 |
+
.speed-row { display: flex; align-items: center; gap: 10px; }
|
| 381 |
+
.speed-range {
|
| 382 |
+
flex: 1; -webkit-appearance: none; height: 5px;
|
| 383 |
+
background: rgba(124,106,239,0.2); border-radius: 3px; outline: none;
|
| 384 |
+
accent-color: var(--fg);
|
| 385 |
+
}
|
| 386 |
+
.speed-range::-webkit-slider-thumb {
|
| 387 |
+
-webkit-appearance: none; width: 16px; height: 16px;
|
| 388 |
+
border-radius: 50%; background: var(--fg); cursor: pointer;
|
| 389 |
+
}
|
| 390 |
+
.speed-range::-moz-range-thumb {
|
| 391 |
+
width: 16px; height: 16px; border: none;
|
| 392 |
+
border-radius: 50%; background: var(--fg); cursor: pointer;
|
| 393 |
+
}
|
| 394 |
+
.speed-val {
|
| 395 |
+
font-weight: 600; color: var(--fg2); min-width: 42px; text-align: center;
|
| 396 |
+
font-size: 0.9rem;
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
/* Checkboxes */
|
| 400 |
+
.check-row {
|
| 401 |
+
display: flex; align-items: center; gap: 7px;
|
| 402 |
+
font-size: 0.82rem; color: var(--text-dim); cursor: pointer;
|
| 403 |
+
}
|
| 404 |
+
.check-row input { accent-color: var(--fg); width: 15px; height: 15px; cursor: pointer; }
|
| 405 |
+
|
| 406 |
+
/* Buttons */
|
| 407 |
+
.btn {
|
| 408 |
+
width: 100%; padding: 12px 20px; border: none; border-radius: 8px;
|
| 409 |
+
font-size: 0.95rem; font-weight: 600; cursor: pointer;
|
| 410 |
+
transition: all 0.2s; font-family: var(--font);
|
| 411 |
+
display: flex; align-items: center; justify-content: center; gap: 8px;
|
| 412 |
+
}
|
| 413 |
+
.btn-gen {
|
| 414 |
+
background: linear-gradient(135deg, var(--fg), #6d5ce7);
|
| 415 |
+
color: white; position: relative; overflow: hidden;
|
| 416 |
+
}
|
| 417 |
+
.btn-gen:hover:not(:disabled) {
|
| 418 |
+
transform: translateY(-1px);
|
| 419 |
+
box-shadow: 0 6px 20px rgba(124,106,239,0.35);
|
| 420 |
+
}
|
| 421 |
+
.btn-gen:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
| 422 |
+
.btn-cancel {
|
| 423 |
+
background: rgba(248,113,113,0.15); color: var(--error);
|
| 424 |
+
border: 1px solid rgba(248,113,113,0.3); margin-top: 8px;
|
| 425 |
+
}
|
| 426 |
+
.btn-cancel:hover { background: rgba(248,113,113,0.25); }
|
| 427 |
+
|
| 428 |
+
/* Spinner */
|
| 429 |
+
.spinner {
|
| 430 |
+
width: 18px; height: 18px; border: 2px solid rgba(255,255,255,0.3);
|
| 431 |
+
border-top-color: white; border-radius: 50%;
|
| 432 |
+
animation: spin 0.7s linear infinite; display: none;
|
| 433 |
+
}
|
| 434 |
+
.spinning .spinner { display: block; }
|
| 435 |
+
.spinning .btn-label { display: none; }
|
| 436 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 437 |
+
|
| 438 |
+
/* Model management panel */
|
| 439 |
+
.model-panel { margin-top: 1rem; }
|
| 440 |
+
.model-tag {
|
| 441 |
+
display: inline-flex; align-items: center; gap: 6px;
|
| 442 |
+
background: rgba(74,222,128,0.1); border: 1px solid rgba(74,222,128,0.2);
|
| 443 |
+
border-radius: 6px; padding: 4px 10px; font-size: 0.78rem;
|
| 444 |
+
color: var(--success); margin: 3px;
|
| 445 |
+
}
|
| 446 |
+
.model-tag .unload-x {
|
| 447 |
+
cursor: pointer; opacity: 0.6; font-weight: 700;
|
| 448 |
+
transition: opacity 0.2s;
|
| 449 |
+
}
|
| 450 |
+
.model-tag .unload-x:hover { opacity: 1; }
|
| 451 |
+
.model-load-row { display: flex; gap: 8px; margin-top: 8px; }
|
| 452 |
+
.btn-sm {
|
| 453 |
+
padding: 7px 14px; border-radius: 6px; border: 1px solid var(--border);
|
| 454 |
+
background: var(--surface2); color: var(--text); font-size: 0.8rem;
|
| 455 |
+
cursor: pointer; font-family: var(--font); transition: all 0.2s;
|
| 456 |
+
}
|
| 457 |
+
.btn-sm:hover { border-color: var(--fg); color: var(--fg2); }
|
| 458 |
+
|
| 459 |
+
/* Model tag expanded (with device info) */
|
| 460 |
+
.model-tag-expanded {
|
| 461 |
+
display: flex; align-items: center; gap: 8px;
|
| 462 |
+
background: rgba(74,222,128,0.06); border: 1px solid rgba(74,222,128,0.15);
|
| 463 |
+
border-radius: 8px; padding: 8px 12px; font-size: 0.8rem;
|
| 464 |
+
color: var(--text); margin-bottom: 6px;
|
| 465 |
+
}
|
| 466 |
+
.model-tag-expanded .model-name { font-weight: 600; color: var(--success); }
|
| 467 |
+
.model-tag-expanded .model-device-badge {
|
| 468 |
+
font-size: 0.68rem; font-weight: 600; padding: 2px 7px;
|
| 469 |
+
border-radius: 4px; text-transform: uppercase;
|
| 470 |
+
}
|
| 471 |
+
.model-device-badge.gpu-badge {
|
| 472 |
+
background: rgba(74,222,128,0.15); color: #4ade80; border: 1px solid rgba(74,222,128,0.25);
|
| 473 |
+
}
|
| 474 |
+
.model-device-badge.cpu-badge {
|
| 475 |
+
background: rgba(148,163,184,0.1); color: var(--text-dim); border: 1px solid var(--border);
|
| 476 |
+
}
|
| 477 |
+
.model-tag-expanded .model-lang {
|
| 478 |
+
font-size: 0.65rem; background: rgba(124,106,239,0.12);
|
| 479 |
+
color: var(--fg2); border-radius: 4px; padding: 1px 6px;
|
| 480 |
+
}
|
| 481 |
+
.model-tag-expanded .model-actions { margin-left: auto; display: flex; gap: 4px; }
|
| 482 |
+
.model-tag-expanded .toggle-device-btn {
|
| 483 |
+
padding: 3px 8px; border-radius: 4px; border: 1px solid var(--border);
|
| 484 |
+
background: var(--surface2); color: var(--text-dim); font-size: 0.68rem;
|
| 485 |
+
cursor: pointer; font-family: var(--font); transition: all 0.2s;
|
| 486 |
+
}
|
| 487 |
+
.model-tag-expanded .toggle-device-btn:hover { border-color: var(--fg); color: var(--fg2); }
|
| 488 |
+
.model-tag-expanded .unload-btn {
|
| 489 |
+
padding: 3px 8px; border-radius: 4px; border: 1px solid rgba(248,113,113,0.2);
|
| 490 |
+
background: rgba(248,113,113,0.06); color: var(--error); font-size: 0.68rem;
|
| 491 |
+
cursor: pointer; font-family: var(--font); transition: all 0.2s; font-weight: 600;
|
| 492 |
+
}
|
| 493 |
+
.model-tag-expanded .unload-btn:hover { background: rgba(248,113,113,0.15); }
|
| 494 |
+
|
| 495 |
+
/* Loading card */
|
| 496 |
+
.loading-card {
|
| 497 |
+
display: flex; align-items: center; gap: 10px;
|
| 498 |
+
background: rgba(124,106,239,0.06); border: 1px solid rgba(124,106,239,0.15);
|
| 499 |
+
border-radius: 8px; padding: 10px 12px; margin-bottom: 6px;
|
| 500 |
+
font-size: 0.8rem; color: var(--text-dim); flex-wrap: wrap;
|
| 501 |
+
}
|
| 502 |
+
.loading-spinner-sm {
|
| 503 |
+
width: 16px; height: 16px; border: 2px solid rgba(124,106,239,0.2);
|
| 504 |
+
border-top-color: var(--fg); border-radius: 50%;
|
| 505 |
+
animation: spin 0.8s linear infinite; flex-shrink: 0;
|
| 506 |
+
}
|
| 507 |
+
.loading-card .loading-model { font-weight: 600; color: var(--fg2); }
|
| 508 |
+
.loading-card .loading-phase { color: var(--text-dim); font-size: 0.72rem; }
|
| 509 |
+
.loading-card .loading-time { margin-left: auto; font-size: 0.72rem; font-variant-numeric: tabular-nums; }
|
| 510 |
+
.loading-card.error-card {
|
| 511 |
+
background: rgba(248,113,113,0.06); border-color: rgba(248,113,113,0.2);
|
| 512 |
+
}
|
| 513 |
+
.loading-card.error-card .loading-model { color: var(--error); }
|
| 514 |
+
.loading-card.ready-card {
|
| 515 |
+
background: rgba(74,222,128,0.06); border-color: rgba(74,222,128,0.2);
|
| 516 |
+
}
|
| 517 |
+
.loading-card.ready-card .loading-model { color: var(--success); }
|
| 518 |
+
|
| 519 |
+
.loading-progress-bar {
|
| 520 |
+
width: 100%; height: 3px; background: rgba(124,106,239,0.1);
|
| 521 |
+
border-radius: 2px; margin-top: 4px; overflow: hidden;
|
| 522 |
+
flex-basis: 100%;
|
| 523 |
+
}
|
| 524 |
+
.loading-progress-bar-fill {
|
| 525 |
+
height: 100%; border-radius: 2px; transition: width 0.5s, background 0.5s;
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
/* Language filter pills */
|
| 529 |
+
.lang-filter { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 8px; }
|
| 530 |
+
.lang-pill {
|
| 531 |
+
padding: 3px 10px; border-radius: 12px; font-size: 0.7rem;
|
| 532 |
+
border: 1px solid var(--border); background: var(--surface2);
|
| 533 |
+
color: var(--text-dim); cursor: pointer; transition: all 0.2s;
|
| 534 |
+
font-family: var(--font);
|
| 535 |
+
}
|
| 536 |
+
.lang-pill:hover { border-color: var(--fg); color: var(--fg2); }
|
| 537 |
+
.lang-pill.active {
|
| 538 |
+
background: rgba(124,106,239,0.15); border-color: rgba(124,106,239,0.3);
|
| 539 |
+
color: var(--fg2); font-weight: 600;
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
/* Voice hint */
|
| 543 |
+
.voice-hint {
|
| 544 |
+
background: rgba(251,191,36,0.08); border: 1px solid rgba(251,191,36,0.2);
|
| 545 |
+
border-radius: 6px; padding: 8px 10px; font-size: 0.75rem;
|
| 546 |
+
color: var(--warning); margin-top: 8px; line-height: 1.4;
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
/* Status message */
|
| 550 |
+
.status-msg {
|
| 551 |
+
padding: 10px 14px; border-radius: 8px;
|
| 552 |
+
font-size: 0.82rem; font-weight: 500; text-align: center;
|
| 553 |
+
margin-top: 10px; transition: all 0.3s; opacity: 0;
|
| 554 |
+
}
|
| 555 |
+
.status-msg.info { opacity: 1; background: rgba(124,106,239,0.1); border: 1px solid rgba(124,106,239,0.2); }
|
| 556 |
+
.status-msg.error { opacity: 1; background: rgba(248,113,113,0.1); border: 1px solid rgba(248,113,113,0.2); color: var(--error); }
|
| 557 |
+
.status-msg.success { opacity: 1; background: rgba(74,222,128,0.1); border: 1px solid rgba(74,222,128,0.2); color: var(--success); }
|
| 558 |
+
|
| 559 |
+
/* Scrollbar */
|
| 560 |
+
::-webkit-scrollbar { width: 6px; }
|
| 561 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 562 |
+
::-webkit-scrollbar-thumb { background: rgba(124,106,239,0.3); border-radius: 3px; }
|
| 563 |
+
|
| 564 |
+
/* Mobile player */
|
| 565 |
+
@media (max-width: 860px) {
|
| 566 |
+
.player-bar { flex-wrap: wrap; height: auto; gap: 8px; padding: 10px; }
|
| 567 |
+
.seek-wrap { order: 10; flex-basis: 100%; }
|
| 568 |
+
.vol-group { border: none; padding: 0; }
|
| 569 |
+
.time-display { border: none; padding: 0; min-width: 60px; }
|
| 570 |
+
.wave-box { height: 40px; }
|
| 571 |
+
}
|
| 572 |
+
</style>
|
| 573 |
+
</head>
|
| 574 |
+
<body>
|
| 575 |
+
<div class="bg-glow"></div>
|
| 576 |
+
<div class="bg-grid"></div>
|
| 577 |
+
|
| 578 |
+
<div class="app">
|
| 579 |
+
<header>
|
| 580 |
+
<div class="logo">NeuTTS-FastAPI</div>
|
| 581 |
+
<p class="subtitle">OpenAI-compatible Text-to-Speech powered by Neuphonic NeuTTS</p>
|
| 582 |
+
<div class="badges">
|
| 583 |
+
<div class="badge"><span class="dot dot-off" id="srv-dot"></span><span id="srv-text">Connecting...</span></div>
|
| 584 |
+
<div class="badge" id="model-badge">Models: ...</div>
|
| 585 |
+
<div class="badge" id="voice-badge">Voices: ...</div>
|
| 586 |
+
<div class="device-badge cpu" id="device-badge">
|
| 587 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M9 1v3M15 1v3M9 20v3M15 20v3M1 9h3M1 15h3M20 9h3M20 15h3"/></svg>
|
| 588 |
+
<span id="device-badge-text">CPU Only</span>
|
| 589 |
+
</div>
|
| 590 |
+
</div>
|
| 591 |
+
</header>
|
| 592 |
+
|
| 593 |
+
<div id="gpu-warning-banner" style="display:none"></div>
|
| 594 |
+
|
| 595 |
+
<div class="main-grid">
|
| 596 |
+
<!-- Left Column: Editor + Player -->
|
| 597 |
+
<div class="left-col">
|
| 598 |
+
<div class="card text-editor">
|
| 599 |
+
<div class="card-label">Text Input</div>
|
| 600 |
+
<textarea id="tts-text" placeholder="Enter text to synthesize..." maxlength="10000">Hello, this is a test of the NeuTTS text-to-speech system. It supports multiple languages including English, German, French and Spanish.</textarea>
|
| 601 |
+
<div class="text-meta">
|
| 602 |
+
<span><span id="char-cnt">0</span> / 10,000 characters</span>
|
| 603 |
+
<span><kbd>Ctrl</kbd>+<kbd>Enter</kbd> to generate</span>
|
| 604 |
+
</div>
|
| 605 |
+
</div>
|
| 606 |
+
|
| 607 |
+
<div class="card player">
|
| 608 |
+
<div class="card-label">Audio Player</div>
|
| 609 |
+
<div class="player-bar">
|
| 610 |
+
<button class="player-btn" id="play-btn" title="Play/Pause">
|
| 611 |
+
<svg id="play-icon" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
| 612 |
+
<svg id="pause-icon" viewBox="0 0 24 24" style="display:none"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
| 613 |
+
</button>
|
| 614 |
+
<div class="seek-wrap">
|
| 615 |
+
<input type="range" class="seek-slider" id="seek-slider" min="0" max="1000" value="0">
|
| 616 |
+
</div>
|
| 617 |
+
<div class="time-display" id="time-disp">0:00 / 0:00</div>
|
| 618 |
+
<div class="vol-group">
|
| 619 |
+
<svg class="vol-icon" width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>
|
| 620 |
+
<input type="range" class="vol-slider" id="vol-slider" min="0" max="100" value="100">
|
| 621 |
+
</div>
|
| 622 |
+
</div>
|
| 623 |
+
<div class="wave-box" id="wave-box">
|
| 624 |
+
<canvas id="wave-canvas"></canvas>
|
| 625 |
+
<div class="gen-progress" id="gen-progress" style="width:0%"></div>
|
| 626 |
+
<div class="dl-wrap" id="dl-wrap">
|
| 627 |
+
<a class="dl-btn" id="dl-btn" download title="Download audio">
|
| 628 |
+
<div class="dl-glow"></div>
|
| 629 |
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
|
| 630 |
+
<path d="M8 10L4.5 6.5h7L8 10z" fill="currentColor"/>
|
| 631 |
+
<path d="M8 2v8M3 13h10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
| 632 |
+
</svg>
|
| 633 |
+
</a>
|
| 634 |
+
</div>
|
| 635 |
+
</div>
|
| 636 |
+
</div>
|
| 637 |
+
</div>
|
| 638 |
+
|
| 639 |
+
<!-- Right Column: Controls -->
|
| 640 |
+
<div class="controls-panel">
|
| 641 |
+
<!-- Voice Selection -->
|
| 642 |
+
<div class="card">
|
| 643 |
+
<div class="card-label">Voice</div>
|
| 644 |
+
<input type="text" id="voice-search" placeholder="Search voices..." autocomplete="off">
|
| 645 |
+
<div class="voice-list" id="voice-list"></div>
|
| 646 |
+
<div id="voice-hint-container"></div>
|
| 647 |
+
<button class="voice-upload-toggle" id="voice-upload-toggle">+ Clone a Voice (Upload or Record)</button>
|
| 648 |
+
<div class="voice-upload-panel" id="voice-upload-panel">
|
| 649 |
+
<input type="text" id="upload-voice-id" placeholder="Voice ID (e.g. my-voice)" autocomplete="off">
|
| 650 |
+
<div class="field-row">
|
| 651 |
+
<select id="upload-language">
|
| 652 |
+
<option value="unknown">Language...</option>
|
| 653 |
+
<option value="en-us">English (en-us)</option>
|
| 654 |
+
<option value="de">German (de)</option>
|
| 655 |
+
<option value="fr-fr">French (fr-fr)</option>
|
| 656 |
+
<option value="es">Spanish (es)</option>
|
| 657 |
+
<option value="other">Other</option>
|
| 658 |
+
</select>
|
| 659 |
+
<select id="upload-gender">
|
| 660 |
+
<option value="unknown">Gender...</option>
|
| 661 |
+
<option value="male">Male</option>
|
| 662 |
+
<option value="female">Female</option>
|
| 663 |
+
<option value="other">Other</option>
|
| 664 |
+
</select>
|
| 665 |
+
</div>
|
| 666 |
+
<div class="drop-zone" id="upload-drop-zone">Drop WAV file here or click to browse
|
| 667 |
+
<input type="file" id="upload-file-input" accept=".wav,audio/wav" style="display:none">
|
| 668 |
+
</div>
|
| 669 |
+
<div class="record-section">
|
| 670 |
+
<div class="record-section-label">or record from microphone</div>
|
| 671 |
+
<button class="record-btn" id="record-btn" title="Record from microphone">REC</button>
|
| 672 |
+
<span class="record-timer" id="record-timer">0:00</span>
|
| 673 |
+
</div>
|
| 674 |
+
<textarea id="upload-ref-text" placeholder="Exact transcription of the audio..."></textarea>
|
| 675 |
+
<div class="voice-upload-progress" id="upload-progress">
|
| 676 |
+
<div class="voice-upload-progress-bar" id="upload-progress-bar"></div>
|
| 677 |
+
</div>
|
| 678 |
+
<button class="btn-upload" id="upload-btn" disabled>Upload & Clone Voice</button>
|
| 679 |
+
<div class="voice-upload-status" id="upload-status"></div>
|
| 680 |
+
</div>
|
| 681 |
+
</div>
|
| 682 |
+
|
| 683 |
+
<!-- Settings -->
|
| 684 |
+
<div class="card">
|
| 685 |
+
<div class="card-label">Settings</div>
|
| 686 |
+
<div class="field-row">
|
| 687 |
+
<div class="field">
|
| 688 |
+
<label class="field-label">Format</label>
|
| 689 |
+
<select id="fmt-select">
|
| 690 |
+
<option value="mp3" selected>MP3</option>
|
| 691 |
+
<option value="wav">WAV</option>
|
| 692 |
+
<option value="opus">Opus (OGG)</option>
|
| 693 |
+
<option value="flac">FLAC</option>
|
| 694 |
+
<option value="aac">AAC</option>
|
| 695 |
+
<option value="pcm">PCM (raw)</option>
|
| 696 |
+
</select>
|
| 697 |
+
</div>
|
| 698 |
+
<div class="field">
|
| 699 |
+
<label class="field-label">Model</label>
|
| 700 |
+
<select id="model-select"></select>
|
| 701 |
+
</div>
|
| 702 |
+
</div>
|
| 703 |
+
<div class="field">
|
| 704 |
+
<label class="field-label">Speed</label>
|
| 705 |
+
<div class="speed-row">
|
| 706 |
+
<span style="font-size:0.7rem;color:var(--text-dim)">0.25x</span>
|
| 707 |
+
<input type="range" class="speed-range" id="speed-range" min="0.25" max="4.0" step="0.05" value="1.0">
|
| 708 |
+
<span style="font-size:0.7rem;color:var(--text-dim)">4.0x</span>
|
| 709 |
+
<span class="speed-val" id="speed-val">1.00x</span>
|
| 710 |
+
</div>
|
| 711 |
+
</div>
|
| 712 |
+
<div style="display:flex;gap:16px;flex-wrap:wrap">
|
| 713 |
+
<label class="check-row"><input type="checkbox" id="stream-chk"> Streaming</label>
|
| 714 |
+
<label class="check-row"><input type="checkbox" id="autoplay-chk" checked> Auto-play</label>
|
| 715 |
+
</div>
|
| 716 |
+
</div>
|
| 717 |
+
|
| 718 |
+
<!-- Generate -->
|
| 719 |
+
<div class="card">
|
| 720 |
+
<button class="btn btn-gen" id="gen-btn" disabled>
|
| 721 |
+
<span class="btn-label">Generate Speech</span>
|
| 722 |
+
<div class="spinner"></div>
|
| 723 |
+
</button>
|
| 724 |
+
<button class="btn btn-cancel" id="cancel-btn" style="display:none">Cancel</button>
|
| 725 |
+
<div class="status-msg" id="status-msg"></div>
|
| 726 |
+
</div>
|
| 727 |
+
|
| 728 |
+
<!-- Model Management -->
|
| 729 |
+
<div class="card model-panel">
|
| 730 |
+
<div class="card-label">Model Management</div>
|
| 731 |
+
<div id="loaded-models-list"></div>
|
| 732 |
+
<div id="loading-tasks-list"></div>
|
| 733 |
+
<div class="lang-filter" id="lang-filter"></div>
|
| 734 |
+
<div class="model-load-row">
|
| 735 |
+
<select id="registry-select" style="font-size:0.8rem"></select>
|
| 736 |
+
<button class="btn-sm" id="load-btn">Load</button>
|
| 737 |
+
</div>
|
| 738 |
+
</div>
|
| 739 |
+
</div>
|
| 740 |
+
</div>
|
| 741 |
+
</div>
|
| 742 |
+
|
| 743 |
+
<audio id="audio-el" preload="none"></audio>
|
| 744 |
+
|
| 745 |
+
<script>
|
| 746 |
+
(function() {
|
| 747 |
+
const API = location.origin;
|
| 748 |
+
|
| 749 |
+
// DOM
|
| 750 |
+
const $ = id => document.getElementById(id);
|
| 751 |
+
const ttsText = $('tts-text');
|
| 752 |
+
const charCnt = $('char-cnt');
|
| 753 |
+
const voiceSearch = $('voice-search');
|
| 754 |
+
const voiceList = $('voice-list');
|
| 755 |
+
const modelSelect = $('model-select');
|
| 756 |
+
const fmtSelect = $('fmt-select');
|
| 757 |
+
const speedRange = $('speed-range');
|
| 758 |
+
const speedVal = $('speed-val');
|
| 759 |
+
const streamChk = $('stream-chk');
|
| 760 |
+
const autoplayChk = $('autoplay-chk');
|
| 761 |
+
const genBtn = $('gen-btn');
|
| 762 |
+
const cancelBtn = $('cancel-btn');
|
| 763 |
+
const statusMsg = $('status-msg');
|
| 764 |
+
const srvDot = $('srv-dot');
|
| 765 |
+
const srvText = $('srv-text');
|
| 766 |
+
const modelBadge = $('model-badge');
|
| 767 |
+
const voiceBadge = $('voice-badge');
|
| 768 |
+
const deviceBadge = $('device-badge');
|
| 769 |
+
const deviceBadgeText = $('device-badge-text');
|
| 770 |
+
const playBtn = $('play-btn');
|
| 771 |
+
const playIcon = $('play-icon');
|
| 772 |
+
const pauseIcon = $('pause-icon');
|
| 773 |
+
const seekSlider = $('seek-slider');
|
| 774 |
+
const timeDisp = $('time-disp');
|
| 775 |
+
const volSlider = $('vol-slider');
|
| 776 |
+
const waveCanvas = $('wave-canvas');
|
| 777 |
+
const genProgress = $('gen-progress');
|
| 778 |
+
const dlWrap = $('dl-wrap');
|
| 779 |
+
const dlBtn = $('dl-btn');
|
| 780 |
+
const loadedModelsList = $('loaded-models-list');
|
| 781 |
+
const loadingTasksList = $('loading-tasks-list');
|
| 782 |
+
const langFilter = $('lang-filter');
|
| 783 |
+
const registrySelect = $('registry-select');
|
| 784 |
+
const loadBtn = $('load-btn');
|
| 785 |
+
const voiceHintContainer = $('voice-hint-container');
|
| 786 |
+
const gpuWarningBanner = $('gpu-warning-banner');
|
| 787 |
+
const audio = $('audio-el');
|
| 788 |
+
|
| 789 |
+
// Upload elements
|
| 790 |
+
const uploadToggle = $('voice-upload-toggle');
|
| 791 |
+
const uploadPanel = $('voice-upload-panel');
|
| 792 |
+
const uploadVoiceId = $('upload-voice-id');
|
| 793 |
+
const uploadLanguage = $('upload-language');
|
| 794 |
+
const uploadGender = $('upload-gender');
|
| 795 |
+
const uploadDropZone = $('upload-drop-zone');
|
| 796 |
+
const uploadFileInput = $('upload-file-input');
|
| 797 |
+
const uploadRefText = $('upload-ref-text');
|
| 798 |
+
const uploadProgress = $('upload-progress');
|
| 799 |
+
const uploadProgressBar = $('upload-progress-bar');
|
| 800 |
+
const uploadBtn = $('upload-btn');
|
| 801 |
+
const uploadStatus = $('upload-status');
|
| 802 |
+
const recordBtn = $('record-btn');
|
| 803 |
+
const recordTimer = $('record-timer');
|
| 804 |
+
|
| 805 |
+
let voices = [];
|
| 806 |
+
let selectedVoice = '';
|
| 807 |
+
let loadedModels = [];
|
| 808 |
+
let abortCtrl = null;
|
| 809 |
+
let waveCtx = waveCanvas.getContext('2d');
|
| 810 |
+
let waveAnim = null;
|
| 811 |
+
let analyser = null;
|
| 812 |
+
let audioCtx = null;
|
| 813 |
+
let audioSource = null;
|
| 814 |
+
let currentBlobUrl = null;
|
| 815 |
+
let gpuAvailable = false;
|
| 816 |
+
let gpuName = '';
|
| 817 |
+
let loadingTaskTimers = {};
|
| 818 |
+
let registryData = [];
|
| 819 |
+
let activeLangFilter = 'all';
|
| 820 |
+
let gpuWarningDismissed = false;
|
| 821 |
+
|
| 822 |
+
// Upload state
|
| 823 |
+
let uploadFile = null;
|
| 824 |
+
let mediaRecorder = null;
|
| 825 |
+
let recordChunks = [];
|
| 826 |
+
let recordTimerInterval = null;
|
| 827 |
+
let recordStartTime = 0;
|
| 828 |
+
|
| 829 |
+
// --- Audio Context & Analyser ---
|
| 830 |
+
function ensureAudioCtx() {
|
| 831 |
+
if (!audioCtx) {
|
| 832 |
+
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
| 833 |
+
analyser = audioCtx.createAnalyser();
|
| 834 |
+
analyser.fftSize = 256;
|
| 835 |
+
analyser.smoothingTimeConstant = 0.8;
|
| 836 |
+
audioSource = audioCtx.createMediaElementSource(audio);
|
| 837 |
+
audioSource.connect(analyser);
|
| 838 |
+
analyser.connect(audioCtx.destination);
|
| 839 |
+
}
|
| 840 |
+
}
|
| 841 |
+
|
| 842 |
+
// --- Wave Visualization ---
|
| 843 |
+
function resizeCanvas() {
|
| 844 |
+
const box = $('wave-box');
|
| 845 |
+
waveCanvas.width = box.clientWidth * window.devicePixelRatio;
|
| 846 |
+
waveCanvas.height = box.clientHeight * window.devicePixelRatio;
|
| 847 |
+
waveCtx.scale(window.devicePixelRatio, window.devicePixelRatio);
|
| 848 |
+
}
|
| 849 |
+
resizeCanvas();
|
| 850 |
+
window.addEventListener('resize', resizeCanvas);
|
| 851 |
+
|
| 852 |
+
function drawWave() {
|
| 853 |
+
const w = waveCanvas.width / window.devicePixelRatio;
|
| 854 |
+
const h = waveCanvas.height / window.devicePixelRatio;
|
| 855 |
+
waveCtx.clearRect(0, 0, w, h);
|
| 856 |
+
|
| 857 |
+
if (analyser && !audio.paused) {
|
| 858 |
+
const data = new Uint8Array(analyser.frequencyBinCount);
|
| 859 |
+
analyser.getByteFrequencyData(data);
|
| 860 |
+
|
| 861 |
+
const barCount = Math.min(data.length, 80);
|
| 862 |
+
const barW = w / barCount;
|
| 863 |
+
const grad = waveCtx.createLinearGradient(0, h, 0, 0);
|
| 864 |
+
grad.addColorStop(0, 'rgba(124,106,239,0.2)');
|
| 865 |
+
grad.addColorStop(1, 'rgba(167,139,250,0.8)');
|
| 866 |
+
waveCtx.fillStyle = grad;
|
| 867 |
+
|
| 868 |
+
for (let i = 0; i < barCount; i++) {
|
| 869 |
+
const barH = (data[i] / 255) * h * 0.85;
|
| 870 |
+
const x = i * barW;
|
| 871 |
+
waveCtx.fillRect(x + 1, h - barH, barW - 2, barH);
|
| 872 |
+
}
|
| 873 |
+
} else {
|
| 874 |
+
// Idle wave
|
| 875 |
+
const t = Date.now() / 1000;
|
| 876 |
+
waveCtx.strokeStyle = 'rgba(124,106,239,0.3)';
|
| 877 |
+
waveCtx.lineWidth = 1.5;
|
| 878 |
+
waveCtx.beginPath();
|
| 879 |
+
for (let x = 0; x < w; x++) {
|
| 880 |
+
const y = h/2 + Math.sin(x/30 + t*2) * 6 + Math.sin(x/15 + t*3) * 3;
|
| 881 |
+
x === 0 ? waveCtx.moveTo(x, y) : waveCtx.lineTo(x, y);
|
| 882 |
+
}
|
| 883 |
+
waveCtx.stroke();
|
| 884 |
+
}
|
| 885 |
+
waveAnim = requestAnimationFrame(drawWave);
|
| 886 |
+
}
|
| 887 |
+
drawWave();
|
| 888 |
+
|
| 889 |
+
// --- Char counter ---
|
| 890 |
+
ttsText.addEventListener('input', () => { charCnt.textContent = ttsText.value.length; });
|
| 891 |
+
charCnt.textContent = ttsText.value.length;
|
| 892 |
+
|
| 893 |
+
// --- Speed ---
|
| 894 |
+
speedRange.addEventListener('input', () => {
|
| 895 |
+
speedVal.textContent = parseFloat(speedRange.value).toFixed(2) + 'x';
|
| 896 |
+
});
|
| 897 |
+
|
| 898 |
+
// --- Model change: check language compatibility ---
|
| 899 |
+
modelSelect.addEventListener('change', () => {
|
| 900 |
+
if (!selectedVoice) return;
|
| 901 |
+
const voice = voices.find(v => v.name === selectedVoice);
|
| 902 |
+
const model = loadedModels.find(m => m.id === modelSelect.value);
|
| 903 |
+
if (voice && model && voice.language && model.language
|
| 904 |
+
&& voice.language !== model.language) {
|
| 905 |
+
showLangMismatchWarning(voice.language);
|
| 906 |
+
} else {
|
| 907 |
+
clearLangMismatchWarning();
|
| 908 |
+
}
|
| 909 |
+
});
|
| 910 |
+
|
| 911 |
+
// --- Format time ---
|
| 912 |
+
function fmtTime(s) {
|
| 913 |
+
if (!s || !isFinite(s)) return '0:00';
|
| 914 |
+
const m = Math.floor(s / 60);
|
| 915 |
+
const sec = Math.floor(s % 60);
|
| 916 |
+
return m + ':' + String(sec).padStart(2, '0');
|
| 917 |
+
}
|
| 918 |
+
|
| 919 |
+
// --- Audio player ---
|
| 920 |
+
audio.addEventListener('timeupdate', () => {
|
| 921 |
+
if (audio.duration) {
|
| 922 |
+
seekSlider.value = Math.floor((audio.currentTime / audio.duration) * 1000);
|
| 923 |
+
timeDisp.textContent = fmtTime(audio.currentTime) + ' / ' + fmtTime(audio.duration);
|
| 924 |
+
}
|
| 925 |
+
});
|
| 926 |
+
audio.addEventListener('play', () => {
|
| 927 |
+
playIcon.style.display = 'none'; pauseIcon.style.display = 'block';
|
| 928 |
+
ensureAudioCtx(); if (audioCtx.state === 'suspended') audioCtx.resume();
|
| 929 |
+
});
|
| 930 |
+
audio.addEventListener('pause', () => {
|
| 931 |
+
playIcon.style.display = 'block'; pauseIcon.style.display = 'none';
|
| 932 |
+
});
|
| 933 |
+
audio.addEventListener('ended', () => {
|
| 934 |
+
playIcon.style.display = 'block'; pauseIcon.style.display = 'none';
|
| 935 |
+
seekSlider.value = 0;
|
| 936 |
+
});
|
| 937 |
+
playBtn.addEventListener('click', () => {
|
| 938 |
+
ensureAudioCtx();
|
| 939 |
+
if (audio.paused && audio.src) audio.play(); else audio.pause();
|
| 940 |
+
});
|
| 941 |
+
seekSlider.addEventListener('input', () => {
|
| 942 |
+
if (audio.duration) audio.currentTime = (seekSlider.value / 1000) * audio.duration;
|
| 943 |
+
});
|
| 944 |
+
volSlider.addEventListener('input', () => { audio.volume = volSlider.value / 100; });
|
| 945 |
+
|
| 946 |
+
// --- Fetch server state ---
|
| 947 |
+
async function refreshState() {
|
| 948 |
+
try {
|
| 949 |
+
const [hR, vR, mR, rR, sR, lR] = await Promise.all([
|
| 950 |
+
fetch(API + '/health'), fetch(API + '/v1/audio/voices'),
|
| 951 |
+
fetch(API + '/v1/models'), fetch(API + '/v1/models/registry'),
|
| 952 |
+
fetch(API + '/debug/system'), fetch(API + '/v1/models/loaded')
|
| 953 |
+
]);
|
| 954 |
+
const health = await hR.json();
|
| 955 |
+
const vData = await vR.json();
|
| 956 |
+
const mData = await mR.json();
|
| 957 |
+
const rData = await rR.json();
|
| 958 |
+
const sData = await sR.json();
|
| 959 |
+
const lData = await lR.json();
|
| 960 |
+
|
| 961 |
+
// Status
|
| 962 |
+
srvDot.className = 'dot dot-ok';
|
| 963 |
+
srvText.textContent = 'Online v' + health.version;
|
| 964 |
+
|
| 965 |
+
// GPU Badge
|
| 966 |
+
gpuAvailable = sData.gpu_available || false;
|
| 967 |
+
if (gpuAvailable && sData.gpu_info && sData.gpu_info.length > 0) {
|
| 968 |
+
gpuName = sData.gpu_info[0].name || 'GPU';
|
| 969 |
+
deviceBadge.className = 'device-badge gpu';
|
| 970 |
+
deviceBadgeText.textContent = gpuName;
|
| 971 |
+
} else {
|
| 972 |
+
gpuName = '';
|
| 973 |
+
deviceBadge.className = 'device-badge cpu';
|
| 974 |
+
deviceBadgeText.textContent = 'CPU Only';
|
| 975 |
+
}
|
| 976 |
+
|
| 977 |
+
// GPU Warning Banner
|
| 978 |
+
if (sData.gpu_detected_but_unusable && !gpuWarningDismissed) {
|
| 979 |
+
gpuWarningBanner.style.display = 'block';
|
| 980 |
+
gpuWarningBanner.innerHTML = '<div class="gpu-warning">'
|
| 981 |
+
+ '<span class="gpu-warning-icon">⚠</span>'
|
| 982 |
+
+ '<div class="gpu-warning-content">'
|
| 983 |
+
+ '<div class="gpu-warning-title">GPU Detected but Unusable</div>'
|
| 984 |
+
+ '<div class="gpu-warning-text">'
|
| 985 |
+
+ (sData.gpu_fix_instructions || 'GPU detected but PyTorch cannot use it.')
|
| 986 |
+
+ '</div></div>'
|
| 987 |
+
+ '<button class="gpu-warning-dismiss" id="gpu-dismiss-btn">Dismiss</button>'
|
| 988 |
+
+ '</div>';
|
| 989 |
+
const dismissBtn = $('gpu-dismiss-btn');
|
| 990 |
+
if (dismissBtn) dismissBtn.addEventListener('click', () => {
|
| 991 |
+
gpuWarningDismissed = true;
|
| 992 |
+
gpuWarningBanner.style.display = 'none';
|
| 993 |
+
});
|
| 994 |
+
} else if (!sData.gpu_detected_but_unusable) {
|
| 995 |
+
gpuWarningBanner.style.display = 'none';
|
| 996 |
+
}
|
| 997 |
+
|
| 998 |
+
// Voices
|
| 999 |
+
voices = vData.voices || [];
|
| 1000 |
+
voiceBadge.textContent = 'Voices: ' + voices.length;
|
| 1001 |
+
renderVoices(voices);
|
| 1002 |
+
if (!selectedVoice && voices.length) {
|
| 1003 |
+
const firstAvailable = voices.find(v => v.available !== false);
|
| 1004 |
+
if (firstAvailable) selectVoice(firstAvailable.name);
|
| 1005 |
+
}
|
| 1006 |
+
|
| 1007 |
+
// Models (for select dropdown) - preserve current selection
|
| 1008 |
+
const prevModel = modelSelect.value;
|
| 1009 |
+
loadedModels = mData.data || [];
|
| 1010 |
+
modelBadge.textContent = 'Models: ' + loadedModels.length + ' loaded';
|
| 1011 |
+
modelSelect.innerHTML = '';
|
| 1012 |
+
loadedModels.forEach(m => {
|
| 1013 |
+
const o = document.createElement('option');
|
| 1014 |
+
o.value = m.id; o.textContent = m.id;
|
| 1015 |
+
modelSelect.appendChild(o);
|
| 1016 |
+
});
|
| 1017 |
+
// Restore previous selection if still loaded
|
| 1018 |
+
if (prevModel && loadedModels.some(m => m.id === prevModel)) {
|
| 1019 |
+
modelSelect.value = prevModel;
|
| 1020 |
+
}
|
| 1021 |
+
genBtn.disabled = loadedModels.length === 0;
|
| 1022 |
+
|
| 1023 |
+
// Loaded models (detailed)
|
| 1024 |
+
const loadedDetailed = lData.models || [];
|
| 1025 |
+
renderLoadedModels(loadedDetailed);
|
| 1026 |
+
|
| 1027 |
+
// Voice hint
|
| 1028 |
+
updateVoiceHint(loadedDetailed);
|
| 1029 |
+
|
| 1030 |
+
// Registry
|
| 1031 |
+
registryData = rData.backbones || [];
|
| 1032 |
+
renderLangFilter();
|
| 1033 |
+
renderRegistryDropdown(activeLangFilter);
|
| 1034 |
+
} catch {
|
| 1035 |
+
srvDot.className = 'dot dot-off';
|
| 1036 |
+
srvText.textContent = 'Offline';
|
| 1037 |
+
genBtn.disabled = true;
|
| 1038 |
+
}
|
| 1039 |
+
}
|
| 1040 |
+
|
| 1041 |
+
// --- Voice hint ---
|
| 1042 |
+
function updateVoiceHint(loadedDetailed) {
|
| 1043 |
+
voiceHintContainer.innerHTML = '';
|
| 1044 |
+
const loadedLangs = new Set(loadedDetailed.map(m => m.language));
|
| 1045 |
+
loadedLangs.forEach(lang => {
|
| 1046 |
+
if (!lang) return;
|
| 1047 |
+
const langVoices = voices.filter(v => v.language === lang && v.available !== false);
|
| 1048 |
+
if (langVoices.length <= 1) {
|
| 1049 |
+
const langLabel = {de:'German',es:'Spanish','fr-fr':'French','en-us':'English'}[lang] || lang;
|
| 1050 |
+
const hint = document.createElement('div');
|
| 1051 |
+
hint.className = 'voice-hint';
|
| 1052 |
+
hint.textContent = 'Only ' + langVoices.length + ' ' + langLabel
|
| 1053 |
+
+ ' voice' + (langVoices.length !== 1 ? 's' : '') + ' available. '
|
| 1054 |
+
+ "Use 'Clone a Voice' above to record or upload a custom voice.";
|
| 1055 |
+
voiceHintContainer.appendChild(hint);
|
| 1056 |
+
}
|
| 1057 |
+
});
|
| 1058 |
+
}
|
| 1059 |
+
|
| 1060 |
+
// --- Loaded models rendering ---
|
| 1061 |
+
function renderLoadedModels(models) {
|
| 1062 |
+
loadedModelsList.innerHTML = '';
|
| 1063 |
+
models.forEach(m => {
|
| 1064 |
+
const div = document.createElement('div');
|
| 1065 |
+
div.className = 'model-tag-expanded';
|
| 1066 |
+
|
| 1067 |
+
const isCuda = m.backbone_device && m.backbone_device.startsWith('cuda');
|
| 1068 |
+
const deviceLabel = isCuda ? 'GPU' : 'CPU';
|
| 1069 |
+
const deviceClass = isCuda ? 'gpu-badge' : 'cpu-badge';
|
| 1070 |
+
const isGGUF = m.backend === 'gguf';
|
| 1071 |
+
|
| 1072 |
+
let actionsHtml = '<button class="unload-btn" data-id="' + m.model_id + '">×</button>';
|
| 1073 |
+
if (!isGGUF && gpuAvailable) {
|
| 1074 |
+
const targetDevice = isCuda ? 'cpu' : 'cuda';
|
| 1075 |
+
const toggleLabel = isCuda ? 'CPU' : 'GPU';
|
| 1076 |
+
actionsHtml = '<button class="toggle-device-btn" data-id="' + m.model_id + '" data-target="' + targetDevice + '">'
|
| 1077 |
+
+ toggleLabel + '</button>' + actionsHtml;
|
| 1078 |
+
}
|
| 1079 |
+
|
| 1080 |
+
div.innerHTML = '<span class="model-name">' + m.model_id + '</span>'
|
| 1081 |
+
+ '<span class="model-device-badge ' + deviceClass + '">' + deviceLabel + '</span>'
|
| 1082 |
+
+ (m.language ? '<span class="model-lang">' + m.language + '</span>' : '')
|
| 1083 |
+
+ '<span class="model-actions">' + actionsHtml + '</span>';
|
| 1084 |
+
|
| 1085 |
+
loadedModelsList.appendChild(div);
|
| 1086 |
+
});
|
| 1087 |
+
|
| 1088 |
+
// Event listeners
|
| 1089 |
+
loadedModelsList.querySelectorAll('.unload-btn').forEach(el => {
|
| 1090 |
+
el.addEventListener('click', () => unloadModel(el.dataset.id));
|
| 1091 |
+
});
|
| 1092 |
+
loadedModelsList.querySelectorAll('.toggle-device-btn').forEach(el => {
|
| 1093 |
+
el.addEventListener('click', () => switchDevice(el.dataset.id, el.dataset.target));
|
| 1094 |
+
});
|
| 1095 |
+
}
|
| 1096 |
+
|
| 1097 |
+
// --- Language filter ---
|
| 1098 |
+
function renderLangFilter() {
|
| 1099 |
+
const langs = new Set(registryData.map(b => b.language));
|
| 1100 |
+
const allLangs = ['all', ...Array.from(langs).sort()];
|
| 1101 |
+
langFilter.innerHTML = '';
|
| 1102 |
+
allLangs.forEach(lang => {
|
| 1103 |
+
const pill = document.createElement('button');
|
| 1104 |
+
pill.className = 'lang-pill' + (activeLangFilter === lang ? ' active' : '');
|
| 1105 |
+
pill.textContent = lang === 'all' ? 'All' : lang;
|
| 1106 |
+
pill.addEventListener('click', () => {
|
| 1107 |
+
activeLangFilter = lang;
|
| 1108 |
+
renderLangFilter();
|
| 1109 |
+
renderRegistryDropdown(lang);
|
| 1110 |
+
});
|
| 1111 |
+
langFilter.appendChild(pill);
|
| 1112 |
+
});
|
| 1113 |
+
}
|
| 1114 |
+
|
| 1115 |
+
function renderRegistryDropdown(langFilterVal) {
|
| 1116 |
+
const loadedIds = new Set(loadedModels.map(m => m.id));
|
| 1117 |
+
registrySelect.innerHTML = '';
|
| 1118 |
+
registryData.forEach(b => {
|
| 1119 |
+
if (loadedIds.has(b.model_id)) return;
|
| 1120 |
+
if (langFilterVal !== 'all' && b.language !== langFilterVal) return;
|
| 1121 |
+
const o = document.createElement('option');
|
| 1122 |
+
o.value = b.model_id;
|
| 1123 |
+
o.textContent = b.model_id + ' (' + b.language + ', ' + b.backend + ')';
|
| 1124 |
+
registrySelect.appendChild(o);
|
| 1125 |
+
});
|
| 1126 |
+
}
|
| 1127 |
+
|
| 1128 |
+
// --- Voice rendering ---
|
| 1129 |
+
function renderVoices(list) {
|
| 1130 |
+
voiceList.innerHTML = '';
|
| 1131 |
+
list.forEach(v => {
|
| 1132 |
+
const isUnavailable = v.available === false;
|
| 1133 |
+
const div = document.createElement('div');
|
| 1134 |
+
div.className = 'voice-item'
|
| 1135 |
+
+ (v.name === selectedVoice ? ' active' : '')
|
| 1136 |
+
+ (isUnavailable ? ' voice-unavailable' : '');
|
| 1137 |
+
|
| 1138 |
+
let html = '<span class="v-name">' + v.name + '</span>'
|
| 1139 |
+
+ '<span class="v-lang">' + v.language + '</span>'
|
| 1140 |
+
+ '<span class="v-meta">' + v.gender + '</span>';
|
| 1141 |
+
|
| 1142 |
+
if (v.custom && !isUnavailable) {
|
| 1143 |
+
html += '<span class="v-delete" data-name="' + v.name + '" title="Delete voice">×</span>';
|
| 1144 |
+
}
|
| 1145 |
+
|
| 1146 |
+
div.innerHTML = html;
|
| 1147 |
+
if (!isUnavailable) {
|
| 1148 |
+
div.addEventListener('click', (e) => {
|
| 1149 |
+
if (e.target.classList.contains('v-delete')) return;
|
| 1150 |
+
selectVoice(v.name);
|
| 1151 |
+
});
|
| 1152 |
+
}
|
| 1153 |
+
voiceList.appendChild(div);
|
| 1154 |
+
});
|
| 1155 |
+
|
| 1156 |
+
// Delete listeners
|
| 1157 |
+
voiceList.querySelectorAll('.v-delete').forEach(el => {
|
| 1158 |
+
el.addEventListener('click', (e) => {
|
| 1159 |
+
e.stopPropagation();
|
| 1160 |
+
deleteVoice(el.dataset.name);
|
| 1161 |
+
});
|
| 1162 |
+
});
|
| 1163 |
+
}
|
| 1164 |
+
function selectVoice(name) {
|
| 1165 |
+
selectedVoice = name;
|
| 1166 |
+
voiceSearch.value = name;
|
| 1167 |
+
document.querySelectorAll('.voice-item').forEach(el => {
|
| 1168 |
+
el.classList.toggle('active', el.querySelector('.v-name').textContent === name);
|
| 1169 |
+
});
|
| 1170 |
+
// Auto-select a language-compatible model
|
| 1171 |
+
autoSelectModelForVoice(name);
|
| 1172 |
+
}
|
| 1173 |
+
function autoSelectModelForVoice(voiceName) {
|
| 1174 |
+
const voice = voices.find(v => v.name === voiceName);
|
| 1175 |
+
if (!voice || !voice.language || loadedModels.length === 0) return;
|
| 1176 |
+
// Check if current model already matches
|
| 1177 |
+
const currentModel = loadedModels.find(m => m.id === modelSelect.value);
|
| 1178 |
+
if (currentModel && currentModel.language === voice.language) {
|
| 1179 |
+
clearLangMismatchWarning();
|
| 1180 |
+
return;
|
| 1181 |
+
}
|
| 1182 |
+
// Find a loaded model that matches the voice language
|
| 1183 |
+
const match = loadedModels.find(m => m.language === voice.language);
|
| 1184 |
+
if (match) {
|
| 1185 |
+
modelSelect.value = match.id;
|
| 1186 |
+
clearLangMismatchWarning();
|
| 1187 |
+
} else {
|
| 1188 |
+
showLangMismatchWarning(voice.language);
|
| 1189 |
+
}
|
| 1190 |
+
}
|
| 1191 |
+
function getLanguageLabel(code) {
|
| 1192 |
+
const labels = { 'en-us': 'English', 'de': 'German', 'fr-fr': 'French', 'es': 'Spanish' };
|
| 1193 |
+
return labels[code] || code;
|
| 1194 |
+
}
|
| 1195 |
+
function showLangMismatchWarning(voiceLang) {
|
| 1196 |
+
let w = $('lang-mismatch-warning');
|
| 1197 |
+
if (!w) {
|
| 1198 |
+
w = document.createElement('div');
|
| 1199 |
+
w.id = 'lang-mismatch-warning';
|
| 1200 |
+
w.style.cssText = 'background:rgba(251,191,36,0.15);border:1px solid var(--warning);color:var(--warning);padding:8px 12px;border-radius:8px;font-size:0.82rem;margin-top:8px;';
|
| 1201 |
+
modelSelect.parentElement.appendChild(w);
|
| 1202 |
+
}
|
| 1203 |
+
const label = getLanguageLabel(voiceLang);
|
| 1204 |
+
w.textContent = 'No ' + label + ' model loaded! Load a matching model from the registry below.';
|
| 1205 |
+
w.style.display = 'block';
|
| 1206 |
+
}
|
| 1207 |
+
function clearLangMismatchWarning() {
|
| 1208 |
+
const w = $('lang-mismatch-warning');
|
| 1209 |
+
if (w) w.style.display = 'none';
|
| 1210 |
+
}
|
| 1211 |
+
voiceSearch.addEventListener('input', () => {
|
| 1212 |
+
const q = voiceSearch.value.toLowerCase();
|
| 1213 |
+
renderVoices(voices.filter(v =>
|
| 1214 |
+
v.name.toLowerCase().includes(q) || v.language.toLowerCase().includes(q)
|
| 1215 |
+
));
|
| 1216 |
+
});
|
| 1217 |
+
voiceSearch.addEventListener('focus', () => {
|
| 1218 |
+
voiceSearch.select();
|
| 1219 |
+
renderVoices(voices);
|
| 1220 |
+
});
|
| 1221 |
+
|
| 1222 |
+
// --- Delete custom voice ---
|
| 1223 |
+
async function deleteVoice(name) {
|
| 1224 |
+
if (!confirm('Delete custom voice "' + name + '"?')) return;
|
| 1225 |
+
try {
|
| 1226 |
+
const r = await fetch(API + '/v1/audio/voices/' + encodeURIComponent(name), { method: 'DELETE' });
|
| 1227 |
+
if (!r.ok) { const e = await r.json(); throw new Error(e.detail?.error?.message || 'Delete failed'); }
|
| 1228 |
+
if (selectedVoice === name) selectedVoice = '';
|
| 1229 |
+
refreshState();
|
| 1230 |
+
} catch (e) { showStatus('Delete failed: ' + e.message, 'error'); }
|
| 1231 |
+
}
|
| 1232 |
+
|
| 1233 |
+
// --- Voice Upload Logic ---
|
| 1234 |
+
uploadToggle.addEventListener('click', () => {
|
| 1235 |
+
uploadPanel.classList.toggle('open');
|
| 1236 |
+
uploadToggle.textContent = uploadPanel.classList.contains('open')
|
| 1237 |
+
? '- Hide Clone Panel' : '+ Clone a Voice (Upload or Record)';
|
| 1238 |
+
});
|
| 1239 |
+
|
| 1240 |
+
// Drag-and-drop
|
| 1241 |
+
uploadDropZone.addEventListener('click', () => uploadFileInput.click());
|
| 1242 |
+
uploadDropZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadDropZone.classList.add('dragover'); });
|
| 1243 |
+
uploadDropZone.addEventListener('dragleave', () => uploadDropZone.classList.remove('dragover'));
|
| 1244 |
+
uploadDropZone.addEventListener('drop', (e) => {
|
| 1245 |
+
e.preventDefault();
|
| 1246 |
+
uploadDropZone.classList.remove('dragover');
|
| 1247 |
+
if (e.dataTransfer.files.length) handleUploadFile(e.dataTransfer.files[0]);
|
| 1248 |
+
});
|
| 1249 |
+
uploadFileInput.addEventListener('change', () => {
|
| 1250 |
+
if (uploadFileInput.files.length) handleUploadFile(uploadFileInput.files[0]);
|
| 1251 |
+
});
|
| 1252 |
+
|
| 1253 |
+
function handleUploadFile(file) {
|
| 1254 |
+
if (!file.name.toLowerCase().endsWith('.wav') && file.type !== 'audio/wav') {
|
| 1255 |
+
setUploadStatus('Only WAV files are supported', 'error');
|
| 1256 |
+
return;
|
| 1257 |
+
}
|
| 1258 |
+
uploadFile = file;
|
| 1259 |
+
uploadDropZone.textContent = file.name + ' (' + (file.size / 1024).toFixed(1) + ' KB)';
|
| 1260 |
+
uploadDropZone.classList.add('has-file');
|
| 1261 |
+
validateUploadForm();
|
| 1262 |
+
}
|
| 1263 |
+
|
| 1264 |
+
// Form validation
|
| 1265 |
+
function validateUploadForm() {
|
| 1266 |
+
const hasId = uploadVoiceId.value.trim().length > 0;
|
| 1267 |
+
const hasFile = uploadFile !== null;
|
| 1268 |
+
const hasText = uploadRefText.value.trim().length > 0;
|
| 1269 |
+
uploadBtn.disabled = !(hasId && hasFile && hasText);
|
| 1270 |
+
}
|
| 1271 |
+
uploadVoiceId.addEventListener('input', validateUploadForm);
|
| 1272 |
+
uploadRefText.addEventListener('input', validateUploadForm);
|
| 1273 |
+
|
| 1274 |
+
// Upload
|
| 1275 |
+
uploadBtn.addEventListener('click', async () => {
|
| 1276 |
+
if (!uploadFile || uploadBtn.disabled) return;
|
| 1277 |
+
uploadBtn.disabled = true;
|
| 1278 |
+
uploadBtn.textContent = 'Uploading...';
|
| 1279 |
+
uploadProgress.style.display = 'block';
|
| 1280 |
+
uploadProgressBar.style.width = '0%';
|
| 1281 |
+
setUploadStatus('', '');
|
| 1282 |
+
|
| 1283 |
+
const formData = new FormData();
|
| 1284 |
+
formData.append('voice_id', uploadVoiceId.value.trim());
|
| 1285 |
+
formData.append('ref_text', uploadRefText.value.trim());
|
| 1286 |
+
formData.append('audio', uploadFile);
|
| 1287 |
+
formData.append('language', uploadLanguage.value);
|
| 1288 |
+
formData.append('gender', uploadGender.value);
|
| 1289 |
+
|
| 1290 |
+
try {
|
| 1291 |
+
const xhr = new XMLHttpRequest();
|
| 1292 |
+
await new Promise((resolve, reject) => {
|
| 1293 |
+
xhr.upload.addEventListener('progress', (e) => {
|
| 1294 |
+
if (e.lengthComputable) {
|
| 1295 |
+
uploadProgressBar.style.width = Math.round((e.loaded / e.total) * 100) + '%';
|
| 1296 |
+
}
|
| 1297 |
+
});
|
| 1298 |
+
xhr.addEventListener('load', () => {
|
| 1299 |
+
if (xhr.status >= 200 && xhr.status < 300) resolve(JSON.parse(xhr.responseText));
|
| 1300 |
+
else reject(new Error(JSON.parse(xhr.responseText)?.detail?.error?.message || 'Upload failed'));
|
| 1301 |
+
});
|
| 1302 |
+
xhr.addEventListener('error', () => reject(new Error('Network error')));
|
| 1303 |
+
xhr.open('POST', API + '/v1/audio/voices/upload');
|
| 1304 |
+
xhr.send(formData);
|
| 1305 |
+
});
|
| 1306 |
+
|
| 1307 |
+
setUploadStatus('Voice uploaded successfully!', 'success');
|
| 1308 |
+
// Reset form
|
| 1309 |
+
uploadVoiceId.value = '';
|
| 1310 |
+
uploadRefText.value = '';
|
| 1311 |
+
uploadFile = null;
|
| 1312 |
+
uploadFileInput.value = '';
|
| 1313 |
+
uploadDropZone.textContent = 'Drop WAV file here or click to browse';
|
| 1314 |
+
uploadDropZone.classList.remove('has-file');
|
| 1315 |
+
uploadLanguage.value = 'unknown';
|
| 1316 |
+
uploadGender.value = 'unknown';
|
| 1317 |
+
refreshState();
|
| 1318 |
+
} catch (e) {
|
| 1319 |
+
setUploadStatus(e.message, 'error');
|
| 1320 |
+
} finally {
|
| 1321 |
+
uploadBtn.disabled = false;
|
| 1322 |
+
uploadBtn.textContent = 'Upload & Clone Voice';
|
| 1323 |
+
uploadProgress.style.display = 'none';
|
| 1324 |
+
validateUploadForm();
|
| 1325 |
+
}
|
| 1326 |
+
});
|
| 1327 |
+
|
| 1328 |
+
function setUploadStatus(msg, type) {
|
| 1329 |
+
uploadStatus.textContent = msg;
|
| 1330 |
+
uploadStatus.className = 'voice-upload-status' + (type ? ' upload-' + type : '');
|
| 1331 |
+
if (msg && type) {
|
| 1332 |
+
setTimeout(() => { uploadStatus.className = 'voice-upload-status'; }, 5000);
|
| 1333 |
+
}
|
| 1334 |
+
}
|
| 1335 |
+
|
| 1336 |
+
// --- Browser Voice Recording ---
|
| 1337 |
+
recordBtn.addEventListener('click', async () => {
|
| 1338 |
+
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
| 1339 |
+
mediaRecorder.stop();
|
| 1340 |
+
return;
|
| 1341 |
+
}
|
| 1342 |
+
|
| 1343 |
+
try {
|
| 1344 |
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
| 1345 |
+
recordChunks = [];
|
| 1346 |
+
mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
|
| 1347 |
+
|
| 1348 |
+
mediaRecorder.addEventListener('dataavailable', (e) => {
|
| 1349 |
+
if (e.data.size > 0) recordChunks.push(e.data);
|
| 1350 |
+
});
|
| 1351 |
+
|
| 1352 |
+
mediaRecorder.addEventListener('stop', async () => {
|
| 1353 |
+
stream.getTracks().forEach(t => t.stop());
|
| 1354 |
+
recordBtn.classList.remove('recording');
|
| 1355 |
+
recordBtn.textContent = 'REC';
|
| 1356 |
+
clearInterval(recordTimerInterval);
|
| 1357 |
+
|
| 1358 |
+
const webmBlob = new Blob(recordChunks, { type: 'audio/webm' });
|
| 1359 |
+
try {
|
| 1360 |
+
const wavBlob = await convertToWav(webmBlob);
|
| 1361 |
+
uploadFile = new File([wavBlob], 'recording.wav', { type: 'audio/wav' });
|
| 1362 |
+
uploadDropZone.textContent = 'Recording (' + (wavBlob.size / 1024).toFixed(1) + ' KB)';
|
| 1363 |
+
uploadDropZone.classList.add('has-file');
|
| 1364 |
+
validateUploadForm();
|
| 1365 |
+
} catch (err) {
|
| 1366 |
+
setUploadStatus('Failed to convert recording: ' + err.message, 'error');
|
| 1367 |
+
}
|
| 1368 |
+
});
|
| 1369 |
+
|
| 1370 |
+
mediaRecorder.start();
|
| 1371 |
+
recordBtn.classList.add('recording');
|
| 1372 |
+
recordBtn.textContent = 'STOP';
|
| 1373 |
+
recordStartTime = Date.now();
|
| 1374 |
+
recordTimer.textContent = '0:00';
|
| 1375 |
+
recordTimerInterval = setInterval(() => {
|
| 1376 |
+
const elapsed = Math.floor((Date.now() - recordStartTime) / 1000);
|
| 1377 |
+
recordTimer.textContent = Math.floor(elapsed / 60) + ':' + String(elapsed % 60).padStart(2, '0');
|
| 1378 |
+
if (elapsed >= 30) mediaRecorder.stop(); // Auto-stop at 30s
|
| 1379 |
+
}, 1000);
|
| 1380 |
+
|
| 1381 |
+
} catch (err) {
|
| 1382 |
+
setUploadStatus('Microphone access denied: ' + err.message, 'error');
|
| 1383 |
+
}
|
| 1384 |
+
});
|
| 1385 |
+
|
| 1386 |
+
// --- WAV Conversion (WebM -> WAV via AudioContext) ---
|
| 1387 |
+
async function convertToWav(webmBlob) {
|
| 1388 |
+
const convCtx = new (window.AudioContext || window.webkitAudioContext)();
|
| 1389 |
+
const arrayBuf = await webmBlob.arrayBuffer();
|
| 1390 |
+
const audioBuf = await convCtx.decodeAudioData(arrayBuf);
|
| 1391 |
+
convCtx.close();
|
| 1392 |
+
|
| 1393 |
+
// Downmix to mono
|
| 1394 |
+
const numFrames = audioBuf.length;
|
| 1395 |
+
const sampleRate = audioBuf.sampleRate;
|
| 1396 |
+
let mono;
|
| 1397 |
+
if (audioBuf.numberOfChannels === 1) {
|
| 1398 |
+
mono = audioBuf.getChannelData(0);
|
| 1399 |
+
} else {
|
| 1400 |
+
mono = new Float32Array(numFrames);
|
| 1401 |
+
for (let ch = 0; ch < audioBuf.numberOfChannels; ch++) {
|
| 1402 |
+
const chData = audioBuf.getChannelData(ch);
|
| 1403 |
+
for (let i = 0; i < numFrames; i++) mono[i] += chData[i];
|
| 1404 |
+
}
|
| 1405 |
+
for (let i = 0; i < numFrames; i++) mono[i] /= audioBuf.numberOfChannels;
|
| 1406 |
+
}
|
| 1407 |
+
|
| 1408 |
+
// Write WAV header + 16-bit PCM data
|
| 1409 |
+
const dataLen = mono.length * 2;
|
| 1410 |
+
const buffer = new ArrayBuffer(44 + dataLen);
|
| 1411 |
+
const view = new DataView(buffer);
|
| 1412 |
+
|
| 1413 |
+
function writeStr(offset, str) { for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i)); }
|
| 1414 |
+
|
| 1415 |
+
writeStr(0, 'RIFF');
|
| 1416 |
+
view.setUint32(4, 36 + dataLen, true);
|
| 1417 |
+
writeStr(8, 'WAVE');
|
| 1418 |
+
writeStr(12, 'fmt ');
|
| 1419 |
+
view.setUint32(16, 16, true);
|
| 1420 |
+
view.setUint16(20, 1, true); // PCM
|
| 1421 |
+
view.setUint16(22, 1, true); // mono
|
| 1422 |
+
view.setUint32(24, sampleRate, true);
|
| 1423 |
+
view.setUint32(28, sampleRate * 2, true); // byte rate
|
| 1424 |
+
view.setUint16(32, 2, true); // block align
|
| 1425 |
+
view.setUint16(34, 16, true); // bits per sample
|
| 1426 |
+
writeStr(36, 'data');
|
| 1427 |
+
view.setUint32(40, dataLen, true);
|
| 1428 |
+
|
| 1429 |
+
for (let i = 0; i < mono.length; i++) {
|
| 1430 |
+
const s = Math.max(-1, Math.min(1, mono[i]));
|
| 1431 |
+
view.setInt16(44 + i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
|
| 1432 |
+
}
|
| 1433 |
+
|
| 1434 |
+
return new Blob([buffer], { type: 'audio/wav' });
|
| 1435 |
+
}
|
| 1436 |
+
|
| 1437 |
+
// --- Model load/unload ---
|
| 1438 |
+
async function unloadModel(id) {
|
| 1439 |
+
showStatus('Unloading ' + id + '...', 'info');
|
| 1440 |
+
try {
|
| 1441 |
+
const r = await fetch(API + '/v1/models/' + id, { method: 'DELETE' });
|
| 1442 |
+
if (!r.ok) throw new Error(await r.text());
|
| 1443 |
+
showStatus(id + ' unloaded', 'success');
|
| 1444 |
+
refreshState();
|
| 1445 |
+
} catch (e) { showStatus('Unload failed: ' + e.message, 'error'); }
|
| 1446 |
+
}
|
| 1447 |
+
|
| 1448 |
+
async function switchDevice(modelId, targetDevice) {
|
| 1449 |
+
showStatus('Switching ' + modelId + ' to ' + targetDevice + '...', 'info');
|
| 1450 |
+
try {
|
| 1451 |
+
const r = await fetch(API + '/v1/models/' + modelId + '/switch-device', {
|
| 1452 |
+
method: 'POST',
|
| 1453 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1454 |
+
body: JSON.stringify({ backbone_device: targetDevice, codec_device: targetDevice })
|
| 1455 |
+
});
|
| 1456 |
+
if (!r.ok) {
|
| 1457 |
+
const e = await r.json();
|
| 1458 |
+
throw new Error(e.detail?.error?.message || 'Switch failed');
|
| 1459 |
+
}
|
| 1460 |
+
const task = await r.json();
|
| 1461 |
+
startPollingTask(task.task_id);
|
| 1462 |
+
showStatus('Switching ' + modelId + ' to ' + targetDevice + '...', 'info');
|
| 1463 |
+
} catch (e) { showStatus('Switch failed: ' + e.message, 'error'); }
|
| 1464 |
+
}
|
| 1465 |
+
|
| 1466 |
+
// --- Task polling ---
|
| 1467 |
+
function startPollingTask(taskId) {
|
| 1468 |
+
if (loadingTaskTimers[taskId]) return;
|
| 1469 |
+
renderLoadingTask({ task_id: taskId, model_id: '...', status: 'pending', progress_message: 'Starting...', elapsed_seconds: 0 });
|
| 1470 |
+
const timer = setInterval(async () => {
|
| 1471 |
+
try {
|
| 1472 |
+
const r = await fetch(API + '/v1/models/load/' + taskId);
|
| 1473 |
+
if (!r.ok) { clearInterval(timer); delete loadingTaskTimers[taskId]; return; }
|
| 1474 |
+
const task = await r.json();
|
| 1475 |
+
renderLoadingTask(task);
|
| 1476 |
+
if (task.status === 'ready') {
|
| 1477 |
+
clearInterval(timer);
|
| 1478 |
+
delete loadingTaskTimers[taskId];
|
| 1479 |
+
showStatus(task.model_id + ' loaded!', 'success');
|
| 1480 |
+
refreshState();
|
| 1481 |
+
} else if (task.status === 'error') {
|
| 1482 |
+
clearInterval(timer);
|
| 1483 |
+
delete loadingTaskTimers[taskId];
|
| 1484 |
+
showStatus('Failed to load ' + task.model_id + ': ' + task.error_message, 'error');
|
| 1485 |
+
setTimeout(() => { removeLoadingCard(taskId); }, 5000);
|
| 1486 |
+
}
|
| 1487 |
+
} catch {
|
| 1488 |
+
clearInterval(timer);
|
| 1489 |
+
delete loadingTaskTimers[taskId];
|
| 1490 |
+
}
|
| 1491 |
+
}, 1500);
|
| 1492 |
+
loadingTaskTimers[taskId] = timer;
|
| 1493 |
+
}
|
| 1494 |
+
|
| 1495 |
+
function renderLoadingTask(task) {
|
| 1496 |
+
let card = document.querySelector('[data-task-id="' + task.task_id + '"]');
|
| 1497 |
+
if (!card) {
|
| 1498 |
+
card = document.createElement('div');
|
| 1499 |
+
card.className = 'loading-card';
|
| 1500 |
+
card.setAttribute('data-task-id', task.task_id);
|
| 1501 |
+
loadingTasksList.appendChild(card);
|
| 1502 |
+
}
|
| 1503 |
+
|
| 1504 |
+
const statusLabels = {
|
| 1505 |
+
pending: 'Queued',
|
| 1506 |
+
downloading: 'Downloading',
|
| 1507 |
+
loading: 'Loading',
|
| 1508 |
+
ready: 'Ready',
|
| 1509 |
+
error: 'Error'
|
| 1510 |
+
};
|
| 1511 |
+
|
| 1512 |
+
card.className = 'loading-card'
|
| 1513 |
+
+ (task.status === 'error' ? ' error-card' : '')
|
| 1514 |
+
+ (task.status === 'ready' ? ' ready-card' : '');
|
| 1515 |
+
|
| 1516 |
+
const spinnerHtml = (task.status === 'pending' || task.status === 'downloading' || task.status === 'loading')
|
| 1517 |
+
? '<div class="loading-spinner-sm"></div>' : '';
|
| 1518 |
+
|
| 1519 |
+
// Progress bar percentages by phase
|
| 1520 |
+
const progressPct = { pending: 10, downloading: 40, loading: 75, ready: 100, error: 0 };
|
| 1521 |
+
const progressColors = {
|
| 1522 |
+
pending: 'rgba(148,163,184,0.5)',
|
| 1523 |
+
downloading: 'linear-gradient(90deg, var(--fg), var(--fg2))',
|
| 1524 |
+
loading: 'linear-gradient(90deg, #a78bfa, #c084fc)',
|
| 1525 |
+
ready: 'var(--success)',
|
| 1526 |
+
error: 'var(--error)'
|
| 1527 |
+
};
|
| 1528 |
+
const pct = progressPct[task.status] || 0;
|
| 1529 |
+
const color = progressColors[task.status] || 'var(--fg)';
|
| 1530 |
+
|
| 1531 |
+
card.innerHTML = spinnerHtml
|
| 1532 |
+
+ '<span class="loading-model">' + task.model_id + '</span>'
|
| 1533 |
+
+ '<span class="loading-phase">' + (task.progress_message || statusLabels[task.status] || task.status) + '</span>'
|
| 1534 |
+
+ '<span class="loading-time">' + task.elapsed_seconds.toFixed(1) + 's</span>'
|
| 1535 |
+
+ '<div class="loading-progress-bar"><div class="loading-progress-bar-fill" style="width:' + pct + '%;background:' + color + '"></div></div>';
|
| 1536 |
+
}
|
| 1537 |
+
|
| 1538 |
+
function removeLoadingCard(taskId) {
|
| 1539 |
+
const card = document.querySelector('[data-task-id="' + taskId + '"]');
|
| 1540 |
+
if (card) card.remove();
|
| 1541 |
+
}
|
| 1542 |
+
|
| 1543 |
+
loadBtn.addEventListener('click', async () => {
|
| 1544 |
+
const id = registrySelect.value;
|
| 1545 |
+
if (!id) return;
|
| 1546 |
+
loadBtn.disabled = true; loadBtn.textContent = 'Loading...';
|
| 1547 |
+
showStatus('Loading ' + id + '...', 'info');
|
| 1548 |
+
try {
|
| 1549 |
+
const r = await fetch(API + '/v1/models/load', {
|
| 1550 |
+
method: 'POST',
|
| 1551 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1552 |
+
body: JSON.stringify({ model_id: id })
|
| 1553 |
+
});
|
| 1554 |
+
if (!r.ok) { const e = await r.json(); throw new Error(e.detail?.error?.message || 'Load failed'); }
|
| 1555 |
+
const task = await r.json();
|
| 1556 |
+
if (task.status === 'ready') {
|
| 1557 |
+
showStatus(id + ' loaded!', 'success');
|
| 1558 |
+
refreshState();
|
| 1559 |
+
} else {
|
| 1560 |
+
startPollingTask(task.task_id);
|
| 1561 |
+
}
|
| 1562 |
+
} catch (e) { showStatus('Load failed: ' + e.message, 'error'); }
|
| 1563 |
+
finally { loadBtn.disabled = false; loadBtn.textContent = 'Load'; }
|
| 1564 |
+
});
|
| 1565 |
+
|
| 1566 |
+
// --- Generate ---
|
| 1567 |
+
async function generate() {
|
| 1568 |
+
const text = ttsText.value.trim();
|
| 1569 |
+
if (!text || !modelSelect.value) return;
|
| 1570 |
+
|
| 1571 |
+
// Language mismatch check
|
| 1572 |
+
const selVoice = voices.find(v => v.name === selectedVoice);
|
| 1573 |
+
const selModel = loadedModels.find(m => m.id === modelSelect.value);
|
| 1574 |
+
if (selVoice && selModel && selVoice.language && selModel.language
|
| 1575 |
+
&& selVoice.language !== selModel.language) {
|
| 1576 |
+
const vLang = getLanguageLabel(selVoice.language);
|
| 1577 |
+
const mLang = getLanguageLabel(selModel.language);
|
| 1578 |
+
if (!confirm('Language mismatch!\n\nVoice "' + selectedVoice + '" is ' + vLang
|
| 1579 |
+
+ ' but model "' + selModel.id + '" is ' + mLang + '.\n\n'
|
| 1580 |
+
+ 'This will likely produce wrong accent/pronunciation.\nContinue anyway?')) {
|
| 1581 |
+
return;
|
| 1582 |
+
}
|
| 1583 |
+
}
|
| 1584 |
+
|
| 1585 |
+
genBtn.classList.add('spinning');
|
| 1586 |
+
genBtn.disabled = true;
|
| 1587 |
+
cancelBtn.style.display = 'block';
|
| 1588 |
+
dlWrap.classList.remove('ready');
|
| 1589 |
+
genProgress.style.width = '0%';
|
| 1590 |
+
showStatus('Generating...', 'info');
|
| 1591 |
+
|
| 1592 |
+
if (currentBlobUrl) { URL.revokeObjectURL(currentBlobUrl); currentBlobUrl = null; }
|
| 1593 |
+
|
| 1594 |
+
abortCtrl = new AbortController();
|
| 1595 |
+
const t0 = performance.now();
|
| 1596 |
+
|
| 1597 |
+
try {
|
| 1598 |
+
const res = await fetch(API + '/v1/audio/speech', {
|
| 1599 |
+
method: 'POST',
|
| 1600 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1601 |
+
body: JSON.stringify({
|
| 1602 |
+
model: modelSelect.value,
|
| 1603 |
+
input: text,
|
| 1604 |
+
voice: selectedVoice,
|
| 1605 |
+
response_format: fmtSelect.value,
|
| 1606 |
+
speed: parseFloat(speedRange.value),
|
| 1607 |
+
stream: streamChk.checked,
|
| 1608 |
+
}),
|
| 1609 |
+
signal: abortCtrl.signal,
|
| 1610 |
+
});
|
| 1611 |
+
|
| 1612 |
+
if (!res.ok) {
|
| 1613 |
+
const err = await res.json();
|
| 1614 |
+
throw new Error(err.detail?.error?.message || err.detail || 'Failed');
|
| 1615 |
+
}
|
| 1616 |
+
|
| 1617 |
+
// Track progress for streaming with enhanced status
|
| 1618 |
+
const reader = res.body.getReader();
|
| 1619 |
+
const chunks = [];
|
| 1620 |
+
let received = 0;
|
| 1621 |
+
|
| 1622 |
+
while (true) {
|
| 1623 |
+
const { done, value } = await reader.read();
|
| 1624 |
+
if (done) break;
|
| 1625 |
+
chunks.push(value);
|
| 1626 |
+
received += value.length;
|
| 1627 |
+
const elapsedNow = ((performance.now() - t0) / 1000).toFixed(1);
|
| 1628 |
+
const kbReceived = (received / 1024).toFixed(1);
|
| 1629 |
+
statusMsg.textContent = 'Generating... ' + kbReceived + ' KB | ' + elapsedNow + 's';
|
| 1630 |
+
genProgress.style.width = Math.min(90, received / 500) + '%';
|
| 1631 |
+
}
|
| 1632 |
+
|
| 1633 |
+
genProgress.style.width = '100%';
|
| 1634 |
+
const blob = new Blob(chunks);
|
| 1635 |
+
const elapsed = ((performance.now() - t0) / 1000).toFixed(2);
|
| 1636 |
+
const sizeKB = (blob.size / 1024).toFixed(1);
|
| 1637 |
+
|
| 1638 |
+
currentBlobUrl = URL.createObjectURL(blob);
|
| 1639 |
+
audio.src = currentBlobUrl;
|
| 1640 |
+
dlBtn.href = currentBlobUrl;
|
| 1641 |
+
dlBtn.download = 'neutts_' + selectedVoice + '_' + Date.now() + '.' + fmtSelect.value;
|
| 1642 |
+
dlBtn.title = 'Download (' + sizeKB + ' KB)';
|
| 1643 |
+
dlWrap.classList.add('ready');
|
| 1644 |
+
|
| 1645 |
+
showStatus(
|
| 1646 |
+
'Done! ' + fmtSelect.value.toUpperCase() + ' | '
|
| 1647 |
+
+ sizeKB + ' KB | '
|
| 1648 |
+
+ elapsed + 's | ' + text.length + ' chars',
|
| 1649 |
+
'success'
|
| 1650 |
+
);
|
| 1651 |
+
|
| 1652 |
+
if (autoplayChk.checked) {
|
| 1653 |
+
ensureAudioCtx();
|
| 1654 |
+
audio.play().catch(() => {});
|
| 1655 |
+
}
|
| 1656 |
+
|
| 1657 |
+
} catch (e) {
|
| 1658 |
+
if (e.name === 'AbortError') {
|
| 1659 |
+
showStatus('Cancelled', 'info');
|
| 1660 |
+
} else {
|
| 1661 |
+
showStatus(e.message, 'error');
|
| 1662 |
+
}
|
| 1663 |
+
} finally {
|
| 1664 |
+
genBtn.classList.remove('spinning');
|
| 1665 |
+
genBtn.disabled = loadedModels.length === 0;
|
| 1666 |
+
cancelBtn.style.display = 'none';
|
| 1667 |
+
abortCtrl = null;
|
| 1668 |
+
setTimeout(() => { genProgress.style.width = '0%'; }, 2000);
|
| 1669 |
+
}
|
| 1670 |
+
}
|
| 1671 |
+
|
| 1672 |
+
genBtn.addEventListener('click', generate);
|
| 1673 |
+
cancelBtn.addEventListener('click', () => { if (abortCtrl) abortCtrl.abort(); });
|
| 1674 |
+
ttsText.addEventListener('keydown', e => {
|
| 1675 |
+
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
| 1676 |
+
e.preventDefault();
|
| 1677 |
+
if (!genBtn.disabled) generate();
|
| 1678 |
+
}
|
| 1679 |
+
});
|
| 1680 |
+
|
| 1681 |
+
// --- Status ---
|
| 1682 |
+
function showStatus(msg, type) {
|
| 1683 |
+
statusMsg.textContent = msg;
|
| 1684 |
+
statusMsg.className = 'status-msg ' + type;
|
| 1685 |
+
}
|
| 1686 |
+
|
| 1687 |
+
// --- Init ---
|
| 1688 |
+
refreshState();
|
| 1689 |
+
setInterval(refreshState, 20000);
|
| 1690 |
+
})();
|
| 1691 |
+
</script>
|
| 1692 |
+
</body>
|
| 1693 |
+
</html>
|
api/src/structures/__init__.py
ADDED
|
File without changes
|
api/src/structures/schemas.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Literal
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
# --- OpenAI-compatible Request ---
|
| 9 |
+
|
| 10 |
+
class OpenAISpeechRequest(BaseModel):
|
| 11 |
+
model: str = "neutts-nano-q4-gguf"
|
| 12 |
+
input: str = Field(..., min_length=1, max_length=10000)
|
| 13 |
+
voice: str = "jo"
|
| 14 |
+
response_format: Literal["mp3", "opus", "aac", "flac", "wav", "pcm"] = "mp3"
|
| 15 |
+
speed: float = Field(default=1.0, ge=0.25, le=4.0)
|
| 16 |
+
stream: bool = False
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# --- OpenAI-compatible Responses ---
|
| 20 |
+
|
| 21 |
+
class VoiceInfo(BaseModel):
|
| 22 |
+
voice_id: str
|
| 23 |
+
name: str
|
| 24 |
+
language: str
|
| 25 |
+
gender: str
|
| 26 |
+
custom: bool = False
|
| 27 |
+
available: bool = True
|
| 28 |
+
preview_url: str | None = None
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class VoiceListResponse(BaseModel):
|
| 32 |
+
voices: list[VoiceInfo]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class ModelInfo(BaseModel):
|
| 36 |
+
id: str
|
| 37 |
+
object: str = "model"
|
| 38 |
+
created: int = 0
|
| 39 |
+
owned_by: str = "neuphonic"
|
| 40 |
+
language: str | None = None
|
| 41 |
+
backend: str | None = None
|
| 42 |
+
supports_streaming: bool = False
|
| 43 |
+
backbone_device: str | None = None
|
| 44 |
+
codec_device: str | None = None
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class ModelListResponse(BaseModel):
|
| 48 |
+
object: str = "list"
|
| 49 |
+
data: list[ModelInfo]
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class ModelDetailResponse(ModelInfo):
|
| 53 |
+
loaded: bool = False
|
| 54 |
+
codec: str | None = None
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# --- Model Management ---
|
| 58 |
+
|
| 59 |
+
class LoadModelRequest(BaseModel):
|
| 60 |
+
model_id: str
|
| 61 |
+
codec: str | None = None
|
| 62 |
+
backbone_device: str | None = None
|
| 63 |
+
codec_device: str | None = None
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class UnloadModelResponse(BaseModel):
|
| 67 |
+
model_id: str
|
| 68 |
+
status: str = "unloaded"
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class ModelLoadTaskResponse(BaseModel):
|
| 72 |
+
task_id: str
|
| 73 |
+
model_id: str
|
| 74 |
+
status: str
|
| 75 |
+
progress_message: str = ""
|
| 76 |
+
error_message: str = ""
|
| 77 |
+
elapsed_seconds: float = 0.0
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class SwitchDeviceRequest(BaseModel):
|
| 81 |
+
backbone_device: str | None = None
|
| 82 |
+
codec_device: str | None = None
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class LoadedModelInfo(BaseModel):
|
| 86 |
+
model_id: str
|
| 87 |
+
codec: str
|
| 88 |
+
backbone_device: str
|
| 89 |
+
codec_device: str
|
| 90 |
+
language: str | None = None
|
| 91 |
+
backend: str | None = None
|
| 92 |
+
supports_streaming: bool = False
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
class LoadedModelsResponse(BaseModel):
|
| 96 |
+
models: list[LoadedModelInfo]
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
class RegistryModelInfo(BaseModel):
|
| 100 |
+
model_id: str
|
| 101 |
+
repo: str
|
| 102 |
+
language: str
|
| 103 |
+
backend: str
|
| 104 |
+
supports_streaming: bool
|
| 105 |
+
description: str
|
| 106 |
+
loaded: bool = False
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
class ModelRegistryResponse(BaseModel):
|
| 110 |
+
backbones: list[RegistryModelInfo]
|
| 111 |
+
codecs: list[dict]
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
# --- Voice Management ---
|
| 115 |
+
|
| 116 |
+
class VoiceEncodeRequest(BaseModel):
|
| 117 |
+
codec: str | None = None
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
class VoiceUploadResponse(BaseModel):
|
| 121 |
+
voice_id: str
|
| 122 |
+
status: str = "uploaded"
|
| 123 |
+
message: str = ""
|
| 124 |
+
language: str = "unknown"
|
| 125 |
+
gender: str = "unknown"
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
class VoiceDeleteResponse(BaseModel):
|
| 129 |
+
voice_id: str
|
| 130 |
+
status: str = "deleted"
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
class VoiceEncodeResponse(BaseModel):
|
| 134 |
+
voice_id: str
|
| 135 |
+
codec: str
|
| 136 |
+
status: str = "encoded"
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
# --- Health & Debug ---
|
| 140 |
+
|
| 141 |
+
class HealthResponse(BaseModel):
|
| 142 |
+
status: str = "ok"
|
| 143 |
+
version: str = "0.1.0"
|
| 144 |
+
models_loaded: int = 0
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
class SystemDebugResponse(BaseModel):
|
| 148 |
+
cpu_count: int
|
| 149 |
+
cpu_percent: float
|
| 150 |
+
memory_total_gb: float
|
| 151 |
+
memory_used_gb: float
|
| 152 |
+
memory_percent: float
|
| 153 |
+
gpu_available: bool
|
| 154 |
+
gpu_info: list[dict] | None = None
|
| 155 |
+
torch_version: str | None = None
|
| 156 |
+
cuda_version: str | None = None
|
| 157 |
+
cuda_driver_version: str | None = None
|
| 158 |
+
gpu_detected_but_unusable: bool = False
|
| 159 |
+
gpu_fix_instructions: str | None = None
|
| 160 |
+
models_loaded: list[str]
|
| 161 |
+
voices_available: int
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
# --- Error ---
|
| 165 |
+
|
| 166 |
+
class ErrorResponse(BaseModel):
|
| 167 |
+
error: dict
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def make_error(message: str, error_type: str = "invalid_request_error", code: int = 400) -> dict:
|
| 171 |
+
return {
|
| 172 |
+
"error": {
|
| 173 |
+
"message": message,
|
| 174 |
+
"type": error_type,
|
| 175 |
+
"code": code,
|
| 176 |
+
}
|
| 177 |
+
}
|
api/src/structures/websocket_schemas.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Literal
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class WSMessage(BaseModel):
|
| 9 |
+
type: Literal["start", "text", "stop", "ping"]
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class WSStartMessage(WSMessage):
|
| 13 |
+
type: Literal["start"] = "start"
|
| 14 |
+
model: str = "neutts-nano-q4-gguf"
|
| 15 |
+
voice: str = "jo"
|
| 16 |
+
response_format: Literal["mp3", "opus", "aac", "flac", "wav", "pcm"] = "pcm"
|
| 17 |
+
sample_rate: int = 24000
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class WSTextMessage(WSMessage):
|
| 21 |
+
type: Literal["text"] = "text"
|
| 22 |
+
text: str = Field(..., min_length=1)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class WSStopMessage(WSMessage):
|
| 26 |
+
type: Literal["stop"] = "stop"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class WSPingMessage(WSMessage):
|
| 30 |
+
type: Literal["ping"] = "ping"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class WSResponseMessage(BaseModel):
|
| 34 |
+
type: Literal["audio", "error", "done", "pong"]
|
| 35 |
+
data: str | None = None
|
| 36 |
+
message: str | None = None
|
| 37 |
+
format: str | None = None
|
api/src/voices/builtin/dave.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
So I'm live on radio. And I say, well, my dear friend James here clearly, and the whole room just froze. Turns out I'd completely misspoken and mentioned our other friend.
|
api/src/voices/builtin/dave.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:6c6b0e0c730ad0eddb53f321bd571ba55c3ed7ac4422610a17de7f62675c09e9
|
| 3 |
+
size 1313748
|
api/src/voices/builtin/dave_neuphonic_distill-neucodec.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:9ffe2bdbece11d7a19df1e81eb34a4dd32b5da81988dac3afc8d0459c8777c56
|
| 3 |
+
size 3281
|
api/src/voices/builtin/greta.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Es wurde eine Untersuchung zur Aufklärung des Unfalls eingeleitet.
|
api/src/voices/builtin/greta.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:af9167fc76e824cc8e46ce1afecbc3144c6bc84195ba9fd9884de95716dd5534
|
| 3 |
+
size 191566
|
api/src/voices/builtin/greta_neuphonic_distill-neucodec.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:52b08ae1537c430076d29f7ec3c607d18a1689cc54d7d1e6c757619a2649a432
|
| 3 |
+
size 2584
|
api/src/voices/builtin/jo.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
So I just tried Neuphonic and I’m genuinely impressed. It's super responsive, it sounds clean, supports voice cloning, and the agent feature is fun to play with too. Highly recommend it for podcasts, conversations, or even just messing around with voiceovers.
|
api/src/voices/builtin/jo.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f789bc3799e64675a6324a978a3fffb0dd7850739d232cbe3d4bbbbc9973a314
|
| 3 |
+
size 575990
|
api/src/voices/builtin/jo_neuphonic_distill-neucodec.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:788d04716eebc7abaf639dbab50750558535f1693832e1105576c4b45e89ca6c
|
| 3 |
+
size 4419
|
api/src/voices/builtin/juliette.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Dans les zones rurales où de nombreuses communautés n'ont pas accès à l'électricité, l'énergie solaire peut faire une énorme différence.
|
api/src/voices/builtin/juliette.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:22255eeb3fc4d49df09952ce59fde7d7b7de17ff117abcdb206f9bc276e64cde
|
| 3 |
+
size 357254
|
api/src/voices/builtin/mateo.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Además su eficiencia depende del clima. En días nublados o durante la noche producen menos energía.
|
api/src/voices/builtin/mateo.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:73a7740c771191ab781df867ce6b39b14de8267cc6580711a956b202a4b645cb
|
| 3 |
+
size 280520
|
api/src/voices/custom/.gitkeep
ADDED
|
File without changes
|
docker/scripts/download_models.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pre-download models during Docker build or startup."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
import sys
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def main() -> None:
|
| 9 |
+
models_str = os.environ.get("NEUTTS_DEFAULT_MODELS", "neutts-nano-q4-gguf")
|
| 10 |
+
codec = os.environ.get("NEUTTS_DEFAULT_CODEC", "neuphonic/neucodec-onnx-decoder")
|
| 11 |
+
|
| 12 |
+
models = [m.strip() for m in models_str.split(",") if m.strip()]
|
| 13 |
+
|
| 14 |
+
# Import model config to resolve repos
|
| 15 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
| 16 |
+
from api.src.core.model_config import get_backbone_info
|
| 17 |
+
|
| 18 |
+
print(f"Pre-downloading {len(models)} model(s) and codec '{codec}'...")
|
| 19 |
+
|
| 20 |
+
for model_id in models:
|
| 21 |
+
info = get_backbone_info(model_id)
|
| 22 |
+
if info is None:
|
| 23 |
+
print(f" WARNING: Unknown model '{model_id}', skipping")
|
| 24 |
+
continue
|
| 25 |
+
|
| 26 |
+
print(f" Downloading backbone: {info.repo}")
|
| 27 |
+
try:
|
| 28 |
+
from huggingface_hub import snapshot_download
|
| 29 |
+
|
| 30 |
+
snapshot_download(info.repo)
|
| 31 |
+
print(f" OK: {info.repo}")
|
| 32 |
+
except Exception as e:
|
| 33 |
+
print(f" FAILED: {info.repo} - {e}")
|
| 34 |
+
|
| 35 |
+
print(f" Downloading codec: {codec}")
|
| 36 |
+
try:
|
| 37 |
+
from huggingface_hub import snapshot_download
|
| 38 |
+
|
| 39 |
+
snapshot_download(codec)
|
| 40 |
+
print(f" OK: {codec}")
|
| 41 |
+
except Exception as e:
|
| 42 |
+
print(f" FAILED: {codec} - {e}")
|
| 43 |
+
|
| 44 |
+
print("Download complete.")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
if __name__ == "__main__":
|
| 48 |
+
main()
|
entrypoint.sh
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
set -e
|
| 3 |
+
|
| 4 |
+
export PHONEMIZER_ESPEAK_LIBRARY=${PHONEMIZER_ESPEAK_LIBRARY:-/usr/lib/x86_64-linux-gnu/libespeak-ng.so.1}
|
| 5 |
+
export ESPEAK_DATA_PATH=${ESPEAK_DATA_PATH:-/usr/lib/x86_64-linux-gnu/espeak-ng-data}
|
| 6 |
+
|
| 7 |
+
if [ "${NEUTTS_PREDOWNLOAD_MODELS:-false}" = "true" ]; then
|
| 8 |
+
echo "Pre-downloading models..."
|
| 9 |
+
python -m docker.scripts.download_models
|
| 10 |
+
fi
|
| 11 |
+
|
| 12 |
+
echo "Starting NeuTTS-FastAPI server on port ${NEUTTS_PORT:-7860}..."
|
| 13 |
+
exec uvicorn api.src.main:app \
|
| 14 |
+
--host "${NEUTTS_HOST:-0.0.0.0}" \
|
| 15 |
+
--port "${NEUTTS_PORT:-7860}" \
|
| 16 |
+
--workers 1 \
|
| 17 |
+
--log-level "${NEUTTS_LOG_LEVEL:-info}"
|
pyproject.toml
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["hatchling"]
|
| 3 |
+
build-backend = "hatchling.build"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "neutts-fastapi-hf"
|
| 7 |
+
version = "0.1.0"
|
| 8 |
+
description = "NeuTTS TTS API for Hugging Face Spaces"
|
| 9 |
+
requires-python = ">=3.10,<3.14"
|
| 10 |
+
|
| 11 |
+
dependencies = [
|
| 12 |
+
"neutts>=1.1.0",
|
| 13 |
+
"fastapi>=0.115.0",
|
| 14 |
+
"uvicorn[standard]>=0.30.0",
|
| 15 |
+
"pydantic-settings>=2.0.0",
|
| 16 |
+
"av>=12.0.0",
|
| 17 |
+
"loguru>=0.7.0",
|
| 18 |
+
"psutil>=5.9.0",
|
| 19 |
+
"python-multipart>=0.0.7",
|
| 20 |
+
"soundfile>=0.13.0",
|
| 21 |
+
"websockets>=12.0",
|
| 22 |
+
"setuptools>=70.0.0",
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
[project.optional-dependencies]
|
| 26 |
+
gpu = ["neutts[all]", "onnxruntime-gpu", "torch>=2.6.0"]
|
| 27 |
+
cpu = ["neutts[all]", "onnxruntime"]
|
| 28 |
+
dev = ["pytest>=8.0.0", "pytest-asyncio>=0.23.0", "httpx>=0.27.0", "ruff>=0.4.0"]
|
| 29 |
+
|
| 30 |
+
[tool.hatch.build.targets.wheel]
|
| 31 |
+
packages = ["api"]
|