grimshaw commited on
Commit
f021ebf
·
verified ·
1 Parent(s): 48642f0

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.dockerignore ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Docker build artifacts
2
+ .git
3
+ .gitignore
4
+ .env
5
+ *.md
6
+ !README.md
7
+ *.pyc
8
+ __pycache__
9
+ *.pyo
10
+ *.pyd
11
+ .Python
12
+ *.so
13
+ .eggs
14
+ *.egg-info
15
+ *.egg
16
+ dist
17
+ build
18
+ *.spec
19
+
20
+ # PyInstaller
21
+ *.exe
22
+ *.bat
23
+
24
+ # IDE
25
+ .vscode
26
+ .idea
27
+ *.swp
28
+ *.swo
29
+
30
+ # Logs (we mount these as volume)
31
+ logs/
32
+
33
+ # Virtual environments
34
+ venv/
35
+ .venv/
36
+
37
+ # Test files
38
+ tests/
39
+ *.test.py
40
+ pytest.ini
41
+
42
+ # Frozen requirements (use requirements.txt for Docker)
43
+ frozen_requirements.txt
44
+ pyinstaller_command.txt
.env.example ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Docker environment configuration template
2
+ # Copy to .env and customize as needed
3
+
4
+ # Server settings
5
+ POCKET_TTS_PORT=49112
6
+ POCKET_TTS_LOG_LEVEL=INFO
7
+ POCKET_TTS_STREAM_DEFAULT=true
8
+
9
+ # Model language (requires pocket-tts>=2.0.0)
10
+ # Options: english, french_24l, german_24l, portuguese, italian, spanish_24l
11
+ # Mutually exclusive with POCKET_TTS_MODEL_PATH
12
+ # POCKET_TTS_LANGUAGE=english
13
+
14
+ # Enable int8 quantization for lower memory usage and improved speed
15
+ # POCKET_TTS_QUANTIZE=false
16
+
17
+ # Custom voices directory (mounted to container)
18
+ # POCKET_TTS_VOICES_DIR=./my_custom_voices
19
+
20
+ # Hugging Face token for voice cloning (optional)
21
+ # Get your token from: https://huggingface.co/settings/tokens
22
+ # HF_TOKEN=hf_xxxxxxxxxxxxx
23
+
24
+ # Writable cache for per-model cloned voice safetensors
25
+ # Defaults to <app_base>/voice_cache. In Docker this is /app/voice_cache backed
26
+ # by the `pockettts-voice-cache` named volume.
27
+ # POCKET_TTS_VOICE_CACHE_DIR=/path/to/cache
.gitattributes CHANGED
@@ -33,3 +33,160 @@ 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
+ pocket-tts-logo.ico filter=lfs diff=lfs merge=lfs -text
37
+ static/images/pocket-tts-logo.png filter=lfs diff=lfs merge=lfs -text
38
+ voices/ASEN.wav filter=lfs diff=lfs merge=lfs -text
39
+ voices/Aadi.wav filter=lfs diff=lfs merge=lfs -text
40
+ voices/AbD.wav filter=lfs diff=lfs merge=lfs -text
41
+ voices/Abhinox.wav filter=lfs diff=lfs merge=lfs -text
42
+ voices/Abo_Ayman.wav filter=lfs diff=lfs merge=lfs -text
43
+ voices/Abob_Malay.wav filter=lfs diff=lfs merge=lfs -text
44
+ voices/AgentCobra.wav filter=lfs diff=lfs merge=lfs -text
45
+ voices/Ajith.wav filter=lfs diff=lfs merge=lfs -text
46
+ voices/Alejandro_espanol_latino.wav filter=lfs diff=lfs merge=lfs -text
47
+ voices/Allen.wav filter=lfs diff=lfs merge=lfs -text
48
+ voices/AmitNag.wav filter=lfs diff=lfs merge=lfs -text
49
+ voices/Andrea.wav filter=lfs diff=lfs merge=lfs -text
50
+ voices/Aon.wav filter=lfs diff=lfs merge=lfs -text
51
+ voices/Aryobe.wav filter=lfs diff=lfs merge=lfs -text
52
+ voices/Bijay.wav filter=lfs diff=lfs merge=lfs -text
53
+ voices/Blake.wav filter=lfs diff=lfs merge=lfs -text
54
+ voices/Bobby_McFern.wav filter=lfs diff=lfs merge=lfs -text
55
+ voices/Breaking_1.wav filter=lfs diff=lfs merge=lfs -text
56
+ voices/BrokenHypocrite.wav filter=lfs diff=lfs merge=lfs -text
57
+ voices/Butter.wav filter=lfs diff=lfs merge=lfs -text
58
+ voices/CPS_001.wav filter=lfs diff=lfs merge=lfs -text
59
+ voices/Chujus.wav filter=lfs diff=lfs merge=lfs -text
60
+ voices/Darya_khan.wav filter=lfs diff=lfs merge=lfs -text
61
+ voices/Deepak.wav filter=lfs diff=lfs merge=lfs -text
62
+ voices/Dhruv_Rao.wav filter=lfs diff=lfs merge=lfs -text
63
+ voices/Dil.wav filter=lfs diff=lfs merge=lfs -text
64
+ voices/Enrique.wav filter=lfs diff=lfs merge=lfs -text
65
+ voices/Ernesto_Y.wav filter=lfs diff=lfs merge=lfs -text
66
+ voices/Eshan.wav filter=lfs diff=lfs merge=lfs -text
67
+ voices/Esteban_Aguirre_Arias.wav filter=lfs diff=lfs merge=lfs -text
68
+ voices/Ferdinand.wav filter=lfs diff=lfs merge=lfs -text
69
+ voices/FlorDaddy.wav filter=lfs diff=lfs merge=lfs -text
70
+ voices/Fred_Mara.wav filter=lfs diff=lfs merge=lfs -text
71
+ voices/Giovanne.wav filter=lfs diff=lfs merge=lfs -text
72
+ voices/Glenn.wav filter=lfs diff=lfs merge=lfs -text
73
+ voices/Goku.wav filter=lfs diff=lfs merge=lfs -text
74
+ voices/Haku.wav filter=lfs diff=lfs merge=lfs -text
75
+ voices/Hannah.wav filter=lfs diff=lfs merge=lfs -text
76
+ voices/Hardik_Clone.wav filter=lfs diff=lfs merge=lfs -text
77
+ voices/Hillbilly_Jim.wav filter=lfs diff=lfs merge=lfs -text
78
+ voices/Hkl.wav filter=lfs diff=lfs merge=lfs -text
79
+ voices/Ilyass_yea.wav filter=lfs diff=lfs merge=lfs -text
80
+ voices/Indian_guy.wav filter=lfs diff=lfs merge=lfs -text
81
+ voices/Ineedthisnow.wav filter=lfs diff=lfs merge=lfs -text
82
+ voices/JJis2123.wav filter=lfs diff=lfs merge=lfs -text
83
+ voices/JOSHE.wav filter=lfs diff=lfs merge=lfs -text
84
+ voices/James.wav filter=lfs diff=lfs merge=lfs -text
85
+ voices/Jaspino.wav filter=lfs diff=lfs merge=lfs -text
86
+ voices/Jaw.wav filter=lfs diff=lfs merge=lfs -text
87
+ voices/Jeff_Andrew.wav filter=lfs diff=lfs merge=lfs -text
88
+ voices/Jeffrey.wav filter=lfs diff=lfs merge=lfs -text
89
+ voices/Jeremy_Q.wav filter=lfs diff=lfs merge=lfs -text
90
+ voices/Jimmy.wav filter=lfs diff=lfs merge=lfs -text
91
+ voices/Joaopedrobil1.wav filter=lfs diff=lfs merge=lfs -text
92
+ voices/John_Triguero.wav filter=lfs diff=lfs merge=lfs -text
93
+ voices/Karti.wav filter=lfs diff=lfs merge=lfs -text
94
+ voices/Koorosh.wav filter=lfs diff=lfs merge=lfs -text
95
+ voices/LC.wav filter=lfs diff=lfs merge=lfs -text
96
+ voices/L_Roy.wav filter=lfs diff=lfs merge=lfs -text
97
+ voices/Lake.wav filter=lfs diff=lfs merge=lfs -text
98
+ voices/Lara.wav filter=lfs diff=lfs merge=lfs -text
99
+ voices/Latin_Accent.wav filter=lfs diff=lfs merge=lfs -text
100
+ voices/Louis.wav filter=lfs diff=lfs merge=lfs -text
101
+ voices/Lucas.wav filter=lfs diff=lfs merge=lfs -text
102
+ voices/MJDePedro.wav filter=lfs diff=lfs merge=lfs -text
103
+ voices/Maisako.wav filter=lfs diff=lfs merge=lfs -text
104
+ voices/Manahen.wav filter=lfs diff=lfs merge=lfs -text
105
+ voices/Marshal_Indian.wav filter=lfs diff=lfs merge=lfs -text
106
+ voices/Midlands_Bedfordshire_Dialect.wav filter=lfs diff=lfs merge=lfs -text
107
+ voices/Moses.wav filter=lfs diff=lfs merge=lfs -text
108
+ voices/MrHat.wav filter=lfs diff=lfs merge=lfs -text
109
+ voices/Mystery_Sir.wav filter=lfs diff=lfs merge=lfs -text
110
+ voices/Narrum.wav filter=lfs diff=lfs merge=lfs -text
111
+ voices/Nick.wav filter=lfs diff=lfs merge=lfs -text
112
+ voices/P0LFR.wav filter=lfs diff=lfs merge=lfs -text
113
+ voices/Parthiban.wav filter=lfs diff=lfs merge=lfs -text
114
+ voices/Prakash369.wav filter=lfs diff=lfs merge=lfs -text
115
+ voices/Puzzle.wav filter=lfs diff=lfs merge=lfs -text
116
+ voices/Qasim_Wali_Khan.wav filter=lfs diff=lfs merge=lfs -text
117
+ voices/RAJ.wav filter=lfs diff=lfs merge=lfs -text
118
+ voices/Rafaelpazv.wav filter=lfs diff=lfs merge=lfs -text
119
+ voices/Raj25.wav filter=lfs diff=lfs merge=lfs -text
120
+ voices/Ramu.wav filter=lfs diff=lfs merge=lfs -text
121
+ voices/Ranjith.wav filter=lfs diff=lfs merge=lfs -text
122
+ voices/ReadyOrNotTOC.wav filter=lfs diff=lfs merge=lfs -text
123
+ voices/Richard_cuban.wav filter=lfs diff=lfs merge=lfs -text
124
+ voices/Roscoe.wav filter=lfs diff=lfs merge=lfs -text
125
+ voices/Rup.wav filter=lfs diff=lfs merge=lfs -text
126
+ voices/STONE.wav filter=lfs diff=lfs merge=lfs -text
127
+ voices/Selfie.wav filter=lfs diff=lfs merge=lfs -text
128
+ voices/Sheddy.wav filter=lfs diff=lfs merge=lfs -text
129
+ voices/Siddh_Indian.wav filter=lfs diff=lfs merge=lfs -text
130
+ voices/Sir_TJ.wav filter=lfs diff=lfs merge=lfs -text
131
+ voices/Sp46.wav filter=lfs diff=lfs merge=lfs -text
132
+ voices/Sr_Erick.wav filter=lfs diff=lfs merge=lfs -text
133
+ voices/Standollars.wav filter=lfs diff=lfs merge=lfs -text
134
+ voices/TESLLA.wav filter=lfs diff=lfs merge=lfs -text
135
+ voices/TheFin.wav filter=lfs diff=lfs merge=lfs -text
136
+ voices/The_Sustainabler.wav filter=lfs diff=lfs merge=lfs -text
137
+ voices/The_other_brother.wav filter=lfs diff=lfs merge=lfs -text
138
+ voices/Titorium.wav filter=lfs diff=lfs merge=lfs -text
139
+ voices/Umair.wav filter=lfs diff=lfs merge=lfs -text
140
+ voices/Vexat.wav filter=lfs diff=lfs merge=lfs -text
141
+ voices/Victor_Garcia.wav filter=lfs diff=lfs merge=lfs -text
142
+ voices/Vivaldi.wav filter=lfs diff=lfs merge=lfs -text
143
+ voices/W_A_H.wav filter=lfs diff=lfs merge=lfs -text
144
+ voices/Wealthiest.wav filter=lfs diff=lfs merge=lfs -text
145
+ voices/WhisperInEar.wav filter=lfs diff=lfs merge=lfs -text
146
+ voices/Yesid.wav filter=lfs diff=lfs merge=lfs -text
147
+ voices/Youfied.wav filter=lfs diff=lfs merge=lfs -text
148
+ voices/Yuush.wav filter=lfs diff=lfs merge=lfs -text
149
+ voices/ada.wav filter=lfs diff=lfs merge=lfs -text
150
+ voices/aela_voice_sample.wav filter=lfs diff=lfs merge=lfs -text
151
+ voices/amazon_box.wav filter=lfs diff=lfs merge=lfs -text
152
+ voices/andrea_spanish.wav filter=lfs diff=lfs merge=lfs -text
153
+ voices/awais_shah.wav filter=lfs diff=lfs merge=lfs -text
154
+ voices/bathri.wav filter=lfs diff=lfs merge=lfs -text
155
+ voices/bevi.wav filter=lfs diff=lfs merge=lfs -text
156
+ voices/boom.wav filter=lfs diff=lfs merge=lfs -text
157
+ voices/cybina.wav filter=lfs diff=lfs merge=lfs -text
158
+ voices/david_attenborough.wav filter=lfs diff=lfs merge=lfs -text
159
+ voices/default_voice.wav filter=lfs diff=lfs merge=lfs -text
160
+ voices/developpeuse_3.wav filter=lfs diff=lfs merge=lfs -text
161
+ voices/english_with_german_accent.wav filter=lfs diff=lfs merge=lfs -text
162
+ voices/enrique_spanish.wav filter=lfs diff=lfs merge=lfs -text
163
+ voices/erihppas.wav filter=lfs diff=lfs merge=lfs -text
164
+ voices/ex03_ex01_angry_001_channel1_201s.wav filter=lfs diff=lfs merge=lfs -text
165
+ voices/ex03_ex01_calm_001_channel1_1143s.wav filter=lfs diff=lfs merge=lfs -text
166
+ voices/ex03_ex01_happy_001_channel1_334s.wav filter=lfs diff=lfs merge=lfs -text
167
+ voices/ex04_narration_longform_00001.wav filter=lfs diff=lfs merge=lfs -text
168
+ voices/fabieng_enhanced_v2.wav filter=lfs diff=lfs merge=lfs -text
169
+ voices/gmaskell92.wav filter=lfs diff=lfs merge=lfs -text
170
+ voices/hielos.wav filter=lfs diff=lfs merge=lfs -text
171
+ voices/hielos_2.wav filter=lfs diff=lfs merge=lfs -text
172
+ voices/james_earl_jones1.wav filter=lfs diff=lfs merge=lfs -text
173
+ voices/kbrn1.wav filter=lfs diff=lfs merge=lfs -text
174
+ voices/kitt.wav filter=lfs diff=lfs merge=lfs -text
175
+ voices/muhtasims_voice.wav filter=lfs diff=lfs merge=lfs -text
176
+ voices/nms_suit.wav filter=lfs diff=lfs merge=lfs -text
177
+ voices/obama.wav filter=lfs diff=lfs merge=lfs -text
178
+ voices/oldNerd.wav filter=lfs diff=lfs merge=lfs -text
179
+ voices/oldNerd2.wav filter=lfs diff=lfs merge=lfs -text
180
+ voices/oldNerd3.wav filter=lfs diff=lfs merge=lfs -text
181
+ voices/ra_XOr.wav filter=lfs diff=lfs merge=lfs -text
182
+ voices/rewi.wav filter=lfs diff=lfs merge=lfs -text
183
+ voices/robert.wav filter=lfs diff=lfs merge=lfs -text
184
+ voices/robert2.wav filter=lfs diff=lfs merge=lfs -text
185
+ voices/siddharth_khanna.wav filter=lfs diff=lfs merge=lfs -text
186
+ voices/solace.wav filter=lfs diff=lfs merge=lfs -text
187
+ voices/stein.wav filter=lfs diff=lfs merge=lfs -text
188
+ voices/surazy.wav filter=lfs diff=lfs merge=lfs -text
189
+ voices/thepolishdane.wav filter=lfs diff=lfs merge=lfs -text
190
+ voices/vinayak.wav filter=lfs diff=lfs merge=lfs -text
191
+ voices/willbas.wav filter=lfs diff=lfs merge=lfs -text
192
+ voices/zerocool.wav filter=lfs diff=lfs merge=lfs -text
.vscode/extensions.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "recommendations": [
3
+ "ms-python.python",
4
+ "charliermarsh.ruff",
5
+ "ms-python.debugpy"
6
+ ]
7
+ }
.vscode/launch.json ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": "0.2.0",
3
+ "configurations": [
4
+ {
5
+ "name": "PocketTTS Server",
6
+ "type": "debugpy",
7
+ "request": "launch",
8
+ "program": "${workspaceFolder}/server.py",
9
+ "console": "integratedTerminal",
10
+ "justMyCode": true,
11
+ "env": {
12
+ "POCKET_TTS_LOG_LEVEL": "DEBUG",
13
+ "POCKET_TTS_PORT": "49112"
14
+ },
15
+ "args": []
16
+ },
17
+ {
18
+ "name": "PocketTTS Server (Custom Port)",
19
+ "type": "debugpy",
20
+ "request": "launch",
21
+ "program": "${workspaceFolder}/server.py",
22
+ "console": "integratedTerminal",
23
+ "justMyCode": true,
24
+ "args": [
25
+ "--port",
26
+ "8080",
27
+ "--log-level",
28
+ "DEBUG"
29
+ ]
30
+ },
31
+ {
32
+ "name": "PocketTTS Server (With Voices Dir)",
33
+ "type": "debugpy",
34
+ "request": "launch",
35
+ "program": "${workspaceFolder}/server.py",
36
+ "console": "integratedTerminal",
37
+ "justMyCode": true,
38
+ "args": [
39
+ "--voices-dir",
40
+ "${workspaceFolder}/voices",
41
+ "--stream",
42
+ "--log-level",
43
+ "DEBUG"
44
+ ]
45
+ },
46
+ {
47
+ "name": "Python: Current File",
48
+ "type": "debugpy",
49
+ "request": "launch",
50
+ "program": "${file}",
51
+ "console": "integratedTerminal",
52
+ "justMyCode": true
53
+ }
54
+ ]
55
+ }
.vscode/settings.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "python.defaultInterpreterPath": "${workspaceFolder}/venv/bin/python",
3
+ "[python]": {
4
+ "editor.formatOnSave": true,
5
+ "editor.codeActionsOnSave": {
6
+ "source.fixAll": "explicit",
7
+ "source.organizeImports": "explicit"
8
+ },
9
+ "editor.defaultFormatter": "charliermarsh.ruff"
10
+ }
11
+ }
AGENTS.md ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Project Overview
2
+
3
+ **PocketTTS OpenAI-Compatible Server** wraps [pocket-tts](https://github.com/kyutai-labs/pocket-tts) to provide OpenAI-compatible TTS endpoints. Any OpenAI TTS client can use this for local, CPU-based text-to-speech.
4
+
5
+ ## Why This Exists
6
+
7
+ The official `pocket-tts` has a FastAPI server with `/tts` endpoint, but it's **not OpenAI API compatible**. This project adds:
8
+
9
+ - `/v1/audio/speech` matching OpenAI's schema
10
+ - `/v1/voices` for voice listing
11
+ - Docker deployment with voice mounting
12
+ - Windows executable distribution
13
+
14
+ ## Architecture
15
+
16
+ ```
17
+ server.py # Entry point, CLI, starts Waitress
18
+ └── app/__init__.py # Flask app factory
19
+ ├── app/routes.py # API endpoints
20
+ ├── app/config.py # Environment config
21
+ └── app/services/
22
+ ├── tts.py # TTSService: model, voice cache
23
+ └── audio.py # Format conversion, streaming
24
+ ```
25
+
26
+ ## Key Files
27
+
28
+ | File | Purpose |
29
+ | --------------------- | ---------------------------------------------------------------- |
30
+ | `server.py` | Entry point, CLI args, Waitress server |
31
+ | `app/routes.py` | HTTP endpoints: `/`, `/health`, `/v1/voices`, `/v1/audio/speech` |
32
+ | `app/services/tts.py` | Model loading, voice caching, generation |
33
+ | `app/config.py` | Environment variables, path resolution |
34
+
35
+ ## API Endpoints
36
+
37
+ | Endpoint | Method | Purpose |
38
+ | ------------------ | ------ | ----------------------------------- |
39
+ | `/` | GET | Web UI |
40
+ | `/health` | GET | Health check for containers |
41
+ | `/v1/voices` | GET | List voices |
42
+ | `/v1/audio/speech` | POST | Generate speech (OpenAI-compatible) |
43
+
44
+ ### Speech Request
45
+
46
+ ```json
47
+ {
48
+ "model": "tts-1",
49
+ "input": "Text to speak",
50
+ "voice": "alba",
51
+ "response_format": "mp3",
52
+ "stream": false
53
+ }
54
+ ```
55
+
56
+ ## CLI Arguments
57
+
58
+ | Argument | Env Variable | Default | Purpose |
59
+ | --------------------- | --------------------------- | ------- | ------------------ |
60
+ | `--host` | `POCKET_TTS_HOST` | `0.0.0.0` | Bind address |
61
+ | `--port` | `POCKET_TTS_PORT` | `49112` | Port |
62
+ | `--model-path` | `POCKET_TTS_MODEL_PATH` | None | Path to model config file (.yaml) or variant |
63
+ | `--language` | `POCKET_TTS_LANGUAGE` | None | Model language (english, french_24l, etc.) |
64
+ | `--quantize` | `POCKET_TTS_QUANTIZE` | `false` | Enable int8 quantization for lower memory usage |
65
+ | `--voices-dir` | `POCKET_TTS_VOICES_DIR` | None | Custom voices directory |
66
+ | `--stream` | `POCKET_TTS_STREAM_DEFAULT`| `false` | Enable streaming by default |
67
+ | `--text-preprocess` | `POCKET_TTS_TEXT_PREPROCESS_DEFAULT` | `false` | Enable text preprocessing by default |
68
+ | `--log-level` | `POCKET_TTS_LOG_LEVEL` | `INFO` | Log verbosity |
69
+
70
+ **Note**: `--language` and `--model-path` are mutually exclusive. Use `--language` to select a built-in language model (requires pocket-tts>=2.0.0).
71
+
72
+ ## Voice Resolution Order
73
+
74
+ 1. Built-in names (`alba`, `marius`, etc.) → pass to pocket-tts
75
+ 2. HuggingFace models (`hf://` URLs) → pass to pocket-tts
76
+ 3. Files in `POCKET_TTS_VOICES_DIR`
77
+ 4. Absolute paths
78
+ 5. Fallback to pocket-tts
79
+
80
+ **Security Note**: HTTP/HTTPS URLs are blocked to prevent SSRF attacks. Only `hf://` URLs are allowed for remote models.
81
+
82
+ ## Development
83
+
84
+ ```bash
85
+ # Install
86
+ pip install -r requirements.txt
87
+
88
+ # Run with defaults (English model)
89
+ python server.py --log-level DEBUG
90
+
91
+ # Run with French model and quantization
92
+ python server.py --language french_24l --quantize --log-level DEBUG
93
+
94
+ # Test
95
+ curl http://localhost:49112/health
96
+ curl -X POST http://localhost:49112/v1/audio/speech \
97
+ -H "Content-Type: application/json" \
98
+ -d '{"input": "Hello", "voice": "alba"}' -o test.mp3
99
+ ```
100
+
101
+ ## Code Style
102
+
103
+ - Linter/formatter: `ruff` (config in `pyproject.toml`)
104
+ - Line length: 100
105
+ - Single quotes
106
+
107
+ ## Deployment
108
+
109
+ - **Python**: `python server.py`
110
+ - **Docker**: `docker compose up -d`
111
+ - **Windows EXE**: Built via GitHub Actions on release tags
Dockerfile ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dockerfile for PocketTTS OpenAI-Compatible Server
2
+ # Optimized for CPU inference (pocket-tts runs efficiently on CPU)
3
+ # Uses CPU-only PyTorch for smaller image size (~700MB vs ~2GB)
4
+
5
+ FROM python:3.10-slim AS builder
6
+
7
+ # Install build dependencies
8
+ RUN apt-get update && apt-get install -y --no-install-recommends \
9
+ build-essential \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
12
+ # Create virtual environment
13
+ RUN python -m venv /opt/venv
14
+ ENV PATH="/opt/venv/bin:$PATH"
15
+
16
+ # Install Python dependencies (requirements.txt specifies CPU-only PyTorch)
17
+ COPY requirements.txt /tmp/requirements.txt
18
+ RUN pip install --no-cache-dir --upgrade pip && \
19
+ pip install --no-cache-dir -r /tmp/requirements.txt
20
+
21
+
22
+ # Production image
23
+ FROM python:3.10-slim
24
+
25
+ # Install runtime dependencies for audio processing
26
+ RUN apt-get update && apt-get install -y --no-install-recommends \
27
+ libsndfile1 \
28
+ ffmpeg \
29
+ && rm -rf /var/lib/apt/lists/* \
30
+ && apt-get clean
31
+
32
+ # Copy virtual environment from builder
33
+ COPY --from=builder /opt/venv /opt/venv
34
+ ENV PATH="/opt/venv/bin:$PATH"
35
+
36
+ # Create non-root user
37
+ RUN useradd --create-home --shell /bin/bash pockettts
38
+ WORKDIR /app
39
+
40
+ # Copy application code
41
+ COPY --chown=pockettts:pockettts app/ ./app/
42
+ COPY --chown=pockettts:pockettts static/ ./static/
43
+ COPY --chown=pockettts:pockettts templates/ ./templates/
44
+ COPY --chown=pockettts:pockettts voices/ ./voices/
45
+ COPY --chown=pockettts:pockettts server.py ./
46
+
47
+ # Create logs directory, and ensure app directory is owned by user
48
+ RUN chown pockettts:pockettts /app && mkdir -p /app/logs && chown pockettts:pockettts /app/logs
49
+
50
+ # Create HuggingFace cache directory (for volume mount)
51
+ RUN mkdir -p /home/pockettts/.cache/huggingface && \
52
+ chown -R pockettts:pockettts /home/pockettts/.cache
53
+
54
+ # Create voice cache directory with correct ownership for the named volume
55
+ RUN mkdir -p /app/voice_cache && chown pockettts:pockettts /app/voice_cache
56
+
57
+ # Switch to non-root user
58
+ USER pockettts
59
+
60
+ # Environment variables with defaults
61
+ ENV POCKET_TTS_HOST=0.0.0.0 \
62
+ POCKET_TTS_PORT=49112 \
63
+ POCKET_TTS_VOICES_DIR=/app/voices \
64
+ POCKET_TTS_LOG_DIR=/app/logs \
65
+ POCKET_TTS_LOG_LEVEL=INFO \
66
+ PYTHONUNBUFFERED=1
67
+
68
+ # Expose port
69
+ EXPOSE 49112
70
+
71
+ # Health check
72
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
73
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:49112/health')" || exit 1
74
+
75
+ # Run server
76
+ CMD ["python", "server.py"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 teddybear082
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
PocketTTS-Server.desktop ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ [Desktop Entry]
2
+ Type=Application
3
+ Name=PocketTTS Server
4
+ Comment=OpenAI-compatible TTS API server powered by Pocket-TTS
5
+ Exec=pocket-tts-server
6
+ Icon=PocketTTS-Server
7
+ Terminal=true
8
+ Categories=Development;AudioVideo;
README.md CHANGED
@@ -1,10 +1,324 @@
1
- ---
2
- title: Pocket
3
- emoji: 🏆
4
- colorFrom: purple
5
- colorTo: pink
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PocketTTS OpenAI-Compatible Server
2
+
3
+ An OpenAI-compatible Text-to-Speech API server powered by [Pocket-TTS](https://github.com/kyutai-labs/pocket-tts). Drop-in replacement for OpenAI's TTS API with support for streaming, custom voices, and voice cloning.
4
+
5
+ Tested and working fully with [WingmanAI by Shipbit](https://www.wingman-ai.com/). Due to low resource use, can be used for real time local text to speech even while playing intensive video games (even in VR!) with WingmanAI.
6
+
7
+ **Key Features:**
8
+
9
+ - 🎯 **OpenAI API Compatible** - Works with any OpenAI TTS client
10
+ - 🚀 **Real-time Streaming** - Low-latency audio generation
11
+ - 🎤 **150+ Community Voices** - Ready-to-use voice library included
12
+ - 🎭 **Voice Cloning** - Clone any voice from a short audio sample
13
+ - 🐳 **Docker Ready** - One-command deployment
14
+ - 💻 **Cross-platform** - Runs on Windows, macOS, and Linux
15
+ - ⚡ **CPU Optimized** - No GPU required
16
+ - 🎤 **Text pre-processing** - Clean text for words and symbols TTS usually has difficulty with, automatically
17
+
18
+ ## Quick Start
19
+
20
+ ### Option 1: Docker (Recommended)
21
+
22
+ ```bash
23
+ # Clone the repository
24
+ git clone https://github.com/teddybear082/pocket-tts-openai_streaming_server.git
25
+ cd pocket-tts-openai_streaming_server
26
+
27
+ # Start the server
28
+ docker compose up -d
29
+
30
+ # View logs
31
+ docker compose logs -f
32
+ ```
33
+
34
+ The server will be available at `http://localhost:49112`
35
+
36
+ **Custom Configuration:**
37
+
38
+ ```bash
39
+ # Change port
40
+ POCKET_TTS_PORT=8080 docker compose up -d
41
+
42
+ # Use custom voices directory
43
+ POCKET_TTS_VOICES_DIR=/path/to/my/voices docker compose up -d
44
+ ```
45
+
46
+ ### Option 2: Python (from source)
47
+
48
+ ```bash
49
+ # Clone the repository
50
+ git clone https://github.com/teddybear082/pocket-tts-openai_streaming_server.git
51
+ cd pocket-tts-openai_streaming_server
52
+
53
+ # Create virtual environment
54
+ python -m venv venv
55
+ source venv/bin/activate # On Windows: venv\Scripts\activate
56
+
57
+ # Install dependencies
58
+ pip install -r requirements.txt
59
+
60
+ # Start the server
61
+ python server.py
62
+ ```
63
+
64
+ **Command Line Options:**
65
+
66
+ ```bash
67
+ python server.py --help
68
+
69
+ # Custom port and voices
70
+ python server.py --port 8080 --voices-dir ./my_voices
71
+
72
+ # Enable streaming by default
73
+ python server.py --stream
74
+
75
+ # Enable text preprocessing
76
+ python server.py --text-preprocess
77
+ ```
78
+
79
+ ### Option 3: Windows Executable
80
+
81
+ 1. Download the latest release from [Releases](https://github.com/teddybear082/pocket-tts-openai_streaming_server/releases)
82
+ 2. Extract the ZIP file
83
+ 3. Double-click `PocketTTS-Server.exe` to run with defaults
84
+ 4. Or run `run_pocket_tts_server_exe.bat` for custom configuration
85
+
86
+ ## Web Interface
87
+
88
+ Open `http://localhost:49112` in your browser to access the built-in web UI:
89
+
90
+ - Select from available voices
91
+ - Enter text to synthesize
92
+ - Listen to generated audio directly
93
+
94
+ ## API Usage
95
+
96
+ ### Generate Speech
97
+
98
+ **Endpoint:** `POST /v1/audio/speech`
99
+
100
+ ```bash
101
+ curl http://localhost:49112/v1/audio/speech \
102
+ -H "Content-Type: application/json" \
103
+ -d '{
104
+ "model": "tts-1",
105
+ "input": "Hello world! This is a test.",
106
+ "voice": "alba"
107
+ }' \
108
+ --output speech.mp3
109
+ ```
110
+
111
+ ### Python Client
112
+
113
+ ```python
114
+ from openai import OpenAI
115
+
116
+ client = OpenAI(
117
+ base_url="http://localhost:49112/v1",
118
+ api_key="not-needed" # No authentication required
119
+ )
120
+
121
+ # Generate and save audio
122
+ response = client.audio.speech.create(
123
+ model="tts-1",
124
+ voice="alba",
125
+ input="Hello world! This is a test."
126
+ )
127
+ response.stream_to_file("output.mp3")
128
+
129
+ # Streaming
130
+ with client.audio.speech.with_streaming_response.create(
131
+ model="tts-1",
132
+ voice="alba",
133
+ input="This is streaming audio.",
134
+ response_format="pcm"
135
+ ) as response:
136
+ for chunk in response.iter_bytes():
137
+ # Process audio chunks in real-time
138
+ pass
139
+ ```
140
+
141
+ ### API Reference
142
+
143
+ | Endpoint | Method | Description |
144
+ | ------------------ | ------ | ---------------------------------------- |
145
+ | `/` | GET | Web interface |
146
+ | `/health` | GET | Health check for container orchestration |
147
+ | `/v1/voices` | GET | List available voices |
148
+ | `/v1/audio/speech` | POST | Generate speech audio |
149
+
150
+ **Speech Parameters:**
151
+
152
+ | Parameter | Type | Required | Default | Description |
153
+ | ----------------- | ------- | -------- | ------- | -------------------------------------------------- |
154
+ | `model` | string | No | - | Ignored (for OpenAI compatibility) |
155
+ | `input` | string | Yes | - | Text to synthesize |
156
+ | `voice` | string | No | `alba` | Voice ID (see `/v1/voices`) |
157
+ | `response_format` | string | No | `mp3` | Output format: `mp3`, `wav`, `pcm`, `opus`, `aac`, `flac` |
158
+ | `stream` | boolean | No | `false` | Enable streaming response |
159
+
160
+ ## Custom Voices
161
+
162
+ ### Using Custom Voice Files
163
+
164
+ 1. **Create a voices directory** with your audio files (`.wav`, `.mp3`, `.flac`)
165
+ 2. **Configure the server** to use your directory:
166
+
167
+ **Docker:**
168
+
169
+ ```bash
170
+ POCKET_TTS_VOICES_DIR=/path/to/voices docker compose up -d
171
+ ```
172
+
173
+ **Python:**
174
+
175
+ ```bash
176
+ python server.py --voices-dir /path/to/voices
177
+ ```
178
+
179
+ **Windows EXE:**
180
+ Use the batch launcher and specify the voices directory when prompted.
181
+
182
+ 3. **Use your voice** by filename:
183
+ ```json
184
+ { "voice": "my_voice.wav", "input": "Hello!" }
185
+ ```
186
+
187
+ ### Voice File Guidelines
188
+
189
+ - **Duration:** 3-15 seconds of clear speech works best
190
+ - **Quality:** Clean audio without background noise
191
+ - **Format:** WAV, MP3, or FLAC
192
+ - **Tip:** Use [Adobe Podcast Enhance](https://podcast.adobe.com/enhance) to clean noisy samples
193
+
194
+ ### Built-in Voices
195
+
196
+ The following voices are available by default:
197
+ `alba`, `marius`, `javert`, `jean`, `fantine`, `cosette`, `eponine`, `azelma`
198
+
199
+ The `voices/` directory includes 150+ community-contributed voices.
200
+
201
+ ## Configuration
202
+
203
+ ### Environment Variables
204
+
205
+ | Variable | Default | Description |
206
+ | ------------------------------------| ---------- | -------------------------------------- |
207
+ | `POCKET_TTS_HOST` | `0.0.0.0` | Server bind address |
208
+ | `POCKET_TTS_PORT` | `49112` | Server port |
209
+ | `POCKET_TTS_VOICES_DIR` | `./voices` | Custom voices directory |
210
+ | `POCKET_TTS_MODEL_PATH` | - | Custom model path |
211
+ | `POCKET_TTS_STREAM_DEFAULT` | `true` | Enable streaming by default |
212
+ | `POCKET_TTS_TEXT_PREPROCESS_DEFAULT`| `true` | Enable text preprocessing by default |
213
+ | `POCKET_TTS_LOG_LEVEL` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR |
214
+ | `POCKET_TTS_LOG_DIR` | `./logs` | Log files directory |
215
+ | `HF_TOKEN` | - | Hugging Face token (for voice cloning) |
216
+
217
+ ### Docker Compose Options
218
+
219
+ See [docker-compose.yml](docker-compose.yml) for all available options including:
220
+
221
+ - Volume mounts for custom voices
222
+ - Resource limits
223
+ - Health check configuration
224
+ - HuggingFace cache persistence
225
+
226
+ ## Project Structure
227
+
228
+ ```
229
+ pocket-tts-openai_streaming_server/
230
+ ├── app/ # Application modules
231
+ │ ├── __init__.py # Flask app factory
232
+ │ ├── config.py # Configuration management
233
+ │ ├── logging_config.py # Logging setup
234
+ │ ├── routes.py # API endpoints
235
+ │ └── services/ # Business logic
236
+ │ ├── audio.py # Audio conversion
237
+ │ └── tts.py # TTS service
238
+ | |-- preprocess.py # Text preprocessor
239
+ ├── static/ # Web UI assets
240
+ ├── templates/ # HTML templates
241
+ ├── voices/ # Voice files
242
+ ├── server.py # Main entry point
243
+ ├── Dockerfile # Container build
244
+ ├── docker-compose.yml # Container orchestration
245
+ └── requirements.txt # Python dependencies
246
+ ```
247
+
248
+ ## Development
249
+
250
+ ### Dependencies
251
+
252
+ | File | Purpose |
253
+ | ---------------------- | ---------------------------------------------------- |
254
+ | `requirements.txt` | Runtime dependencies only (Flask, torch, pocket-tts) |
255
+ | `requirements-dev.txt` | Adds dev tools: ruff (linting), pytest (testing) |
256
+
257
+ ### Running Locally
258
+
259
+ ```bash
260
+ # Install runtime dependencies only
261
+ pip install -r requirements.txt
262
+
263
+ # Or install with dev tools (recommended for contributors)
264
+ pip install -r requirements-dev.txt
265
+
266
+ # Run with debug logging
267
+ python server.py --log-level DEBUG
268
+ ```
269
+
270
+ ### Linting
271
+
272
+ ```bash
273
+ pip install ruff
274
+ ruff check .
275
+ ruff format .
276
+ ```
277
+
278
+ ### Building Windows EXE
279
+
280
+ ```bash
281
+ pip install pyinstaller
282
+ pyinstaller --onefile --name PocketTTS-Server \
283
+ --add-data "static;static" \
284
+ --add-data "templates;templates" \
285
+ --add-data "voices;voices" \
286
+ --add-data "app;app" \
287
+ server.py
288
+ ```
289
+
290
+ ## Troubleshooting
291
+
292
+ ### Model Loading Takes Long
293
+
294
+ First run downloads the model (~500MB). Subsequent runs use cached model.
295
+
296
+ **Docker:** Model cache is persisted in a Docker volume.
297
+
298
+ ### Voice Cloning Requires HF Token
299
+
300
+ For voice cloning, you may need a Hugging Face token:
301
+
302
+ 1. Get token from https://huggingface.co/settings/tokens
303
+ 2. Set `HF_TOKEN` environment variable
304
+
305
+ ### Port Already in Use
306
+
307
+ ```bash
308
+ # Use a different port
309
+ python server.py --port 8080
310
+
311
+ # Or with Docker
312
+ POCKET_TTS_PORT=8080 docker compose up -d
313
+ ```
314
+
315
+ ## Credits
316
+
317
+ - [Pocket-TTS](https://github.com/kyutai-labs/pocket-tts) by Kyutai Labs
318
+ - Community voice contributors (see [voices/credits.txt](voices/credits.txt))
319
+
320
+ ## License
321
+
322
+ This project is licensed under the MIT License - see [LICENSE](LICENSE) for details.
323
+
324
+ Pocket-TTS is subject to its own license terms.
app/__init__.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PocketTTS OpenAI-Compatible Server
3
+
4
+ Flask application factory and initialization.
5
+ """
6
+
7
+ # Keep in sync with pyproject.toml — used as the version fallback when the
8
+ # package isn't installed via pip (e.g. running directly from a clone).
9
+ __version__ = '2.5.4'
10
+
11
+ from flask import Flask
12
+
13
+ from app.config import Config
14
+ from app.logging_config import get_logger, setup_logging
15
+
16
+
17
+ def create_app(config_overrides: dict = None) -> Flask:
18
+ """
19
+ Application factory for creating the Flask app.
20
+
21
+ Args:
22
+ config_overrides: Optional dictionary of config values to override
23
+
24
+ Returns:
25
+ Configured Flask application
26
+ """
27
+ # Setup logging first
28
+ setup_logging()
29
+ logger = get_logger()
30
+
31
+ # Create Flask app with correct paths
32
+ app = Flask(
33
+ __name__,
34
+ template_folder=Config.get_template_folder(),
35
+ static_folder=Config.get_static_folder(),
36
+ )
37
+
38
+ # Apply default config
39
+ app.config['STREAM_DEFAULT'] = Config.STREAM_DEFAULT
40
+
41
+ # Apply overrides
42
+ if config_overrides:
43
+ app.config.update(config_overrides)
44
+
45
+ # Register blueprints
46
+ from app.routes import api
47
+
48
+ app.register_blueprint(api)
49
+
50
+ logger.info('Flask application created')
51
+
52
+ return app
53
+
54
+
55
+ def init_tts_service(
56
+ model_path: str = None,
57
+ voices_dir: str = None,
58
+ language: str = None,
59
+ quantize: bool = False,
60
+ ) -> None:
61
+ """
62
+ Initialize the TTS service with model and voices.
63
+
64
+ Args:
65
+ model_path: Optional path to model config file
66
+ voices_dir: Optional path to voices directory
67
+ language: Optional language identifier (e.g., english, french_24l)
68
+ quantize: Whether to apply dynamic int8 quantization
69
+ """
70
+ from app.services.tts import get_tts_service
71
+
72
+ logger = get_logger()
73
+ tts = get_tts_service()
74
+
75
+ # Load model
76
+ tts.load_model(model_path=model_path, language=language, quantize=quantize)
77
+
78
+ # Pre-create the voice cache directory (or log warning if not writable)
79
+ tts._ensure_cache_dir()
80
+
81
+ # Set voices directory
82
+ if voices_dir:
83
+ tts.set_voices_dir(voices_dir)
84
+ else:
85
+ # Check for bundled voices
86
+ bundle_voices, _ = Config.get_bundle_paths()
87
+ if bundle_voices:
88
+ tts.set_voices_dir(bundle_voices)
89
+
90
+ logger.info('TTS service initialized')
app/config.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Configuration management for PocketTTS OpenAI Server.
3
+ Loads settings from environment variables with sensible defaults.
4
+ """
5
+
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+
11
+ def get_base_path() -> Path:
12
+ """Get the base path for the application, handling PyInstaller frozen state."""
13
+ if getattr(sys, 'frozen', False):
14
+ if hasattr(sys, '_MEIPASS'):
15
+ # One-file mode
16
+ return Path(sys._MEIPASS)
17
+ else:
18
+ # One-dir mode
19
+ return Path(sys.executable).parent
20
+ return Path(__file__).parent.parent
21
+
22
+
23
+ class Config:
24
+ """Application configuration loaded from environment variables."""
25
+
26
+ # Base paths
27
+ BASE_PATH = get_base_path()
28
+ IS_FROZEN = getattr(sys, 'frozen', False)
29
+
30
+ # Server settings
31
+ HOST = os.environ.get('POCKET_TTS_HOST', '0.0.0.0')
32
+ PORT = int(os.environ.get('POCKET_TTS_PORT', '49112'))
33
+
34
+ # Model settings
35
+ MODEL_PATH = os.environ.get('POCKET_TTS_MODEL_PATH', None)
36
+ LANGUAGE = os.environ.get('POCKET_TTS_LANGUAGE', None)
37
+ QUANTIZE = os.environ.get('POCKET_TTS_QUANTIZE', 'false').lower() == 'true'
38
+
39
+ # Supported languages (pocket-tts v2.0.0 predefined model YAMLs)
40
+ SUPPORTED_LANGUAGES = [
41
+ 'english', # alias for english_2026-04 (default)
42
+ 'english_2026-01',
43
+ 'english_2026-04',
44
+ 'french_24l', # no bare `french` — upstream raises
45
+ 'german',
46
+ 'german_24l',
47
+ 'italian',
48
+ 'italian_24l',
49
+ 'portuguese',
50
+ 'portuguese_24l',
51
+ 'spanish',
52
+ 'spanish_24l',
53
+ ]
54
+
55
+ # Canonicalize equivalent model IDs so tagged caches dedupe.
56
+ LEGACY_MODEL_ALIASES = {
57
+ 'english': 'english_2026-04',
58
+ 'english_2026-01': 'english_2026-04',
59
+ }
60
+
61
+ # Writable voice cache dir for tagged .safetensors clones.
62
+ VOICE_CACHE_DIR = os.environ.get(
63
+ 'POCKET_TTS_VOICE_CACHE_DIR',
64
+ str(BASE_PATH / 'voice_cache'),
65
+ )
66
+
67
+ DEFAULT_VOICE = os.environ.get(
68
+ 'POCKET_TTS_DEFAULT_VOICE', 'hf://kyutai/tts-voices/alba-mackenna/casual.wav'
69
+ )
70
+
71
+ # Voice directory
72
+ VOICES_DIR = os.environ.get('POCKET_TTS_VOICES_DIR', None)
73
+
74
+ # Streaming default
75
+ STREAM_DEFAULT = os.environ.get('POCKET_TTS_STREAM_DEFAULT', 'false').lower() == 'true'
76
+
77
+ # Text preprocessing default
78
+ TEXT_PREPROCESS_DEFAULT = (
79
+ os.environ.get('POCKET_TTS_TEXT_PREPROCESS_DEFAULT', 'false').lower() == 'true'
80
+ )
81
+
82
+ # Docker detection
83
+ @staticmethod
84
+ def _is_docker() -> bool:
85
+ """Detect if running in a Docker container."""
86
+ # Check for .dockerenv file (most reliable)
87
+ if os.path.exists('/.dockerenv'):
88
+ return True
89
+ # Check cgroup for docker/containerd references
90
+ try:
91
+ with open('/proc/1/cgroup') as f:
92
+ return any('docker' in line or 'containerd' in line for line in f)
93
+ except (FileNotFoundError, PermissionError):
94
+ return False
95
+
96
+ IS_DOCKER = _is_docker.__func__()
97
+
98
+ # Logging
99
+ LOG_LEVEL = os.environ.get('POCKET_TTS_LOG_LEVEL', 'INFO')
100
+ LOG_DIR = os.environ.get('POCKET_TTS_LOG_DIR', str(BASE_PATH / 'logs'))
101
+ LOG_FILE = os.environ.get('POCKET_TTS_LOG_FILE', 'pocket_tts.log')
102
+ LOG_MAX_BYTES = int(os.environ.get('POCKET_TTS_LOG_MAX_BYTES', str(10 * 1024 * 1024))) # 10MB
103
+ LOG_BACKUP_COUNT = int(os.environ.get('POCKET_TTS_LOG_BACKUP_COUNT', '5'))
104
+
105
+ # Built-in voice mappings (these are resolved by pocket-tts internally)
106
+ BUILTIN_VOICES = ['alba', 'marius', 'javert', 'jean', 'fantine', 'cosette', 'eponine', 'azelma']
107
+
108
+ # Supported audio extensions for custom voices
109
+ VOICE_EXTENSIONS = ('.wav', '.mp3', '.flac', '.safetensors')
110
+
111
+ @classmethod
112
+ def get_bundle_paths(cls) -> tuple:
113
+ """Get bundled paths for frozen executables."""
114
+ if cls.IS_FROZEN:
115
+ voices_dir = cls.BASE_PATH / 'voices'
116
+ model_path = cls.BASE_PATH / 'model' / 'b6369a24.yaml'
117
+ return (
118
+ str(voices_dir) if voices_dir.is_dir() else None,
119
+ str(model_path) if model_path.is_file() else None,
120
+ )
121
+ return None, None
122
+
123
+ @classmethod
124
+ def get_template_folder(cls) -> str:
125
+ """Get the templates folder path."""
126
+ return str(cls.BASE_PATH / 'templates')
127
+
128
+ @classmethod
129
+ def get_static_folder(cls) -> str:
130
+ """Get the static files folder path."""
131
+ return str(cls.BASE_PATH / 'static')
app/logging_config.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Logging configuration with file rotation support.
3
+ """
4
+
5
+ import logging
6
+ import sys
7
+ from logging.handlers import RotatingFileHandler
8
+ from pathlib import Path
9
+
10
+ from app.config import Config
11
+
12
+
13
+ def setup_logging(log_level: str = None) -> logging.Logger:
14
+ """
15
+ Configure application logging with console and rotating file handlers.
16
+
17
+ Args:
18
+ log_level: Override log level (default: from Config.LOG_LEVEL)
19
+
20
+ Returns:
21
+ Configured logger instance
22
+ """
23
+ level = getattr(logging, (log_level or Config.LOG_LEVEL).upper(), logging.INFO)
24
+
25
+ # Create logger
26
+ logger = logging.getLogger('PocketTTS')
27
+ logger.setLevel(level)
28
+
29
+ # Avoid duplicate handlers
30
+ if logger.handlers:
31
+ return logger
32
+
33
+ # Console handler - simple format
34
+ console_handler = logging.StreamHandler(sys.stdout)
35
+ console_handler.setLevel(level)
36
+ console_format = logging.Formatter(
37
+ '%(asctime)s - %(levelname)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S'
38
+ )
39
+ console_handler.setFormatter(console_format)
40
+ logger.addHandler(console_handler)
41
+
42
+ # File handler - detailed format with rotation
43
+ try:
44
+ log_dir = Path(Config.LOG_DIR)
45
+ log_dir.mkdir(parents=True, exist_ok=True)
46
+ log_path = log_dir / Config.LOG_FILE
47
+
48
+ file_handler = RotatingFileHandler(
49
+ log_path,
50
+ maxBytes=Config.LOG_MAX_BYTES,
51
+ backupCount=Config.LOG_BACKUP_COUNT,
52
+ encoding='utf-8',
53
+ )
54
+ file_handler.setLevel(level)
55
+ file_format = logging.Formatter(
56
+ '%(asctime)s - %(name)s - %(levelname)s - [%(filename)s:%(lineno)d] - %(message)s',
57
+ datefmt='%Y-%m-%d %H:%M:%S',
58
+ )
59
+ file_handler.setFormatter(file_format)
60
+ logger.addHandler(file_handler)
61
+
62
+ except Exception as e:
63
+ logger.warning(f'Could not set up file logging: {e}')
64
+
65
+ # Suppress noisy third-party loggers
66
+ logging.getLogger('werkzeug').setLevel(logging.WARNING)
67
+ logging.getLogger('urllib3').setLevel(logging.WARNING)
68
+
69
+ return logger
70
+
71
+
72
+ def get_logger(name: str = None) -> logging.Logger:
73
+ """Get a logger instance, optionally with a child name."""
74
+ base_logger = logging.getLogger('PocketTTS')
75
+ if name:
76
+ return base_logger.getChild(name)
77
+ return base_logger
app/routes.py ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Flask routes for the OpenAI-compatible TTS API.
3
+ """
4
+
5
+ import time
6
+
7
+ from flask import (
8
+ Blueprint,
9
+ Response,
10
+ jsonify,
11
+ render_template,
12
+ request,
13
+ send_file,
14
+ stream_with_context,
15
+ )
16
+
17
+ from app.config import Config
18
+ from app.logging_config import get_logger
19
+ from app.services.audio import (
20
+ convert_audio,
21
+ get_mime_type,
22
+ tensor_to_pcm_bytes,
23
+ validate_format,
24
+ write_wav_header,
25
+ )
26
+ from app.services.preprocess import TextPreprocessor
27
+ from app.services.tts import get_tts_service
28
+ from app.services.versions import get_versions
29
+
30
+ logger = get_logger('routes')
31
+
32
+ # Create blueprint
33
+ api = Blueprint('api', __name__)
34
+
35
+ # Create text preprocessor instance, some options changed from defaults
36
+ text_preprocessor = TextPreprocessor(
37
+ remove_urls=False,
38
+ remove_emails=False,
39
+ remove_html=True,
40
+ remove_hashtags=True,
41
+ remove_mentions=False,
42
+ remove_punctuation=False,
43
+ remove_stopwords=False,
44
+ remove_extra_whitespace=False,
45
+ )
46
+
47
+
48
+ @api.route('/')
49
+ def home():
50
+ """Serve the web interface."""
51
+ from app.config import Config
52
+
53
+ return render_template(
54
+ 'index.html',
55
+ is_docker=Config.IS_DOCKER,
56
+ versions=get_versions(),
57
+ )
58
+
59
+
60
+ @api.route('/health', methods=['GET'])
61
+ def health():
62
+ """
63
+ Health check endpoint for container orchestration.
64
+
65
+ Returns service status and basic model info.
66
+ """
67
+ tts = get_tts_service()
68
+
69
+ # Validate a built-in voice quickly
70
+ voice_valid, voice_msg = tts.validate_voice('alba')
71
+
72
+ return jsonify(
73
+ {
74
+ 'status': 'healthy' if tts.is_loaded else 'unhealthy',
75
+ 'model_loaded': tts.is_loaded,
76
+ 'device': tts.device if tts.is_loaded else None,
77
+ 'sample_rate': tts.sample_rate if tts.is_loaded else None,
78
+ 'voices_dir': tts.voices_dir,
79
+ 'voice_check': {'valid': voice_valid, 'message': voice_msg},
80
+ 'active_model': tts._active,
81
+ }
82
+ ), 200 if tts.is_loaded else 503
83
+
84
+
85
+ @api.route('/v1/voices', methods=['GET'])
86
+ def list_voices():
87
+ """
88
+ List available voices.
89
+
90
+ Returns OpenAI-compatible voice list format.
91
+ """
92
+ tts = get_tts_service()
93
+ voices = tts.list_voices()
94
+
95
+ return jsonify(
96
+ {
97
+ 'object': 'list',
98
+ 'data': [
99
+ {
100
+ 'id': v['id'],
101
+ 'name': v['name'],
102
+ 'object': 'voice',
103
+ 'type': v.get('type', 'builtin'),
104
+ }
105
+ for v in voices
106
+ ],
107
+ }
108
+ )
109
+
110
+
111
+ @api.route('/v1/model', methods=['GET'])
112
+ def get_model():
113
+ """Return the active model state, boot snapshot, and supported languages."""
114
+ tts = get_tts_service()
115
+
116
+ active = tts._active or {'source': 'default', 'value': None, 'quantize': False}
117
+ boot = tts._boot_active or active
118
+ differs = active != boot
119
+ model_path_locked = boot.get('source') == 'model_path'
120
+ versions = get_versions()
121
+
122
+ return jsonify(
123
+ {
124
+ 'active': active,
125
+ 'boot': boot,
126
+ 'differs_from_boot': differs,
127
+ 'loading': tts._loading,
128
+ 'loading_target': getattr(tts, '_loading_target', None),
129
+ 'last_error': getattr(tts, '_last_reload_error', None),
130
+ 'model_path_locked': model_path_locked,
131
+ 'available_languages': list(Config.SUPPORTED_LANGUAGES),
132
+ 'server_version': versions['server'],
133
+ 'pocket_tts_version': versions['pocket_tts'],
134
+ }
135
+ )
136
+
137
+
138
+ @api.route('/v1/model', methods=['POST'])
139
+ def post_model():
140
+ """Request a runtime model switch. Returns 202; UI polls GET for completion."""
141
+ data = request.json
142
+ if not isinstance(data, dict):
143
+ return jsonify({'error': 'Request body must be a JSON object'}), 400
144
+
145
+ language = data.get('language')
146
+
147
+ # Reject non-bool `quantize` rather than coercing — `bool('false')` is True,
148
+ # which would silently enable quantization for any client sending a string.
149
+ quantize = False
150
+ if 'quantize' in data:
151
+ if not isinstance(data['quantize'], bool):
152
+ return jsonify({'error': "Field 'quantize' must be a boolean"}), 400
153
+ quantize = data['quantize']
154
+
155
+ if not language:
156
+ return jsonify({'error': "Missing required field 'language'"}), 400
157
+
158
+ if language not in Config.SUPPORTED_LANGUAGES:
159
+ return jsonify(
160
+ {
161
+ 'error': f"Unknown language: '{language}'",
162
+ 'available': list(Config.SUPPORTED_LANGUAGES),
163
+ }
164
+ ), 400
165
+
166
+ tts = get_tts_service()
167
+
168
+ if tts._boot_active and tts._boot_active.get('source') == 'model_path':
169
+ return jsonify(
170
+ {
171
+ 'error': 'Language switching disabled: server started with --model-path.',
172
+ }
173
+ ), 403
174
+
175
+ # `reload_model_async` does the atomic check-and-claim, so the 409 race
176
+ # window between `if tts._loading` and `start()` is gone.
177
+ started = tts.reload_model_async(language=language, quantize=quantize)
178
+ if not started:
179
+ return jsonify({'error': 'A model reload is already in progress.'}), 409
180
+
181
+ return jsonify(
182
+ {
183
+ 'status': 'accepted',
184
+ 'loading_target': {'value': language, 'quantize': quantize},
185
+ }
186
+ ), 202
187
+
188
+
189
+ @api.route('/v1/audio/speech', methods=['POST'])
190
+ def generate_speech():
191
+ """
192
+ OpenAI-compatible speech generation endpoint.
193
+
194
+ Request body:
195
+ model: string (ignored, for compatibility)
196
+ input: string (required) - Text to synthesize
197
+ voice: string (optional) - Voice ID or path
198
+ response_format: string (optional) - Audio format
199
+ stream: boolean (optional) - Enable streaming
200
+
201
+ Returns:
202
+ Audio file or streaming audio response
203
+ """
204
+ from flask import current_app
205
+
206
+ data = request.json
207
+
208
+ if not isinstance(data, dict):
209
+ return jsonify({'error': 'Request body must be a JSON object'}), 400
210
+
211
+ text = data.get('input')
212
+ if not text:
213
+ return jsonify({'error': "Missing 'input' text"}), 400
214
+
215
+ voice = data.get('voice', 'alba')
216
+ stream_request = data.get('stream', False)
217
+
218
+ response_format = data.get('response_format', 'mp3')
219
+ target_format = validate_format(response_format)
220
+
221
+ tts = get_tts_service()
222
+
223
+ if tts._loading:
224
+ return jsonify({'error': 'Model is reloading; retry shortly.'}), 503
225
+
226
+ # Validate voice first
227
+ is_valid, msg = tts.validate_voice(voice)
228
+ if not is_valid:
229
+ available = [v['id'] for v in tts.list_voices()]
230
+ return jsonify(
231
+ {
232
+ 'error': f"Voice '{voice}' not found",
233
+ 'available_voices': available[:10], # Limit to first 10
234
+ 'hint': 'Use /v1/voices to see all available voices',
235
+ }
236
+ ), 400
237
+
238
+ try:
239
+ voice_state = tts.get_voice_state(voice)
240
+
241
+ # Check if streaming should be used
242
+ use_streaming = stream_request or current_app.config.get('STREAM_DEFAULT', False)
243
+
244
+ # Streaming supports only PCM/WAV today; fall back to file for other formats.
245
+ if use_streaming and target_format not in ('pcm', 'wav'):
246
+ logger.warning(
247
+ "Streaming format '%s' is not supported; returning full file instead.",
248
+ target_format,
249
+ )
250
+ use_streaming = False
251
+ # Check if text preprocessing should be used
252
+ use_text_preprocess = current_app.config.get('TEXT_PREPROCESS_DEFAULT', False)
253
+ # Preprocess text
254
+ if use_text_preprocess:
255
+ # logger.info(f'Preprocessing text: {text}')
256
+ text = text_preprocessor.process(text)
257
+ # logger.info(f'Preprocessed text: {text}')
258
+ if use_streaming:
259
+ return _stream_audio(tts, voice_state, text, target_format)
260
+ return _generate_file(tts, voice_state, text, target_format)
261
+
262
+ except ValueError as e:
263
+ msg = str(e)
264
+ # Detect the legacy-unlabeled-safetensors mismatch pattern. Re-resolving
265
+ # can itself raise (e.g. SSRF protection on http:// URLs); treat any
266
+ # failure here as "not a mismatch" and fall through to the generic 400.
267
+ try:
268
+ resolved = tts._resolve_voice_path(voice) if not tts._loading else ''
269
+ except Exception:
270
+ resolved = ''
271
+ is_legacy_st = resolved.endswith('.safetensors') and not any(
272
+ resolved.endswith(f'.{tag}.safetensors') for tag in Config.SUPPORTED_LANGUAGES
273
+ )
274
+ mismatch_markers = ('size mismatch', 'Error(s) in loading state_dict', 'shape')
275
+ if is_legacy_st and any(m in msg for m in mismatch_markers):
276
+ return jsonify(
277
+ {
278
+ 'error': 'voice_model_mismatch',
279
+ 'message': (
280
+ f"Voice '{voice}' appears to have been cloned for a different "
281
+ f'model. Upload the original audio (.wav/.mp3/.flac) to '
282
+ f're-clone for the active model, or switch to the model it '
283
+ f'was generated for.'
284
+ ),
285
+ 'voice': voice,
286
+ 'active_model': (tts._active or {}).get('value'),
287
+ }
288
+ ), 400
289
+
290
+ logger.warning(f'Voice loading failed: {e}')
291
+ return jsonify({'error': msg}), 400
292
+ except Exception as e:
293
+ logger.exception('Generation failed')
294
+ return jsonify({'error': str(e)}), 500
295
+
296
+
297
+ def _generate_file(tts, voice_state, text: str, fmt: str):
298
+ """Generate complete audio and return as file."""
299
+ t0 = time.time()
300
+ audio_tensor = tts.generate_audio(voice_state, text)
301
+ generation_time = time.time() - t0
302
+
303
+ logger.info(f'Generated {len(text)} chars in {generation_time:.2f}s')
304
+
305
+ audio_buffer = convert_audio(audio_tensor, tts.sample_rate, fmt)
306
+ mimetype = get_mime_type(fmt)
307
+
308
+ return send_file(
309
+ audio_buffer, mimetype=mimetype, as_attachment=True, download_name=f'speech.{fmt}'
310
+ )
311
+
312
+
313
+ def _stream_audio(tts, voice_state, text: str, fmt: str):
314
+ """Stream audio chunks."""
315
+ # Normalize streaming format: we always emit PCM bytes, optionally wrapped
316
+ # in a WAV container. For non-PCM/WAV formats (e.g. mp3, opus), coerce to
317
+ # raw PCM to avoid mismatched content-type vs. payload.
318
+ stream_fmt = fmt
319
+ if stream_fmt not in ('pcm', 'wav'):
320
+ logger.warning(
321
+ "Requested streaming format '%s' is not supported for streaming; "
322
+ "falling back to 'pcm'.",
323
+ stream_fmt,
324
+ )
325
+ stream_fmt = 'pcm'
326
+
327
+ def generate():
328
+ stream = tts.generate_audio_stream(voice_state, text)
329
+ for chunk_tensor in stream:
330
+ yield tensor_to_pcm_bytes(chunk_tensor)
331
+
332
+ def stream_with_header():
333
+ # Yield WAV header first if streaming as WAV
334
+ if stream_fmt == 'wav':
335
+ yield write_wav_header(tts.sample_rate, num_channels=1, bits_per_sample=16)
336
+ yield from generate()
337
+
338
+ mimetype = get_mime_type(stream_fmt)
339
+
340
+ return Response(stream_with_context(stream_with_header()), mimetype=mimetype)
app/services/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Services package."""
2
+
3
+ from app.services.tts import TTSService, get_tts_service
4
+
5
+ __all__ = ['TTSService', 'get_tts_service']
app/services/audio.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Audio conversion and streaming utilities.
3
+ """
4
+
5
+ import io
6
+ import struct
7
+
8
+ import torch
9
+ import torchaudio
10
+
11
+ from app.logging_config import get_logger
12
+
13
+ logger = get_logger('audio')
14
+
15
+ # Valid audio formats
16
+ VALID_FORMATS = {'mp3', 'wav', 'opus', 'aac', 'flac', 'pcm'}
17
+
18
+
19
+ def validate_format(fmt: str) -> str:
20
+ """
21
+ Normalize and validate the requested audio format.
22
+
23
+ Args:
24
+ fmt: Requested format string
25
+
26
+ Returns:
27
+ Validated format string
28
+ """
29
+ fmt = fmt.lower()
30
+
31
+ # OpenAI sometimes sends 'mpeg' for mp3
32
+ if fmt == 'mpeg':
33
+ return 'mp3'
34
+
35
+ if fmt not in VALID_FORMATS:
36
+ logger.warning(f"Unknown format '{fmt}', falling back to wav")
37
+ return 'wav'
38
+
39
+ return fmt
40
+
41
+
42
+ def convert_audio(
43
+ audio_tensor: torch.Tensor, sample_rate: int, target_format: str = 'wav'
44
+ ) -> io.BytesIO:
45
+ """
46
+ Convert a raw audio tensor to a byte buffer in the specified format.
47
+
48
+ Args:
49
+ audio_tensor: The audio waveform (1D or 2D)
50
+ sample_rate: The sample rate of the audio
51
+ target_format: The target audio format
52
+
53
+ Returns:
54
+ Buffer containing the encoded audio data
55
+ """
56
+ buffer = io.BytesIO()
57
+
58
+ # Ensure tensor is CPU
59
+ if audio_tensor.is_cuda:
60
+ audio_tensor = audio_tensor.cpu()
61
+
62
+ # Ensure 2D (channels, time)
63
+ if audio_tensor.dim() == 1:
64
+ audio_tensor = audio_tensor.unsqueeze(0)
65
+
66
+ # Handle PCM raw bytes (no container)
67
+ if target_format == 'pcm':
68
+ try:
69
+ pcm_bytes = tensor_to_pcm_bytes(audio_tensor)
70
+ buffer.write(pcm_bytes)
71
+ buffer.seek(0)
72
+ return buffer
73
+ except Exception as e:
74
+ logger.error(f'Error converting audio to PCM: {e}')
75
+ raise
76
+
77
+ # Map OpenAI format names to torchaudio/backend supported format names
78
+ # torchaudio uses 'ogg' as the container for 'opus'
79
+ # 'aac' usually requires 'adts' or 'm4a'
80
+ actual_format = target_format
81
+ if actual_format == 'opus':
82
+ actual_format = 'ogg'
83
+ elif actual_format == 'aac':
84
+ actual_format = 'adts'
85
+
86
+ try:
87
+ torchaudio.save(buffer, audio_tensor, sample_rate, format=actual_format)
88
+ buffer.seek(0)
89
+ return buffer
90
+ except Exception as e:
91
+ logger.error(
92
+ f'Error converting audio to {target_format} (backend format: {actual_format}): {e}'
93
+ )
94
+ raise
95
+
96
+
97
+ def write_wav_header(
98
+ sample_rate: int, num_channels: int = 1, bits_per_sample: int = 16, num_frames: int = 0
99
+ ) -> bytes:
100
+ """
101
+ Generate a WAV header for streaming.
102
+
103
+ If num_frames is 0, set to max value (streaming/unknown length).
104
+
105
+ Args:
106
+ sample_rate: Audio sample rate
107
+ num_channels: Number of audio channels
108
+ bits_per_sample: Bits per sample
109
+ num_frames: Number of frames (0 for unknown/streaming)
110
+
111
+ Returns:
112
+ WAV header bytes
113
+ """
114
+ byte_rate = sample_rate * num_channels * bits_per_sample // 8
115
+ block_align = num_channels * bits_per_sample // 8
116
+
117
+ # Data size: if unknown, max uint32
118
+ data_size = num_frames * block_align
119
+ if num_frames == 0:
120
+ data_size = 0xFFFFFFFF - 36
121
+
122
+ chunk_size = 36 + data_size
123
+
124
+ header = io.BytesIO()
125
+ header.write(b'RIFF')
126
+ header.write(struct.pack('<I', chunk_size))
127
+ header.write(b'WAVE')
128
+ header.write(b'fmt ')
129
+ header.write(struct.pack('<I', 16)) # Subchunk1Size (16 for PCM)
130
+ header.write(struct.pack('<H', 1)) # AudioFormat (1 for PCM)
131
+ header.write(struct.pack('<H', num_channels))
132
+ header.write(struct.pack('<I', sample_rate))
133
+ header.write(struct.pack('<I', byte_rate))
134
+ header.write(struct.pack('<H', block_align))
135
+ header.write(struct.pack('<H', bits_per_sample))
136
+ header.write(b'data')
137
+ header.write(struct.pack('<I', data_size))
138
+
139
+ return header.getvalue()
140
+
141
+
142
+ def tensor_to_pcm_bytes(chunk_tensor: torch.Tensor) -> bytes:
143
+ """
144
+ Convert audio tensor chunk to 16-bit PCM bytes.
145
+
146
+ Args:
147
+ chunk_tensor: Audio tensor chunk
148
+
149
+ Returns:
150
+ PCM audio bytes
151
+ """
152
+ if chunk_tensor.is_cuda:
153
+ chunk_tensor = chunk_tensor.cpu()
154
+
155
+ if chunk_tensor.dim() == 1:
156
+ chunk_tensor = chunk_tensor.unsqueeze(0)
157
+
158
+ # Convert to 16-bit PCM
159
+ pcm = (chunk_tensor * 32767).clamp(-32768, 32767).to(torch.int16)
160
+ return pcm.numpy().tobytes()
161
+
162
+
163
+ def get_mime_type(fmt: str) -> str:
164
+ """
165
+ Get the MIME type for an audio format.
166
+
167
+ Args:
168
+ fmt: Audio format string
169
+
170
+ Returns:
171
+ MIME type string
172
+ """
173
+ mime_types = {
174
+ 'wav': 'audio/wav',
175
+ 'mp3': 'audio/mpeg',
176
+ 'pcm': 'audio/L16',
177
+ 'opus': 'audio/opus',
178
+ 'aac': 'audio/aac',
179
+ 'flac': 'audio/flac',
180
+ }
181
+ return mime_types.get(fmt, f'audio/{fmt}')
app/services/preprocess.py ADDED
@@ -0,0 +1,1093 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Adapted and supplemented from origional at https://github.com/KittenML/KittenTTS/blob/main/kittentts/preprocess.py
3
+ See license at: https://github.com/KittenML/KittenTTS/blob/main/LICENSE (Apache 2.0)
4
+ """
5
+
6
+ import re
7
+ import unicodedata
8
+
9
+ # ─────────────────────────────────────────────
10
+ # Number → Words conversion
11
+ # ─────────────────────────────────────────────
12
+
13
+ _ONES = [
14
+ '',
15
+ 'one',
16
+ 'two',
17
+ 'three',
18
+ 'four',
19
+ 'five',
20
+ 'six',
21
+ 'seven',
22
+ 'eight',
23
+ 'nine',
24
+ 'ten',
25
+ 'eleven',
26
+ 'twelve',
27
+ 'thirteen',
28
+ 'fourteen',
29
+ 'fifteen',
30
+ 'sixteen',
31
+ 'seventeen',
32
+ 'eighteen',
33
+ 'nineteen',
34
+ ]
35
+ _TENS = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety']
36
+ _SCALE = ['', 'thousand', 'million', 'billion', 'trillion']
37
+
38
+ _ORDINAL_EXCEPTIONS = {
39
+ 'one': 'first',
40
+ 'two': 'second',
41
+ 'three': 'third',
42
+ 'four': 'fourth',
43
+ 'five': 'fifth',
44
+ 'six': 'sixth',
45
+ 'seven': 'seventh',
46
+ 'eight': 'eighth',
47
+ 'nine': 'ninth',
48
+ 'twelve': 'twelfth',
49
+ }
50
+
51
+ _CURRENCY_SYMBOLS = {
52
+ '$': 'dollar',
53
+ '€': 'euro',
54
+ '£': 'pound',
55
+ '¥': 'yen',
56
+ '₹': 'rupee',
57
+ '₩': 'won',
58
+ '₿': 'bitcoin',
59
+ }
60
+
61
+ _CURRENCY_SCALE_MAP = {
62
+ 'K': 'thousand',
63
+ 'M': 'million',
64
+ 'B': 'billion',
65
+ 'T': 'trillion',
66
+ 'thousand': 'thousand',
67
+ 'million': 'million',
68
+ 'billion': 'billion',
69
+ 'trillion': 'trillion',
70
+ }
71
+
72
+ _ROMAN = [
73
+ (1000, 'M'),
74
+ (900, 'CM'),
75
+ (500, 'D'),
76
+ (400, 'CD'),
77
+ (100, 'C'),
78
+ (90, 'XC'),
79
+ (50, 'L'),
80
+ (40, 'XL'),
81
+ (10, 'X'),
82
+ (9, 'IX'),
83
+ (5, 'V'),
84
+ (4, 'IV'),
85
+ (1, 'I'),
86
+ ]
87
+ _RE_ROMAN = re.compile(r'\b(M{0,4})(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})\b')
88
+
89
+
90
+ def _three_digits_to_words(n: int) -> str:
91
+ """Convert a number 0–999 to English words."""
92
+ if n == 0:
93
+ return ''
94
+ parts = []
95
+ hundreds = n // 100
96
+ remainder = n % 100
97
+ if hundreds:
98
+ parts.append(f'{_ONES[hundreds]} hundred')
99
+ if remainder < 20:
100
+ if remainder:
101
+ parts.append(_ONES[remainder])
102
+ else:
103
+ tens_word = _TENS[remainder // 10]
104
+ ones_word = _ONES[remainder % 10]
105
+ parts.append(f'{tens_word}-{ones_word}' if ones_word else tens_word)
106
+ return ' '.join(parts)
107
+
108
+
109
+ def number_to_words(n: int) -> str:
110
+ """
111
+ Convert an integer to its English word representation.
112
+
113
+ Examples:
114
+ 1200 → "twelve hundred"
115
+ 1000 → "one thousand"
116
+ 1_000_000 → "one million"
117
+ -42 → "negative forty-two"
118
+ 0 → "zero"
119
+ """
120
+ if not isinstance(n, int):
121
+ n = int(n)
122
+ if n == 0:
123
+ return 'zero'
124
+ if n < 0:
125
+ return f'negative {number_to_words(-n)}'
126
+
127
+ # X00–X999 read as "X hundred" (e.g. 1200 → "twelve hundred")
128
+ # Exclude exact multiples of 1000 (1000 → "one thousand", not "ten hundred")
129
+ if 100 <= n <= 9999 and n % 100 == 0 and n % 1000 != 0:
130
+ hundreds = n // 100
131
+ if hundreds < 20:
132
+ return f'{_ONES[hundreds]} hundred'
133
+
134
+ parts = []
135
+ for _i, scale in enumerate(_SCALE):
136
+ chunk = n % 1000
137
+ if chunk:
138
+ chunk_words = _three_digits_to_words(chunk)
139
+ parts.append(f'{chunk_words} {scale}'.strip() if scale else chunk_words)
140
+ n //= 1000
141
+ if n == 0:
142
+ break
143
+
144
+ return ' '.join(reversed(parts))
145
+
146
+
147
+ def float_to_words(value, decimal_sep: str = 'point') -> str:
148
+ """
149
+ Convert a float (or numeric string) to words, reading decimal digits individually.
150
+ Accepts a string to preserve trailing zeros (e.g. "1.50" → "one point five zero").
151
+
152
+ Examples:
153
+ 3.14 → "three point one four"
154
+ -0.5 → "negative zero point five"
155
+ "3.10" → "three point one zero"
156
+ 1.007 → "one point zero zero seven"
157
+ """
158
+ text = value if isinstance(value, str) else f'{value}'
159
+ negative = text.startswith('-')
160
+ if negative:
161
+ text = text[1:]
162
+
163
+ if '.' in text:
164
+ int_part, dec_part = text.split('.', 1)
165
+ int_words = number_to_words(int(int_part)) if int_part else 'zero'
166
+ # Read each decimal digit individually; "0" → "zero"
167
+ digit_map = ['zero'] + _ONES[1:] # index 0 → "zero"
168
+ dec_words = ' '.join(digit_map[int(d)] for d in dec_part)
169
+ result = f'{int_words} {decimal_sep} {dec_words}'
170
+ else:
171
+ result = number_to_words(int(text))
172
+
173
+ return f'negative {result}' if negative else result
174
+
175
+
176
+ def roman_to_int(s: str) -> int:
177
+ """Convert a Roman numeral string to an integer."""
178
+ val = {'I': 1, 'V': 5, 'X': 10, 'L': 50, 'C': 100, 'D': 500, 'M': 1000}
179
+ result = 0
180
+ prev = 0
181
+ for ch in reversed(s.upper()):
182
+ curr = val[ch]
183
+ result += curr if curr >= prev else -curr
184
+ prev = curr
185
+ return result
186
+
187
+
188
+ # ─────────────────────────────────────────────
189
+ # Regex patterns
190
+ # ─────────────────────────────────────────────
191
+
192
+ _RE_URL = re.compile(r'https?://\S+|www\.\S+')
193
+ _RE_EMAIL = re.compile(r'\b[\w.+-]+@[\w-]+\.[a-z]{2,}\b', re.IGNORECASE)
194
+ _RE_HASHTAG = re.compile(r'#\w+')
195
+ _RE_MENTION = re.compile(r'@\w+')
196
+ _RE_HTML = re.compile(r'<[^>]+>')
197
+ _RE_PUNCT = re.compile(r'[^\w\s]')
198
+ _RE_SPACES = re.compile(r'\s+')
199
+ _RE_AI = re.compile(r'\bAI\b')
200
+ _RE_DOT_COM = re.compile(r'\.com\b', re.IGNORECASE)
201
+ _RE_PLUS = re.compile(r'\+')
202
+ _RE_AMPERSAND = re.compile(r'&')
203
+ _RE_AT_SYMBOL = re.compile(r'@')
204
+ _RE_NEWLINE = re.compile(r'[\r\n]+')
205
+ _RE_TILDE = re.compile(r'~')
206
+
207
+ _MONTH_MAP = {
208
+ 'Jan': 'January',
209
+ 'Feb': 'February',
210
+ 'Mar': 'March',
211
+ 'Apr': 'April',
212
+ 'Jun': 'June',
213
+ 'Jul': 'July',
214
+ 'Aug': 'August',
215
+ 'Sep': 'September',
216
+ 'Sept': 'September',
217
+ 'Oct': 'October',
218
+ 'Nov': 'November',
219
+ 'Dec': 'December',
220
+ }
221
+
222
+ # Regex looks for Title Case months followed by a period or a digit
223
+ # We handle "May" separately because it's a common word.
224
+ _RE_MONTHS = re.compile(r'\b(Jan|Feb|Mar|Apr|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)\.?\b(?=\s*\d|\s*$)')
225
+ _RE_MAY = re.compile(r'\bMay\b(?=\s*\d)') # Only expand May if followed by a number (May 5)
226
+
227
+ # Number: do NOT match a leading minus if it is immediately preceded by a letter
228
+ # (handles "gpt-3", "gpl-3", "v-2" etc.)
229
+ _RE_NUMBER = re.compile(r'(?<![a-zA-Z])-?[\d,]+(?:\.\d+)?')
230
+
231
+ # Ordinals: 1st, 2nd, 3rd, 4th … 21st, 101st …
232
+ _RE_ORDINAL = re.compile(r'\b(\d+)(st|nd|rd|th)\b', re.IGNORECASE)
233
+
234
+ # Percentages: 50%, 3.5%
235
+ _RE_PERCENT = re.compile(r'(-?[\d,]+(?:\.\d+)?)\s*%')
236
+
237
+ # Currency: $100, €1,200.50, £50, $85K, $2.5M (optional scale suffix)
238
+ _RE_CURRENCY = re.compile(
239
+ r'([$€£¥₹₩₿])\s*([\d,]+(?:\.\d+)?)\s*(million|billion|trillion|thousand|[KMBT])?\b',
240
+ re.IGNORECASE,
241
+ )
242
+
243
+ # Time: 3:30pm, 14:00, 3:30 AM — requires 2-digit minutes so "3:0" (score) doesn't match
244
+ _RE_TIME = re.compile(r'\b(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(am|pm)?\b', re.IGNORECASE)
245
+
246
+ # Ranges: 10-20, 100-200 (both sides numeric, hyphen between them)
247
+ _RE_RANGE = re.compile(r'(?<!\w)(\d+)-(\d+)(?!\w)')
248
+
249
+ # Version/model names: gpt-3, gpt-3.5, v2.0, Python-3.10, GPL-3
250
+ # Letter(s) + hyphen + digit(s) [+ more version parts]
251
+ _RE_MODEL_VER = re.compile(r'\b([a-zA-Z][a-zA-Z0-9]*)-(\d[\d.]*)(?=[^\d.]|$)')
252
+
253
+ # Measurement units glued to numbers: 100km, 50kg, 25°C, 5GB
254
+ _RE_UNIT = re.compile(
255
+ r'(\d+(?:\.\d+)?)\s*(km|kg|mg|ml|gb|mb|kb|tb|hz|khz|mhz|ghz|mph|kph|°[cCfF]|[cCfF]°|ms|ns|µs)\b',
256
+ re.IGNORECASE,
257
+ )
258
+
259
+ # Scale suffixes (uppercase only to avoid ambiguity): 7B, 340M, 1.5K, 2T
260
+ # Must NOT be preceded by a letter (so 'MB' is handled by unit regex first)
261
+ _RE_SCALE = re.compile(r'(?<![a-zA-Z])(\d+(?:\.\d+)?)\s*([KMBT])(?![a-zA-Z\d])')
262
+
263
+ # Scientific notation: 1e-4, 2.5e10, 6.022E23
264
+ _RE_SCI = re.compile(r'(?<![a-zA-Z\d])(-?\d+(?:\.\d+)?)[eE]([+-]?\d+)(?![a-zA-Z\d])')
265
+
266
+ # Fractions: 1/2, 3/4, 2/3
267
+ _RE_FRACTION = re.compile(r'\b(\d+)\s*/\s*(\d+)\b')
268
+
269
+ # Decades: 80s, 90s, 1980s, 2020s (number ending in 0 followed by 's')
270
+ _RE_DECADE = re.compile(r'\b(\d{1,3})0s\b')
271
+
272
+ # Leading decimal (no digit before the dot): .5, .75
273
+ _RE_LEAD_DEC = re.compile(r'(?<!\d)\.([\d])')
274
+
275
+
276
+ # ─────────────────────────────────────────────
277
+ # Expansion helpers
278
+ # ─────────────────────────────────────────────
279
+ def expand_abbreviations(text: str) -> str:
280
+ """
281
+ Handles specific abbreviations before lowercase normalization.
282
+ AI -> A.I.
283
+ .com -> dot com
284
+ """
285
+ # 1. AI to A.I. (Case sensitive)
286
+ text = _RE_AI.sub('A.I.', text)
287
+ # 2. .com to dot com
288
+ text = _RE_DOT_COM.sub(' dot com', text)
289
+ return text
290
+
291
+
292
+ def expand_symbols(text: str) -> str:
293
+ """
294
+ Translates mathematical and connector symbols to words.
295
+ """
296
+ text = _RE_PLUS.sub(' plus ', text)
297
+ text = _RE_AMPERSAND.sub(' and ', text)
298
+ text = _RE_AT_SYMBOL.sub(' at ', text)
299
+ return text
300
+
301
+
302
+ def _ordinal_suffix(n: int) -> str:
303
+ """Return the ordinal word for n (e.g. 1 → 'first', 5 → 'fifth', 21 → 'twenty-first')."""
304
+ word = number_to_words(n)
305
+ # For hyphenated compounds like "twenty-one", convert only the last part
306
+ if '-' in word:
307
+ prefix, last = word.rsplit('-', 1)
308
+ joiner = '-'
309
+ else:
310
+ parts = word.rsplit(' ', 1)
311
+ prefix, last, joiner = (parts[0], parts[1], ' ') if len(parts) == 2 else ('', parts[0], '')
312
+
313
+ # Check exception table
314
+ for base, ordinal in _ORDINAL_EXCEPTIONS.items():
315
+ if last == base:
316
+ last_ord = ordinal
317
+ break
318
+ else:
319
+ # General rule
320
+ if last.endswith('t'):
321
+ last_ord = last + 'h'
322
+ elif last.endswith('e'):
323
+ last_ord = last[:-1] + 'th'
324
+ else:
325
+ last_ord = last + 'th'
326
+
327
+ return f'{prefix}{joiner}{last_ord}' if prefix else last_ord
328
+
329
+
330
+ def expand_ordinals(text: str) -> str:
331
+ """
332
+ Convert ordinal numbers to words.
333
+
334
+ Examples:
335
+ "1st place" → "first place"
336
+ "2nd floor" → "second floor"
337
+ "3rd base" → "third base"
338
+ "21st century" → "twenty-first century"
339
+ "100th day" → "one hundredth day"
340
+ """
341
+
342
+ def _replace(m: re.Match) -> str:
343
+ return _ordinal_suffix(int(m.group(1)))
344
+
345
+ return _RE_ORDINAL.sub(_replace, text)
346
+
347
+
348
+ def expand_percentages(text: str) -> str:
349
+ """
350
+ Expand percentage expressions.
351
+
352
+ Examples:
353
+ "50% off" → "fifty percent off"
354
+ "3.5% rate" → "three point five percent rate"
355
+ "-2% change" → "negative two percent change"
356
+ """
357
+
358
+ def _replace(m: re.Match) -> str:
359
+ raw = m.group(1).replace(',', '')
360
+ if '.' in raw:
361
+ return float_to_words(float(raw)) + ' percent'
362
+ return number_to_words(int(raw)) + ' percent'
363
+
364
+ return _RE_PERCENT.sub(_replace, text)
365
+
366
+
367
+ def expand_newlines(text: str) -> str:
368
+ """Change newlines/returns to a period and space for TTS pausing."""
369
+ return _RE_NEWLINE.sub('. ', text)
370
+
371
+
372
+ def expand_tilde(text: str) -> str:
373
+ """Change ~ to 'about'."""
374
+ return _RE_TILDE.sub('about ', text)
375
+
376
+
377
+ def expand_currency(text: str) -> str:
378
+ """
379
+ Expand currency amounts, including optional scale suffixes.
380
+
381
+ Examples:
382
+ "$100" → "one hundred dollars"
383
+ "€1,200.50" → "twelve hundred euros and fifty cents"
384
+ "£9.99" → "nine pounds and ninety-nine cents"
385
+ "$85K" → "eighty five thousand dollars"
386
+ "$2.5M" → "two point five million dollars"
387
+ """
388
+
389
+ def _replace(m: re.Match) -> str:
390
+ symbol = m.group(1)
391
+ raw = m.group(2).replace(',', '')
392
+ scale_suffix = m.group(3)
393
+ unit = _CURRENCY_SYMBOLS.get(symbol, '')
394
+
395
+ # Handle Scaled Currency ($17.5 billion or $17.5B)
396
+ if scale_suffix:
397
+ # Normalize suffix (e.g., 'B' or 'billion' -> 'billion')
398
+ scale_word = _CURRENCY_SCALE_MAP.get(scale_suffix.upper(), scale_suffix.lower())
399
+ num = float_to_words(raw) if '.' in raw else number_to_words(int(raw))
400
+ return f'{num} {scale_word} {unit}{"s" if unit else ""}'.strip()
401
+
402
+ # Handle Standard Currency ($17.50)
403
+ if '.' in raw:
404
+ int_part, dec_part = raw.split('.', 1)
405
+ dec_val = int(dec_part[:2].ljust(2, '0'))
406
+ int_words = number_to_words(int(int_part))
407
+ result = f'{int_words} {unit}s' if unit else int_words
408
+ if dec_val:
409
+ cents = number_to_words(dec_val)
410
+ result += f' and {cents} cent{"s" if dec_val != 1 else ""}'
411
+ else:
412
+ val = int(raw)
413
+ words = number_to_words(val)
414
+ result = f'{words} {unit}{"s" if val != 1 and unit else ""}' if unit else words
415
+ return result
416
+
417
+ return _RE_CURRENCY.sub(_replace, text)
418
+
419
+
420
+ def expand_time(text: str) -> str:
421
+ """
422
+ Expand time expressions.
423
+
424
+ Examples:
425
+ "3:30pm" → "three thirty pm"
426
+ "14:00" → "fourteen hundred"
427
+ "9:05 AM" → "nine oh five am"
428
+ "12:00pm" → "twelve pm"
429
+ """
430
+
431
+ def _replace(m: re.Match) -> str:
432
+ h = int(m.group(1))
433
+ mins = int(m.group(2))
434
+ suffix = (' ' + m.group(4).lower()) if m.group(4) else ''
435
+ h_words = number_to_words(h)
436
+ if mins == 0:
437
+ return f'{h_words} hundred{suffix}' if not m.group(4) else f'{h_words}{suffix}'
438
+ elif mins < 10:
439
+ return f'{h_words} oh {number_to_words(mins)}{suffix}'
440
+ else:
441
+ return f'{h_words} {number_to_words(mins)}{suffix}'
442
+
443
+ return _RE_TIME.sub(_replace, text)
444
+
445
+
446
+ def expand_ranges(text: str) -> str:
447
+ """
448
+ Expand numeric ranges.
449
+
450
+ Examples:
451
+ "10-20 items" → "ten to twenty items"
452
+ "pages 100-200" → "pages one hundred to two hundred"
453
+ "2020-2024" → "twenty twenty to twenty twenty-four"
454
+ """
455
+
456
+ def _replace(m: re.Match) -> str:
457
+ lo = number_to_words(int(m.group(1)))
458
+ hi = number_to_words(int(m.group(2)))
459
+ return f'{lo} to {hi}'
460
+
461
+ return _RE_RANGE.sub(_replace, text)
462
+
463
+
464
+ def expand_model_names(text: str) -> str:
465
+ """
466
+ Normalise version/model names that use letter-hyphen-number patterns,
467
+ so the number is not misread as negative.
468
+
469
+ Examples:
470
+ "GPT-3" → "GPT 3"
471
+ "gpt-3.5" → "gpt 3.5"
472
+ "GPL-3" → "GPL 3"
473
+ "Python-3.10"→ "Python 3.10"
474
+ "v2.0" stays as "v2.0" (no hyphen — handled by number replacement)
475
+ "IPv6" stays as "IPv6"
476
+ """
477
+ return _RE_MODEL_VER.sub(lambda m: f'{m.group(1)} {m.group(2)}', text)
478
+
479
+
480
+ def expand_units(text: str) -> str:
481
+ """
482
+ Expand common measurement units glued to numbers.
483
+
484
+ Examples:
485
+ "100km" → "one hundred kilometers"
486
+ "50kg" → "fifty kilograms"
487
+ "25°C" → "twenty-five degrees Celsius"
488
+ "5GB" → "five gigabytes"
489
+ """
490
+ _unit_map = {
491
+ 'km': 'kilometers',
492
+ 'kg': 'kilograms',
493
+ 'mg': 'milligrams',
494
+ 'ml': 'milliliters',
495
+ 'gb': 'gigabytes',
496
+ 'mb': 'megabytes',
497
+ 'kb': 'kilobytes',
498
+ 'tb': 'terabytes',
499
+ 'hz': 'hertz',
500
+ 'khz': 'kilohertz',
501
+ 'mhz': 'megahertz',
502
+ 'ghz': 'gigahertz',
503
+ 'mph': 'miles per hour',
504
+ 'kph': 'kilometers per hour',
505
+ 'ms': 'milliseconds',
506
+ 'ns': 'nanoseconds',
507
+ 'µs': 'microseconds',
508
+ '°c': 'degrees Celsius',
509
+ 'c°': 'degrees Celsius',
510
+ '°f': 'degrees Fahrenheit',
511
+ 'f°': 'degrees Fahrenheit',
512
+ }
513
+
514
+ def _replace(m: re.Match) -> str:
515
+ raw = m.group(1)
516
+ unit = m.group(2).lower()
517
+ expanded = _unit_map.get(unit, m.group(2))
518
+ num = float_to_words(float(raw)) if '.' in raw else number_to_words(int(raw))
519
+ return f'{num} {expanded}'
520
+
521
+ return _RE_UNIT.sub(_replace, text)
522
+
523
+
524
+ def expand_roman_numerals(text: str, context_words: bool = True) -> str:
525
+ """
526
+ Expand Roman numerals that appear as standalone tokens (optionally
527
+ only when preceded by a title-like word to avoid false positives).
528
+
529
+ Examples:
530
+ "World War II" → "World War two"
531
+ "Chapter IV" → "Chapter four"
532
+ "Louis XIV" → "Louis fourteen"
533
+ "mix I with V" → left unchanged (ambiguous single letters)
534
+ """
535
+ _TITLE_WORDS = re.compile(
536
+ r'\b(war|chapter|part|volume|act|scene|book|section|article|'
537
+ r'king|queen|pope|louis|henry|edward|george|william|james|'
538
+ r'phase|round|level|stage|class|type|version|episode|season)\b',
539
+ re.IGNORECASE,
540
+ )
541
+
542
+ def _replace(m: re.Match) -> str:
543
+ roman = m.group(0)
544
+ if not roman.strip():
545
+ return roman
546
+ # Skip single ambiguous letters (I, V, X) unless context present
547
+ if len(roman) == 1 and roman in 'IVX':
548
+ # Only expand if preceded by a title word
549
+ start = m.start()
550
+ preceding = text[max(0, start - 30) : start]
551
+ if not _TITLE_WORDS.search(preceding):
552
+ return roman
553
+ try:
554
+ val = roman_to_int(roman)
555
+ if val == 0:
556
+ return roman
557
+ return number_to_words(val)
558
+ except Exception:
559
+ return roman
560
+
561
+ return _RE_ROMAN.sub(_replace, text)
562
+
563
+
564
+ def normalize_leading_decimals(text: str) -> str:
565
+ """
566
+ Normalise bare leading-decimal floats so the number pipeline handles them.
567
+
568
+ Examples:
569
+ ".5 teaspoons" → "0.5 teaspoons"
570
+ "-.25 adjustment" → "-0.25 adjustment"
571
+ """
572
+ # Handle -.5 → -0.5 and .5 → 0.5
573
+ text = re.sub(r'(?<!\d)(-)\.([\d])', r'\g<1>0.\2', text)
574
+ return _RE_LEAD_DEC.sub(r'0.\1', text)
575
+
576
+
577
+ def expand_scientific_notation(text: str) -> str:
578
+ """
579
+ Expand scientific-notation numbers to spoken form.
580
+
581
+ Examples:
582
+ "1e-4" → "one times ten to the negative four"
583
+ "2.5e10" → "two point five times ten to the ten"
584
+ "6.022E23"→ "six point zero two two times ten to the twenty three"
585
+ """
586
+
587
+ def _replace(m: re.Match) -> str:
588
+ coeff_raw = m.group(1)
589
+ exp = int(m.group(2))
590
+ coeff_words = (
591
+ float_to_words(coeff_raw) if '.' in coeff_raw else number_to_words(int(coeff_raw))
592
+ )
593
+ exp_words = number_to_words(abs(exp))
594
+ sign = 'negative ' if exp < 0 else ''
595
+ return f'{coeff_words} times ten to the {sign}{exp_words}'
596
+
597
+ return _RE_SCI.sub(_replace, text)
598
+
599
+
600
+ def expand_scale_suffixes(text: str) -> str:
601
+ """
602
+ Expand standalone uppercase scale suffixes attached to numbers.
603
+
604
+ Examples:
605
+ "7B parameters" → "seven billion parameters"
606
+ "340M model" → "three hundred forty million model"
607
+ "1.5K salary" → "one point five thousand salary"
608
+ "$100K budget" → "$100K budget" (currency handled upstream)
609
+ """
610
+ _map = {'K': 'thousand', 'M': 'million', 'B': 'billion', 'T': 'trillion'}
611
+
612
+ def _replace(m: re.Match) -> str:
613
+ raw = m.group(1)
614
+ suffix = m.group(2)
615
+ scale_word = _map.get(suffix, suffix)
616
+ num = float_to_words(raw) if '.' in raw else number_to_words(int(raw))
617
+ return f'{num} {scale_word}'
618
+
619
+ return _RE_SCALE.sub(_replace, text)
620
+
621
+
622
+ def expand_fractions(text: str) -> str:
623
+ """
624
+ Expand simple numeric fractions.
625
+
626
+ Examples:
627
+ "1/2 cup" → "one half cup"
628
+ "3/4 mile" → "three quarters mile"
629
+ "2/3 done" → "two thirds done"
630
+ "5/8 inch" → "five eighths inch"
631
+ """
632
+
633
+ def _replace(m: re.Match) -> str:
634
+ num = int(m.group(1))
635
+ den = int(m.group(2))
636
+ if den == 0:
637
+ return m.group()
638
+ num_words = number_to_words(num)
639
+ if den == 2:
640
+ denom_word = 'half' if num == 1 else 'halves'
641
+ elif den == 4:
642
+ denom_word = 'quarter' if num == 1 else 'quarters'
643
+ else:
644
+ denom_word = _ordinal_suffix(den)
645
+ if num != 1:
646
+ denom_word += 's'
647
+ return f'{num_words} {denom_word}'
648
+
649
+ return _RE_FRACTION.sub(_replace, text)
650
+
651
+
652
+ def expand_decades(text: str) -> str:
653
+ """
654
+ Expand decade expressions to words.
655
+
656
+ Examples:
657
+ "the 80s" → "the eighties"
658
+ "the 1980s" → "the nineteen eighties"
659
+ "the 2020s" → "the twenty twenties"
660
+ "'90s music" → "nineties music"
661
+ """
662
+ _decade_map = {
663
+ 0: 'hundreds',
664
+ 1: 'tens',
665
+ 2: 'twenties',
666
+ 3: 'thirties',
667
+ 4: 'forties',
668
+ 5: 'fifties',
669
+ 6: 'sixties',
670
+ 7: 'seventies',
671
+ 8: 'eighties',
672
+ 9: 'nineties',
673
+ }
674
+
675
+ def _replace(m: re.Match) -> str:
676
+ base = int(m.group(1)) # e.g. 8 for "80s", 198 for "1980s"
677
+ decade_digit = base % 10
678
+ decade_word = _decade_map.get(decade_digit, '')
679
+ if base < 10:
680
+ return decade_word
681
+ century_part = base // 10 # e.g. 19 for 198
682
+ return f'{number_to_words(century_part)} {decade_word}'
683
+
684
+ return _RE_DECADE.sub(_replace, text)
685
+
686
+
687
+ def expand_ip_addresses(text: str) -> str:
688
+ """
689
+ Expand IPv4 addresses to spoken digits per octet.
690
+
691
+ Examples:
692
+ "192.168.1.1" → "one nine two dot one six eight dot one dot one"
693
+ "10.0.0.1" → "one zero dot zero dot zero dot one"
694
+ """
695
+ _d = {
696
+ '0': 'zero',
697
+ '1': 'one',
698
+ '2': 'two',
699
+ '3': 'three',
700
+ '4': 'four',
701
+ '5': 'five',
702
+ '6': 'six',
703
+ '7': 'seven',
704
+ '8': 'eight',
705
+ '9': 'nine',
706
+ }
707
+
708
+ def _octet(s: str) -> str:
709
+ return ' '.join(_d[c] for c in s)
710
+
711
+ def _replace(m: re.Match) -> str:
712
+ return ' dot '.join(_octet(g) for g in m.groups())
713
+
714
+ return re.sub(r'\b(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\b', _replace, text)
715
+
716
+
717
+ def expand_phone_numbers(text: str) -> str:
718
+ """
719
+ Expand US phone numbers to spoken digits before range expansion claims the hyphens.
720
+
721
+ Examples:
722
+ "555-1234" → "five five five one two three four"
723
+ "555-123-4567" → "five five five one two three four five six seven"
724
+ "1-800-555-0199" → "one eight zero zero five five five zero one nine nine"
725
+ """
726
+ _d = {
727
+ '0': 'zero',
728
+ '1': 'one',
729
+ '2': 'two',
730
+ '3': 'three',
731
+ '4': 'four',
732
+ '5': 'five',
733
+ '6': 'six',
734
+ '7': 'seven',
735
+ '8': 'eight',
736
+ '9': 'nine',
737
+ }
738
+
739
+ def _digits(s: str) -> str:
740
+ return ' '.join(_d[c] for c in s)
741
+
742
+ def _join(*groups) -> str:
743
+ return ' '.join(_digits(g) for g in groups)
744
+
745
+ # Match longest pattern first to avoid partial matches
746
+ # 11-digit: 1-800-555-0199
747
+ text = re.sub(
748
+ r'(?<!\d-)(?<!\d)\b(\d{1,2})-(\d{3})-(\d{3})-(\d{4})\b(?!-\d)',
749
+ lambda m: _join(*m.groups()),
750
+ text,
751
+ )
752
+ # 10-digit: 555-123-4567
753
+ text = re.sub(
754
+ r'(?<!\d-)(?<!\d)\b(\d{3})-(\d{3})-(\d{4})\b(?!-\d)', lambda m: _join(*m.groups()), text
755
+ )
756
+ # 7-digit local: 555-1234 (not preceded or followed by digit-hyphen to avoid sub-matching)
757
+ text = re.sub(r'(?<!\d-)\b(\d{3})-(\d{4})\b(?!-\d)', lambda m: _join(*m.groups()), text)
758
+ return text
759
+
760
+
761
+ def expand_months(text: str) -> str:
762
+ """
763
+ Expands Jan, Feb, etc. to January, February.
764
+ Only triggers if the abbreviation is likely a date.
765
+ """
766
+
767
+ def _replace(m: re.Match) -> str:
768
+ return _MONTH_MAP.get(m.group(1), m.group(1))
769
+
770
+ # 1. Standard abbreviations
771
+ text = _RE_MONTHS.sub(_replace, text)
772
+
773
+ # 2. May (Special case: only if followed by a digit)
774
+ text = _RE_MAY.sub('May', text) # Essentially just ensuring it's treated as a word
775
+
776
+ return text
777
+
778
+
779
+ # ─────────────────────────────────────────────
780
+ # Core preprocessing functions
781
+ # ─────────────────────────────────────────────
782
+
783
+
784
+ def replace_numbers(text: str, replace_floats: bool = True) -> str:
785
+ """
786
+ Replace all numeric tokens with their word equivalents.
787
+
788
+ Examples:
789
+ "There are 1200 students" → "There are twelve hundred students"
790
+ "Pi is 3.14" → "Pi is three point one four"
791
+ "gpt-3 rocks" → "gpt-3 rocks" (hyphen not treated as minus)
792
+ """
793
+
794
+ def _replace(m: re.Match) -> str:
795
+ raw = m.group().replace(',', '')
796
+ try:
797
+ if '.' in raw and replace_floats:
798
+ # Pass raw string so trailing zeros are preserved ("1.50" → "one point five zero")
799
+ return float_to_words(raw)
800
+ else:
801
+ return number_to_words(int(float(raw)))
802
+ except (ValueError, OverflowError):
803
+ return m.group()
804
+
805
+ return _RE_NUMBER.sub(_replace, text)
806
+
807
+
808
+ def to_lowercase(text: str) -> str:
809
+ """Convert text to lowercase."""
810
+ return text.lower()
811
+
812
+
813
+ def remove_urls(text: str, replacement: str = '') -> str:
814
+ """Remove URLs from text."""
815
+ return _RE_URL.sub(replacement, text).strip()
816
+
817
+
818
+ def remove_emails(text: str, replacement: str = '') -> str:
819
+ """Remove email addresses from text."""
820
+ return _RE_EMAIL.sub(replacement, text).strip()
821
+
822
+
823
+ def remove_html_tags(text: str) -> str:
824
+ """Strip HTML tags from text."""
825
+ return _RE_HTML.sub(' ', text)
826
+
827
+
828
+ def remove_hashtags(text: str, replacement: str = '') -> str:
829
+ """Remove hashtags (e.g. #NLP) from text."""
830
+ return _RE_HASHTAG.sub(replacement, text)
831
+
832
+
833
+ def remove_mentions(text: str, replacement: str = '') -> str:
834
+ """Remove @mentions from text."""
835
+ return _RE_MENTION.sub(replacement, text)
836
+
837
+
838
+ def remove_punctuation(text: str) -> str:
839
+ """Remove all punctuation characters."""
840
+ return _RE_PUNCT.sub(' ', text)
841
+
842
+
843
+ def remove_extra_whitespace(text: str) -> str:
844
+ """Collapse multiple whitespace characters into a single space and strip ends."""
845
+ return _RE_SPACES.sub(' ', text).strip()
846
+
847
+
848
+ def normalize_unicode(text: str, form: str = 'NFC') -> str:
849
+ """Normalize unicode characters (NFC, NFD, NFKC, or NFKD)."""
850
+ return unicodedata.normalize(form, text)
851
+
852
+
853
+ def remove_accents(text: str) -> str:
854
+ """Remove diacritical marks (accents) from characters."""
855
+ nfkd = unicodedata.normalize('NFD', text)
856
+ return ''.join(c for c in nfkd if unicodedata.category(c) != 'Mn')
857
+
858
+
859
+ def expand_contractions(text: str) -> str:
860
+ """
861
+ Expand common English contractions.
862
+
863
+ Examples:
864
+ "don't" → "do not"
865
+ "they're" → "they are"
866
+ "I've" → "I have"
867
+ """
868
+ contractions = {
869
+ r"\bcan't\b": 'cannot',
870
+ r"\bwon't\b": 'will not',
871
+ r"\bshan't\b": 'shall not',
872
+ r"\bain't\b": 'is not',
873
+ r"\blet's\b": 'let us',
874
+ r"\b(\w+)n't\b": r'\1 not',
875
+ r"\b(\w+)'re\b": r'\1 are',
876
+ r"\b(\w+)'ve\b": r'\1 have',
877
+ r"\b(\w+)'ll\b": r'\1 will',
878
+ r"\b(\w+)'d\b": r'\1 would',
879
+ r"\b(\w+)'m\b": r'\1 am',
880
+ r"\bit's\b": 'it is',
881
+ }
882
+ for pattern, replacement in contractions.items():
883
+ text = re.sub(pattern, replacement, text, flags=re.IGNORECASE)
884
+ return text
885
+
886
+
887
+ def remove_stopwords(text: str, stopwords: set | None = None) -> str:
888
+ """
889
+ Remove stopwords from text.
890
+
891
+ Args:
892
+ stopwords: Set of words to remove. Uses a built-in English set if None.
893
+ """
894
+ if stopwords is None:
895
+ stopwords = {
896
+ 'a',
897
+ 'an',
898
+ 'the',
899
+ 'and',
900
+ 'or',
901
+ 'but',
902
+ 'in',
903
+ 'on',
904
+ 'at',
905
+ 'to',
906
+ 'for',
907
+ 'of',
908
+ 'with',
909
+ 'by',
910
+ 'from',
911
+ 'is',
912
+ 'was',
913
+ 'are',
914
+ 'were',
915
+ 'be',
916
+ 'been',
917
+ 'being',
918
+ 'have',
919
+ 'has',
920
+ 'had',
921
+ 'do',
922
+ 'does',
923
+ 'did',
924
+ 'will',
925
+ 'would',
926
+ 'could',
927
+ 'should',
928
+ 'may',
929
+ 'might',
930
+ 'this',
931
+ 'that',
932
+ 'these',
933
+ 'those',
934
+ 'it',
935
+ 'its',
936
+ 'i',
937
+ 'me',
938
+ 'my',
939
+ 'we',
940
+ 'our',
941
+ 'you',
942
+ 'your',
943
+ 'he',
944
+ 'she',
945
+ 'him',
946
+ 'her',
947
+ 'they',
948
+ 'them',
949
+ 'their',
950
+ }
951
+ tokens = text.split()
952
+ return ' '.join(t for t in tokens if t.lower() not in stopwords)
953
+
954
+
955
+ # ─────────────────────────────────────────────
956
+ # Pipeline helper
957
+ # ─────────────────────────────────────────────
958
+
959
+
960
+ class TextPreprocessor:
961
+ """
962
+ Configurable preprocessing pipeline.
963
+
964
+ Usage:
965
+ pp = TextPreprocessor(
966
+ lowercase=True,
967
+ replace_numbers=True,
968
+ remove_urls=True,
969
+ remove_html=True,
970
+ remove_punctuation=True,
971
+ )
972
+ clean = pp("GPT-3 costs $0.002 per token — 50% cheaper than before!")
973
+ # → "gpt three costs zero dollars and zero point two cents per token fifty percent cheaper than before"
974
+ """
975
+
976
+ def __init__(
977
+ self,
978
+ lowercase: bool = True,
979
+ replace_numbers: bool = True,
980
+ replace_floats: bool = True,
981
+ expand_newlines: bool = True,
982
+ expand_tilde: bool = True,
983
+ expand_abbreviations: bool = True,
984
+ expand_symbols: bool = True,
985
+ expand_contractions: bool = True,
986
+ expand_model_names: bool = True,
987
+ expand_ordinals: bool = True,
988
+ expand_percentages: bool = True,
989
+ expand_currency: bool = True,
990
+ expand_time: bool = True,
991
+ expand_ranges: bool = True,
992
+ expand_units: bool = True,
993
+ expand_scale_suffixes: bool = True,
994
+ expand_scientific_notation: bool = True,
995
+ expand_fractions: bool = True,
996
+ expand_decades: bool = True,
997
+ expand_phone_numbers: bool = True,
998
+ expand_ip_addresses: bool = True,
999
+ normalize_leading_decimals: bool = True,
1000
+ expand_roman_numerals: bool = False,
1001
+ remove_urls: bool = True,
1002
+ remove_emails: bool = True,
1003
+ remove_html: bool = True,
1004
+ remove_hashtags: bool = False,
1005
+ remove_mentions: bool = False,
1006
+ remove_punctuation: bool = True,
1007
+ remove_stopwords: bool = False,
1008
+ stopwords: set | None = None,
1009
+ normalize_unicode: bool = True,
1010
+ remove_accents: bool = False,
1011
+ remove_extra_whitespace: bool = True,
1012
+ ):
1013
+ self.config = {k: v for k, v in locals().items() if k != 'self'}
1014
+ self._stopwords = stopwords
1015
+
1016
+ def __call__(self, text: str) -> str:
1017
+ return self.process(text)
1018
+
1019
+ def process(self, text: str) -> str:
1020
+ cfg = self.config
1021
+ if cfg.get('expand_abbreviations'):
1022
+ text = expand_abbreviations(text)
1023
+ text = expand_months(text)
1024
+ if cfg.get('expand_newlines'):
1025
+ text = expand_newlines(text)
1026
+ if cfg.get('expand_symbols'):
1027
+ text = expand_symbols(text)
1028
+ if cfg.get('expand_tilde'):
1029
+ text = expand_tilde(text)
1030
+ if cfg['normalize_unicode']:
1031
+ text = normalize_unicode(text)
1032
+ if cfg['remove_html']:
1033
+ text = remove_html_tags(text)
1034
+ if cfg['remove_urls']:
1035
+ text = remove_urls(text)
1036
+ if cfg['remove_emails']:
1037
+ text = remove_emails(text)
1038
+ if cfg['remove_hashtags']:
1039
+ text = remove_hashtags(text)
1040
+ if cfg['remove_mentions']:
1041
+ text = remove_mentions(text)
1042
+ if cfg['expand_contractions']:
1043
+ text = expand_contractions(text)
1044
+ # IP addresses before normalize_leading_decimals (IPs contain dots before digits)
1045
+ if cfg['expand_ip_addresses']:
1046
+ text = expand_ip_addresses(text)
1047
+ # Normalise bare leading decimals early so downstream regexes see "0.5" not ".5"
1048
+ if cfg['normalize_leading_decimals']:
1049
+ text = normalize_leading_decimals(text)
1050
+ # Expand special forms before generic number replacement
1051
+ if cfg['expand_currency']:
1052
+ text = expand_currency(text)
1053
+ if cfg['expand_percentages']:
1054
+ text = expand_percentages(text)
1055
+ # Scientific notation before model-name expansion (e.g. "1e-4" contains "e-4")
1056
+ if cfg['expand_scientific_notation']:
1057
+ text = expand_scientific_notation(text)
1058
+ if cfg['expand_time']:
1059
+ text = expand_time(text)
1060
+ if cfg['expand_ordinals']:
1061
+ text = expand_ordinals(text)
1062
+ if cfg['expand_units']:
1063
+ text = expand_units(text)
1064
+ # Scale suffixes after units (units handles "MB"/"GB"; this handles bare "B"/"M")
1065
+ if cfg['expand_scale_suffixes']:
1066
+ text = expand_scale_suffixes(text)
1067
+ if cfg['expand_fractions']:
1068
+ text = expand_fractions(text)
1069
+ if cfg['expand_decades']:
1070
+ text = expand_decades(text)
1071
+ # Phone numbers before ranges, otherwise NNN-NNNN is treated as a range
1072
+ if cfg['expand_phone_numbers']:
1073
+ text = expand_phone_numbers(text)
1074
+ if cfg['expand_ranges']:
1075
+ text = expand_ranges(text)
1076
+ if cfg['expand_model_names']:
1077
+ text = expand_model_names(text)
1078
+ if cfg['expand_roman_numerals']:
1079
+ text = expand_roman_numerals(text)
1080
+ if cfg['replace_numbers']:
1081
+ text = replace_numbers(text, replace_floats=cfg['replace_floats'])
1082
+ if cfg['remove_accents']:
1083
+ text = remove_accents(text)
1084
+ if cfg['remove_punctuation']:
1085
+ text = remove_punctuation(text)
1086
+ if cfg['lowercase']:
1087
+ text = to_lowercase(text)
1088
+ if cfg['remove_stopwords']:
1089
+ text = remove_stopwords(text, self._stopwords)
1090
+ if cfg['remove_extra_whitespace']:
1091
+ text = remove_extra_whitespace(text)
1092
+
1093
+ return text
app/services/tts.py ADDED
@@ -0,0 +1,516 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TTS Service - handles model loading, voice management, and audio generation.
3
+ """
4
+
5
+ import os
6
+ import time
7
+ from pathlib import Path
8
+
9
+ from app.config import Config
10
+ from app.logging_config import get_logger
11
+
12
+ logger = get_logger('tts')
13
+
14
+ # Lazy import pocket_tts to allow for better error handling
15
+ TTSModel = None
16
+ export_model_state = None
17
+
18
+
19
+ def _ensure_pocket_tts():
20
+ """Ensure pocket-tts is imported."""
21
+ global TTSModel, export_model_state
22
+ if TTSModel is None:
23
+ try:
24
+ from pocket_tts import TTSModel as _TTSModel
25
+ from pocket_tts.models.tts_model import export_model_state as _export_state
26
+
27
+ TTSModel = _TTSModel
28
+ export_model_state = _export_state
29
+ except ImportError as exc:
30
+ raise ImportError('pocket-tts not found. Install with: pip install pocket-tts') from exc
31
+
32
+
33
+ class TTSService:
34
+ """
35
+ Service class for Text-to-Speech operations.
36
+ Manages model loading, voice caching, and audio generation.
37
+ """
38
+
39
+ def __init__(self):
40
+ import threading
41
+ from collections import OrderedDict
42
+
43
+ self.model = None
44
+ self.voice_cache: OrderedDict = OrderedDict()
45
+ self.voices_dir: str | None = None
46
+ self._model_loaded = False
47
+
48
+ # Concurrency + reload state.
49
+ # _lock is held for the duration of model operations (load, generate);
50
+ # _state_lock is held only briefly to mutate the loading flag and
51
+ # related state, so concurrent reload claims are atomic without
52
+ # waiting for in-flight generation to finish.
53
+ self._lock = threading.Lock()
54
+ self._state_lock = threading.Lock()
55
+ self._loading = False # fast-path flag; read without lock, written under _state_lock
56
+ self._active: dict | None = None
57
+ self._boot_active: dict | None = None
58
+ self._loading_target: dict | None = None
59
+ self._last_reload_error: str | None = None
60
+
61
+ self.cache_dir: Path | None = Path(Config.VOICE_CACHE_DIR)
62
+
63
+ def _ensure_cache_dir(self) -> None:
64
+ """Create the voice cache directory on first need. Tolerate read-only FS."""
65
+ if self.cache_dir is None:
66
+ return
67
+ try:
68
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
69
+ except OSError as e:
70
+ logger.warning(
71
+ f'Voice cache dir {self.cache_dir} is not writable ({e}); '
72
+ f'cache persistence disabled.'
73
+ )
74
+ self.cache_dir = None
75
+
76
+ def _save_cloned_state(self, state: dict, audio_path) -> None:
77
+ """Persist a freshly-cloned state as <stem>.<active_tag>.safetensors."""
78
+
79
+ from app.services.voice_cache import active_model_tag
80
+
81
+ self._ensure_cache_dir()
82
+ if self.cache_dir is None:
83
+ return
84
+
85
+ audio_path = Path(audio_path)
86
+ tag = active_model_tag((self._active or {}).get('value') or 'english')
87
+ target = self.cache_dir / f'{audio_path.stem}.{tag}.safetensors'
88
+ # Caching is best-effort: swallow any failure so a broken cache write
89
+ # never blocks voice loading. safetensors raises SafetensorError (not
90
+ # OSError) on serialization I/O failures, so we catch broadly. Keep
91
+ # exc_info=True so unexpected failures stay diagnosable.
92
+ try:
93
+ export_model_state(state, target)
94
+ logger.info(f'Saved cloned voice state to {target}')
95
+ except Exception:
96
+ logger.warning(f'Could not save voice cache to {target}', exc_info=True)
97
+
98
+ @property
99
+ def is_loaded(self) -> bool:
100
+ """Check if the model is loaded."""
101
+ return self._model_loaded and self.model is not None
102
+
103
+ @property
104
+ def sample_rate(self) -> int:
105
+ """Get the model's sample rate."""
106
+ if self.model:
107
+ return self.model.sample_rate
108
+ return 24000 # Default pocket-tts sample rate
109
+
110
+ @property
111
+ def device(self) -> str:
112
+ """Get the model's device."""
113
+ if self.model:
114
+ return str(self.model.device)
115
+ return 'unknown'
116
+
117
+ def load_model(
118
+ self,
119
+ model_path: str | None = None,
120
+ language: str | None = None,
121
+ quantize: bool = False,
122
+ _is_boot: bool = True,
123
+ ) -> None:
124
+ """
125
+ Load the TTS model.
126
+
127
+ Args:
128
+ model_path: Optional path to model config file (.yaml)
129
+ language: Optional language identifier (e.g., english, french_24l).
130
+ Incompatible with model_path.
131
+ quantize: If True, apply dynamic int8 quantization to reduce memory.
132
+ _is_boot: Internal; True only for the first boot-time load. Controls
133
+ whether _boot_active is initialized.
134
+ """
135
+ _ensure_pocket_tts()
136
+
137
+ logger.info('Loading Pocket TTS model...')
138
+ t0 = time.time()
139
+
140
+ effective_path = model_path
141
+
142
+ if not effective_path:
143
+ _, bundle_model = Config.get_bundle_paths()
144
+ if bundle_model and os.path.isfile(bundle_model):
145
+ effective_path = bundle_model
146
+ logger.info(f'Using bundled model: {effective_path}')
147
+
148
+ try:
149
+ if effective_path:
150
+ logger.info(f'Loading model from: {effective_path}')
151
+ self.model = TTSModel.load_model(config=effective_path, quantize=quantize)
152
+ active = {'source': 'model_path', 'value': effective_path, 'quantize': quantize}
153
+ elif language:
154
+ logger.info(f'Loading model with language: {language}')
155
+ self.model = TTSModel.load_model(language=language, quantize=quantize)
156
+ active = {'source': 'language', 'value': language, 'quantize': quantize}
157
+ else:
158
+ logger.info('Loading default model from HuggingFace...')
159
+ self.model = TTSModel.load_model(quantize=quantize)
160
+ active = {'source': 'default', 'value': None, 'quantize': quantize}
161
+
162
+ self._model_loaded = True
163
+ self._active = active
164
+ if _is_boot:
165
+ self._boot_active = dict(active)
166
+
167
+ load_time = time.time() - t0
168
+ logger.info(
169
+ f'Model loaded in {load_time:.2f}s. '
170
+ f'Device: {self.device}, Sample Rate: {self.sample_rate}'
171
+ )
172
+
173
+ except Exception as e:
174
+ logger.error(f'Failed to load model: {e}')
175
+ raise
176
+
177
+ def _validate_reload(self, language: str) -> None:
178
+ """Pre-flight checks shared by sync and async reload paths."""
179
+ if self._boot_active and self._boot_active['source'] == 'model_path':
180
+ raise RuntimeError(
181
+ 'Cannot switch language: server was started with a custom model_path.'
182
+ )
183
+ if language not in Config.SUPPORTED_LANGUAGES:
184
+ raise ValueError(f'Unsupported language: {language!r}')
185
+
186
+ def _claim_loading(self, language: str, quantize: bool) -> bool:
187
+ """Atomically check-and-set the loading flag.
188
+
189
+ Returns True if this caller owns the reload slot, False if another
190
+ reload is already in progress. Held briefly under _state_lock so
191
+ concurrent claims race-free without waiting on the long-held _lock.
192
+ """
193
+ with self._state_lock:
194
+ if self._loading:
195
+ return False
196
+ self._loading = True
197
+ self._loading_target = {'value': language, 'quantize': quantize}
198
+ self._last_reload_error = None
199
+ return True
200
+
201
+ def _release_loading(self) -> None:
202
+ with self._state_lock:
203
+ self._loading = False
204
+ self._loading_target = None
205
+
206
+ def _do_reload(self, language: str, quantize: bool) -> None:
207
+ """Perform the actual model swap. Caller must already hold the loading
208
+ slot via `_claim_loading`. Restores the previous model on failure."""
209
+ with self._lock:
210
+ previous_model = self.model
211
+ previous_active = self._active
212
+ try:
213
+ self.load_model(language=language, quantize=quantize, _is_boot=False)
214
+ self.voice_cache.clear()
215
+ except Exception:
216
+ # Restore previous state on failure so the server remains usable.
217
+ self.model = previous_model
218
+ self._active = previous_active
219
+ raise
220
+
221
+ def reload_model(self, language: str, quantize: bool) -> None:
222
+ """Reload the model synchronously.
223
+
224
+ Validates, atomically claims the reload slot, then performs the swap
225
+ while holding the model lock. Pocket-tts v2 `TTSModel` is not
226
+ thread-safe so generation is serialized via the same lock.
227
+
228
+ Raises:
229
+ ValueError: unknown language.
230
+ RuntimeError: model_path locked, already loading, or load failure.
231
+ """
232
+ self._validate_reload(language)
233
+ if not self._claim_loading(language, quantize):
234
+ raise RuntimeError('already loading')
235
+ try:
236
+ self._do_reload(language, quantize)
237
+ finally:
238
+ self._release_loading()
239
+
240
+ def reload_model_async(self, language: str, quantize: bool) -> bool:
241
+ """Atomically claim the reload slot and start a worker thread.
242
+
243
+ Returns True if the claim succeeded (worker started), False if a
244
+ reload was already in progress. Validation errors are still raised
245
+ synchronously so the caller can surface them as 400/403.
246
+ """
247
+ import threading
248
+
249
+ self._validate_reload(language)
250
+ if not self._claim_loading(language, quantize):
251
+ return False
252
+
253
+ def _worker():
254
+ try:
255
+ self._do_reload(language, quantize)
256
+ except Exception as e:
257
+ with self._state_lock:
258
+ self._last_reload_error = f'{type(e).__name__}: {e}'
259
+ logger.error(f'Reload failed: {self._last_reload_error}')
260
+ finally:
261
+ self._release_loading()
262
+
263
+ threading.Thread(target=_worker, daemon=True, name='tts-reload').start()
264
+ return True
265
+
266
+ def set_voices_dir(self, voices_dir: str | None) -> None:
267
+ """
268
+ Set the directory for custom voice files.
269
+
270
+ Args:
271
+ voices_dir: Path to directory containing voice files
272
+ """
273
+ if voices_dir and os.path.isdir(voices_dir):
274
+ self.voices_dir = voices_dir
275
+ logger.info(f'Voices directory set to: {voices_dir}')
276
+ elif voices_dir:
277
+ logger.warning(f'Voices directory not found: {voices_dir}')
278
+ self.voices_dir = None
279
+ else:
280
+ self.voices_dir = None
281
+
282
+ def get_voice_state(self, voice_id_or_path: str) -> dict:
283
+ """Resolve a voice ID to a cached model state.
284
+
285
+ When the resolved path is raw audio, encode it against the active model
286
+ and persist the result as <stem>.<active_tag>.safetensors in cache_dir.
287
+ If a tagged cache exists but its source audio is newer, regenerate.
288
+
289
+ Pocket-tts v2 `TTSModel` is not thread-safe, so model invocations are
290
+ serialized under `self._lock` (the same lock that protects generation
291
+ and reload).
292
+ """
293
+
294
+ from app.services.voice_cache import (
295
+ AUDIO_EXTENSIONS,
296
+ cache_is_stale,
297
+ known_model_tags,
298
+ parse_safetensors_name,
299
+ )
300
+
301
+ if self._loading:
302
+ raise RuntimeError('model reloading')
303
+ if not self.is_loaded:
304
+ raise RuntimeError('Model not loaded. Call load_model() first.')
305
+
306
+ resolved_key = self._resolve_voice_path(voice_id_or_path)
307
+
308
+ # Cache hit fast path. The dict can be cleared concurrently by
309
+ # `reload_model`, so guard against a KeyError between `in` and access.
310
+ if resolved_key in self.voice_cache:
311
+ try:
312
+ self.voice_cache.move_to_end(resolved_key)
313
+ logger.debug(f'Using in-memory voice state for: {resolved_key}')
314
+ return self.voice_cache[resolved_key]
315
+ except KeyError:
316
+ pass # raced with cache clear; fall through and re-encode
317
+
318
+ # If resolved to a tagged cache, check staleness against raw-audio source.
319
+ # Treat any existing filesystem path as local — relative paths from a
320
+ # configured `voices_dir` (common in local dev) must still get the
321
+ # truncate=True clone path and disk caching.
322
+ resolved_path = (
323
+ Path(resolved_key)
324
+ if os.path.isabs(resolved_key) or os.path.exists(resolved_key)
325
+ else None
326
+ )
327
+ regenerate_from_source: Path | None = None
328
+
329
+ if resolved_path and resolved_path.suffix == '.safetensors' and self.voices_dir:
330
+ # Use the same parser the cache module uses so stems containing
331
+ # dots (e.g. "John.Doe.english_2026-04.safetensors" → "John.Doe")
332
+ # are extracted correctly.
333
+ stem, _tag = parse_safetensors_name(resolved_path.name, known_model_tags())
334
+ for ext in AUDIO_EXTENSIONS:
335
+ source = Path(self.voices_dir) / f'{stem}{ext}'
336
+ if cache_is_stale(cache_path=resolved_path, source_path=source):
337
+ regenerate_from_source = source
338
+ break
339
+
340
+ logger.info(f'Loading voice: {resolved_key}')
341
+ t0 = time.time()
342
+
343
+ try:
344
+ with self._lock:
345
+ if regenerate_from_source:
346
+ logger.info(f'Regenerating stale cache from {regenerate_from_source}')
347
+ state = self.model.get_state_for_audio_prompt(
348
+ regenerate_from_source, truncate=True
349
+ )
350
+ self._save_cloned_state(state, regenerate_from_source)
351
+ elif resolved_path and resolved_path.suffix.lower() in AUDIO_EXTENSIONS:
352
+ state = self.model.get_state_for_audio_prompt(resolved_path, truncate=True)
353
+ self._save_cloned_state(state, resolved_path)
354
+ else:
355
+ # Pre-made .safetensors OR built-in OR hf:// — let pocket-tts handle it.
356
+ state = self.model.get_state_for_audio_prompt(resolved_key)
357
+
358
+ # LRU insert (under the lock for consistency with the dict mutation
359
+ # in `reload_model.voice_cache.clear()`).
360
+ self.voice_cache[resolved_key] = state
361
+ if len(self.voice_cache) > 32:
362
+ self.voice_cache.popitem(last=False)
363
+
364
+ load_time = time.time() - t0
365
+ logger.info(f'Voice loaded in {load_time:.2f}s: {resolved_key}')
366
+ return state
367
+
368
+ except Exception as e:
369
+ logger.error(f"Failed to load voice '{voice_id_or_path}': {e}")
370
+ raise ValueError(f"Voice '{voice_id_or_path}' could not be loaded: {e}") from e
371
+
372
+ def _resolve_voice_path(self, voice_id_or_path: str) -> str:
373
+ """Resolve a voice identifier using per-model cache preference.
374
+
375
+ Raises ValueError on unsafe URL schemes (retained from previous behavior).
376
+ """
377
+
378
+ from app.services.voice_cache import resolve_voice_path
379
+
380
+ # Retain SSRF protection.
381
+ if voice_id_or_path.startswith(('http://', 'https://')):
382
+ raise ValueError(
383
+ f'URL scheme not allowed for security reasons: {voice_id_or_path[:50]}. '
384
+ "Use 'hf://' for HuggingFace models or provide a local file path."
385
+ )
386
+
387
+ if voice_id_or_path.startswith('hf://'):
388
+ return voice_id_or_path
389
+
390
+ # Built-in names pass through untouched (pocket-tts handles resolution).
391
+ if voice_id_or_path.lower() in Config.BUILTIN_VOICES:
392
+ return voice_id_or_path.lower()
393
+
394
+ # Absolute path hit.
395
+ if os.path.isabs(voice_id_or_path) and os.path.exists(voice_id_or_path):
396
+ return voice_id_or_path
397
+
398
+ voices_path = Path(self.voices_dir) if self.voices_dir else None
399
+
400
+ # Backwards-compat: accept full filenames (e.g. `emma.wav`) by checking
401
+ # for an exact match in cache_dir / voices_dir before falling through
402
+ # to stem-based resolution. Without this, `voice_id_or_path` containing
403
+ # an extension would never match anything and pocket-tts would receive
404
+ # the raw string.
405
+ for directory in (self.cache_dir, voices_path):
406
+ if directory:
407
+ exact = directory / voice_id_or_path
408
+ if exact.exists():
409
+ return str(exact)
410
+
411
+ active_model = (self._active or {}).get('value') or 'english'
412
+
413
+ resolved = resolve_voice_path(
414
+ voice_id=voice_id_or_path,
415
+ active_model=active_model,
416
+ voices_dir=voices_path,
417
+ cache_dir=self.cache_dir,
418
+ )
419
+ return str(resolved) if isinstance(resolved, Path) else resolved
420
+
421
+ def validate_voice(self, voice_id_or_path: str) -> tuple[bool, str]:
422
+ """
423
+ Validate if a voice can be loaded (fast check without full loading).
424
+
425
+ Args:
426
+ voice_id_or_path: Voice identifier
427
+
428
+ Returns:
429
+ Tuple of (is_valid, message)
430
+ """
431
+ # Block unsafe URL schemes first
432
+ if voice_id_or_path.startswith(('http://', 'https://')):
433
+ return (
434
+ False,
435
+ 'HTTP/HTTPS URLs are not allowed for security reasons. Use hf:// for HuggingFace models.',
436
+ )
437
+
438
+ try:
439
+ resolved = self._resolve_voice_path(voice_id_or_path)
440
+ except ValueError as e:
441
+ return False, str(e)
442
+
443
+ # Built-in voices are always valid
444
+ if resolved.lower() in Config.BUILTIN_VOICES:
445
+ return True, f'Built-in voice: {resolved}'
446
+
447
+ # HuggingFace URLs - assume valid
448
+ if resolved.startswith('hf://'):
449
+ return True, f'HuggingFace voice: {resolved}'
450
+
451
+ # Local file - check existence
452
+ if os.path.exists(resolved):
453
+ return True, f'Local voice file: {resolved}'
454
+
455
+ return False, f'Voice not found: {voice_id_or_path}'
456
+
457
+ def generate_audio(self, voice_state: dict, text: str):
458
+ """Generate complete audio for given text."""
459
+ import torch # noqa: F401 — kept for return-type doc
460
+
461
+ if self._loading:
462
+ raise RuntimeError('model reloading')
463
+ if not self.is_loaded:
464
+ raise RuntimeError('Model not loaded')
465
+
466
+ with self._lock:
467
+ t0 = time.time()
468
+ audio = self.model.generate_audio(voice_state, text)
469
+ gen_time = time.time() - t0
470
+
471
+ logger.info(f'Generated {len(text)} chars in {gen_time:.2f}s')
472
+ return audio
473
+
474
+ def generate_audio_stream(self, voice_state: dict, text: str):
475
+ """Generate audio in streaming chunks. Holds the lock for the entire stream."""
476
+ if self._loading:
477
+ raise RuntimeError('model reloading')
478
+ if not self.is_loaded:
479
+ raise RuntimeError('Model not loaded')
480
+
481
+ logger.info(f'Starting streaming generation for {len(text)} chars')
482
+ with self._lock:
483
+ yield from self.model.generate_audio_stream(voice_state, text)
484
+
485
+ def list_voices(self) -> list[dict]:
486
+ """List built-in voices and custom voices (one entry per stem)."""
487
+
488
+ from app.services.voice_cache import list_voice_stems
489
+
490
+ voices: list[dict] = []
491
+
492
+ # Built-in voices (sorted).
493
+ for name in sorted(Config.BUILTIN_VOICES):
494
+ voices.append({'id': name, 'name': name.capitalize(), 'type': 'builtin'})
495
+
496
+ voices_path = Path(self.voices_dir) if self.voices_dir else None
497
+ stems = list_voice_stems(voices_dir=voices_path, cache_dir=self.cache_dir)
498
+
499
+ for stem in stems:
500
+ # Format name: "bobby_mcfern" -> "Bobby Mcfern"
501
+ clean_name = stem.replace('_', ' ').replace('-', ' ').title()
502
+ voices.append({'id': stem, 'name': clean_name, 'type': 'custom'})
503
+
504
+ return voices
505
+
506
+
507
+ # Global service instance
508
+ _tts_service: TTSService | None = None
509
+
510
+
511
+ def get_tts_service() -> TTSService:
512
+ """Get the global TTS service instance."""
513
+ global _tts_service
514
+ if _tts_service is None:
515
+ _tts_service = TTSService()
516
+ return _tts_service
app/services/versions.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cached version lookup for the web UI."""
2
+
3
+ from functools import lru_cache
4
+ from importlib.metadata import PackageNotFoundError, version
5
+
6
+ from app import __version__ as _SERVER_VERSION_FALLBACK
7
+
8
+
9
+ @lru_cache(maxsize=1)
10
+ def get_versions() -> dict[str, str]:
11
+ """Return {'server': ..., 'pocket_tts': ...}.
12
+
13
+ Falls back to the hardcoded `app.__version__` for the server when the
14
+ package isn't installed via pip. `pocket_tts` falls back to 'unknown'.
15
+ """
16
+ try:
17
+ server = version('pocket-tts-openai-server')
18
+ except PackageNotFoundError:
19
+ server = _SERVER_VERSION_FALLBACK
20
+
21
+ try:
22
+ pocket_tts = version('pocket-tts')
23
+ except PackageNotFoundError:
24
+ pocket_tts = 'unknown'
25
+
26
+ return {'server': server, 'pocket_tts': pocket_tts}
app/services/voice_cache.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure filename and path logic for per-model cached voice states.
2
+
3
+ Kept separate from tts.py so it can be exercised without loading pocket-tts.
4
+ """
5
+
6
+ import re
7
+ from pathlib import Path
8
+
9
+ from app.config import Config
10
+
11
+ # Substituted away when sanitizing path-derived tags. Includes filesystem-illegal
12
+ # characters AND `.`, since `parse_safetensors_name` uses `.` to delimit
13
+ # <stem>.<tag>.safetensors — a tag containing a dot would mis-parse.
14
+ _TAG_ILLEGAL_CHARS = re.compile(r'[\\/:*?"<>|.]')
15
+ _CONFIG_EXTENSIONS = ('.yaml', '.yml', '.json')
16
+
17
+
18
+ def active_model_tag(raw_model: str) -> str:
19
+ """Normalize a language identifier for use as a filename tag.
20
+
21
+ Handles three input shapes:
22
+ - plain language identifier (e.g. 'english_2026-04') → unchanged
23
+ - aliased identifier (e.g. 'english') → canonical alias target
24
+ - custom --model-path value (e.g. r'C:\\models\\english.yaml') →
25
+ reduced to the file stem ('english'), since path separators and
26
+ Windows drive colons are illegal in filenames and would crash
27
+ safetensors serialization (issue #13).
28
+ """
29
+ canonical = Config.LEGACY_MODEL_ALIASES.get(raw_model, raw_model)
30
+
31
+ # If the value looks like a file path (separators present, or it ends in
32
+ # a known config extension), reduce it to a filesystem-safe stem.
33
+ if '/' in canonical or '\\' in canonical or canonical.lower().endswith(_CONFIG_EXTENSIONS):
34
+ last = canonical.replace('\\', '/').rsplit('/', 1)[-1]
35
+ for ext in _CONFIG_EXTENSIONS:
36
+ if last.lower().endswith(ext):
37
+ last = last[: -len(ext)]
38
+ break
39
+ canonical = _TAG_ILLEGAL_CHARS.sub('_', last)
40
+
41
+ return canonical
42
+
43
+
44
+ def known_model_tags() -> set[str]:
45
+ """Filename tags we treat as model identifiers during parsing.
46
+
47
+ Includes both the raw supported languages and the alias-target canonicals.
48
+ """
49
+ return set(Config.SUPPORTED_LANGUAGES) | set(Config.LEGACY_MODEL_ALIASES.values())
50
+
51
+
52
+ def parse_safetensors_name(filename: str, tags: set[str]) -> tuple[str, str | None]:
53
+ """Split `stem.model_tag.safetensors` into (stem, tag).
54
+
55
+ Returns (stem, None) when the filename is unlabeled or the final segment
56
+ is not a recognized tag.
57
+ """
58
+ base = Path(filename).stem # strips .safetensors
59
+ if '.' in base:
60
+ stem, tag = base.rsplit('.', 1)
61
+ if tag in tags:
62
+ return stem, tag
63
+ return base, None
64
+
65
+
66
+ def resolve_voice_path(
67
+ voice_id: str,
68
+ active_model: str,
69
+ voices_dir: Path | None,
70
+ cache_dir: Path | None,
71
+ ) -> Path | str:
72
+ """Resolve a voice identifier to its on-disk source.
73
+
74
+ Preference order (within each directory, the canonical-tagged filename
75
+ is checked first; if `active_model` is an alias, the alias-tagged
76
+ filename is checked as a fallback so files written under either name
77
+ resolve correctly):
78
+ 1. cache_dir/<voice_id>.<canonical_tag>.safetensors
79
+ 2. cache_dir/<voice_id>.<active_model>.safetensors (alias fallback)
80
+ 3. voices_dir/<voice_id>.<canonical_tag>.safetensors
81
+ 4. voices_dir/<voice_id>.<active_model>.safetensors (alias fallback)
82
+ 5. voices_dir/<voice_id>.{wav,mp3,flac}
83
+ 6. voices_dir/<voice_id>.safetensors (legacy unlabeled)
84
+ 7. The bare voice_id string — pocket-tts will resolve (e.g. built-ins).
85
+
86
+ New caches are always written using the canonical tag (see
87
+ `_save_cloned_state` in tts.py), so the alias-tagged paths exist only
88
+ for files placed by external tools or by users running an older version.
89
+ """
90
+ canonical_tag = active_model_tag(active_model)
91
+ candidates = [f'{voice_id}.{canonical_tag}.safetensors']
92
+ if active_model != canonical_tag:
93
+ candidates.append(f'{voice_id}.{active_model}.safetensors')
94
+
95
+ for directory in (cache_dir, voices_dir):
96
+ if not directory:
97
+ continue
98
+ for name in candidates:
99
+ p = directory / name
100
+ if p.exists():
101
+ return p
102
+
103
+ if voices_dir:
104
+ for ext in ('.wav', '.mp3', '.flac'):
105
+ p = voices_dir / f'{voice_id}{ext}'
106
+ if p.exists():
107
+ return p
108
+
109
+ p = voices_dir / f'{voice_id}.safetensors'
110
+ if p.exists():
111
+ return p
112
+
113
+ return voice_id
114
+
115
+
116
+ AUDIO_EXTENSIONS = ('.wav', '.mp3', '.flac')
117
+
118
+
119
+ def list_voice_stems(
120
+ voices_dir: Path | None,
121
+ cache_dir: Path | None,
122
+ ) -> list[str]:
123
+ """Return the unique voice stems present across voices_dir and cache_dir."""
124
+ stems: set[str] = set()
125
+ tags = known_model_tags()
126
+
127
+ for directory in (voices_dir, cache_dir):
128
+ if not directory or not directory.is_dir():
129
+ continue
130
+ for ext in AUDIO_EXTENSIONS:
131
+ for f in directory.glob(f'*{ext}'):
132
+ stems.add(f.stem)
133
+ for f in directory.glob('*.safetensors'):
134
+ stem, _tag = parse_safetensors_name(f.name, tags)
135
+ stems.add(stem)
136
+
137
+ return sorted(stems)
138
+
139
+
140
+ def cache_is_stale(cache_path: Path, source_path: Path) -> bool:
141
+ """True when `source_path` exists and is newer than `cache_path`."""
142
+ if not source_path.exists():
143
+ return False
144
+ if not cache_path.exists():
145
+ return False
146
+ return source_path.stat().st_mtime > cache_path.stat().st_mtime
docker-compose.yml ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Docker Compose for PocketTTS OpenAI-Compatible Server
2
+ #
3
+ # Usage:
4
+ # docker compose up -d # Start the server
5
+ # docker compose logs -f # View logs
6
+ # docker compose down # Stop the server
7
+ #
8
+ # Custom voices:
9
+ # Mount your own voices directory to /app/voices
10
+
11
+ services:
12
+ pockettts:
13
+ build:
14
+ context: .
15
+ dockerfile: Dockerfile
16
+ image: pockettts-openai-server:latest
17
+ container_name: pockettts-server
18
+
19
+ ports:
20
+ - '${POCKET_TTS_PORT:-49112}:49112'
21
+
22
+ environment:
23
+ - POCKET_TTS_HOST=0.0.0.0
24
+ - POCKET_TTS_PORT=49112
25
+ - POCKET_TTS_VOICES_DIR=/app/voices
26
+ - POCKET_TTS_LOG_LEVEL=${POCKET_TTS_LOG_LEVEL:-INFO}
27
+ - POCKET_TTS_STREAM_DEFAULT=${POCKET_TTS_STREAM_DEFAULT:-true}
28
+ # Model language (e.g., english, french_24l, german_24l, portuguese, italian, spanish_24l)
29
+ # Mutually exclusive with POCKET_TTS_MODEL_PATH. Requires pocket-tts>=2.0.0.
30
+ - POCKET_TTS_LANGUAGE=${POCKET_TTS_LANGUAGE:-}
31
+ # Enable int8 quantization for lower memory usage and improved speed
32
+ - POCKET_TTS_QUANTIZE=${POCKET_TTS_QUANTIZE:-false}
33
+ - POCKET_TTS_VOICE_CACHE_DIR=/app/voice_cache
34
+ # Hugging Face token for voice cloning (optional)
35
+ - HF_TOKEN=${HF_TOKEN:-}
36
+
37
+ volumes:
38
+ # Mount custom voices (optional - overrides bundled voices)
39
+ - ${POCKET_TTS_VOICES_DIR:-./voices}:/app/voices:ro
40
+ # Persist logs
41
+ - ./logs:/app/logs
42
+ # Cache HuggingFace models to avoid re-downloading
43
+ - pockettts-cache:/home/pockettts/.cache/huggingface
44
+ # Writable cache for per-model cloned voice safetensors
45
+ - pockettts-voice-cache:/app/voice_cache
46
+
47
+ restart: unless-stopped
48
+
49
+ # Resource limits (adjust based on your hardware)
50
+ deploy:
51
+ resources:
52
+ limits:
53
+ memory: 4G
54
+ reservations:
55
+ memory: 2G
56
+
57
+ healthcheck:
58
+ test:
59
+ [
60
+ 'CMD',
61
+ 'python',
62
+ '-c',
63
+ "import urllib.request; urllib.request.urlopen('http://localhost:49112/health')",
64
+ ]
65
+ interval: 30s
66
+ timeout: 10s
67
+ retries: 3
68
+ start_period: 120s # Model loading takes time
69
+
70
+ volumes:
71
+ pockettts-cache:
72
+ name: pockettts-huggingface-cache
73
+ pockettts-voice-cache:
74
+ name: pockettts-voice-cache
logs/.gitkeep ADDED
@@ -0,0 +1 @@
 
 
1
+ # Keep this directory in git (logs are gitignored but directory needed)
pocket-tts-logo.ico ADDED

Git LFS Details

  • SHA256: b8a5758b82e1232b760e23535b364fdd500ec65172eedd70a3212d0681bd7f97
  • Pointer size: 131 Bytes
  • Size of remote file: 148 kB
pyproject.toml ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ description = "OpenAI-compatible TTS API server powered by Pocket-TTS"
3
+ license = {text = "MIT"}
4
+ name = "pocket-tts-openai-server"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ version = "2.5.4"
8
+
9
+ dependencies = [
10
+ "flask>=3.0.0",
11
+ "waitress>=3.0.0",
12
+ "pocket-tts>=2.0.0",
13
+ "torch>=2.0.0,<=2.8.0",
14
+ "torchaudio>=2.0.0,<=2.8.0",
15
+ "torchao==0.13.0",
16
+ "scipy>=1.10.0",
17
+ "numpy>=1.24.0",
18
+ "soundfile>=0.12.0",
19
+ ]
20
+
21
+ [project.optional-dependencies]
22
+ build = ["pyinstaller"]
23
+ dev = ["ruff", "pytest"]
24
+
25
+ [project.scripts]
26
+ pocket-tts-server = "server:main"
27
+
28
+ [tool.ruff]
29
+ line-length = 100
30
+ target-version = "py310"
31
+
32
+ [tool.ruff.lint]
33
+ ignore = ["E501", "B008", "B904"]
34
+ select = ["E", "W", "F", "I", "B", "C4", "UP"]
35
+
36
+ [tool.ruff.lint.isort]
37
+ known-first-party = ["app"]
38
+
39
+ [tool.ruff.format]
40
+ quote-style = "single"
pytest.ini ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ [pytest]
2
+ testpaths = tests
3
+ python_files = test_*.py
4
+ addopts = -v --tb=short
requirements-dev.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # Development dependencies
2
+ # Install with: pip install -r requirements-dev.txt
3
+
4
+ -r requirements.txt
5
+
6
+ # Linting and formatting
7
+ ruff
8
+
9
+ # Testing
10
+ pytest
requirements.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CPU-only PyTorch (smaller, faster install - this project is CPU-only)
2
+ --extra-index-url https://download.pytorch.org/whl/cpu
3
+
4
+ # Core dependencies
5
+ flask>=3.0.0
6
+ waitress>=3.0.0
7
+ pocket-tts>=2.1.0
8
+
9
+ # Audio processing
10
+ torch>=2.0.0,<=2.8.0
11
+ torchaudio>=2.0.0,<=2.8.0
12
+ torchao==0.13.0
13
+ scipy>=1.10.0
14
+ numpy>=1.24.0
15
+ soundfile>=0.12.0
run_pocket_tts_server.bat ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @echo off
2
+ setlocal EnableDelayedExpansion
3
+
4
+ title Pocket TTS Server Launcher
5
+
6
+ echo.
7
+ echo ========================================================
8
+ echo Pocket TTS OpenAI Streaming Server Launcher
9
+ echo ========================================================
10
+ echo.
11
+ :: 0. Hugging Face Authentication
12
+ set "HF_TOKEN=your_token_here_if_you_want_to_hardcode_it"
13
+ if "%HF_TOKEN%"=="your_token_here_if_you_want_to_hardcode_it" (
14
+ set /p "HF_TOKEN=Enter Hugging Face Token (leave blank if already logged in): "
15
+ )
16
+
17
+ if not "%HF_TOKEN%"=="" (
18
+ echo [INFO] Setting Hugging Face Token...
19
+ set "HF_TOKEN=%HF_TOKEN%"
20
+ )
21
+
22
+ :: 1. Activate Virtual Environment
23
+ if exist "venv\Scripts\activate.bat" (
24
+ echo [INFO] Activating virtual environment...
25
+ call venv\Scripts\activate.bat
26
+ ) else (
27
+ echo [WARNING] venv not found at .\venv. Attempting to run with system python...
28
+ )
29
+
30
+ echo.
31
+ echo Please configure the server (Press ENTER to use defaults):
32
+ echo.
33
+
34
+ :: 2. Host
35
+ set "HOST=0.0.0.0"
36
+ set /p "INPUT_HOST=Host IP [%HOST%]: "
37
+ if not "%INPUT_HOST%"=="" set "HOST=%INPUT_HOST%"
38
+
39
+ :: 3. Port
40
+ set "PORT=49112"
41
+ set /p "INPUT_PORT=Port [%PORT%]: "
42
+ if not "%INPUT_PORT%"=="" set "PORT=%INPUT_PORT%"
43
+
44
+ :: 4. Model Path
45
+ set "MODEL_PATH="
46
+ set /p "INPUT_MODEL=Model Config Path (.yaml)/Variant Name (Optional, default=built-in): "
47
+ if not "%INPUT_MODEL%"=="" set "MODEL_PATH=--model-path ^"%INPUT_MODEL%^""
48
+
49
+ :: 5. Voices Directory
50
+ set "DEFAULT_VOICES=%~dp0voices"
51
+ set /p "INPUT_VOICES=Voices Directory [%DEFAULT_VOICES%]: "
52
+
53
+ if "!INPUT_VOICES!"=="" (
54
+ set "VOICES_DIR_ARG=--voices-dir "!DEFAULT_VOICES!""
55
+ ) else (
56
+ set "VOICES_DIR_ARG=--voices-dir "!INPUT_VOICES!""
57
+ )
58
+
59
+ :: 6. Streaming Default
60
+ :: Changed: Defaults to ON. Only unsets if the user types 'N'.
61
+ set "STREAM_ARG=--stream"
62
+ set /p "INPUT_STREAM=Enable Streaming? (Y/N) [Y]: "
63
+ if /i "%INPUT_STREAM%"=="N" set "STREAM_ARG="
64
+
65
+ :: 7. Text Preprocessing Default
66
+ :: Defaults to ON. Only unsets if the user types 'N'.
67
+ set "TEXT_PREPROCESS_ARG=--text-preprocess"
68
+ set /p "INPUT_PREPROCESS=Enable Text Preprocessing? (Y/N) [Y]: "
69
+ if /i "%INPUT_PREPROCESS%"=="N" set "TEXT_PREPROCESS_ARG="
70
+
71
+ :: 8. Language
72
+ set "LANGUAGE_ARG="
73
+ set /p "INPUT_LANGUAGE=Language (english, french_24l, german_24l, portuguese, italian, spanish_24l - leave blank for default): "
74
+ if not "%INPUT_LANGUAGE%"=="" set "LANGUAGE_ARG=--language %INPUT_LANGUAGE%"
75
+
76
+ :: 9. Quantization
77
+ set "QUANTIZE_ARG="
78
+ set /p "INPUT_QUANTIZE=Enable int8 Quantization? (Y/N) [N]: "
79
+ if /i "%INPUT_QUANTIZE%"=="Y" set "QUANTIZE_ARG=--quantize"
80
+
81
+ echo.
82
+ echo ========================================================
83
+ echo Starting Pocket TTS Server...
84
+ echo Host: %HOST%
85
+ echo Port: %PORT%
86
+ if defined MODEL_PATH echo Model: %MODEL_PATH%
87
+ if defined VOICES_DIR echo Voices: %VOICES_DIR%
88
+ if defined STREAM_ARG echo Streaming: Enabled
89
+ if defined TEXT_PREPROCESS_ARG echo Text Preprocessing: Enabled
90
+ if defined LANGUAGE_ARG echo Language: %INPUT_LANGUAGE%
91
+ if defined QUANTIZE_ARG echo Quantization: Enabled
92
+ echo ========================================================
93
+ echo.
94
+
95
+ :: 10. Run Command
96
+ python server.py --host %HOST% --port %PORT% %MODEL_PATH% %VOICES_DIR_ARG% %STREAM_ARG% %TEXT_PREPROCESS_ARG% %LANGUAGE_ARG% %QUANTIZE_ARG%
97
+
98
+ if %ERRORLEVEL% NEQ 0 (
99
+ echo.
100
+ echo [ERROR] Server exited with error code %ERRORLEVEL%.
101
+ pause
102
+ )
run_pocket_tts_server.sh ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Pocket TTS Server Launcher (Linux/macOS)
3
+ # This script provides an interactive configuration menu before starting the server.
4
+
5
+ set -euo pipefail
6
+
7
+ # Resolve the script directory (handles symlinks)
8
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
+ cd "$SCRIPT_DIR"
10
+
11
+ # Banner
12
+ echo ""
13
+ echo "========================================"
14
+ echo " Pocket TTS OpenAI Streaming Server"
15
+ echo "========================================"
16
+ echo ""
17
+
18
+ # --- 0. Hugging Face Token ---
19
+ HF_TOKEN="${HF_TOKEN:-}"
20
+ if [ -z "$HF_TOKEN" ]; then
21
+ read -r -p "Hugging Face Token (leave blank if already logged in): " INPUT_HF
22
+ if [ -n "$INPUT_HF" ]; then
23
+ HF_TOKEN="$INPUT_HF"
24
+ export HF_TOKEN
25
+ echo "[INFO] Hugging Face Token set."
26
+ fi
27
+ fi
28
+
29
+ # --- 1. Activate Virtual Environment ---
30
+ if [ -d "venv" ]; then
31
+ echo "[INFO] Activating virtual environment..."
32
+ source venv/bin/activate
33
+ else
34
+ echo "[WARNING] 'venv' not found. Using system Python..."
35
+ fi
36
+
37
+ echo ""
38
+ echo "Configure the server (press Enter to accept defaults):"
39
+ echo ""
40
+
41
+ # --- 2. Host ---
42
+ read -r -p "Host IP [0.0.0.0]: " INPUT_HOST
43
+ HOST="${INPUT_HOST:-0.0.0.0}"
44
+
45
+ # --- 3. Port ---
46
+ read -r -p "Port [49112]: " INPUT_PORT
47
+ PORT="${INPUT_PORT:-49112}"
48
+ if ! [[ "$PORT" =~ ^[0-9]+$ ]]; then
49
+ echo "[ERROR] Port must be a number."
50
+ exit 1
51
+ fi
52
+
53
+ # --- 4. Model Path ---
54
+ echo "Model Config Path (.yaml) or variant name (leave blank for built-in):"
55
+ read -r INPUT_MODEL
56
+ MODEL_ARG=()
57
+ if [ -n "$INPUT_MODEL" ]; then
58
+ MODEL_ARG+=("--model-path" "$INPUT_MODEL")
59
+ fi
60
+
61
+ # --- 5. Voices Directory ---
62
+ DEFAULT_VOICES="$SCRIPT_DIR/voices"
63
+ [ ! -d "$DEFAULT_VOICES" ] && DEFAULT_VOICES="None"
64
+
65
+ read -r -p "Voices Directory [$DEFAULT_VOICES]: " INPUT_VOICES
66
+ VOICES_ARG=()
67
+ if [ -n "$INPUT_VOICES" ] && [ "$INPUT_VOICES" != "None" ]; then
68
+ VOICES_ARG+=("--voices-dir" "$INPUT_VOICES")
69
+ fi
70
+
71
+ # --- 6. Streaming ---
72
+ read -r -p "Enable Streaming? (y/n) [Y]: " INPUT_STREAM
73
+ STREAM_ARG=()
74
+ if [ "${INPUT_STREAM,,}" != "n" ]; then
75
+ STREAM_ARG+=("--stream")
76
+ fi
77
+
78
+ # --- 7. Text Preprocessing ---
79
+ read -r -p "Enable Text Preprocessing? (y/n) [Y]: " INPUT_PREPROCESS
80
+ PREPROCESS_ARG=()
81
+ if [ "${INPUT_PREPROCESS,,}" != "n" ]; then
82
+ PREPROCESS_ARG+=("--text-preprocess")
83
+ fi
84
+
85
+ # --- 8. Language ---
86
+ echo "Language (english, french_24l, german_24l, portuguese, italian, spanish_24l - leave blank for default):"
87
+ read -r INPUT_LANGUAGE
88
+ LANGUAGE_ARG=()
89
+ if [ -n "$INPUT_LANGUAGE" ]; then
90
+ LANGUAGE_ARG+=("--language" "$INPUT_LANGUAGE")
91
+ fi
92
+
93
+ # --- 9. Quantization ---
94
+ read -r -p "Enable int8 Quantization? (y/n) [N]: " INPUT_QUANTIZE
95
+ QUANTIZE_ARG=()
96
+ if [ "${INPUT_QUANTIZE,,}" = "y" ]; then
97
+ QUANTIZE_ARG+=("--quantize")
98
+ fi
99
+
100
+ # --- Summary ---
101
+ echo ""
102
+ echo "========================================"
103
+ echo "Starting Pocket TTS Server..."
104
+ echo " Host : $HOST"
105
+ echo " Port : $PORT"
106
+ [ -n "$INPUT_MODEL" ] && echo " Model : $INPUT_MODEL"
107
+ [ -n "$INPUT_VOICES" ] && echo " Voices : $INPUT_VOICES"
108
+ echo " Streaming : $([ "${INPUT_STREAM,,}" = "n" ] && echo "Disabled" || echo "Enabled")"
109
+ echo " Preprocessing: $([ "${INPUT_PREPROCESS,,}" = "n" ] && echo "Disabled" || echo "Enabled")"
110
+ [ -n "$INPUT_LANGUAGE" ] && echo " Language : $INPUT_LANGUAGE"
111
+ [ "${INPUT_QUANTIZE,,}" = "y" ] && echo " Quantization : Enabled"
112
+ echo "========================================"
113
+ echo ""
114
+
115
+ # --- Run ---
116
+ python3 server.py \
117
+ --host "$HOST" \
118
+ --port "$PORT" \
119
+ "${MODEL_ARG[@]+"${MODEL_ARG[@]}"}" \
120
+ "${VOICES_ARG[@]+"${VOICES_ARG[@]}"}" \
121
+ "${STREAM_ARG[@]+"${STREAM_ARG[@]}"}" \
122
+ "${PREPROCESS_ARG[@]+"${PREPROCESS_ARG[@]}"}" \
123
+ "${LANGUAGE_ARG[@]+"${LANGUAGE_ARG[@]}"}" \
124
+ "${QUANTIZE_ARG[@]+"${QUANTIZE_ARG[@]}"}"
125
+
126
+ EXIT_CODE=$?
127
+
128
+ if [ $EXIT_CODE -ne 0 ]; then
129
+ echo ""
130
+ echo "[ERROR] Server exited with error code $EXIT_CODE."
131
+ fi
132
+ exit $EXIT_CODE
run_pocket_tts_server_exe.bat ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @echo off
2
+ setlocal EnableDelayedExpansion
3
+
4
+ title Pocket TTS Server Launcher (EXE)
5
+
6
+ echo.
7
+ echo ========================================================
8
+ echo Pocket TTS OpenAI Streaming Server Launcher
9
+ echo (Standalone EXE)
10
+ echo ========================================================
11
+ echo.
12
+
13
+ :: 0. Hugging Face Authentication
14
+ set "HF_TOKEN=your_token_here_if_you_want_to_hardcode_it"
15
+ if "%HF_TOKEN%"=="your_token_here_if_you_want_to_hardcode_it" (
16
+ set /p "HF_TOKEN=Enter Hugging Face Token (leave blank if already logged in): "
17
+ )
18
+
19
+ if not "%HF_TOKEN%"=="" (
20
+ echo [INFO] Setting Hugging Face Token...
21
+ set "HF_TOKEN=%HF_TOKEN%"
22
+ )
23
+
24
+ echo.
25
+ echo Please configure the server (Press ENTER to use defaults):
26
+ echo.
27
+
28
+ :: 1. Host
29
+ set "HOST=0.0.0.0"
30
+ set /p "INPUT_HOST=Host IP [%HOST%]: "
31
+ if not "%INPUT_HOST%"=="" set "HOST=%INPUT_HOST%"
32
+
33
+ :: 2. Port
34
+ set "PORT=49112"
35
+ set /p "INPUT_PORT=Port [%PORT%]: "
36
+ if not "%INPUT_PORT%"=="" set "PORT=%INPUT_PORT%"
37
+
38
+ :: 3. Model Path
39
+ set "MODEL_PATH="
40
+ set /p "INPUT_MODEL=Model Config Path (.yaml)/Variant Name (Optional, default=built-in): "
41
+ if not "%INPUT_MODEL%"=="" set "MODEL_PATH=--model-path ^"%INPUT_MODEL%^""
42
+
43
+ :: 4. Voices Directory
44
+ :: Changed: Now remains empty if the user hits ENTER.
45
+ set "VOICES_DIR="
46
+ set /p "INPUT_VOICES=Voices Directory (Optional, leave blank to skip): "
47
+
48
+ if not "!INPUT_VOICES!"=="" (
49
+ set "VOICES_DIR=--voices-dir "!INPUT_VOICES!""
50
+ )
51
+
52
+ :: 5. Streaming Default
53
+ :: Changed: Defaults to ON. Only unsets if the user types 'N'.
54
+ set "STREAM_ARG=--stream"
55
+ set /p "INPUT_STREAM=Enable Streaming? (Y/N) [Y]: "
56
+ if /i "%INPUT_STREAM%"=="N" set "STREAM_ARG="
57
+
58
+ :: 6. Text Preprocessing Default
59
+ :: Defaults to ON. Only unsets if the user types 'N'.
60
+ set "TEXT_PREPROCESS_ARG=--text-preprocess"
61
+ set /p "INPUT_PREPROCESS=Enable Text Preprocessing? (Y/N) [Y]: "
62
+ if /i "%INPUT_PREPROCESS%"=="N" set "TEXT_PREPROCESS_ARG="
63
+
64
+ :: 7. Language
65
+ set "LANGUAGE_ARG="
66
+ set /p "INPUT_LANGUAGE=Language (english, french_24l, german_24l, portuguese, italian, spanish_24l - leave blank for default): "
67
+ if not "%INPUT_LANGUAGE%"=="" set "LANGUAGE_ARG=--language %INPUT_LANGUAGE%"
68
+
69
+ :: 8. Quantization
70
+ set "QUANTIZE_ARG="
71
+ set /p "INPUT_QUANTIZE=Enable int8 Quantization? (Y/N) [N]: "
72
+ if /i "%INPUT_QUANTIZE%"=="Y" set "QUANTIZE_ARG=--quantize"
73
+
74
+ echo.
75
+ echo ========================================================
76
+ echo Starting Pocket TTS Server (EXE)...
77
+ echo Host: %HOST%
78
+ echo Port: %PORT%
79
+ if defined MODEL_PATH echo Model: %MODEL_PATH%
80
+ if defined VOICES_DIR (echo Voices: %VOICES_DIR%) else (echo Voices: Default/None)
81
+ if defined STREAM_ARG (echo Streaming: Enabled) else (echo Streaming: Disabled)
82
+ if defined TEXT_PREPROCESS_ARG (echo Text Preprocessing: Enabled) else (echo Text Preprocessing: Disabled)
83
+ if defined LANGUAGE_ARG echo Language: %INPUT_LANGUAGE%
84
+ if defined QUANTIZE_ARG echo Quantization: Enabled
85
+ echo ========================================================
86
+ echo.
87
+
88
+ :: 9. Run Command
89
+ if exist "%~dp0PocketTTS-Server.exe" (
90
+ "%~dp0PocketTTS-Server.exe" --host %HOST% --port %PORT% %MODEL_PATH% %VOICES_DIR% %STREAM_ARG% %TEXT_PREPROCESS_ARG% %LANGUAGE_ARG% %QUANTIZE_ARG%
91
+ ) else (
92
+ echo [ERROR] PocketTTS-Server.exe not found in the current directory.
93
+ echo Please make sure the executable is located in: %~dp0
94
+ )
95
+
96
+ if %ERRORLEVEL% NEQ 0 (
97
+ echo.
98
+ echo [ERROR] Server exited with error code %ERRORLEVEL%.
99
+ pause
100
+ )
server.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ PocketTTS OpenAI-Compatible Server
4
+
5
+ A drop-in replacement for OpenAI's TTS API using the pocket-tts model.
6
+ Supports streaming, custom voices, and runs on CPU.
7
+
8
+ Usage:
9
+ python server.py [OPTIONS]
10
+
11
+ # Or with environment variables:
12
+ POCKET_TTS_PORT=8080 python server.py
13
+ """
14
+
15
+ import argparse
16
+ import os
17
+ import sys
18
+
19
+ from app import create_app, init_tts_service
20
+ from app.config import Config
21
+ from app.logging_config import get_logger
22
+
23
+
24
+ def parse_args():
25
+ """Parse command line arguments."""
26
+ parser = argparse.ArgumentParser(
27
+ description='PocketTTS OpenAI-Compatible Server',
28
+ formatter_class=argparse.RawDescriptionHelpFormatter,
29
+ epilog="""
30
+ Examples:
31
+ # Start with defaults
32
+ python server.py
33
+
34
+ # Custom port and voices directory
35
+ python server.py --port 8080 --voices-dir ./my_voices
36
+
37
+ # Load French model with quantization
38
+ python server.py --language french_24l --quantize
39
+
40
+ Environment Variables:
41
+ POCKET_TTS_HOST Server host (default: 0.0.0.0)
42
+ POCKET_TTS_PORT Server port (default: 49112)
43
+ POCKET_TTS_MODEL_PATH Path to model config file
44
+ POCKET_TTS_LANGUAGE Model language (e.g., english, french_24l)
45
+ POCKET_TTS_QUANTIZE Enable int8 quantization (default: false)
46
+ POCKET_TTS_VOICES_DIR Path to voices directory
47
+ POCKET_TTS_STREAM_DEFAULT Enable streaming by default
48
+ POCKET_TTS_TEXT_PREPROCESS_DEFAULT Enable text preprocessing by default
49
+ POCKET_TTS_LOG_DIR Log directory path
50
+ """,
51
+ )
52
+
53
+ parser.add_argument(
54
+ '--host', type=str, default=Config.HOST, help=f'Host to bind to (default: {Config.HOST})'
55
+ )
56
+ parser.add_argument(
57
+ '--port', type=int, default=Config.PORT, help=f'Port to listen on (default: {Config.PORT})'
58
+ )
59
+ parser.add_argument(
60
+ '--model-path',
61
+ type=str,
62
+ default=Config.MODEL_PATH,
63
+ dest='model_path',
64
+ help='Path to model config file (.yaml) or variant name',
65
+ )
66
+ parser.add_argument(
67
+ '--voices-dir',
68
+ type=str,
69
+ default=Config.VOICES_DIR,
70
+ dest='voices_dir',
71
+ help='Directory containing voice files',
72
+ )
73
+ parser.add_argument(
74
+ '--stream',
75
+ action='store_true',
76
+ default=Config.STREAM_DEFAULT,
77
+ help='Enable streaming by default for all requests',
78
+ )
79
+ parser.add_argument(
80
+ '--text-preprocess',
81
+ action='store_true',
82
+ default=Config.TEXT_PREPROCESS_DEFAULT,
83
+ help='Enable text preprocessing for all requests',
84
+ )
85
+ parser.add_argument(
86
+ '--language',
87
+ type=str,
88
+ default=Config.LANGUAGE,
89
+ dest='language',
90
+ help='Model language (e.g., english, french_24l, german_24l, portuguese, italian, spanish_24l). Incompatible with --model-path.',
91
+ )
92
+ parser.add_argument(
93
+ '--quantize',
94
+ action='store_true',
95
+ default=Config.QUANTIZE,
96
+ help='Apply dynamic int8 quantization to reduce memory usage and improve speed.',
97
+ )
98
+ parser.add_argument(
99
+ '--log-level',
100
+ type=str,
101
+ default=Config.LOG_LEVEL,
102
+ choices=['DEBUG', 'INFO', 'WARNING', 'ERROR'],
103
+ dest='log_level',
104
+ help='Logging level',
105
+ )
106
+
107
+ return parser.parse_args()
108
+
109
+
110
+ def main():
111
+ """Main entry point."""
112
+ args = parse_args()
113
+
114
+ # Update config from args (environment takes precedence via Config class)
115
+ os.environ.setdefault('POCKET_TTS_LOG_LEVEL', args.log_level)
116
+
117
+ # Create app
118
+ app = create_app(
119
+ {'STREAM_DEFAULT': args.stream, 'TEXT_PREPROCESS_DEFAULT': args.text_preprocess}
120
+ )
121
+
122
+ logger = get_logger()
123
+
124
+ # Validate mutually exclusive options
125
+ if args.language and args.model_path:
126
+ logger.error('--language and --model-path are mutually exclusive. Use one or the other.')
127
+ sys.exit(1)
128
+
129
+ # Validate --language against supported list (prevents cryptic pocket-tts errors).
130
+ if args.language and args.language not in Config.SUPPORTED_LANGUAGES:
131
+ logger.error(
132
+ f"Unknown language '{args.language}'. "
133
+ f'Supported: {", ".join(Config.SUPPORTED_LANGUAGES)}'
134
+ )
135
+ sys.exit(1)
136
+
137
+ # Initialize TTS service
138
+ try:
139
+ init_tts_service(
140
+ model_path=args.model_path,
141
+ voices_dir=args.voices_dir,
142
+ language=args.language,
143
+ quantize=args.quantize,
144
+ )
145
+ except Exception as e:
146
+ logger.error(f'Failed to initialize TTS service: {e}')
147
+ sys.exit(1)
148
+
149
+ # Start server with Waitress (production WSGI server)
150
+ try:
151
+ from waitress import serve
152
+
153
+ logger.info(f'Starting PocketTTS server on http://{args.host}:{args.port}')
154
+ logger.info('Press Ctrl+C to stop')
155
+
156
+ serve(app, host=args.host, port=args.port, threads=4, url_scheme='http')
157
+
158
+ except ImportError:
159
+ logger.warning('Waitress not installed, falling back to Flask dev server')
160
+ logger.warning('Install waitress for production: pip install waitress')
161
+ app.run(host=args.host, port=args.port, debug=False, threaded=True)
162
+
163
+
164
+ if __name__ == '__main__':
165
+ main()
static/css/style.css ADDED
@@ -0,0 +1,819 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg-color: #0d1117;
3
+ --card-bg: #161b22;
4
+ --accent-color: #58a6ff;
5
+ --accent-hover: #1f6feb;
6
+ --text-primary: #c9d1d9;
7
+ --text-secondary: #8b949e;
8
+ --border-color: #30363d;
9
+ --input-bg: #0d1117;
10
+ --font-family:
11
+ 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial,
12
+ sans-serif;
13
+ --glass-bg: rgba(22, 27, 34, 0.7);
14
+ --glass-border: rgba(48, 54, 61, 0.5);
15
+ }
16
+
17
+ * {
18
+ box-sizing: border-box;
19
+ margin: 0;
20
+ padding: 0;
21
+ }
22
+
23
+ body {
24
+ font-family: var(--font-family);
25
+ background-color: var(--bg-color);
26
+ color: var(--text-primary);
27
+ display: flex;
28
+ justify-content: center;
29
+ align-items: center;
30
+ min-height: 100vh;
31
+ background-image: radial-gradient(circle at 50% 0%, #1f2530 0%, #0d1117 100%);
32
+ }
33
+
34
+ .container {
35
+ width: 100%;
36
+ max-width: 800px;
37
+ padding: 2rem;
38
+ background: var(--glass-bg);
39
+ backdrop-filter: blur(12px);
40
+ border: 1px solid var(--glass-border);
41
+ border-radius: 16px;
42
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
43
+ animation: fadeIn 0.5s ease-out;
44
+ }
45
+
46
+ @keyframes fadeIn {
47
+ from {
48
+ opacity: 0;
49
+ transform: translateY(20px);
50
+ }
51
+ to {
52
+ opacity: 1;
53
+ transform: translateY(0);
54
+ }
55
+ }
56
+
57
+ header {
58
+ text-align: center;
59
+ margin-bottom: 2rem;
60
+ }
61
+
62
+ .logo {
63
+ max-width: 120px;
64
+ margin-bottom: 1rem;
65
+ filter: drop-shadow(0 0 10px rgba(88, 166, 255, 0.3));
66
+ }
67
+
68
+ h1 {
69
+ font-size: 2rem;
70
+ font-weight: 700;
71
+ color: var(--text-primary);
72
+ margin-bottom: 0.5rem;
73
+ background: linear-gradient(90deg, #58a6ff, #a371f7);
74
+ -webkit-background-clip: text;
75
+ -webkit-text-fill-color: transparent;
76
+ }
77
+
78
+ .subtitle {
79
+ color: var(--text-secondary);
80
+ font-size: 0.95rem;
81
+ }
82
+
83
+ .control-group {
84
+ margin-bottom: 1.5rem;
85
+ position: relative;
86
+ }
87
+
88
+ label {
89
+ display: block;
90
+ margin-bottom: 0.5rem;
91
+ font-weight: 500;
92
+ color: var(--text-secondary);
93
+ font-size: 0.9rem;
94
+ }
95
+
96
+ input[type='text'],
97
+ textarea,
98
+ select {
99
+ width: 100%;
100
+ padding: 0.75rem 1rem;
101
+ background-color: var(--input-bg);
102
+ border: 1px solid var(--border-color);
103
+ border-radius: 8px;
104
+ color: var(--text-primary);
105
+ font-family: inherit;
106
+ font-size: 1rem;
107
+ transition:
108
+ border-color 0.2s,
109
+ box-shadow 0.2s;
110
+ }
111
+
112
+ /* Replace native select chevron with a custom one positioned right next to
113
+ the content area instead of floating at the far edge of the box.
114
+ `!important` on background-image is needed because #format-select has an
115
+ inline `background: var(--input-bg)` that would otherwise reset the image. */
116
+ select {
117
+ appearance: none;
118
+ -webkit-appearance: none;
119
+ -moz-appearance: none;
120
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='none' stroke='%238b949e' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M1 1l5 5 5-5'/%3E%3C/svg%3E") !important;
121
+ background-repeat: no-repeat !important;
122
+ background-position: right 14px center !important;
123
+ background-size: 12px 8px !important;
124
+ padding-right: 36px !important;
125
+ }
126
+
127
+ input[type='file'] {
128
+ width: 100%;
129
+ padding: 0.5rem;
130
+ background-color: var(--input-bg);
131
+ border: 1px dashed var(--border-color);
132
+ border-radius: 8px;
133
+ color: var(--text-secondary);
134
+ cursor: pointer;
135
+ }
136
+
137
+ input:focus,
138
+ textarea:focus,
139
+ select:focus {
140
+ outline: none;
141
+ border-color: var(--accent-color);
142
+ box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.1);
143
+ }
144
+
145
+ textarea {
146
+ min-height: 120px;
147
+ resize: vertical;
148
+ }
149
+
150
+ .input-with-action {
151
+ position: relative;
152
+ display: flex;
153
+ align-items: center;
154
+ }
155
+
156
+ .input-with-action input[type='text'] {
157
+ width: 100%;
158
+ padding-right: 2.5rem;
159
+ }
160
+
161
+ .clear-btn {
162
+ position: absolute;
163
+ right: 0.5rem;
164
+ top: 50%;
165
+ transform: translateY(-50%);
166
+ width: 1.8rem;
167
+ height: 1.8rem;
168
+ padding: 0;
169
+ background: transparent;
170
+ color: var(--text-secondary);
171
+ border: none;
172
+ border-radius: 50%;
173
+ font-size: 0.85rem;
174
+ font-weight: 600;
175
+ line-height: 1;
176
+ cursor: pointer;
177
+ display: flex;
178
+ align-items: center;
179
+ justify-content: center;
180
+ transition:
181
+ background-color 0.2s,
182
+ color 0.2s;
183
+ }
184
+
185
+ .clear-btn:hover {
186
+ color: var(--text-primary);
187
+ background-color: rgba(255, 255, 255, 0.1);
188
+ }
189
+
190
+ .clear-btn:disabled {
191
+ cursor: default;
192
+ opacity: 0;
193
+ pointer-events: none;
194
+ }
195
+
196
+ .voice-list {
197
+ position: absolute;
198
+ top: 100%;
199
+ left: 0;
200
+ width: 100%;
201
+ z-index: 100;
202
+ list-style: none;
203
+ margin-top: 4px;
204
+ padding: 0;
205
+ background: var(--card-bg);
206
+ border: 1px solid var(--border-color);
207
+ border-radius: 8px;
208
+ max-height: 250px;
209
+ overflow-y: auto;
210
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6);
211
+ display: none; /* Changed from just being in flow */
212
+ }
213
+
214
+ .voice-list.show {
215
+ display: block;
216
+ }
217
+
218
+ .voice-list li {
219
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
220
+ }
221
+ .voice-list li:last-child {
222
+ border-bottom: none;
223
+ }
224
+
225
+ .voice-list-item {
226
+ width: 100%;
227
+ text-align: left;
228
+ background: transparent;
229
+ border: none;
230
+ border-radius: 0;
231
+ color: var(--text-primary);
232
+ padding: 0.75rem 1rem;
233
+ font-size: 0.95rem;
234
+ cursor: pointer;
235
+ transition: background-color 0.1s;
236
+ display: grid;
237
+ grid-template-columns: 1fr auto;
238
+ align-items: center;
239
+ gap: 0.5rem;
240
+ }
241
+
242
+ .voice-list-item .voice-info {
243
+ display: flex;
244
+ flex-direction: column;
245
+ gap: 2px;
246
+ overflow: hidden;
247
+ }
248
+
249
+ .voice-list-item .voice-name {
250
+ font-weight: 500;
251
+ white-space: nowrap;
252
+ overflow: hidden;
253
+ text-overflow: ellipsis;
254
+ }
255
+
256
+ .voice-list-item .voice-sub {
257
+ font-size: 0.75rem;
258
+ color: var(--text-secondary);
259
+ font-family: 'SF Mono', 'Consolas', monospace;
260
+ white-space: nowrap;
261
+ overflow: hidden;
262
+ text-overflow: ellipsis;
263
+ opacity: 0.7;
264
+ }
265
+
266
+ .voice-list-item span.voice-badge {
267
+ font-size: 0.7rem;
268
+ font-weight: 600;
269
+ padding: 2px 8px;
270
+ border-radius: 12px;
271
+ text-transform: uppercase;
272
+ letter-spacing: 0.5px;
273
+ white-space: nowrap;
274
+ }
275
+
276
+ .voice-list-item span.voice-badge.badge-builtin {
277
+ color: var(--text-secondary);
278
+ background: rgba(255, 255, 255, 0.1);
279
+ border: 1px solid rgba(255, 255, 255, 0.1);
280
+ }
281
+
282
+ .voice-list-item span.voice-badge.badge-custom {
283
+ color: #7ee787; /* Greenish for custom */
284
+ background: rgba(46, 160, 67, 0.15);
285
+ border: 1px solid rgba(46, 160, 67, 0.2);
286
+ }
287
+
288
+ .voice-list-item:hover,
289
+ .voice-list-item:focus {
290
+ outline: none;
291
+ background: rgba(88, 166, 255, 0.15);
292
+ color: var(--accent-color);
293
+ }
294
+
295
+ .voice-list-empty {
296
+ color: var(--text-secondary);
297
+ padding: 1rem;
298
+ text-align: center;
299
+ font-size: 0.9rem;
300
+ }
301
+
302
+ .btn-primary {
303
+ display: block;
304
+ width: 100%;
305
+ padding: 1rem;
306
+ border: none;
307
+ border-radius: 8px;
308
+ background: linear-gradient(135deg, var(--accent-color), var(--accent-hover));
309
+ color: white;
310
+ font-weight: 600;
311
+ font-size: 1.1rem;
312
+ cursor: pointer;
313
+ transition:
314
+ transform 0.1s,
315
+ opacity 0.2s;
316
+ box-shadow: 0 4px 12px rgba(31, 111, 235, 0.3);
317
+ }
318
+
319
+ .btn-primary:hover {
320
+ opacity: 0.9;
321
+ }
322
+
323
+ .btn-primary:active {
324
+ transform: scale(0.98);
325
+ }
326
+
327
+ .btn-primary:disabled {
328
+ background: var(--border-color);
329
+ cursor: not-allowed;
330
+ opacity: 0.7;
331
+ box-shadow: none;
332
+ }
333
+
334
+ .output-section {
335
+ margin-top: 2rem;
336
+ padding-top: 2rem;
337
+ border-top: 1px solid var(--border-color);
338
+ text-align: center;
339
+ display: none; /* Hidden by default */
340
+ }
341
+
342
+ .output-section.active {
343
+ display: block;
344
+ animation: slideDown 0.3s ease-out;
345
+ }
346
+
347
+ @keyframes slideDown {
348
+ from {
349
+ opacity: 0;
350
+ transform: translateY(-10px);
351
+ }
352
+ to {
353
+ opacity: 1;
354
+ transform: translateY(0);
355
+ }
356
+ }
357
+
358
+ audio {
359
+ width: 100%;
360
+ margin-bottom: 1rem;
361
+ border-radius: 8px;
362
+ }
363
+
364
+ .download-link {
365
+ display: inline-block;
366
+ padding: 0.5rem 1rem;
367
+ color: var(--accent-color);
368
+ text-decoration: none;
369
+ border: 1px solid var(--accent-color);
370
+ border-radius: 6px;
371
+ font-size: 0.9rem;
372
+ transition: all 0.2s;
373
+ }
374
+
375
+ .download-link:hover {
376
+ background: rgba(88, 166, 255, 0.1);
377
+ }
378
+
379
+ /* Spinner */
380
+ .spinner {
381
+ display: inline-block;
382
+ width: 1.5rem;
383
+ height: 1.5rem;
384
+ border: 3px solid rgba(255, 255, 255, 0.3);
385
+ border-radius: 50%;
386
+ border-top-color: #fff;
387
+ animation: spin 1s ease-in-out infinite;
388
+ margin-left: 0.5rem;
389
+ vertical-align: middle;
390
+ display: none;
391
+ }
392
+
393
+ .btn-primary.loading .spinner {
394
+ display: inline-block;
395
+ }
396
+
397
+ .btn-primary.loading span {
398
+ display: none;
399
+ }
400
+
401
+ @keyframes spin {
402
+ to {
403
+ transform: rotate(360deg);
404
+ }
405
+ }
406
+
407
+ .hidden {
408
+ display: none;
409
+ }
410
+
411
+ /* API Documentation Styles */
412
+ .api-docs {
413
+ margin-top: 3rem;
414
+ padding-top: 2rem;
415
+ border-top: 1px solid var(--border-color);
416
+ }
417
+
418
+ .api-docs h2 {
419
+ font-size: 1.5rem;
420
+ margin-bottom: 0.5rem;
421
+ color: var(--text-primary);
422
+ }
423
+
424
+ .api-intro {
425
+ color: var(--text-secondary);
426
+ margin-bottom: 2rem;
427
+ font-size: 0.95rem;
428
+ }
429
+
430
+ .endpoint {
431
+ background: var(--input-bg);
432
+ border: 1px solid var(--border-color);
433
+ border-radius: 12px;
434
+ padding: 1.5rem;
435
+ margin-bottom: 1.5rem;
436
+ }
437
+
438
+ .endpoint-header {
439
+ display: flex;
440
+ align-items: center;
441
+ gap: 1rem;
442
+ margin-bottom: 1rem;
443
+ }
444
+
445
+ .method {
446
+ padding: 0.25rem 0.75rem;
447
+ border-radius: 4px;
448
+ font-weight: 600;
449
+ font-size: 0.8rem;
450
+ text-transform: uppercase;
451
+ }
452
+
453
+ .method.get {
454
+ background: rgba(46, 160, 67, 0.2);
455
+ color: #3fb950;
456
+ border: 1px solid rgba(46, 160, 67, 0.4);
457
+ }
458
+
459
+ .method.post {
460
+ background: rgba(88, 166, 255, 0.2);
461
+ color: #58a6ff;
462
+ border: 1px solid rgba(88, 166, 255, 0.4);
463
+ }
464
+
465
+ .path {
466
+ font-family: 'SF Mono', 'Consolas', monospace;
467
+ font-size: 1rem;
468
+ color: var(--text-primary);
469
+ background: none;
470
+ padding: 0;
471
+ }
472
+
473
+ .endpoint-desc {
474
+ color: var(--text-secondary);
475
+ margin-bottom: 1rem;
476
+ font-size: 0.9rem;
477
+ }
478
+
479
+ .params-section,
480
+ .example-section,
481
+ .response-example,
482
+ .error-section {
483
+ margin-top: 1rem;
484
+ }
485
+
486
+ .params-section strong,
487
+ .example-section strong,
488
+ .response-example strong,
489
+ .error-section strong {
490
+ display: block;
491
+ color: var(--text-secondary);
492
+ font-size: 0.85rem;
493
+ margin-bottom: 0.5rem;
494
+ }
495
+
496
+ .params-table {
497
+ width: 100%;
498
+ border-collapse: collapse;
499
+ font-size: 0.85rem;
500
+ margin-top: 0.5rem;
501
+ }
502
+
503
+ .params-table th,
504
+ .params-table td {
505
+ padding: 0.6rem 0.75rem;
506
+ text-align: left;
507
+ border-bottom: 1px solid var(--border-color);
508
+ }
509
+
510
+ .params-table th {
511
+ color: var(--text-secondary);
512
+ font-weight: 500;
513
+ background: rgba(0, 0, 0, 0.2);
514
+ }
515
+
516
+ .params-table td {
517
+ color: var(--text-primary);
518
+ }
519
+
520
+ .params-table code {
521
+ background: rgba(88, 166, 255, 0.1);
522
+ color: var(--accent-color);
523
+ padding: 0.15rem 0.4rem;
524
+ border-radius: 4px;
525
+ font-size: 0.85em;
526
+ }
527
+
528
+ .api-docs pre {
529
+ background: rgba(0, 0, 0, 0.3);
530
+ border: 1px solid var(--border-color);
531
+ border-radius: 8px;
532
+ padding: 1rem;
533
+ overflow-x: auto;
534
+ font-family: 'SF Mono', 'Consolas', monospace;
535
+ font-size: 0.85rem;
536
+ color: var(--text-primary);
537
+ line-height: 1.5;
538
+ }
539
+
540
+ .voices-reference {
541
+ margin-top: 2rem;
542
+ padding: 1.5rem;
543
+ background: var(--input-bg);
544
+ border: 1px solid var(--border-color);
545
+ border-radius: 12px;
546
+ }
547
+
548
+ .voices-reference h3 {
549
+ font-size: 1.1rem;
550
+ margin-bottom: 0.5rem;
551
+ color: var(--text-primary);
552
+ }
553
+
554
+ .voices-reference p {
555
+ color: var(--text-secondary);
556
+ font-size: 0.9rem;
557
+ margin-bottom: 1rem;
558
+ }
559
+
560
+ .voice-chips {
561
+ display: flex;
562
+ flex-wrap: wrap;
563
+ gap: 0.5rem;
564
+ margin-bottom: 1rem;
565
+ }
566
+
567
+ .voice-chip {
568
+ background: rgba(88, 166, 255, 0.15);
569
+ color: var(--accent-color);
570
+ padding: 0.4rem 0.8rem;
571
+ border-radius: 20px;
572
+ font-size: 0.85rem;
573
+ font-family: 'SF Mono', 'Consolas', monospace;
574
+ border: 1px solid rgba(88, 166, 255, 0.3);
575
+ }
576
+
577
+ .voice-note {
578
+ font-size: 0.85rem;
579
+ color: var(--text-secondary);
580
+ margin-top: 1rem;
581
+ margin-bottom: 0;
582
+ }
583
+
584
+ .voice-note a {
585
+ color: var(--accent-color);
586
+ text-decoration: none;
587
+ }
588
+
589
+ .voice-note a:hover {
590
+ text-decoration: underline;
591
+ }
592
+
593
+ /* ID Helper */
594
+ .voice-id-helper {
595
+ margin-top: 0.5rem;
596
+ font-size: 0.8rem;
597
+ color: var(--text-secondary);
598
+ font-family: 'SF Mono', 'Consolas', monospace;
599
+ display: flex;
600
+ align-items: center;
601
+ padding-left: 2px;
602
+ animation: fadeIn 0.3s ease-out;
603
+ }
604
+
605
+ .voice-id-helper span {
606
+ color: var(--accent-color);
607
+ margin-left: 0.5rem;
608
+ background: rgba(88, 166, 255, 0.1);
609
+ padding: 2px 6px;
610
+ border-radius: 4px;
611
+ user-select: all;
612
+ cursor: text;
613
+ }
614
+
615
+ .copy-btn {
616
+ background: transparent;
617
+ border: none;
618
+ color: var(--text-secondary);
619
+ cursor: pointer;
620
+ padding: 4px;
621
+ margin-left: 0.5rem;
622
+ display: flex;
623
+ align-items: center;
624
+ justify-content: center;
625
+ border-radius: 4px;
626
+ transition:
627
+ background-color 0.2s,
628
+ color 0.2s;
629
+ }
630
+
631
+ .copy-btn:hover {
632
+ background: rgba(255, 255, 255, 0.1);
633
+ color: var(--text-primary);
634
+ }
635
+
636
+ .copy-btn:active {
637
+ transform: translateY(1px);
638
+ }
639
+
640
+ .version-strip {
641
+ color: var(--text-secondary);
642
+ font-size: 0.8em;
643
+ margin-top: 4px;
644
+ margin-bottom: 0;
645
+ }
646
+
647
+ .version-strip a {
648
+ color: var(--text-secondary);
649
+ text-decoration: none;
650
+ border-bottom: 1px dotted currentColor;
651
+ }
652
+
653
+ .version-strip a:hover {
654
+ color: var(--text-color);
655
+ }
656
+
657
+ .model-settings {
658
+ margin-bottom: 20px;
659
+ border: 1px solid var(--border-color);
660
+ border-radius: 6px;
661
+ background: var(--input-bg);
662
+ }
663
+
664
+ .model-settings-summary {
665
+ padding: 10px 14px;
666
+ cursor: pointer;
667
+ display: flex;
668
+ align-items: center;
669
+ gap: 8px;
670
+ font-weight: 500;
671
+ list-style: none;
672
+ }
673
+
674
+ .model-settings-summary::-webkit-details-marker {
675
+ display: none;
676
+ }
677
+
678
+ .disclosure-caret {
679
+ display: inline-block;
680
+ transition: transform 0.15s ease;
681
+ color: var(--text-secondary);
682
+ }
683
+
684
+ .model-settings[open] .disclosure-caret {
685
+ transform: rotate(90deg);
686
+ }
687
+
688
+ .summary-label #active-model-label {
689
+ font-family: monospace;
690
+ color: var(--text-color);
691
+ }
692
+
693
+ .model-badge {
694
+ font-size: 0.75em;
695
+ padding: 2px 6px;
696
+ border-radius: 3px;
697
+ background: var(--border-color);
698
+ color: var(--text-secondary);
699
+ text-transform: uppercase;
700
+ letter-spacing: 0.5px;
701
+ }
702
+
703
+ .session-badge {
704
+ background: #b45309;
705
+ color: #fff;
706
+ }
707
+
708
+ .loading-indicator {
709
+ display: inline-flex;
710
+ align-items: center;
711
+ gap: 6px;
712
+ font-size: 0.9em;
713
+ color: var(--text-secondary);
714
+ }
715
+
716
+ .spinner-small {
717
+ width: 12px;
718
+ height: 12px;
719
+ border: 2px solid var(--border-color);
720
+ border-top-color: var(--text-color);
721
+ border-radius: 50%;
722
+ display: inline-block;
723
+ animation: spin 0.8s linear infinite;
724
+ }
725
+
726
+ .model-settings-body {
727
+ padding: 14px 14px 14px 14px;
728
+ border-top: 1px solid var(--border-color);
729
+ }
730
+
731
+ /* HTML [hidden] is otherwise overridden by display: inline-flex etc. */
732
+ .model-settings [hidden] {
733
+ display: none !important;
734
+ }
735
+
736
+ .btn-secondary {
737
+ padding: 8px 18px;
738
+ margin-top: 14px;
739
+ border: 1px solid var(--border-color);
740
+ border-radius: 6px;
741
+ background: transparent;
742
+ color: var(--text-color);
743
+ font-weight: 500;
744
+ cursor: pointer;
745
+ transition:
746
+ background 0.15s,
747
+ border-color 0.15s,
748
+ opacity 0.15s;
749
+ }
750
+
751
+ .btn-secondary:hover:not(:disabled) {
752
+ background: var(--input-bg);
753
+ border-color: var(--accent-color);
754
+ }
755
+
756
+ .btn-secondary:active:not(:disabled) {
757
+ transform: translateY(1px);
758
+ }
759
+
760
+ .btn-secondary:disabled {
761
+ opacity: 0.45;
762
+ cursor: not-allowed;
763
+ }
764
+
765
+ .inline-label {
766
+ display: inline-flex;
767
+ align-items: center;
768
+ gap: 8px;
769
+ cursor: pointer;
770
+ }
771
+
772
+ .info-banner {
773
+ padding: 10px 12px;
774
+ border-radius: 4px;
775
+ margin-top: 14px;
776
+ font-size: 0.9em;
777
+ line-height: 1.4;
778
+ }
779
+
780
+ .info-banner code {
781
+ font-family: monospace;
782
+ background: rgba(255,255,255,0.06);
783
+ padding: 1px 4px;
784
+ border-radius: 2px;
785
+ }
786
+
787
+ .info-banner.info {
788
+ background: rgba(59,130,246,0.08);
789
+ border-left: 3px solid #3b82f6;
790
+ color: var(--text-color);
791
+ }
792
+
793
+ .info-banner.warning {
794
+ background: rgba(234,179,8,0.08);
795
+ border-left: 3px solid #eab308;
796
+ color: var(--text-color);
797
+ }
798
+
799
+ .info-banner.error {
800
+ background: rgba(239,68,68,0.08);
801
+ border-left: 3px solid #ef4444;
802
+ color: var(--text-color);
803
+ }
804
+
805
+ /* Responsive adjustments */
806
+ @media (max-width: 600px) {
807
+ .params-table {
808
+ font-size: 0.75rem;
809
+ }
810
+
811
+ .params-table th,
812
+ .params-table td {
813
+ padding: 0.4rem 0.5rem;
814
+ }
815
+
816
+ .endpoint-header {
817
+ flex-wrap: wrap;
818
+ }
819
+ }
static/images/pocket-tts-logo.png ADDED

Git LFS Details

  • SHA256: f674a22e3728493d14e045999963b9326bf513424cce1d8f7ac45c8aff6225ca
  • Pointer size: 131 Bytes
  • Size of remote file: 179 kB
static/js/app.js ADDED
@@ -0,0 +1,680 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ document.addEventListener('DOMContentLoaded', async () => {
2
+ const voiceInput = document.getElementById('voice-input');
3
+ const voiceList = document.getElementById('voice-list');
4
+ const voiceClearBtn = document.getElementById('voice-clear-btn');
5
+ const customVoiceGroup = document.getElementById('custom-voice-group');
6
+ const generateBtn = document.getElementById('generate-btn');
7
+ const textInput = document.getElementById('text-input');
8
+ const voiceFile = document.getElementById('voice-file');
9
+ const outputSection = document.getElementById('output-section');
10
+ const audioPlayer = document.getElementById('audio-player');
11
+ const downloadBtn = document.getElementById('download-btn');
12
+ const streamToggle = document.getElementById('stream-toggle');
13
+ const formatSelect = document.getElementById('format-select');
14
+
15
+ let availableVoices = [];
16
+ let selectedVoiceId = null; // The actual value used for generation
17
+
18
+ // Format & Streaming Logic
19
+ function updateStreamingAvailability() {
20
+ const fmt = formatSelect.value;
21
+ // Server only supports streaming for PCM and WAV currently
22
+ const supportsStreaming = ['wav', 'pcm'].includes(fmt);
23
+ const infoLabel = document.getElementById('format-info');
24
+
25
+ if (supportsStreaming) {
26
+ streamToggle.disabled = false;
27
+ streamToggle.parentElement.title = '';
28
+
29
+ if (fmt === 'pcm') {
30
+ infoLabel.textContent =
31
+ "Streaming is available for Raw PCM. Note: This format creates a specialized raw stream that will not play in the browser's audio player.";
32
+ } else {
33
+ // WAV
34
+ infoLabel.textContent =
35
+ 'Streaming is available for WAV. The server streams audio chunks for lower latency.';
36
+ }
37
+ } else {
38
+ streamToggle.disabled = true;
39
+ streamToggle.checked = false;
40
+ streamToggle.parentElement.title =
41
+ 'Streaming is only available for WAV and PCM formats';
42
+
43
+ if (fmt === 'mp3') {
44
+ infoLabel.textContent =
45
+ 'Streaming is not available for MP3 (Server limitation). A full file will be generated and played.';
46
+ } else if (['opus', 'aac', 'flac'].includes(fmt)) {
47
+ infoLabel.textContent = `Streaming is not available for ${fmt.toUpperCase()}. A full file will be generated and played.`;
48
+ } else {
49
+ infoLabel.textContent = 'Streaming is not available for this format.';
50
+ }
51
+ }
52
+ }
53
+
54
+ formatSelect.addEventListener('change', updateStreamingAvailability);
55
+ // Initialize state
56
+ updateStreamingAvailability();
57
+
58
+ // 1. Load Voices
59
+ async function loadVoices() {
60
+ try {
61
+ const res = await fetch('/v1/voices');
62
+ const data = await res.json();
63
+ availableVoices = [];
64
+
65
+ if (data.data) {
66
+ data.data.forEach((voice) => {
67
+ availableVoices.push({
68
+ id: voice.id,
69
+ label: voice.name || voice.id,
70
+ display: voice.name || voice.id, // For search
71
+ type: voice.type || 'builtin',
72
+ });
73
+ });
74
+
75
+ // Custom option
76
+ availableVoices.push({
77
+ id: 'custom',
78
+ label: 'Custom Voice',
79
+ display: 'Custom (Upload .wav, .mp3, .flac)...',
80
+ type: 'manual',
81
+ });
82
+
83
+ // Default selection: Prefer first non-custom voice
84
+ const defaultVoice = availableVoices.find((v) => v.id !== 'custom');
85
+ if (defaultVoice) {
86
+ selectVoice(defaultVoice.id, false);
87
+ }
88
+ }
89
+ } catch (e) {
90
+ console.error('Failed to list voices:', e);
91
+ }
92
+ }
93
+
94
+ // 2. Core Search & Selection Logic
95
+
96
+ function selectVoice(id, closeList = true) {
97
+ const voice = availableVoices.find((v) => v.id === id);
98
+ if (!voice) return;
99
+
100
+ selectedVoiceId = voice.id;
101
+ voiceInput.value = voice.label; // Display nice name
102
+
103
+ // Update ID Display helper
104
+ const idDisplay = document.getElementById('voice-id-display');
105
+ if (idDisplay) {
106
+ if (id !== 'custom') {
107
+ const idSpan = idDisplay.querySelector('.voice-id-text');
108
+ if (idSpan) {
109
+ // Clean extension from ID for cleaner display/copying
110
+ const cleanId = voice.id.replace(
111
+ /\.(wav|mp3|flac|safetensors)$/i,
112
+ '',
113
+ );
114
+ idSpan.textContent = cleanId;
115
+ }
116
+ idDisplay.classList.remove('hidden');
117
+ } else {
118
+ idDisplay.classList.add('hidden');
119
+ }
120
+ }
121
+
122
+ // Handle UI state
123
+ voiceClearBtn.disabled = false;
124
+ if (closeList) hideVoiceList();
125
+
126
+ // Handle Custom
127
+ if (id === 'custom') {
128
+ const isDocker = window.POCKET_TTS_CONFIG?.isDocker || false;
129
+ if (isDocker) {
130
+ alert('Custom voices are not available in Docker mode.');
131
+ // Fallback to the first non-custom voice if available
132
+ const fallbackVoice = availableVoices.find((v) => v.id !== 'custom');
133
+ if (fallbackVoice) {
134
+ selectVoice(fallbackVoice.id || '');
135
+ } else {
136
+ // No valid fallback; clear selection and hide custom UI
137
+ selectedVoiceId = null;
138
+ voiceInput.value = '';
139
+ voiceClearBtn.disabled = true;
140
+ customVoiceGroup.classList.add('hidden');
141
+ }
142
+ return;
143
+ }
144
+ customVoiceGroup.classList.remove('hidden');
145
+ document.querySelector('#custom-voice-group label').textContent =
146
+ 'Absolute Path to Audio File:';
147
+ voiceFile.type = 'text';
148
+ voiceFile.placeholder = 'C:\\path\\to\\voice.wav';
149
+ } else {
150
+ customVoiceGroup.classList.add('hidden');
151
+ }
152
+ }
153
+
154
+ function renderVoiceList(filterText = '') {
155
+ const normalizedFilter = filterText.trim().toLowerCase();
156
+ const fragment = document.createDocumentFragment();
157
+
158
+ let matchCount = 0;
159
+ let firstMatchId = null;
160
+
161
+ const isDocker = window.POCKET_TTS_CONFIG?.isDocker || false;
162
+ const filtered = availableVoices.filter((v) => {
163
+ if (v.id === 'custom' && isDocker) return false;
164
+ if (!normalizedFilter) return true;
165
+ return (
166
+ v.id.toLowerCase().includes(normalizedFilter) ||
167
+ v.label.toLowerCase().includes(normalizedFilter) ||
168
+ (v.display && v.display.toLowerCase().includes(normalizedFilter))
169
+ );
170
+ });
171
+
172
+ voiceList.innerHTML = '';
173
+
174
+ if (filtered.length === 0) {
175
+ const emptyItem = document.createElement('li');
176
+ emptyItem.className = 'voice-list-empty';
177
+ emptyItem.textContent = 'No matching voices';
178
+ voiceList.appendChild(emptyItem);
179
+ } else {
180
+ filtered.forEach((voice) => {
181
+ matchCount++;
182
+ if (matchCount === 1) firstMatchId = voice.id;
183
+
184
+ const item = document.createElement('li');
185
+ const btn = document.createElement('button');
186
+ btn.type = 'button';
187
+ btn.className = 'voice-list-item';
188
+ btn.dataset.voiceId = voice.id;
189
+
190
+ // Better content structure
191
+ const infoDiv = document.createElement('div');
192
+ infoDiv.className = 'voice-info';
193
+
194
+ const nameSpan = document.createElement('span');
195
+ nameSpan.className = 'voice-name';
196
+ nameSpan.textContent = voice.display || voice.label;
197
+
198
+ const subSpan = document.createElement('span');
199
+ subSpan.className = 'voice-sub';
200
+ if (voice.id === 'custom') {
201
+ subSpan.textContent = ''; // No ID for the upload button itself
202
+ } else {
203
+ subSpan.textContent = voice.id;
204
+ }
205
+
206
+ infoDiv.appendChild(nameSpan);
207
+ if (subSpan.textContent) infoDiv.appendChild(subSpan);
208
+
209
+ const badgeSpan = document.createElement('span');
210
+ badgeSpan.className = 'voice-badge';
211
+
212
+ // Format badge text: "builtin" -> "Default", "custom" -> "Custom"
213
+ let badgeText = 'Default';
214
+ if (voice.type === 'custom') badgeText = 'Custom';
215
+ if (voice.type === 'manual') badgeText = 'Upload';
216
+
217
+ badgeSpan.textContent = badgeText;
218
+
219
+ // Add specific class for styling if needed
220
+ badgeSpan.classList.add(
221
+ voice.type === 'builtin' ? 'badge-builtin' : 'badge-custom',
222
+ );
223
+
224
+ btn.appendChild(infoDiv);
225
+ btn.appendChild(badgeSpan);
226
+
227
+ item.appendChild(btn);
228
+ fragment.appendChild(item);
229
+ });
230
+ voiceList.appendChild(fragment);
231
+ }
232
+
233
+ return { count: matchCount, firstId: firstMatchId };
234
+ }
235
+
236
+ function showVoiceList() {
237
+ voiceList.classList.add('show');
238
+ renderVoiceList(
239
+ voiceInput.value === getSelectedVoiceLabel() ? '' : voiceInput.value,
240
+ );
241
+ }
242
+
243
+ function hideVoiceList() {
244
+ // Small delay to allow click events to propagate
245
+ setTimeout(() => {
246
+ voiceList.classList.remove('show');
247
+ }, 150);
248
+ }
249
+
250
+ function getSelectedVoiceLabel() {
251
+ const v = availableVoices.find((v) => v.id === selectedVoiceId);
252
+ return v ? v.label : '';
253
+ }
254
+
255
+ // Smart Input Handling
256
+ voiceInput.addEventListener('focus', () => {
257
+ // On focus, if the input value matches the current selection, wipe it to allow fresh search?
258
+ // Or keep it? Standard combobox keeps it but selects text.
259
+ // Let's select text so user can type over immediately.
260
+ voiceInput.select();
261
+ showVoiceList();
262
+ });
263
+
264
+ voiceInput.addEventListener('input', () => {
265
+ voiceClearBtn.disabled = voiceInput.value.length === 0;
266
+ // If user types, we conceptually deselect until they pick or we auto-match
267
+ // But strictly clearing selectedVoiceId might be annoying if they just made a typo.
268
+ // Let's keep selectedVoiceId as fallback, but filter.
269
+ renderVoiceList(voiceInput.value);
270
+ voiceList.classList.add('show');
271
+ });
272
+
273
+ voiceInput.addEventListener('keydown', (e) => {
274
+ if (e.key === 'Escape') {
275
+ voiceInput.value = getSelectedVoiceLabel();
276
+ hideVoiceList();
277
+ voiceInput.blur();
278
+ } else if (e.key === 'Enter') {
279
+ e.preventDefault();
280
+ // Auto-select if 1 result
281
+ const { count, firstId } = renderVoiceList(voiceInput.value);
282
+ if (count === 1 && firstId) {
283
+ selectVoice(firstId);
284
+ voiceInput.blur();
285
+ } else if (count > 0 && firstId) {
286
+ // If multiple, maybe select first? Or do nothing?
287
+ // User asked: "If I filter so much that there is just 1 result, I still have to select it"
288
+ // implies standard Enter behavior triggers selection of top result usually.
289
+ selectVoice(firstId);
290
+ voiceInput.blur();
291
+ }
292
+ }
293
+ });
294
+
295
+ // Handle Blur: Auto-select if logic dictates
296
+ voiceInput.addEventListener('blur', () => {
297
+ // Delay logic slightly to allow Click to happen first
298
+ setTimeout(() => {
299
+ if (!document.activeElement.classList.contains('voice-list-item')) {
300
+ // Validate: Is text a partial match for exactly one voice?
301
+ const val = voiceInput.value.trim();
302
+ if (!val) {
303
+ // Cleared -> maybe clear selection? Or revert?
304
+ // Let's revert to last selected for safety unless user explicitly cleared?
305
+ // If they cleared, they probably want to clear.
306
+ // But we need a voice to generate?
307
+ // Let's revert if empty.
308
+ voiceInput.value = getSelectedVoiceLabel();
309
+ hideVoiceList();
310
+ return;
311
+ }
312
+
313
+ // If the text matches the currently selected label, do nothing
314
+ if (val === getSelectedVoiceLabel()) {
315
+ hideVoiceList();
316
+ return;
317
+ }
318
+
319
+ // Try to find a match
320
+ // 1. Exact Name Match
321
+ const exact = availableVoices.find(
322
+ (v) =>
323
+ v.label.toLowerCase() === val.toLowerCase() ||
324
+ v.id.toLowerCase() === val.toLowerCase(),
325
+ );
326
+ if (exact) {
327
+ selectVoice(exact.id);
328
+ } else {
329
+ // 2. Single Filter Match
330
+ const { count, firstId } = renderVoiceList(val);
331
+ if (count === 1) {
332
+ selectVoice(firstId);
333
+ } else {
334
+ // 3. No clean match (0 or >1). Revert to last valid.
335
+ // User said "get an error because the value from the search field is taken"
336
+ // So passing the raw text is bad. We must force valid selection.
337
+ voiceInput.value = getSelectedVoiceLabel();
338
+ }
339
+ }
340
+ hideVoiceList();
341
+ }
342
+ }, 200);
343
+ });
344
+
345
+ // List Click Handling
346
+ voiceList.addEventListener('mousedown', (e) => {
347
+ // Use mousedown to trigger before blur
348
+ const btn = e.target.closest('.voice-list-item');
349
+ if (btn) {
350
+ const id = btn.dataset.voiceId;
351
+ selectVoice(id);
352
+ }
353
+ });
354
+
355
+ voiceClearBtn.addEventListener('mousedown', (e) => {
356
+ e.preventDefault(); // Prevent blur on input
357
+ selectedVoiceId = null;
358
+ voiceInput.value = '';
359
+ voiceInput.focus();
360
+ renderVoiceList('');
361
+ showVoiceList();
362
+ voiceClearBtn.disabled = true;
363
+
364
+ const idDisplay = document.getElementById('voice-id-display');
365
+ if (idDisplay) idDisplay.classList.add('hidden');
366
+ });
367
+
368
+ // Copy Button Logic
369
+ const copyBtn = document.getElementById('voice-id-copy-btn');
370
+ if (copyBtn) {
371
+ copyBtn.addEventListener('click', async () => {
372
+ const idText = document.querySelector('.voice-id-text')?.textContent;
373
+ if (idText) {
374
+ try {
375
+ await navigator.clipboard.writeText(idText);
376
+ const originalHTML = copyBtn.innerHTML;
377
+ // Show checkmark
378
+ copyBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#2ea043" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`;
379
+ copyBtn.classList.add('copied');
380
+
381
+ setTimeout(() => {
382
+ copyBtn.innerHTML = originalHTML;
383
+ copyBtn.classList.remove('copied');
384
+ }, 1500);
385
+ } catch (err) {
386
+ console.error('Failed to copy: ', err);
387
+ // Fallback for non-secure contexts (optional but good for localhost sometimes)
388
+ const input = document.createElement('textarea');
389
+ input.value = idText;
390
+ document.body.appendChild(input);
391
+ input.select();
392
+ document.execCommand('copy');
393
+ document.body.removeChild(input);
394
+ }
395
+ }
396
+ });
397
+ }
398
+
399
+ // 3. Generate Logic
400
+ generateBtn.addEventListener('click', async () => {
401
+ const text = textInput.value.trim();
402
+ if (!text) return alert('Please enter text');
403
+
404
+ // Use the ID, not the Input Value
405
+ let voice = selectedVoiceId;
406
+
407
+ // Fallback: If for some reason ID is null but text exists (shouldn't happen with our blur logic), try to resolve
408
+ if (!voice) {
409
+ // Try to find by name from input
410
+ const val = voiceInput.value.trim();
411
+ const isDocker = window.POCKET_TTS_CONFIG?.isDocker || false;
412
+ const match = availableVoices.find(
413
+ (v) =>
414
+ (v.label === val || v.id === val) &&
415
+ // In Docker mode, do not allow resolving the special "custom" voice
416
+ !(isDocker && v.id === 'custom'),
417
+ );
418
+ if (match) voice = match.id;
419
+ }
420
+
421
+ if (!voice) return alert('Please choose a valid voice from the list');
422
+
423
+ const isDocker = window.POCKET_TTS_CONFIG?.isDocker || false;
424
+ if (isDocker && voice === 'custom') {
425
+ return alert(
426
+ 'The custom voice is not available in Docker mode. Please choose another voice.',
427
+ );
428
+ }
429
+
430
+ if (voice === 'custom') {
431
+ voice = voiceFile.value.trim();
432
+ if (!voice) return alert('Please enter the path to the voice file.');
433
+ }
434
+
435
+ // ... rest of generation logic ...
436
+ const stream = streamToggle.checked;
437
+ const fmt = formatSelect.value;
438
+
439
+ generateBtn.classList.add('loading');
440
+ generateBtn.disabled = true;
441
+ outputSection.classList.remove('active');
442
+
443
+ try {
444
+ const response = await fetch('/v1/audio/speech', {
445
+ method: 'POST',
446
+ headers: { 'Content-Type': 'application/json' },
447
+ body: JSON.stringify({
448
+ model: 'pocket-tts',
449
+ input: text,
450
+ voice: voice,
451
+ response_format: fmt,
452
+ stream: stream,
453
+ }),
454
+ });
455
+
456
+ if (!response.ok) {
457
+ const err = await response.json();
458
+ throw new Error(err.error || response.statusText);
459
+ }
460
+
461
+ // Currently we always fetch the full blob and play it once ready.
462
+ // The `stream` flag is still sent to the server, but client playback
463
+ // uses a single blob path for robustness.
464
+ const blob = await response.blob();
465
+ const url = URL.createObjectURL(blob);
466
+ audioPlayer.src = url;
467
+ downloadBtn.href = url;
468
+ downloadBtn.download = `generated_speech.${fmt}`;
469
+
470
+ // PCM raw audio usually won't play in standard <audio> elements
471
+ if (fmt !== 'pcm') {
472
+ audioPlayer
473
+ .play()
474
+ .catch((e) => console.warn('Auto-play blocked or failed:', e));
475
+ }
476
+ outputSection.classList.add('active');
477
+ } catch (e) {
478
+ alert('Error generating speech: ' + e.message);
479
+ } finally {
480
+ generateBtn.classList.remove('loading');
481
+ generateBtn.disabled = false;
482
+ }
483
+ });
484
+
485
+ // Initial load
486
+ await loadVoices();
487
+ });
488
+
489
+ // ============================================================
490
+ // Model Settings panel
491
+ // ============================================================
492
+
493
+ const modelUI = {
494
+ activeLabel: document.getElementById('active-model-label'),
495
+ quantizeBadge: document.getElementById('quantize-badge'),
496
+ sessionBadge: document.getElementById('session-badge'),
497
+ loadingIndicator: document.getElementById('loading-indicator'),
498
+ loadingTargetLabel: document.getElementById('loading-target-label'),
499
+ languageSelect: document.getElementById('language-select'),
500
+ quantizeToggle: document.getElementById('quantize-toggle'),
501
+ applyBtn: document.getElementById('apply-model-btn'),
502
+ nonEnglishWarning: document.getElementById('non-english-warning'),
503
+ modelPathLockedNotice: document.getElementById('model-path-locked-notice'),
504
+ sessionOnlyNotice: document.getElementById('session-only-notice'),
505
+ applyError: document.getElementById('apply-error'),
506
+ generateBtn: document.getElementById('generate-btn'),
507
+ };
508
+
509
+ let currentModelState = null;
510
+ let pollTimer = null;
511
+ let pollDeadline = 0;
512
+
513
+ function populateLanguageOptions(languages) {
514
+ if (modelUI.languageSelect.options.length > 0) return; // already populated
515
+ for (const lang of languages) {
516
+ const opt = document.createElement('option');
517
+ opt.value = lang;
518
+ opt.textContent = lang;
519
+ modelUI.languageSelect.appendChild(opt);
520
+ }
521
+ }
522
+
523
+ function setHidden(el, hidden) {
524
+ if (hidden) { el.setAttribute('hidden', ''); }
525
+ else { el.removeAttribute('hidden'); }
526
+ }
527
+
528
+ // Backend reports `value: null` when the server was started without a
529
+ // --language flag. Pocket-tts treats that as "english" internally, so we
530
+ // surface the same string in the UI to keep the dropdown, label, and Apply
531
+ // diff comparison consistent.
532
+ function effectiveActiveValue(state) {
533
+ return state.active.value || 'english';
534
+ }
535
+
536
+ function updateUIForState(state) {
537
+ currentModelState = state;
538
+ populateLanguageOptions(state.available_languages);
539
+
540
+ const activeLang = effectiveActiveValue(state);
541
+
542
+ // Header labels
543
+ modelUI.activeLabel.textContent = activeLang;
544
+ setHidden(modelUI.quantizeBadge, !state.active.quantize);
545
+ setHidden(modelUI.sessionBadge, !state.differs_from_boot);
546
+
547
+ // Loading indicator
548
+ if (state.loading && state.loading_target) {
549
+ modelUI.loadingTargetLabel.textContent = `→ ${state.loading_target.value}`;
550
+ setHidden(modelUI.loadingIndicator, false);
551
+ } else {
552
+ setHidden(modelUI.loadingIndicator, true);
553
+ }
554
+
555
+ // Dropdown reflects active (not the pending target).
556
+ if (modelUI.languageSelect.value !== activeLang) {
557
+ modelUI.languageSelect.value = activeLang;
558
+ }
559
+ modelUI.quantizeToggle.checked = state.active.quantize;
560
+
561
+ // Lock state
562
+ const locked = state.model_path_locked;
563
+ modelUI.languageSelect.disabled = locked || state.loading;
564
+ modelUI.quantizeToggle.disabled = locked || state.loading;
565
+ setHidden(modelUI.modelPathLockedNotice, !locked);
566
+
567
+ // Warnings
568
+ const selectedLang = modelUI.languageSelect.value;
569
+ const isEnglishVariant = selectedLang && selectedLang.startsWith('english');
570
+ setHidden(modelUI.nonEnglishWarning, locked || isEnglishVariant);
571
+
572
+ // Session-only notice
573
+ setHidden(modelUI.sessionOnlyNotice, !state.differs_from_boot);
574
+
575
+ // Apply button
576
+ updateApplyButton();
577
+
578
+ // Error banner from last failed reload — also clear it once the backend
579
+ // reports no error (e.g. after a successful subsequent reload).
580
+ if (state.last_error) {
581
+ modelUI.applyError.textContent = state.last_error;
582
+ setHidden(modelUI.applyError, false);
583
+ } else {
584
+ modelUI.applyError.textContent = '';
585
+ setHidden(modelUI.applyError, true);
586
+ }
587
+
588
+ // Generate button disabled during load
589
+ modelUI.generateBtn.disabled = state.loading;
590
+ modelUI.generateBtn.title = state.loading ? 'Model is loading…' : '';
591
+ }
592
+
593
+ function updateApplyButton() {
594
+ if (!currentModelState) return;
595
+ const { active, model_path_locked, loading } = currentModelState;
596
+ const activeLang = effectiveActiveValue(currentModelState);
597
+ const targetLang = modelUI.languageSelect.value;
598
+ const targetQuantize = modelUI.quantizeToggle.checked;
599
+ const differs = targetLang !== activeLang || targetQuantize !== active.quantize;
600
+ modelUI.applyBtn.disabled = loading || model_path_locked || !differs;
601
+ }
602
+
603
+ async function fetchModelState() {
604
+ try {
605
+ const resp = await fetch('/v1/model');
606
+ if (!resp.ok) throw new Error(`GET /v1/model → ${resp.status}`);
607
+ const state = await resp.json();
608
+ updateUIForState(state);
609
+
610
+ if (!state.loading && pollTimer) {
611
+ clearInterval(pollTimer);
612
+ pollTimer = null;
613
+ }
614
+ } catch (err) {
615
+ console.warn('Failed to fetch model state:', err);
616
+ }
617
+ }
618
+
619
+ function startPolling() {
620
+ if (pollTimer) return;
621
+ pollDeadline = Date.now() + 120_000; // 2 min timeout
622
+ pollTimer = setInterval(() => {
623
+ if (Date.now() > pollDeadline) {
624
+ clearInterval(pollTimer);
625
+ pollTimer = null;
626
+ modelUI.applyError.textContent =
627
+ 'Model load timed out after 2 minutes. Check server logs.';
628
+ setHidden(modelUI.applyError, false);
629
+ return;
630
+ }
631
+ fetchModelState();
632
+ }, 1000);
633
+ }
634
+
635
+ async function applyModel() {
636
+ setHidden(modelUI.applyError, true);
637
+ modelUI.applyBtn.disabled = true;
638
+
639
+ try {
640
+ const resp = await fetch('/v1/model', {
641
+ method: 'POST',
642
+ headers: {'Content-Type': 'application/json'},
643
+ body: JSON.stringify({
644
+ language: modelUI.languageSelect.value,
645
+ quantize: modelUI.quantizeToggle.checked,
646
+ }),
647
+ });
648
+ if (resp.status === 202) {
649
+ startPolling();
650
+ // Immediately refresh to show loading state.
651
+ fetchModelState();
652
+ } else {
653
+ const body = await resp.json();
654
+ modelUI.applyError.textContent =
655
+ body.error || `Server returned ${resp.status}`;
656
+ setHidden(modelUI.applyError, false);
657
+ // Revert dropdown to the normalized active value so we never
658
+ // leave the select on an empty string when the backend reports
659
+ // the default model with value=null.
660
+ if (currentModelState) {
661
+ modelUI.languageSelect.value =
662
+ effectiveActiveValue(currentModelState);
663
+ }
664
+ updateApplyButton();
665
+ }
666
+ } catch (err) {
667
+ modelUI.applyError.textContent = `Apply failed: ${err.message}`;
668
+ setHidden(modelUI.applyError, false);
669
+ updateApplyButton();
670
+ }
671
+ }
672
+
673
+ modelUI.languageSelect.addEventListener('change', updateApplyButton);
674
+ modelUI.quantizeToggle.addEventListener('change', updateApplyButton);
675
+ modelUI.applyBtn.addEventListener('click', applyModel);
676
+
677
+ // Kick off on page load.
678
+ fetchModelState().then(() => {
679
+ if (currentModelState?.loading) startPolling();
680
+ });
templates/index.html ADDED
@@ -0,0 +1,440 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>Pocket TTS - Local Streaming</title>
7
+ <link rel="stylesheet" href="/static/css/style.css" />
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
+ <link
11
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
12
+ rel="stylesheet"
13
+ />
14
+ </head>
15
+ <body>
16
+ <script>
17
+ // Pass server config to JavaScript
18
+ window.POCKET_TTS_CONFIG = {
19
+ isDocker: {{ 'true' if is_docker else 'false' }}
20
+ };
21
+ </script>
22
+
23
+ <div class="container">
24
+ <header>
25
+ <img
26
+ src="/static/images/pocket-tts-logo.png"
27
+ alt="Pocket TTS Logo"
28
+ class="logo"
29
+ />
30
+ <h1>Pocket TTS</h1>
31
+ <p class="subtitle">Real-time local speech synthesis & voice cloning</p>
32
+ <p
33
+ class="version-strip"
34
+ data-server-version="{{ versions.server }}"
35
+ data-pocket-tts-version="{{ versions.pocket_tts }}"
36
+ >
37
+ Server v{{ versions.server }}
38
+ · pocket-tts v{{ versions.pocket_tts }}
39
+ · <a
40
+ href="https://github.com/teddybear082/pocket-tts-openai_streaming_server"
41
+ target="_blank"
42
+ rel="noopener"
43
+ >GitHub ↗</a>
44
+ </p>
45
+ </header>
46
+
47
+ <div class="content">
48
+ <details class="model-settings" id="model-settings">
49
+ <summary class="model-settings-summary">
50
+ <span class="disclosure-caret" aria-hidden="true">▸</span>
51
+ <span class="summary-label">
52
+ Model: <span id="active-model-label">loading…</span>
53
+ </span>
54
+ <span class="model-badge" id="quantize-badge" hidden>int8</span>
55
+ <span class="model-badge session-badge" id="session-badge" hidden>session override</span>
56
+ <span class="loading-indicator" id="loading-indicator" hidden>
57
+ <span class="spinner-small"></span>
58
+ <span id="loading-target-label"></span>
59
+ </span>
60
+ </summary>
61
+ <div class="model-settings-body">
62
+ <div class="control-group">
63
+ <label for="language-select">Language</label>
64
+ <select id="language-select">
65
+ <!-- Populated by JS from /v1/model -->
66
+ </select>
67
+ </div>
68
+ <div class="control-group">
69
+ <label class="inline-label">
70
+ <input type="checkbox" id="quantize-toggle" />
71
+ Int8 quantization (lower memory, ~30% faster on CPU)
72
+ </label>
73
+ </div>
74
+ <div id="non-english-warning" class="info-banner warning" hidden>
75
+ Built-in voices are English only. For best results in other languages,
76
+ upload a reference audio sample that matches the target language.
77
+ Cloned voices are cached per-model, so switching back to a previously-used
78
+ model is instant.
79
+ </div>
80
+ <div id="model-path-locked-notice" class="info-banner info" hidden>
81
+ Server started with a custom model path. Restart without
82
+ <code>--model-path</code> to enable language switching.
83
+ </div>
84
+ <button type="button" id="apply-model-btn" class="btn-secondary" disabled>
85
+ Apply
86
+ </button>
87
+ <div id="session-only-notice" class="info-banner info" hidden>
88
+ ⓘ Session-only change. On server restart, the model will revert to the
89
+ startup configuration (<code>POCKET_TTS_LANGUAGE</code> env var or
90
+ <code>--language</code> CLI flag).
91
+ </div>
92
+ <div id="apply-error" class="info-banner error" hidden></div>
93
+ </div>
94
+ </details>
95
+
96
+ <div class="control-group">
97
+ <label for="text-input">Text Prompt</label>
98
+ <textarea
99
+ id="text-input"
100
+ placeholder="Enter text to generate speech..."
101
+ >
102
+ Hello! I am Pocket TTS, running locally on your machine. I can clone voices and stream audio in real-time.</textarea
103
+ >
104
+ </div>
105
+
106
+ <div class="control-group">
107
+ <label for="voice-input">Voice Selection</label>
108
+ <div class="input-with-action">
109
+ <input
110
+ id="voice-input"
111
+ type="text"
112
+ autocomplete="off"
113
+ placeholder="Select a voice..."
114
+ />
115
+ <button
116
+ type="button"
117
+ class="clear-btn"
118
+ id="voice-clear-btn"
119
+ aria-label="Clear voice search"
120
+ >
121
+ <svg
122
+ width="14"
123
+ height="14"
124
+ viewBox="0 0 24 24"
125
+ fill="none"
126
+ stroke="currentColor"
127
+ stroke-width="2"
128
+ stroke-linecap="round"
129
+ stroke-linejoin="round"
130
+ >
131
+ <line x1="18" y1="6" x2="6" y2="18"></line>
132
+ <line x1="6" y1="6" x2="18" y2="18"></line>
133
+ </svg>
134
+ </button>
135
+ <ul
136
+ id="voice-list"
137
+ class="voice-list"
138
+ aria-label="Available voices"
139
+ >
140
+ <!-- Populated by JS -->
141
+ </ul>
142
+ </div>
143
+ <div id="voice-id-display" class="voice-id-helper hidden">
144
+ ID: <span class="voice-id-text"></span>
145
+ <button
146
+ class="copy-btn"
147
+ id="voice-id-copy-btn"
148
+ title="Copy to clipboard"
149
+ >
150
+ <svg
151
+ width="12"
152
+ height="12"
153
+ viewBox="0 0 24 24"
154
+ fill="none"
155
+ stroke="currentColor"
156
+ stroke-width="2"
157
+ stroke-linecap="round"
158
+ stroke-linejoin="round"
159
+ >
160
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
161
+ <path
162
+ d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"
163
+ ></path>
164
+ </svg>
165
+ </button>
166
+ </div>
167
+ </div>
168
+
169
+ <div class="control-group hidden" id="custom-voice-group">
170
+ <label for="voice-file"
171
+ >Upload Reference Audio (WAV, MP3, FLAC)</label
172
+ >
173
+ <input type="file" id="voice-file" accept=".wav,.mp3,.flac" />
174
+ </div>
175
+
176
+ <div class="control-group">
177
+ <label for="format-select">Output Format</label>
178
+ <select
179
+ id="format-select"
180
+ style="
181
+ width: 100%;
182
+ padding: 8px;
183
+ border-radius: 4px;
184
+ border: 1px solid var(--border-color);
185
+ background: var(--input-bg);
186
+ color: var(--text-color);
187
+ "
188
+ >
189
+ <option value="mp3">MP3</option>
190
+ <option value="opus">Opus</option>
191
+ <option value="aac">AAC</option>
192
+ <option value="flac">FLAC</option>
193
+ <option value="wav" selected>WAV</option>
194
+ <option value="pcm">PCM (Raw)</option>
195
+ </select>
196
+ </div>
197
+
198
+ <div class="control-group">
199
+ <label
200
+ for="stream-toggle"
201
+ style="display: inline-flex; align-items: center; cursor: pointer"
202
+ >
203
+ <input
204
+ type="checkbox"
205
+ id="stream-toggle"
206
+ checked
207
+ style="width: auto; margin-right: 8px"
208
+ />
209
+ Enable Streaming Response
210
+ </label>
211
+ <p
212
+ id="format-info"
213
+ style="
214
+ font-size: 0.85em;
215
+ color: var(--text-secondary);
216
+ margin-top: 8px;
217
+ line-height: 1.4;
218
+ "
219
+ ></p>
220
+ </div>
221
+
222
+ <button id="generate-btn" class="btn-primary">
223
+ <span>Generate Speech</span>
224
+ <div class="spinner"></div>
225
+ </button>
226
+
227
+ <div class="output-section" id="output-section">
228
+ <audio id="audio-player" controls></audio>
229
+ <br />
230
+ <a href="#" id="download-btn" class="download-link">Download Audio</a>
231
+ </div>
232
+ </div>
233
+
234
+ <!-- API Documentation -->
235
+ <div class="api-docs">
236
+ <h2>API Documentation</h2>
237
+ <p class="api-intro">
238
+ OpenAI-compatible TTS API. Use any OpenAI TTS client by pointing it to
239
+ this server.
240
+ </p>
241
+
242
+ <!-- Health Endpoint -->
243
+ <div class="endpoint">
244
+ <div class="endpoint-header">
245
+ <span class="method get">GET</span>
246
+ <code class="path">/health</code>
247
+ </div>
248
+ <p class="endpoint-desc">
249
+ Health check endpoint for container orchestration and monitoring.
250
+ </p>
251
+ <div class="response-example">
252
+ <strong>Response:</strong>
253
+ <pre>
254
+ {
255
+ "status": "healthy",
256
+ "model_loaded": true,
257
+ "device": "cpu",
258
+ "sample_rate": 24000
259
+ }</pre
260
+ >
261
+ </div>
262
+ </div>
263
+
264
+ <!-- List Voices Endpoint -->
265
+ <div class="endpoint">
266
+ <div class="endpoint-header">
267
+ <span class="method get">GET</span>
268
+ <code class="path">/v1/voices</code>
269
+ </div>
270
+ <p class="endpoint-desc">
271
+ List all available voices (built-in and custom).
272
+ </p>
273
+ <div class="response-example">
274
+ <strong>Response:</strong>
275
+ <pre>
276
+ {
277
+ "object": "list",
278
+ "data": [
279
+ {"id": "alba", "name": "Alba", "object": "voice"},
280
+ {"id": "marius", "name": "Marius", "object": "voice"}
281
+ ]
282
+ }</pre
283
+ >
284
+ </div>
285
+ </div>
286
+
287
+ <!-- Generate Speech Endpoint -->
288
+ <div class="endpoint">
289
+ <div class="endpoint-header">
290
+ <span class="method post">POST</span>
291
+ <code class="path">/v1/audio/speech</code>
292
+ </div>
293
+ <p class="endpoint-desc">
294
+ Generate speech audio from text. OpenAI-compatible endpoint.
295
+ </p>
296
+
297
+ <div class="params-section">
298
+ <strong>Request Body (JSON):</strong>
299
+ <table class="params-table">
300
+ <thead>
301
+ <tr>
302
+ <th>Parameter</th>
303
+ <th>Type</th>
304
+ <th>Required</th>
305
+ <th>Default</th>
306
+ <th>Description</th>
307
+ </tr>
308
+ </thead>
309
+ <tbody>
310
+ <tr>
311
+ <td><code>input</code></td>
312
+ <td>string</td>
313
+ <td>✓</td>
314
+ <td>-</td>
315
+ <td>The text to generate speech from (max 4096 chars)</td>
316
+ </tr>
317
+ <tr>
318
+ <td><code>voice</code></td>
319
+ <td>string</td>
320
+ <td></td>
321
+ <td><code>alba</code></td>
322
+ <td>
323
+ Voice ID, filename, or URL. See <code>/v1/voices</code>
324
+ </td>
325
+ </tr>
326
+ <tr>
327
+ <td><code>model</code></td>
328
+ <td>string</td>
329
+ <td></td>
330
+ <td>-</td>
331
+ <td>Ignored (for OpenAI compatibility)</td>
332
+ </tr>
333
+ <tr>
334
+ <td><code>response_format</code></td>
335
+ <td>string</td>
336
+ <td></td>
337
+ <td><code>mp3</code></td>
338
+ <td>
339
+ Audio format: <code>mp3</code>, <code>opus</code>,
340
+ <code>aac</code>, <code>flac</code>, <code>wav</code>,
341
+ <code>pcm</code>
342
+ </td>
343
+ </tr>
344
+ <tr>
345
+ <td><code>stream</code></td>
346
+ <td>boolean</td>
347
+ <td></td>
348
+ <td><code>true</code></td>
349
+ <td>Enable streaming response for real-time playback</td>
350
+ </tr>
351
+ <!-- <tr>
352
+ <td><code>speed</code></td>
353
+ <td>number</td>
354
+ <td></td>
355
+ <td><code>1.0</code></td>
356
+ <td>Playback speed (0.25 to 4.0)</td>
357
+ </tr> -->
358
+ </tbody>
359
+ </table>
360
+ </div>
361
+
362
+ <div class="example-section">
363
+ <strong>Example Request (curl):</strong>
364
+ <pre>
365
+ curl -X POST http://localhost:49112/v1/audio/speech \
366
+ -H "Content-Type: application/json" \
367
+ -d '{
368
+ "input": "Hello world!",
369
+ "voice": "alba",
370
+ "response_format": "mp3"
371
+ }' \
372
+ --output speech.mp3</pre
373
+ >
374
+ </div>
375
+
376
+ <div class="example-section">
377
+ <strong>Example (Python with OpenAI client):</strong>
378
+ <pre>
379
+ from openai import OpenAI
380
+
381
+ client = OpenAI(
382
+ base_url="http://localhost:49112/v1",
383
+ api_key="not-needed"
384
+ )
385
+
386
+ response = client.audio.speech.create(
387
+ model="tts-1",
388
+ voice="alba",
389
+ input="Hello world!"
390
+ )
391
+ response.stream_to_file("output.mp3")</pre
392
+ >
393
+ </div>
394
+
395
+ <div class="response-example">
396
+ <strong>Response:</strong>
397
+ <p>Audio file in the requested format (binary stream).</p>
398
+ <p>
399
+ <strong>Content-Type:</strong> <code>audio/mpeg</code>,
400
+ <code>audio/wav</code>, <code>audio/opus</code>, etc.
401
+ </p>
402
+ </div>
403
+
404
+ <div class="error-section">
405
+ <strong>Error Response:</strong>
406
+ <pre>
407
+ {
408
+ "error": "Missing required field: input"
409
+ }</pre
410
+ >
411
+ </div>
412
+ </div>
413
+
414
+ <!-- Built-in Voices Reference -->
415
+ <div class="voices-reference">
416
+ <h3>Built-in Voices</h3>
417
+ <p>These voices work without authentication:</p>
418
+ <div class="voice-chips">
419
+ <span class="voice-chip">alba</span>
420
+ <span class="voice-chip">marius</span>
421
+ <span class="voice-chip">javert</span>
422
+ <span class="voice-chip">jean</span>
423
+ <span class="voice-chip">fantine</span>
424
+ <span class="voice-chip">cosette</span>
425
+ <span class="voice-chip">eponine</span>
426
+ <span class="voice-chip">azelma</span>
427
+ </div>
428
+ <p class="voice-note">
429
+ Custom voice cloning requires a
430
+ <a href="https://huggingface.co/kyutai/pocket-tts" target="_blank"
431
+ >HuggingFace token</a
432
+ >.
433
+ </p>
434
+ </div>
435
+ </div>
436
+ </div>
437
+
438
+ <script src="/static/js/app.js"></script>
439
+ </body>
440
+ </html>
tests/__init__.py ADDED
File without changes
tests/conftest.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared pytest fixtures."""
2
+
3
+ from pathlib import Path
4
+ from unittest.mock import MagicMock
5
+
6
+ import pytest
7
+
8
+
9
+ @pytest.fixture
10
+ def tmp_voices(tmp_path: Path) -> Path:
11
+ """Temporary voices directory (simulates user-provided audio)."""
12
+ d = tmp_path / 'voices'
13
+ d.mkdir()
14
+ return d
15
+
16
+
17
+ @pytest.fixture
18
+ def tmp_cache(tmp_path: Path) -> Path:
19
+ """Temporary voice cache directory (simulates writable cache volume)."""
20
+ d = tmp_path / 'voice_cache'
21
+ d.mkdir()
22
+ return d
23
+
24
+
25
+ @pytest.fixture(autouse=True)
26
+ def reset_tts_singleton():
27
+ """Reset the TTSService singleton between tests."""
28
+ import app.services.tts as tts_module
29
+
30
+ tts_module._tts_service = None
31
+ yield
32
+ tts_module._tts_service = None
33
+
34
+
35
+ @pytest.fixture
36
+ def mock_tts_model():
37
+ """Mock pocket-tts TTSModel for unit tests."""
38
+ model = MagicMock()
39
+ model.sample_rate = 24000
40
+ model.device = 'cpu'
41
+ return model
tests/test_config.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for app.config constants."""
2
+
3
+ from pathlib import Path
4
+
5
+ from app.config import Config
6
+
7
+
8
+ def test_supported_languages_contains_all_yaml_configs():
9
+ """All 12 language YAMLs from pocket-tts v2.0.0 must be listed."""
10
+ expected = {
11
+ 'english',
12
+ 'english_2026-01',
13
+ 'english_2026-04',
14
+ 'french_24l',
15
+ 'german',
16
+ 'german_24l',
17
+ 'italian',
18
+ 'italian_24l',
19
+ 'portuguese',
20
+ 'portuguese_24l',
21
+ 'spanish',
22
+ 'spanish_24l',
23
+ }
24
+ assert set(Config.SUPPORTED_LANGUAGES) == expected
25
+
26
+
27
+ def test_legacy_aliases_canonicalize_english_variants():
28
+ """english and english_2026-01 both resolve to english_2026-04 for cache tagging."""
29
+ assert Config.LEGACY_MODEL_ALIASES == {
30
+ 'english': 'english_2026-04',
31
+ 'english_2026-01': 'english_2026-04',
32
+ }
33
+
34
+
35
+ def test_alias_targets_are_supported_languages():
36
+ """Every alias target must itself be a valid supported language."""
37
+ for target in Config.LEGACY_MODEL_ALIASES.values():
38
+ assert target in Config.SUPPORTED_LANGUAGES
39
+
40
+
41
+ def test_voice_cache_dir_defaults_to_base_path_subdir():
42
+ """Default cache dir sits under BASE_PATH."""
43
+ cache_dir = Path(Config.VOICE_CACHE_DIR)
44
+ assert cache_dir.name == 'voice_cache'
tests/test_routes.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for /v1/model routes."""
2
+
3
+ from unittest.mock import MagicMock
4
+
5
+ import pytest
6
+
7
+ from app import create_app
8
+
9
+
10
+ @pytest.fixture
11
+ def app():
12
+ return create_app()
13
+
14
+
15
+ @pytest.fixture
16
+ def client(app):
17
+ return app.test_client()
18
+
19
+
20
+ @pytest.fixture
21
+ def mock_tts_service():
22
+ """Install a mock TTSService for route tests."""
23
+ import app.services.tts as tts_module
24
+
25
+ service = MagicMock()
26
+ service.is_loaded = True
27
+ service.sample_rate = 24000
28
+ service.device = 'cpu'
29
+ service.voices_dir = None
30
+ service._active = {'source': 'language', 'value': 'english', 'quantize': False}
31
+ service._boot_active = {'source': 'language', 'value': 'english', 'quantize': False}
32
+ service._loading = False
33
+ service._last_reload_error = None
34
+ service._loading_target = None
35
+ service.validate_voice.return_value = (True, 'ok')
36
+ tts_module._tts_service = service
37
+ yield service
38
+ tts_module._tts_service = None
39
+
40
+
41
+ def test_get_model_returns_active_state(client, mock_tts_service):
42
+ resp = client.get('/v1/model')
43
+ assert resp.status_code == 200
44
+ body = resp.get_json()
45
+ assert body['active'] == {'source': 'language', 'value': 'english', 'quantize': False}
46
+ assert body['boot'] == {'source': 'language', 'value': 'english', 'quantize': False}
47
+ assert body['differs_from_boot'] is False
48
+ assert body['loading'] is False
49
+ assert body['model_path_locked'] is False
50
+ assert 'english' in body['available_languages']
51
+ assert len(body['available_languages']) == 12
52
+ assert 'server_version' in body
53
+ assert 'pocket_tts_version' in body
54
+
55
+
56
+ def test_get_model_reports_differs_from_boot(client, mock_tts_service):
57
+ mock_tts_service._active = {'source': 'language', 'value': 'german_24l', 'quantize': True}
58
+ resp = client.get('/v1/model')
59
+ assert resp.get_json()['differs_from_boot'] is True
60
+
61
+
62
+ def test_get_model_reports_model_path_locked(client, mock_tts_service):
63
+ mock_tts_service._boot_active = {'source': 'model_path', 'value': '/x.yaml', 'quantize': False}
64
+ mock_tts_service._active = mock_tts_service._boot_active
65
+ resp = client.get('/v1/model')
66
+ assert resp.get_json()['model_path_locked'] is True
67
+
68
+
69
+ def test_post_model_returns_202_and_starts_load(client, mock_tts_service):
70
+ resp = client.post('/v1/model', json={'language': 'german_24l', 'quantize': True})
71
+ assert resp.status_code == 202
72
+ body = resp.get_json()
73
+ assert body['loading_target'] == {'value': 'german_24l', 'quantize': True}
74
+ mock_tts_service.reload_model_async.assert_called_once_with(
75
+ language='german_24l',
76
+ quantize=True,
77
+ )
78
+
79
+
80
+ def test_post_model_rejects_unknown_language(client, mock_tts_service):
81
+ resp = client.post('/v1/model', json={'language': 'klingon', 'quantize': False})
82
+ assert resp.status_code == 400
83
+ assert 'klingon' in resp.get_json()['error']
84
+
85
+
86
+ def test_post_model_409_when_already_loading(client, mock_tts_service):
87
+ """Route relies on reload_model_async's atomic claim returning False
88
+ when a reload is already in progress, not on its own pre-check."""
89
+ mock_tts_service.reload_model_async.return_value = False
90
+ resp = client.post('/v1/model', json={'language': 'german_24l', 'quantize': False})
91
+ assert resp.status_code == 409
92
+
93
+
94
+ def test_post_model_403_when_model_path_locked(client, mock_tts_service):
95
+ mock_tts_service._boot_active = {'source': 'model_path', 'value': '/x', 'quantize': False}
96
+ resp = client.post('/v1/model', json={'language': 'german_24l', 'quantize': False})
97
+ assert resp.status_code == 403
98
+
99
+
100
+ def test_post_model_400_on_missing_language(client, mock_tts_service):
101
+ resp = client.post('/v1/model', json={'quantize': True})
102
+ assert resp.status_code == 400
103
+
104
+
105
+ def test_post_model_400_on_empty_body(client, mock_tts_service):
106
+ resp = client.post('/v1/model', json={})
107
+ assert resp.status_code == 400
108
+
109
+
110
+ def test_post_model_400_on_non_dict_body(client, mock_tts_service):
111
+ """A JSON value that isn't an object (e.g. a number, string, list) must be
112
+ rejected without raising AttributeError on .get()."""
113
+ resp = client.post('/v1/model', data='42', content_type='application/json')
114
+ assert resp.status_code == 400
115
+ assert 'object' in resp.get_json()['error'].lower()
116
+
117
+
118
+ def test_speech_400_on_non_dict_body(client, mock_tts_service):
119
+ resp = client.post('/v1/audio/speech', data='"hello"', content_type='application/json')
120
+ assert resp.status_code == 400
121
+ assert 'object' in resp.get_json()['error'].lower()
122
+
123
+
124
+ def test_post_model_400_on_non_bool_quantize(client, mock_tts_service):
125
+ """JSON booleans only — string 'false' would be truthy under bool() coercion."""
126
+ resp = client.post('/v1/model', json={'language': 'german_24l', 'quantize': 'false'})
127
+ assert resp.status_code == 400
128
+ assert 'boolean' in resp.get_json()['error'].lower()
129
+
130
+
131
+ def test_post_model_accepts_omitted_quantize(client, mock_tts_service):
132
+ """Omitted quantize defaults to False without triggering the type check."""
133
+ resp = client.post('/v1/model', json={'language': 'german_24l'})
134
+ assert resp.status_code == 202
135
+ mock_tts_service.reload_model_async.assert_called_once_with(
136
+ language='german_24l', quantize=False
137
+ )
138
+
139
+
140
+ def test_speech_returns_503_when_loading(client, mock_tts_service):
141
+ mock_tts_service._loading = True
142
+ resp = client.post('/v1/audio/speech', json={'input': 'hi', 'voice': 'alba'})
143
+ assert resp.status_code == 503
144
+ assert 'reloading' in resp.get_json()['error'].lower()
145
+
146
+
147
+ def test_speech_does_not_500_when_resolve_raises_in_mismatch_path(client, mock_tts_service):
148
+ """If get_voice_state raises ValueError AND _resolve_voice_path itself
149
+ raises (e.g. SSRF protection on http://), the mismatch detection must
150
+ not propagate the second exception — it should fall through to a clean
151
+ 400 with the original error message."""
152
+ mock_tts_service.validate_voice.return_value = (True, 'ok')
153
+ mock_tts_service.get_voice_state.side_effect = ValueError('Voice could not be loaded: nope')
154
+ mock_tts_service._resolve_voice_path.side_effect = ValueError('URL scheme not allowed')
155
+
156
+ resp = client.post(
157
+ '/v1/audio/speech', json={'input': 'hi', 'voice': 'http://evil.example/voice.wav'}
158
+ )
159
+ assert resp.status_code == 400
160
+ assert resp.get_json()['error'].startswith('Voice could not be loaded')
161
+
162
+
163
+ def test_speech_voice_model_mismatch_returns_400_with_code(client, mock_tts_service, tmp_path):
164
+ """When the resolved voice is a legacy unlabeled .safetensors and pocket-tts
165
+ raises a shape-mismatch during load, the route surfaces a helpful error."""
166
+ # Simulate: validate_voice succeeds, get_voice_state raises.
167
+ legacy = tmp_path / 'emma.safetensors'
168
+ legacy.write_bytes(b'x')
169
+ mock_tts_service.validate_voice.return_value = (True, 'ok')
170
+ mock_tts_service._resolve_voice_path.return_value = str(legacy)
171
+ mock_tts_service.get_voice_state.side_effect = ValueError(
172
+ "Voice 'emma' could not be loaded: RuntimeError: size mismatch for layer.0.weight"
173
+ )
174
+ resp = client.post('/v1/audio/speech', json={'input': 'hi', 'voice': 'emma'})
175
+ assert resp.status_code == 400
176
+ body = resp.get_json()
177
+ assert body['error'] == 'voice_model_mismatch'
178
+ assert body['voice'] == 'emma'
179
+ assert body['active_model'] == 'english'
180
+
181
+
182
+ def test_health_includes_active_model(client, mock_tts_service):
183
+ resp = client.get('/health')
184
+ body = resp.get_json()
185
+ assert body['active_model'] == {
186
+ 'source': 'language',
187
+ 'value': 'english',
188
+ 'quantize': False,
189
+ }
190
+
191
+
192
+ def test_home_passes_versions_to_template(client, mock_tts_service):
193
+ resp = client.get('/')
194
+ assert resp.status_code == 200
195
+ html = resp.data.decode()
196
+ assert 'data-server-version=' in html
tests/test_tts_service.py ADDED
@@ -0,0 +1,480 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for TTSService state and load_model."""
2
+
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ import pytest
6
+
7
+ from app.services.tts import TTSService
8
+
9
+
10
+ @pytest.fixture
11
+ def service():
12
+ return TTSService()
13
+
14
+
15
+ def test_service_has_lock_and_flag_on_init(service):
16
+ import threading
17
+
18
+ # threading.Lock() returns a _thread.lock instance; check via acquire/release protocol
19
+ assert isinstance(service._lock, type(threading.Lock()))
20
+ assert service._loading is False
21
+ assert service._active is None
22
+ assert service._boot_active is None
23
+
24
+
25
+ @patch('app.services.tts._ensure_pocket_tts')
26
+ def test_load_model_with_language_populates_active(_ensure, service, mock_tts_model):
27
+ with patch('app.services.tts.TTSModel') as MockModel:
28
+ MockModel.load_model.return_value = mock_tts_model
29
+ service.load_model(language='german_24l', quantize=True)
30
+
31
+ assert service._active == {
32
+ 'source': 'language',
33
+ 'value': 'german_24l',
34
+ 'quantize': True,
35
+ }
36
+ MockModel.load_model.assert_called_once_with(language='german_24l', quantize=True)
37
+
38
+
39
+ @patch('app.services.tts._ensure_pocket_tts')
40
+ def test_boot_load_snapshots_active_to_boot_active(_ensure, service, mock_tts_model):
41
+ with patch('app.services.tts.TTSModel') as MockModel:
42
+ MockModel.load_model.return_value = mock_tts_model
43
+ service.load_model(language='english', quantize=False)
44
+
45
+ assert service._boot_active == service._active
46
+ assert service._boot_active['value'] == 'english'
47
+
48
+
49
+ @patch('app.services.tts._ensure_pocket_tts')
50
+ def test_non_boot_load_does_not_mutate_boot_active(_ensure, service, mock_tts_model):
51
+ with patch('app.services.tts.TTSModel') as MockModel:
52
+ MockModel.load_model.return_value = mock_tts_model
53
+ service.load_model(language='english', quantize=False)
54
+ boot_snapshot = dict(service._boot_active)
55
+
56
+ # Second load with _is_boot=False (reload flow).
57
+ service.load_model(language='german_24l', quantize=True, _is_boot=False)
58
+
59
+ assert service._boot_active == boot_snapshot
60
+ assert service._active['value'] == 'german_24l'
61
+
62
+
63
+ @patch('app.services.tts._ensure_pocket_tts')
64
+ def test_load_model_with_model_path_sets_source(_ensure, service, mock_tts_model):
65
+ with patch('app.services.tts.TTSModel') as MockModel:
66
+ MockModel.load_model.return_value = mock_tts_model
67
+ service.load_model(model_path='/custom/path.yaml', quantize=False)
68
+
69
+ assert service._active['source'] == 'model_path'
70
+ assert service._active['value'] == '/custom/path.yaml'
71
+
72
+
73
+ @patch('app.services.tts._ensure_pocket_tts')
74
+ def test_load_model_default_has_default_source(_ensure, service, mock_tts_model):
75
+ with patch('app.services.tts.TTSModel') as MockModel:
76
+ MockModel.load_model.return_value = mock_tts_model
77
+ service.load_model(quantize=False)
78
+
79
+ assert service._active['source'] == 'default'
80
+ assert service._active['value'] is None
81
+
82
+
83
+ def test_service_initializes_cache_dir(tmp_path, monkeypatch):
84
+ cache = tmp_path / 'voice_cache'
85
+ monkeypatch.setattr('app.config.Config.VOICE_CACHE_DIR', str(cache))
86
+ service = TTSService()
87
+ # Lazy create on first use is fine — just verify the path is stored
88
+ assert service.cache_dir == cache
89
+
90
+
91
+ def test_service_cache_dir_mkdir_on_first_save(tmp_path, monkeypatch):
92
+ cache = tmp_path / 'voice_cache' # does not exist yet
93
+ monkeypatch.setattr('app.config.Config.VOICE_CACHE_DIR', str(cache))
94
+ service = TTSService()
95
+ service._ensure_cache_dir()
96
+ assert cache.is_dir()
97
+
98
+
99
+ def test_service_cache_dir_read_only_is_tolerated(tmp_path, monkeypatch, caplog):
100
+ """If mkdir raises (read-only FS), log and disable caching."""
101
+ import os
102
+
103
+ ro_parent = tmp_path / 'ro'
104
+ ro_parent.mkdir()
105
+ os.chmod(ro_parent, 0o500) # r-x, not writable
106
+ cache = ro_parent / 'voice_cache'
107
+ monkeypatch.setattr('app.config.Config.VOICE_CACHE_DIR', str(cache))
108
+ service = TTSService()
109
+ service._ensure_cache_dir()
110
+ assert service.cache_dir is None
111
+ os.chmod(ro_parent, 0o700) # restore for cleanup
112
+
113
+
114
+ @patch('app.services.tts._ensure_pocket_tts')
115
+ def test_resolve_voice_path_uses_active_model(_ensure, tmp_path, monkeypatch, mock_tts_model):
116
+ voices = tmp_path / 'voices'
117
+ voices.mkdir()
118
+ cache = tmp_path / 'voice_cache'
119
+ cache.mkdir()
120
+ (cache / 'emma.german_24l.safetensors').write_bytes(b'x')
121
+ monkeypatch.setattr('app.config.Config.VOICE_CACHE_DIR', str(cache))
122
+
123
+ service = TTSService()
124
+ service.set_voices_dir(str(voices))
125
+ with patch('app.services.tts.TTSModel') as MockModel:
126
+ MockModel.load_model.return_value = mock_tts_model
127
+ service.load_model(language='german_24l', quantize=False)
128
+
129
+ resolved = service._resolve_voice_path('emma')
130
+ assert resolved == str(cache / 'emma.german_24l.safetensors')
131
+
132
+
133
+ @patch('app.services.tts._ensure_pocket_tts')
134
+ def test_resolve_voice_path_accepts_filename_with_extension(
135
+ _ensure, tmp_path, monkeypatch, mock_tts_model
136
+ ):
137
+ """Backwards-compat: passing 'emma.wav' (with extension) should resolve to
138
+ the existing file, not get joined with another extension."""
139
+ voices = tmp_path / 'voices'
140
+ voices.mkdir()
141
+ cache = tmp_path / 'voice_cache'
142
+ cache.mkdir()
143
+ (voices / 'emma.wav').write_bytes(b'fake-audio')
144
+ monkeypatch.setattr('app.config.Config.VOICE_CACHE_DIR', str(cache))
145
+
146
+ service = TTSService()
147
+ service.set_voices_dir(str(voices))
148
+ with patch('app.services.tts.TTSModel') as MockModel:
149
+ MockModel.load_model.return_value = mock_tts_model
150
+ service.load_model(language='english', quantize=False)
151
+
152
+ resolved = service._resolve_voice_path('emma.wav')
153
+ assert resolved == str(voices / 'emma.wav')
154
+
155
+
156
+ @patch('app.services.tts._ensure_pocket_tts')
157
+ def test_reload_model_swaps_and_clears_voice_cache(_ensure, service, mock_tts_model):
158
+ with patch('app.services.tts.TTSModel') as MockModel:
159
+ MockModel.load_model.return_value = mock_tts_model
160
+ service.load_model(language='english', quantize=False)
161
+ service.voice_cache['stale'] = {'fake': 'state'}
162
+
163
+ new_model = MagicMock()
164
+ new_model.sample_rate = 24000
165
+ new_model.device = 'cpu'
166
+ MockModel.load_model.return_value = new_model
167
+ service.reload_model(language='german_24l', quantize=True)
168
+
169
+ assert service.model is new_model
170
+ assert service.voice_cache == {}
171
+ assert service._active['value'] == 'german_24l'
172
+ assert service._active['quantize'] is True
173
+ assert service._boot_active['value'] == 'english' # unchanged
174
+
175
+
176
+ @patch('app.services.tts._ensure_pocket_tts')
177
+ def test_reload_model_rejects_if_boot_used_model_path(_ensure, service, mock_tts_model):
178
+ with patch('app.services.tts.TTSModel') as MockModel:
179
+ MockModel.load_model.return_value = mock_tts_model
180
+ service.load_model(model_path='/custom/x.yaml', quantize=False)
181
+
182
+ with pytest.raises(RuntimeError, match='model_path'):
183
+ service.reload_model(language='german_24l', quantize=False)
184
+
185
+
186
+ @patch('app.services.tts._ensure_pocket_tts')
187
+ def test_reload_model_rejects_unknown_language(_ensure, service, mock_tts_model):
188
+ with patch('app.services.tts.TTSModel') as MockModel:
189
+ MockModel.load_model.return_value = mock_tts_model
190
+ service.load_model(language='english', quantize=False)
191
+
192
+ with pytest.raises(ValueError, match='klingon'):
193
+ service.reload_model(language='klingon', quantize=False)
194
+
195
+
196
+ @patch('app.services.tts._ensure_pocket_tts')
197
+ def test_reload_model_rejects_if_already_loading(_ensure, service, mock_tts_model):
198
+ with patch('app.services.tts.TTSModel') as MockModel:
199
+ MockModel.load_model.return_value = mock_tts_model
200
+ service.load_model(language='english', quantize=False)
201
+
202
+ service._loading = True
203
+ with pytest.raises(RuntimeError, match='already loading'):
204
+ service.reload_model(language='german_24l', quantize=False)
205
+
206
+
207
+ @patch('app.services.tts._ensure_pocket_tts')
208
+ def test_reload_model_async_returns_false_when_already_loading(_ensure, service, mock_tts_model):
209
+ """Async API uses an atomic check-and-set so concurrent callers can't both
210
+ win the claim. The loser gets False instead of an exception so the route
211
+ can map it directly to 409."""
212
+ with patch('app.services.tts.TTSModel') as MockModel:
213
+ MockModel.load_model.return_value = mock_tts_model
214
+ service.load_model(language='english', quantize=False)
215
+
216
+ service._loading = True
217
+ started = service.reload_model_async(language='german_24l', quantize=False)
218
+ assert started is False
219
+
220
+
221
+ @patch('app.services.tts._ensure_pocket_tts')
222
+ def test_reload_model_async_validation_still_raises(_ensure, service, mock_tts_model):
223
+ """Validation errors are still raised synchronously so the route can
224
+ return 400/403 instead of 409 — only the in-progress check is silent."""
225
+ with patch('app.services.tts.TTSModel') as MockModel:
226
+ MockModel.load_model.return_value = mock_tts_model
227
+ service.load_model(language='english', quantize=False)
228
+
229
+ with pytest.raises(ValueError, match='klingon'):
230
+ service.reload_model_async(language='klingon', quantize=False)
231
+
232
+
233
+ @patch('app.services.tts._ensure_pocket_tts')
234
+ def test_reload_model_restores_previous_on_failure(_ensure, service, mock_tts_model):
235
+ with patch('app.services.tts.TTSModel') as MockModel:
236
+ MockModel.load_model.return_value = mock_tts_model
237
+ service.load_model(language='english', quantize=False)
238
+ original_model = service.model
239
+
240
+ MockModel.load_model.side_effect = RuntimeError('weights corrupted')
241
+ with pytest.raises(RuntimeError, match='weights corrupted'):
242
+ service.reload_model(language='german_24l', quantize=False)
243
+
244
+ assert service.model is original_model
245
+ assert service._active['value'] == 'english'
246
+ assert service._loading is False
247
+
248
+
249
+ @patch('app.services.tts._ensure_pocket_tts')
250
+ def test_generate_audio_raises_when_loading(_ensure, service, mock_tts_model):
251
+ with patch('app.services.tts.TTSModel') as MockModel:
252
+ MockModel.load_model.return_value = mock_tts_model
253
+ service.load_model(language='english', quantize=False)
254
+ service._loading = True
255
+ with pytest.raises(RuntimeError, match='model reloading'):
256
+ service.generate_audio(voice_state={}, text='hi')
257
+
258
+
259
+ @patch('app.services.tts._ensure_pocket_tts')
260
+ def test_get_voice_state_saves_clone_to_cache(_ensure, tmp_path, monkeypatch, mock_tts_model):
261
+ voices = tmp_path / 'voices'
262
+ voices.mkdir()
263
+ cache = tmp_path / 'voice_cache'
264
+ (voices / 'emma.wav').write_bytes(b'fake-audio')
265
+ monkeypatch.setattr('app.config.Config.VOICE_CACHE_DIR', str(cache))
266
+
267
+ service = TTSService()
268
+ service.set_voices_dir(str(voices))
269
+ mock_tts_model.get_state_for_audio_prompt.return_value = {'fake': 'state'}
270
+ with patch('app.services.tts.TTSModel') as MockModel:
271
+ MockModel.load_model.return_value = mock_tts_model
272
+ service.load_model(language='english', quantize=False)
273
+
274
+ with patch('app.services.tts.export_model_state') as mock_export:
275
+ state = service.get_voice_state('emma')
276
+
277
+ assert state == {'fake': 'state'}
278
+ mock_export.assert_called_once()
279
+ # Called with state and expected cache path
280
+ args = mock_export.call_args.args
281
+ assert args[0] == {'fake': 'state'}
282
+ assert str(args[1]).endswith('emma.english_2026-04.safetensors')
283
+ assert (cache).is_dir()
284
+
285
+
286
+ @patch('app.services.tts._ensure_pocket_tts')
287
+ def test_get_voice_state_saves_clone_under_custom_model_path(
288
+ _ensure, tmp_path, monkeypatch, mock_tts_model
289
+ ):
290
+ """Issue #13: when started with --model-path "<full file path>", the cache
291
+ filename must not include the raw path. Otherwise safetensors raises a
292
+ serialization I/O error on Windows because `:` and `\\` are illegal in
293
+ filenames. The cache filename must be safe and the call must succeed."""
294
+ voices = tmp_path / 'voices'
295
+ voices.mkdir()
296
+ cache = tmp_path / 'voice_cache'
297
+ (voices / 'Aadi.wav').write_bytes(b'fake-audio')
298
+ monkeypatch.setattr('app.config.Config.VOICE_CACHE_DIR', str(cache))
299
+
300
+ service = TTSService()
301
+ service.set_voices_dir(str(voices))
302
+ mock_tts_model.get_state_for_audio_prompt.return_value = {'fake': 'state'}
303
+ with patch('app.services.tts.TTSModel') as MockModel:
304
+ MockModel.load_model.return_value = mock_tts_model
305
+ # Simulate the user starting the server with --model-path on Windows.
306
+ service.load_model(
307
+ model_path=r'C:\PocketTTS-Server\model\languages\english\english.yaml',
308
+ quantize=False,
309
+ )
310
+
311
+ with patch('app.services.tts.export_model_state') as mock_export:
312
+ state = service.get_voice_state('Aadi')
313
+
314
+ assert state == {'fake': 'state'}
315
+ mock_export.assert_called_once()
316
+ target_path = mock_export.call_args.args[1]
317
+ name = target_path.name if hasattr(target_path, 'name') else str(target_path).rsplit('/', 1)[-1]
318
+ # The filename must contain no characters disallowed in Windows filenames.
319
+ assert not any(c in name for c in r'\/:*?"<>|'), (
320
+ f'cache filename {name!r} contains path-illegal chars'
321
+ )
322
+ # Stem prefix preserved; suffix is .safetensors.
323
+ assert name.startswith('Aadi.')
324
+ assert name.endswith('.safetensors')
325
+
326
+
327
+ @patch('app.services.tts._ensure_pocket_tts')
328
+ def test_get_voice_state_tolerates_safetensor_serialize_error(
329
+ _ensure, tmp_path, monkeypatch, mock_tts_model
330
+ ):
331
+ """Issue #13: SafetensorError does not inherit from OSError, so a serialize
332
+ failure used to bubble up and break voice loading entirely. Cache writes
333
+ are best-effort: any failure must be logged and the voice must still load."""
334
+ from safetensors import SafetensorError
335
+
336
+ voices = tmp_path / 'voices'
337
+ voices.mkdir()
338
+ cache = tmp_path / 'voice_cache'
339
+ (voices / 'emma.wav').write_bytes(b'fake-audio')
340
+ monkeypatch.setattr('app.config.Config.VOICE_CACHE_DIR', str(cache))
341
+
342
+ service = TTSService()
343
+ service.set_voices_dir(str(voices))
344
+ mock_tts_model.get_state_for_audio_prompt.return_value = {'fake': 'state'}
345
+ with patch('app.services.tts.TTSModel') as MockModel:
346
+ MockModel.load_model.return_value = mock_tts_model
347
+ service.load_model(language='english', quantize=False)
348
+
349
+ with patch('app.services.tts.export_model_state') as mock_export:
350
+ mock_export.side_effect = SafetensorError('I/O error: simulated failure')
351
+ # Must NOT raise — caching is best-effort.
352
+ state = service.get_voice_state('emma')
353
+
354
+ assert state == {'fake': 'state'}
355
+
356
+
357
+ @patch('app.services.tts._ensure_pocket_tts')
358
+ def test_save_cloned_state_failure_logs_traceback(
359
+ _ensure, tmp_path, monkeypatch, mock_tts_model, caplog
360
+ ):
361
+ """Cache write failures must include the traceback in logs (via exc_info)
362
+ so the underlying cause stays diagnosable even though the warning is swallowed."""
363
+ import logging
364
+
365
+ from safetensors import SafetensorError
366
+
367
+ voices = tmp_path / 'voices'
368
+ voices.mkdir()
369
+ cache = tmp_path / 'voice_cache'
370
+ (voices / 'emma.wav').write_bytes(b'fake-audio')
371
+ monkeypatch.setattr('app.config.Config.VOICE_CACHE_DIR', str(cache))
372
+
373
+ service = TTSService()
374
+ service.set_voices_dir(str(voices))
375
+ mock_tts_model.get_state_for_audio_prompt.return_value = {'fake': 'state'}
376
+ with patch('app.services.tts.TTSModel') as MockModel:
377
+ MockModel.load_model.return_value = mock_tts_model
378
+ service.load_model(language='english', quantize=False)
379
+
380
+ with patch('app.services.tts.export_model_state') as mock_export:
381
+ mock_export.side_effect = SafetensorError('I/O error: simulated failure')
382
+ with caplog.at_level(logging.WARNING, logger='PocketTTS.tts'):
383
+ service.get_voice_state('emma')
384
+
385
+ cache_warnings = [r for r in caplog.records if 'Could not save voice cache' in r.message]
386
+ assert cache_warnings, 'expected a warning about the failed cache save'
387
+ # exc_info must be set so the traceback is captured.
388
+ assert cache_warnings[0].exc_info is not None
389
+
390
+
391
+ @patch('app.services.tts._ensure_pocket_tts')
392
+ def test_get_voice_state_regenerates_when_source_newer(
393
+ _ensure, tmp_path, monkeypatch, mock_tts_model
394
+ ):
395
+ import os
396
+ import time
397
+
398
+ voices = tmp_path / 'voices'
399
+ voices.mkdir()
400
+ cache = tmp_path / 'voice_cache'
401
+ cache.mkdir()
402
+ stale = cache / 'emma.english_2026-04.safetensors'
403
+ stale.write_bytes(b'old')
404
+ os.utime(stale, (time.time() - 100, time.time() - 100))
405
+ (voices / 'emma.wav').write_bytes(b'new-audio') # mtime = now
406
+ monkeypatch.setattr('app.config.Config.VOICE_CACHE_DIR', str(cache))
407
+
408
+ service = TTSService()
409
+ service.set_voices_dir(str(voices))
410
+ mock_tts_model.get_state_for_audio_prompt.return_value = {'fresh': 'state'}
411
+ with patch('app.services.tts.TTSModel') as MockModel:
412
+ MockModel.load_model.return_value = mock_tts_model
413
+ service.load_model(language='english', quantize=False)
414
+
415
+ with patch('app.services.tts.export_model_state') as mock_export:
416
+ state = service.get_voice_state('emma')
417
+
418
+ assert state == {'fresh': 'state'}
419
+ # Should have called get_state_for_audio_prompt with the .wav file, not the stale cache
420
+ call_arg = mock_tts_model.get_state_for_audio_prompt.call_args.args[0]
421
+ assert str(call_arg).endswith('emma.wav')
422
+ mock_export.assert_called_once()
423
+
424
+
425
+ @patch('app.services.tts._ensure_pocket_tts')
426
+ def test_get_voice_state_stem_with_dot_regenerates_correctly(
427
+ _ensure, tmp_path, monkeypatch, mock_tts_model
428
+ ):
429
+ """A voice stem containing dots (e.g. 'John.Doe') must be parsed via the
430
+ known_model_tags helper so the staleness check finds the right source
431
+ file. Naive split('.', 1)[0] would yield 'John' and miss 'John.Doe.wav'."""
432
+ import os
433
+ import time
434
+
435
+ voices = tmp_path / 'voices'
436
+ voices.mkdir()
437
+ cache = tmp_path / 'voice_cache'
438
+ cache.mkdir()
439
+ stale = cache / 'John.Doe.english_2026-04.safetensors'
440
+ stale.write_bytes(b'old')
441
+ os.utime(stale, (time.time() - 100, time.time() - 100))
442
+ (voices / 'John.Doe.wav').write_bytes(b'new-audio')
443
+ monkeypatch.setattr('app.config.Config.VOICE_CACHE_DIR', str(cache))
444
+
445
+ service = TTSService()
446
+ service.set_voices_dir(str(voices))
447
+ mock_tts_model.get_state_for_audio_prompt.return_value = {'fresh': 'state'}
448
+ with patch('app.services.tts.TTSModel') as MockModel:
449
+ MockModel.load_model.return_value = mock_tts_model
450
+ service.load_model(language='english', quantize=False)
451
+
452
+ with patch('app.services.tts.export_model_state'):
453
+ service.get_voice_state('John.Doe')
454
+
455
+ call_arg = mock_tts_model.get_state_for_audio_prompt.call_args.args[0]
456
+ assert str(call_arg).endswith('John.Doe.wav')
457
+
458
+
459
+ @patch('app.services.tts._ensure_pocket_tts')
460
+ def test_list_voices_collapses_per_stem(_ensure, tmp_path, monkeypatch, mock_tts_model):
461
+ voices = tmp_path / 'voices'
462
+ voices.mkdir()
463
+ cache = tmp_path / 'voice_cache'
464
+ cache.mkdir()
465
+ (voices / 'emma.wav').write_bytes(b'a')
466
+ (voices / 'emma.safetensors').write_bytes(b'a')
467
+ (cache / 'emma.english_2026-04.safetensors').write_bytes(b'a')
468
+ (cache / 'emma.german_24l.safetensors').write_bytes(b'a')
469
+ (voices / 'morgan.mp3').write_bytes(b'a')
470
+ monkeypatch.setattr('app.config.Config.VOICE_CACHE_DIR', str(cache))
471
+
472
+ service = TTSService()
473
+ service.set_voices_dir(str(voices))
474
+ with patch('app.services.tts.TTSModel') as MockModel:
475
+ MockModel.load_model.return_value = mock_tts_model
476
+ service.load_model(language='english', quantize=False)
477
+
478
+ voices_list = service.list_voices()
479
+ custom_ids = [v['id'] for v in voices_list if v['type'] == 'custom']
480
+ assert custom_ids == ['emma', 'morgan']
tests/test_versions.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for version helpers."""
2
+
3
+ from unittest.mock import patch
4
+
5
+ from app.services.versions import get_versions
6
+
7
+
8
+ def test_get_versions_returns_both():
9
+ result = get_versions()
10
+ assert 'server' in result
11
+ assert 'pocket_tts' in result
12
+
13
+
14
+ def test_get_versions_handles_missing_package():
15
+ """When importlib metadata is unavailable, server falls back to the
16
+ hardcoded `app.__version__` constant; pocket_tts has no such fallback
17
+ and reports 'unknown'."""
18
+ from importlib.metadata import PackageNotFoundError
19
+
20
+ from app import __version__ as server_fallback
21
+
22
+ get_versions.cache_clear()
23
+ with patch('app.services.versions.version', side_effect=PackageNotFoundError):
24
+ result = get_versions()
25
+ assert result == {'server': server_fallback, 'pocket_tts': 'unknown'}
tests/test_voice_cache.py ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for voice cache filename parsing and resolution."""
2
+
3
+ from app.services.voice_cache import (
4
+ active_model_tag,
5
+ cache_is_stale,
6
+ known_model_tags,
7
+ list_voice_stems,
8
+ parse_safetensors_name,
9
+ resolve_voice_path,
10
+ )
11
+
12
+
13
+ def test_active_model_tag_returns_raw_when_not_aliased():
14
+ assert active_model_tag('german_24l') == 'german_24l'
15
+
16
+
17
+ def test_active_model_tag_canonicalizes_english_alias():
18
+ assert active_model_tag('english') == 'english_2026-04'
19
+
20
+
21
+ def test_active_model_tag_canonicalizes_english_2026_01():
22
+ assert active_model_tag('english_2026-01') == 'english_2026-04'
23
+
24
+
25
+ def test_active_model_tag_handles_windows_path():
26
+ """A custom --model-path value (full Windows file path) must produce a
27
+ filename-safe tag — not a literal path with `:` and `\\` that breaks
28
+ safetensors serialization on Windows (issue #13)."""
29
+ tag = active_model_tag(r'C:\PocketTTS-Server\model\languages\english\english.yaml')
30
+ # Tag must contain no characters disallowed in Windows filenames.
31
+ assert not any(c in tag for c in r'\/:*?"<>|')
32
+ # Stem-derived: english.yaml -> english is the natural choice.
33
+ assert tag == 'english'
34
+
35
+
36
+ def test_active_model_tag_handles_posix_path():
37
+ """A POSIX absolute path to a custom model yaml should also produce a
38
+ filename-safe tag derived from the file stem."""
39
+ tag = active_model_tag('/opt/pocket-tts/models/german_24l.yaml')
40
+ assert '/' not in tag
41
+ assert tag == 'german_24l'
42
+
43
+
44
+ def test_active_model_tag_idempotent_for_plain_language():
45
+ """Plain language identifiers must be unaffected by sanitization so the
46
+ existing tag/cache lookup logic continues to work for built-in models."""
47
+ assert active_model_tag('german_24l') == 'german_24l'
48
+ assert active_model_tag('french_24l') == 'french_24l'
49
+
50
+
51
+ def test_active_model_tag_strips_dots_from_derived_tag():
52
+ """A model path like 'english.v2.yaml' must NOT yield a tag with internal
53
+ dots — `parse_safetensors_name` uses `.` to split <stem>.<tag>.safetensors,
54
+ so a dotted tag would be mis-parsed and break voice listing."""
55
+ tag = active_model_tag('/opt/models/english.v2.yaml')
56
+ assert '.' not in tag
57
+
58
+
59
+ def test_active_model_tag_handles_bundled_hash_filename():
60
+ """The bundled model is named like 'b6369a24.yaml' — the derived tag
61
+ should be the hex stem, no dots."""
62
+ tag = active_model_tag('/app/model/b6369a24.yaml')
63
+ assert tag == 'b6369a24'
64
+
65
+
66
+ def test_known_model_tags_includes_all_supported_plus_alias_targets():
67
+ tags = known_model_tags()
68
+ assert 'english_2026-04' in tags
69
+ assert 'german_24l' in tags
70
+ assert 'french_24l' in tags
71
+ # Alias keys are also included so users can name files with them.
72
+ assert 'english' in tags
73
+
74
+
75
+ def test_parse_safetensors_name_recognizes_tagged_file():
76
+ tags = known_model_tags()
77
+ stem, tag = parse_safetensors_name('Emma Watson.english_2026-04.safetensors', tags)
78
+ assert stem == 'Emma Watson'
79
+ assert tag == 'english_2026-04'
80
+
81
+
82
+ def test_parse_safetensors_name_legacy_unlabeled():
83
+ tags = known_model_tags()
84
+ stem, tag = parse_safetensors_name('legacy.safetensors', tags)
85
+ assert stem == 'legacy'
86
+ assert tag is None
87
+
88
+
89
+ def test_parse_safetensors_name_dot_in_stem_but_unknown_tag():
90
+ """A filename like 'my.voice.safetensors' has 'voice' as the final segment —
91
+ but 'voice' isn't a known tag, so we treat the whole thing as the stem."""
92
+ tags = known_model_tags()
93
+ stem, tag = parse_safetensors_name('my.voice.safetensors', tags)
94
+ assert stem == 'my.voice'
95
+ assert tag is None
96
+
97
+
98
+ def test_resolve_voice_path_prefers_tagged_cache(tmp_voices, tmp_cache):
99
+ """Preference: cache_dir tagged > voices_dir tagged > raw audio > legacy."""
100
+ (tmp_voices / 'emma.wav').write_bytes(b'fake-audio')
101
+ (tmp_cache / 'emma.english_2026-04.safetensors').write_bytes(b'fake-st')
102
+
103
+ result = resolve_voice_path(
104
+ 'emma',
105
+ active_model='english_2026-04',
106
+ voices_dir=tmp_voices,
107
+ cache_dir=tmp_cache,
108
+ )
109
+ assert result == tmp_cache / 'emma.english_2026-04.safetensors'
110
+
111
+
112
+ def test_resolve_voice_path_falls_back_to_raw_audio(tmp_voices, tmp_cache):
113
+ (tmp_voices / 'emma.wav').write_bytes(b'fake-audio')
114
+ result = resolve_voice_path(
115
+ 'emma',
116
+ active_model='english_2026-04',
117
+ voices_dir=tmp_voices,
118
+ cache_dir=tmp_cache,
119
+ )
120
+ assert result == tmp_voices / 'emma.wav'
121
+
122
+
123
+ def test_resolve_voice_path_legacy_unlabeled(tmp_voices, tmp_cache):
124
+ (tmp_voices / 'emma.safetensors').write_bytes(b'fake-st')
125
+ result = resolve_voice_path(
126
+ 'emma',
127
+ active_model='english_2026-04',
128
+ voices_dir=tmp_voices,
129
+ cache_dir=tmp_cache,
130
+ )
131
+ assert result == tmp_voices / 'emma.safetensors'
132
+
133
+
134
+ def test_resolve_voice_path_passthrough_for_builtin_name(tmp_voices, tmp_cache):
135
+ """Built-in names (no matching file anywhere) pass through untouched —
136
+ pocket-tts handles them via HuggingFace."""
137
+ result = resolve_voice_path(
138
+ 'alba',
139
+ active_model='english_2026-04',
140
+ voices_dir=tmp_voices,
141
+ cache_dir=tmp_cache,
142
+ )
143
+ assert result == 'alba'
144
+
145
+
146
+ def test_resolve_voice_path_respects_alias(tmp_voices, tmp_cache):
147
+ """Asking for 'english' should find a cache tagged 'english_2026-04'."""
148
+ (tmp_cache / 'emma.english_2026-04.safetensors').write_bytes(b'fake-st')
149
+ result = resolve_voice_path(
150
+ 'emma',
151
+ active_model='english', # alias
152
+ voices_dir=tmp_voices,
153
+ cache_dir=tmp_cache,
154
+ )
155
+ assert result == tmp_cache / 'emma.english_2026-04.safetensors'
156
+
157
+
158
+ def test_resolve_voice_path_finds_alias_tagged_file(tmp_voices, tmp_cache):
159
+ """Files tagged with the alias itself (e.g. emma.english.safetensors) should
160
+ resolve when canonical-tagged file is absent — supports caches written by
161
+ older versions or by external tools using the alias."""
162
+ (tmp_cache / 'emma.english.safetensors').write_bytes(b'fake-st')
163
+ result = resolve_voice_path(
164
+ 'emma',
165
+ active_model='english',
166
+ voices_dir=tmp_voices,
167
+ cache_dir=tmp_cache,
168
+ )
169
+ assert result == tmp_cache / 'emma.english.safetensors'
170
+
171
+
172
+ def test_resolve_voice_path_canonical_preferred_over_alias(tmp_voices, tmp_cache):
173
+ """When both canonical and alias-tagged files exist, prefer canonical."""
174
+ (tmp_cache / 'emma.english.safetensors').write_bytes(b'fake-st')
175
+ (tmp_cache / 'emma.english_2026-04.safetensors').write_bytes(b'fake-st')
176
+ result = resolve_voice_path(
177
+ 'emma',
178
+ active_model='english',
179
+ voices_dir=tmp_voices,
180
+ cache_dir=tmp_cache,
181
+ )
182
+ assert result == tmp_cache / 'emma.english_2026-04.safetensors'
183
+
184
+
185
+ def test_resolve_voice_path_voices_dir_tagged_cache(tmp_voices, tmp_cache):
186
+ """A tagged cache dropped directly into voices_dir (e.g. by WingmanAI) is honored."""
187
+ (tmp_voices / 'emma.german_24l.safetensors').write_bytes(b'fake-st')
188
+ result = resolve_voice_path(
189
+ 'emma',
190
+ active_model='german_24l',
191
+ voices_dir=tmp_voices,
192
+ cache_dir=tmp_cache,
193
+ )
194
+ assert result == tmp_voices / 'emma.german_24l.safetensors'
195
+
196
+
197
+ def test_list_voice_stems_collapses_duplicates(tmp_voices, tmp_cache):
198
+ (tmp_voices / 'emma.wav').write_bytes(b'a')
199
+ (tmp_voices / 'emma.safetensors').write_bytes(b'a')
200
+ (tmp_cache / 'emma.english_2026-04.safetensors').write_bytes(b'a')
201
+ (tmp_cache / 'emma.german_24l.safetensors').write_bytes(b'a')
202
+ (tmp_voices / 'morgan.mp3').write_bytes(b'a')
203
+
204
+ stems = list_voice_stems(voices_dir=tmp_voices, cache_dir=tmp_cache)
205
+ assert stems == ['emma', 'morgan']
206
+
207
+
208
+ def test_list_voice_stems_empty(tmp_voices, tmp_cache):
209
+ assert list_voice_stems(voices_dir=tmp_voices, cache_dir=tmp_cache) == []
210
+
211
+
212
+ def test_list_voice_stems_ignores_unknown_extensions(tmp_voices, tmp_cache):
213
+ (tmp_voices / 'notes.txt').write_bytes(b'a')
214
+ (tmp_voices / 'emma.wav').write_bytes(b'a')
215
+ stems = list_voice_stems(voices_dir=tmp_voices, cache_dir=tmp_cache)
216
+ assert stems == ['emma']
217
+
218
+
219
+ def test_cache_is_stale_true_when_source_newer(tmp_voices, tmp_cache):
220
+ import os
221
+ import time
222
+
223
+ cache = tmp_cache / 'emma.english_2026-04.safetensors'
224
+ cache.write_bytes(b'old')
225
+ old_time = time.time() - 100
226
+ os.utime(cache, (old_time, old_time))
227
+
228
+ source = tmp_voices / 'emma.wav'
229
+ source.write_bytes(b'new') # mtime = now
230
+
231
+ assert cache_is_stale(cache_path=cache, source_path=source) is True
232
+
233
+
234
+ def test_cache_is_stale_false_when_cache_newer(tmp_voices, tmp_cache):
235
+ source = tmp_voices / 'emma.wav'
236
+ source.write_bytes(b'old')
237
+ import os
238
+ import time
239
+
240
+ old_time = time.time() - 100
241
+ os.utime(source, (old_time, old_time))
242
+
243
+ cache = tmp_cache / 'emma.english_2026-04.safetensors'
244
+ cache.write_bytes(b'new')
245
+
246
+ assert cache_is_stale(cache_path=cache, source_path=source) is False
247
+
248
+
249
+ def test_cache_is_stale_false_when_source_missing(tmp_voices, tmp_cache):
250
+ cache = tmp_cache / 'emma.english_2026-04.safetensors'
251
+ cache.write_bytes(b'cached')
252
+ source = tmp_voices / 'emma.wav' # does not exist
253
+ assert cache_is_stale(cache_path=cache, source_path=source) is False
voices/ASEN.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7cc6cbc8c0dc6f944f6c72ae301e3a20075fb5181b728bfc414d22b28cb06890
3
+ size 480044
voices/Aadi.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4b81d4149c141e8e5216bf74a755cad6abccc646160c7443795344495c12ce4c
3
+ size 480044
voices/AbD.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0dbb703b19267351ce09cdd8a67c35fb55b549f8dc7e2e30358dc36803fb690b
3
+ size 480044
voices/Abhinox.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9747a6e3155e27bbec29ae21437dbcba70d83d048c5db39c5ff219b30cb7bf6e
3
+ size 480044
voices/Abo_Ayman.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4f5ea86fae2c312b63f0e1ab59ef52226206ee120c3af11d8ed09b499ee6d869
3
+ size 480044
voices/Abob_Malay.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:79077f568f1cb6abb40be58b40da9aa53ec99f3ec2a958c4e118a68efd5f7683
3
+ size 480044
voices/AgentCobra.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8038cabc8ba8f4430d3cfe09fb88281d400ebed07ebed8336b14eed78dca7428
3
+ size 480044