grimshaw commited on
Commit
35bb6f4
·
verified ·
1 Parent(s): dbd1243

Upload folder using huggingface_hub

Browse files
Files changed (49) hide show
  1. .dockerignore +22 -0
  2. .env +14 -0
  3. .gitattributes +5 -0
  4. Dockerfile +41 -0
  5. README.md +32 -0
  6. api/__init__.py +0 -0
  7. api/src/__init__.py +0 -0
  8. api/src/core/__init__.py +0 -0
  9. api/src/core/config.py +70 -0
  10. api/src/core/model_config.py +216 -0
  11. api/src/core/paths.py +50 -0
  12. api/src/inference/__init__.py +0 -0
  13. api/src/inference/model_manager.py +411 -0
  14. api/src/inference/text_chunker.py +81 -0
  15. api/src/inference/voice_manager.py +156 -0
  16. api/src/main.py +136 -0
  17. api/src/routers/__init__.py +0 -0
  18. api/src/routers/debug.py +177 -0
  19. api/src/routers/health.py +18 -0
  20. api/src/routers/model_management.py +166 -0
  21. api/src/routers/openai_compatible.py +132 -0
  22. api/src/routers/voice_management.py +106 -0
  23. api/src/routers/websocket.py +149 -0
  24. api/src/services/__init__.py +0 -0
  25. api/src/services/audio_utils.py +64 -0
  26. api/src/services/streaming_audio_writer.py +134 -0
  27. api/src/services/temp_manager.py +27 -0
  28. api/src/services/tts_service.py +134 -0
  29. api/src/static/index.html +1693 -0
  30. api/src/structures/__init__.py +0 -0
  31. api/src/structures/schemas.py +177 -0
  32. api/src/structures/websocket_schemas.py +37 -0
  33. api/src/voices/builtin/dave.txt +1 -0
  34. api/src/voices/builtin/dave.wav +3 -0
  35. api/src/voices/builtin/dave_neuphonic_distill-neucodec.pt +3 -0
  36. api/src/voices/builtin/greta.txt +1 -0
  37. api/src/voices/builtin/greta.wav +3 -0
  38. api/src/voices/builtin/greta_neuphonic_distill-neucodec.pt +3 -0
  39. api/src/voices/builtin/jo.txt +1 -0
  40. api/src/voices/builtin/jo.wav +3 -0
  41. api/src/voices/builtin/jo_neuphonic_distill-neucodec.pt +3 -0
  42. api/src/voices/builtin/juliette.txt +1 -0
  43. api/src/voices/builtin/juliette.wav +3 -0
  44. api/src/voices/builtin/mateo.txt +1 -0
  45. api/src/voices/builtin/mateo.wav +3 -0
  46. api/src/voices/custom/.gitkeep +0 -0
  47. docker/scripts/download_models.py +48 -0
  48. entrypoint.sh +17 -0
  49. 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">&#9888;</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 + '">&times;</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">&times;</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"]