Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +44 -0
- .env.example +27 -0
- .gitattributes +157 -0
- .vscode/extensions.json +7 -0
- .vscode/launch.json +55 -0
- .vscode/settings.json +11 -0
- AGENTS.md +111 -0
- Dockerfile +76 -0
- LICENSE +21 -0
- PocketTTS-Server.desktop +8 -0
- README.md +324 -10
- app/__init__.py +90 -0
- app/config.py +131 -0
- app/logging_config.py +77 -0
- app/routes.py +340 -0
- app/services/__init__.py +5 -0
- app/services/audio.py +181 -0
- app/services/preprocess.py +1093 -0
- app/services/tts.py +516 -0
- app/services/versions.py +26 -0
- app/services/voice_cache.py +146 -0
- docker-compose.yml +74 -0
- logs/.gitkeep +1 -0
- pocket-tts-logo.ico +3 -0
- pyproject.toml +40 -0
- pytest.ini +4 -0
- requirements-dev.txt +10 -0
- requirements.txt +15 -0
- run_pocket_tts_server.bat +102 -0
- run_pocket_tts_server.sh +132 -0
- run_pocket_tts_server_exe.bat +100 -0
- server.py +165 -0
- static/css/style.css +819 -0
- static/images/pocket-tts-logo.png +3 -0
- static/js/app.js +680 -0
- templates/index.html +440 -0
- tests/__init__.py +0 -0
- tests/conftest.py +41 -0
- tests/test_config.py +44 -0
- tests/test_routes.py +196 -0
- tests/test_tts_service.py +480 -0
- tests/test_versions.py +25 -0
- tests/test_voice_cache.py +253 -0
- voices/ASEN.wav +3 -0
- voices/Aadi.wav +3 -0
- voices/AbD.wav +3 -0
- voices/Abhinox.wav +3 -0
- voices/Abo_Ayman.wav +3 -0
- voices/Abob_Malay.wav +3 -0
- voices/AgentCobra.wav +3 -0
.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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
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
|
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
|