teddybear082 commited on
Commit ·
aec4421
0
Parent(s):
Initial commit
Browse files- .gitattributes +2 -0
- .gitignore +181 -0
- LICENSE +21 -0
- README.md +2 -0
- pocket-tts-generate-docs.md +83 -0
- pocket-tts-python-api.md +223 -0
- pocket_tts_openai_server.py +250 -0
- project specifications.txt +62 -0
- requirements.txt +6 -0
- run_pocket_tts_server.bat +84 -0
- static/css/style.css +221 -0
- static/js/app.js +106 -0
- templates/index.html +61 -0
- test_server.py +79 -0
- utils.py +88 -0
.gitattributes
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Auto detect text files and perform LF normalization
|
| 2 |
+
* text=auto
|
.gitignore
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Byte-compiled / optimized / DLL files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
|
| 6 |
+
# C extensions
|
| 7 |
+
*.so
|
| 8 |
+
|
| 9 |
+
# Distribution / packaging
|
| 10 |
+
.Python
|
| 11 |
+
build/
|
| 12 |
+
develop-eggs/
|
| 13 |
+
dist/
|
| 14 |
+
downloads/
|
| 15 |
+
eggs/
|
| 16 |
+
.eggs/
|
| 17 |
+
lib/
|
| 18 |
+
lib64/
|
| 19 |
+
parts/
|
| 20 |
+
sdist/
|
| 21 |
+
var/
|
| 22 |
+
wheels/
|
| 23 |
+
share/python-wheels/
|
| 24 |
+
*.egg-info/
|
| 25 |
+
.installed.cfg
|
| 26 |
+
*.egg
|
| 27 |
+
MANIFEST
|
| 28 |
+
model/
|
| 29 |
+
# PyInstaller
|
| 30 |
+
# Usually these files are written by a python script from a template
|
| 31 |
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
| 32 |
+
*.manifest
|
| 33 |
+
*.spec
|
| 34 |
+
|
| 35 |
+
# Installer logs
|
| 36 |
+
pip-log.txt
|
| 37 |
+
pip-delete-this-directory.txt
|
| 38 |
+
|
| 39 |
+
# Unit test / coverage reports
|
| 40 |
+
htmlcov/
|
| 41 |
+
.tox/
|
| 42 |
+
.nox/
|
| 43 |
+
.coverage
|
| 44 |
+
.coverage.*
|
| 45 |
+
.cache
|
| 46 |
+
nosetests.xml
|
| 47 |
+
coverage.xml
|
| 48 |
+
*.cover
|
| 49 |
+
*.py,cover
|
| 50 |
+
.hypothesis/
|
| 51 |
+
.pytest_cache/
|
| 52 |
+
cover/
|
| 53 |
+
|
| 54 |
+
# Translations
|
| 55 |
+
*.mo
|
| 56 |
+
*.pot
|
| 57 |
+
|
| 58 |
+
# Django stuff:
|
| 59 |
+
*.log
|
| 60 |
+
local_settings.py
|
| 61 |
+
db.sqlite3
|
| 62 |
+
db.sqlite3-journal
|
| 63 |
+
|
| 64 |
+
# Flask stuff:
|
| 65 |
+
instance/
|
| 66 |
+
.webassets-cache
|
| 67 |
+
|
| 68 |
+
# Scrapy stuff:
|
| 69 |
+
.scrapy
|
| 70 |
+
|
| 71 |
+
# Sphinx documentation
|
| 72 |
+
docs/_build/
|
| 73 |
+
|
| 74 |
+
# PyBuilder
|
| 75 |
+
.pybuilder/
|
| 76 |
+
target/
|
| 77 |
+
|
| 78 |
+
# Jupyter Notebook
|
| 79 |
+
.ipynb_checkpoints
|
| 80 |
+
|
| 81 |
+
# IPython
|
| 82 |
+
profile_default/
|
| 83 |
+
ipython_config.py
|
| 84 |
+
|
| 85 |
+
# pyenv
|
| 86 |
+
# For a library or package, you might want to ignore these files since the code is
|
| 87 |
+
# intended to run in multiple environments; otherwise, check them in:
|
| 88 |
+
# .python-version
|
| 89 |
+
|
| 90 |
+
# pipenv
|
| 91 |
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
| 92 |
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
| 93 |
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
| 94 |
+
# install all needed dependencies.
|
| 95 |
+
#Pipfile.lock
|
| 96 |
+
|
| 97 |
+
# UV
|
| 98 |
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
| 99 |
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
| 100 |
+
# commonly ignored for libraries.
|
| 101 |
+
#uv.lock
|
| 102 |
+
|
| 103 |
+
# poetry
|
| 104 |
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
| 105 |
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
| 106 |
+
# commonly ignored for libraries.
|
| 107 |
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
| 108 |
+
#poetry.lock
|
| 109 |
+
|
| 110 |
+
# pdm
|
| 111 |
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
| 112 |
+
#pdm.lock
|
| 113 |
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
| 114 |
+
# in version control.
|
| 115 |
+
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
| 116 |
+
.pdm.toml
|
| 117 |
+
.pdm-python
|
| 118 |
+
.pdm-build/
|
| 119 |
+
|
| 120 |
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
| 121 |
+
__pypackages__/
|
| 122 |
+
|
| 123 |
+
# Celery stuff
|
| 124 |
+
celerybeat-schedule
|
| 125 |
+
celerybeat.pid
|
| 126 |
+
|
| 127 |
+
# SageMath parsed files
|
| 128 |
+
*.sage.py
|
| 129 |
+
|
| 130 |
+
# Environments
|
| 131 |
+
.env
|
| 132 |
+
.venv
|
| 133 |
+
env/
|
| 134 |
+
venv/
|
| 135 |
+
ENV/
|
| 136 |
+
env.bak/
|
| 137 |
+
venv.bak/
|
| 138 |
+
|
| 139 |
+
# Spyder project settings
|
| 140 |
+
.spyderproject
|
| 141 |
+
.spyproject
|
| 142 |
+
|
| 143 |
+
# Rope project settings
|
| 144 |
+
.ropeproject
|
| 145 |
+
|
| 146 |
+
# mkdocs documentation
|
| 147 |
+
/site
|
| 148 |
+
|
| 149 |
+
# mypy
|
| 150 |
+
.mypy_cache/
|
| 151 |
+
.dmypy.json
|
| 152 |
+
dmypy.json
|
| 153 |
+
|
| 154 |
+
# Pyre type checker
|
| 155 |
+
.pyre/
|
| 156 |
+
|
| 157 |
+
# pytype static type analyzer
|
| 158 |
+
.pytype/
|
| 159 |
+
|
| 160 |
+
# Cython debug symbols
|
| 161 |
+
cython_debug/
|
| 162 |
+
|
| 163 |
+
# PyCharm
|
| 164 |
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
| 165 |
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
| 166 |
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
| 167 |
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
| 168 |
+
#.idea/
|
| 169 |
+
|
| 170 |
+
# Ruff stuff:
|
| 171 |
+
.ruff_cache/
|
| 172 |
+
|
| 173 |
+
# PyPI configuration file
|
| 174 |
+
.pypirc
|
| 175 |
+
|
| 176 |
+
# Cursor
|
| 177 |
+
# Cursor is an AI-powered code editor.`.cursorignore` specifies files/directories to
|
| 178 |
+
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
| 179 |
+
# refer to https://docs.cursor.com/context/ignore-files
|
| 180 |
+
.cursorignore
|
| 181 |
+
.cursorindexingignore
|
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.
|
README.md
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# pocket-tts-openai_streaming_server
|
| 2 |
+
OpenAI-compatible TTS Server for PocketTTS
|
pocket-tts-generate-docs.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Generate Command Documentation
|
| 2 |
+
|
| 3 |
+
The `generate` command allows you to generate speech from text directly from the command line using Kyutai Pocket TTS.
|
| 4 |
+
|
| 5 |
+
## Basic Usage
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
uvx pocket-tts generate
|
| 9 |
+
# or if installed manually:
|
| 10 |
+
pocket-tts generate
|
| 11 |
+
```
|
| 12 |
+
|
| 13 |
+
This will generate a WAV file `./tts_output.wav` with the default text and voice.
|
| 14 |
+
|
| 15 |
+
## Command Options
|
| 16 |
+
|
| 17 |
+
### Core Options
|
| 18 |
+
|
| 19 |
+
- `--text TEXT`: Text to generate (default: "Hello world! I am Kyutai Pocket TTS. I'm fast enough to run on small CPUs. I hope you'll like me.")
|
| 20 |
+
- `--voice VOICE`: Path to audio conditioning file (voice to clone) (default: "hf://kyutai/tts-voices/alba-mackenna/casual.wav"). Urls and local paths are supported.
|
| 21 |
+
- `--output-path OUTPUT_PATH`: Output path for generated audio (default: "./tts_output.wav")
|
| 22 |
+
|
| 23 |
+
### Generation Parameters
|
| 24 |
+
|
| 25 |
+
- `--variant VARIANT`: Model signature (default: "b6369a24")
|
| 26 |
+
- `--lsd-decode-steps LSD_DECODE_STEPS`: Number of generation steps (default: 1)
|
| 27 |
+
- `--temperature TEMPERATURE`: Temperature for generation (default: 0.7)
|
| 28 |
+
- `--noise-clamp NOISE_CLAMP`: Noise clamp value (default: None)
|
| 29 |
+
- `--eos-threshold EOS_THRESHOLD`: EOS threshold (default: -4.0)
|
| 30 |
+
- `--frames-after-eos FRAMES_AFTER_EOS`: Number of frames to generate after EOS (default: None, auto-calculated based on the text length). Each frame is 80ms.
|
| 31 |
+
|
| 32 |
+
### Performance Options
|
| 33 |
+
|
| 34 |
+
- `--device DEVICE`: Device to use (default: "cpu", you may not get a speedup by using a gpu since it's a small model)
|
| 35 |
+
- `--quiet`, `-q`: Disable logging output
|
| 36 |
+
|
| 37 |
+
## Examples
|
| 38 |
+
|
| 39 |
+
### Basic Generation
|
| 40 |
+
|
| 41 |
+
```bash
|
| 42 |
+
# Generate with default settings
|
| 43 |
+
pocket-tts generate
|
| 44 |
+
|
| 45 |
+
# Custom text
|
| 46 |
+
pocket-tts generate --text "Hello, this is a custom message."
|
| 47 |
+
|
| 48 |
+
# Custom output path
|
| 49 |
+
pocket-tts generate --output-path "./my_audio.wav"
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
### Voice Selection
|
| 53 |
+
|
| 54 |
+
```bash
|
| 55 |
+
# Use different voice from HuggingFace
|
| 56 |
+
pocket-tts generate --voice "hf://kyutai/tts-voices/jessica-jian/casual.wav"
|
| 57 |
+
|
| 58 |
+
# Use local voice file
|
| 59 |
+
pocket-tts generate --voice "./my_voice.wav"
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
### Quality Tuning
|
| 63 |
+
|
| 64 |
+
```bash
|
| 65 |
+
# Higher quality (more steps)
|
| 66 |
+
pocket-tts generate --lsd-decode-steps 5 --temperature 0.5
|
| 67 |
+
|
| 68 |
+
# More expressive (higher temperature)
|
| 69 |
+
pocket-tts generate --temperature 1.0
|
| 70 |
+
|
| 71 |
+
# Adjust EOS threshold, smaller means finishing earlier.
|
| 72 |
+
pocket-tts generate --eos-threshold -3.0
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
## Output Format
|
| 76 |
+
|
| 77 |
+
The generate command always outputs WAV files in the following format:
|
| 78 |
+
- **Sample Rate**: 24kHz
|
| 79 |
+
- **Channels**: Mono
|
| 80 |
+
- **Bit Depth**: 16-bit PCM
|
| 81 |
+
- **Format**: Standard WAV file
|
| 82 |
+
|
| 83 |
+
For more advanced usage, see the [Python API documentation](python-api.md) or consider using the [serve command](serve.md) for web-based generation and quick iteration.
|
pocket-tts-python-api.md
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python API Documentation
|
| 2 |
+
|
| 3 |
+
Kyutai Pocket TTS provides a Python API for integrating text-to-speech capabilities into your applications.
|
| 4 |
+
|
| 5 |
+
## Installation
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
pip install pocket-tts
|
| 9 |
+
```
|
| 10 |
+
|
| 11 |
+
## Quick Start
|
| 12 |
+
|
| 13 |
+
```python
|
| 14 |
+
from pocket_tts import TTSModel
|
| 15 |
+
import scipy.io.wavfile
|
| 16 |
+
|
| 17 |
+
# Load the model
|
| 18 |
+
tts_model = TTSModel.load_model()
|
| 19 |
+
|
| 20 |
+
# Get voice state from an audio file
|
| 21 |
+
voice_state = tts_model.get_state_for_audio_prompt(
|
| 22 |
+
"hf://kyutai/tts-voices/alba-mackenna/casual.wav"
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
# Generate audio
|
| 26 |
+
audio = tts_model.generate_audio(voice_state, "Hello world, this is a test.")
|
| 27 |
+
|
| 28 |
+
# Save to file
|
| 29 |
+
scipy.io.wavfile.write("output.wav", tts_model.sample_rate, audio.numpy())
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
## Core Classes
|
| 33 |
+
|
| 34 |
+
### TTSModel
|
| 35 |
+
|
| 36 |
+
The main class for text-to-speech generation.
|
| 37 |
+
|
| 38 |
+
#### Class Methods
|
| 39 |
+
|
| 40 |
+
##### `load_model(variant="b6369a24", temp=0.7, lsd_decode_steps=1, noise_clamp=None, eos_threshold=-4.0)`
|
| 41 |
+
|
| 42 |
+
Load and return a TTSModel instance with pre-trained weights.
|
| 43 |
+
|
| 44 |
+
**Parameters:**
|
| 45 |
+
- `variant` (str): Model variant identifier (default: "b6369a24")
|
| 46 |
+
- `temp` (float): Sampling temperature for generation (default: 0.7)
|
| 47 |
+
- `lsd_decode_steps` (int): Number of generation steps (default: 1)
|
| 48 |
+
- `noise_clamp` (float | None): Maximum value for noise sampling (default: None)
|
| 49 |
+
- `eos_threshold` (float): Threshold for end-of-sequence detection (default: -4.0)
|
| 50 |
+
|
| 51 |
+
**Returns:**
|
| 52 |
+
- `TTSModel`: Loaded model instance on CPU
|
| 53 |
+
|
| 54 |
+
**Example:**
|
| 55 |
+
```python
|
| 56 |
+
from pocket_tts import TTSModel
|
| 57 |
+
|
| 58 |
+
# Load with default settings
|
| 59 |
+
model = TTSModel.load_model()
|
| 60 |
+
|
| 61 |
+
# Load with custom parameters
|
| 62 |
+
model = TTSModel.load_model(variant="b6369a24", temp=0.5, lsd_decode_steps=5, eos_threshold=-3.0)
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
#### Properties
|
| 66 |
+
|
| 67 |
+
##### `device` (str)
|
| 68 |
+
|
| 69 |
+
Returns the device type where the model is running ("cpu" or "cuda").
|
| 70 |
+
By default, the model runs on CPU.
|
| 71 |
+
|
| 72 |
+
```python
|
| 73 |
+
from pocket_tts import TTSModel
|
| 74 |
+
|
| 75 |
+
model = TTSModel.load_model()
|
| 76 |
+
print(f"Model running on: {model.device}")
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
##### `sample_rate` (int)
|
| 80 |
+
|
| 81 |
+
Returns the generated audio sample rate (typically 24000 Hz).
|
| 82 |
+
|
| 83 |
+
```python
|
| 84 |
+
from pocket_tts import TTSModel
|
| 85 |
+
|
| 86 |
+
model = TTSModel.load_model()
|
| 87 |
+
print(f"Sample rate: {model.sample_rate} Hz")
|
| 88 |
+
```
|
| 89 |
+
|
| 90 |
+
#### Methods
|
| 91 |
+
|
| 92 |
+
##### `get_state_for_audio_prompt(audio_conditioning, truncate=False)`
|
| 93 |
+
|
| 94 |
+
Extract model state for a given audio file or URL (voice cloning).
|
| 95 |
+
|
| 96 |
+
**Parameters:**
|
| 97 |
+
- `audio_conditioning` (Path | str | torch.Tensor): Audio file path, URL, or tensor
|
| 98 |
+
- `truncate` (bool): Whether to truncate the audio (default: False)
|
| 99 |
+
|
| 100 |
+
**Returns:**
|
| 101 |
+
- `dict`: Model state dictionary containing hidden states and positional information
|
| 102 |
+
|
| 103 |
+
**Example:**
|
| 104 |
+
```python
|
| 105 |
+
from pocket_tts import TTSModel
|
| 106 |
+
|
| 107 |
+
model = TTSModel.load_model()
|
| 108 |
+
# From HuggingFace URL
|
| 109 |
+
voice_state = model.get_state_for_audio_prompt("hf://kyutai/tts-voices/alba-mackenna/casual.wav")
|
| 110 |
+
|
| 111 |
+
# From local file
|
| 112 |
+
voice_state = model.get_state_for_audio_prompt("./my_voice.wav")
|
| 113 |
+
|
| 114 |
+
# From HTTP URL
|
| 115 |
+
voice_state = model.get_state_for_audio_prompt(
|
| 116 |
+
"https://huggingface.co/kyutai/tts-voices/resolve"
|
| 117 |
+
"/main/expresso/ex01-ex02_default_001_channel1_168s.wav"
|
| 118 |
+
)
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
##### `generate_audio(model_state, text_to_generate, frames_after_eos=None, copy_state=True)`
|
| 122 |
+
|
| 123 |
+
Generate complete audio tensor from text input.
|
| 124 |
+
|
| 125 |
+
**Parameters:**
|
| 126 |
+
- `model_state` (dict): Model state from `get_state_for_audio_prompt()`
|
| 127 |
+
- `text_to_generate` (str): Text to convert to speech
|
| 128 |
+
- `frames_after_eos` (int | None): Frames to generate after EOS detection (default: None)
|
| 129 |
+
- `copy_state` (bool): Whether to copy the state (default: True)
|
| 130 |
+
|
| 131 |
+
**Returns:**
|
| 132 |
+
- `torch.Tensor`: Audio 1D tensor with shape [samples]
|
| 133 |
+
|
| 134 |
+
**Example:**
|
| 135 |
+
```python
|
| 136 |
+
from pocket_tts import TTSModel
|
| 137 |
+
|
| 138 |
+
model = TTSModel.load_model()
|
| 139 |
+
|
| 140 |
+
voice_state = model.get_state_for_audio_prompt("hf://kyutai/tts-voices/alba-mackenna/casual.wav")
|
| 141 |
+
|
| 142 |
+
# Generate audio
|
| 143 |
+
audio = model.generate_audio(voice_state, "Hello world!", frames_after_eos=2, copy_state=True)
|
| 144 |
+
|
| 145 |
+
print(f"Generated audio shape: {audio.shape}")
|
| 146 |
+
print(f"Audio duration: {audio.shape[-1] / model.sample_rate:.2f} seconds")
|
| 147 |
+
```
|
| 148 |
+
|
| 149 |
+
##### `generate_audio_stream(model_state, text_to_generate, frames_after_eos=None, copy_state=True)`
|
| 150 |
+
|
| 151 |
+
Generate audio streaming chunks from text input.
|
| 152 |
+
|
| 153 |
+
**Parameters:** Same as `generate_audio()`
|
| 154 |
+
|
| 155 |
+
**Yields:**
|
| 156 |
+
- `torch.Tensor`: Audio chunks with shape [samples]
|
| 157 |
+
|
| 158 |
+
**Example:**
|
| 159 |
+
```python
|
| 160 |
+
from pocket_tts import TTSModel
|
| 161 |
+
|
| 162 |
+
model = TTSModel.load_model()
|
| 163 |
+
|
| 164 |
+
voice_state = model.get_state_for_audio_prompt("hf://kyutai/tts-voices/alba-mackenna/casual.wav")
|
| 165 |
+
# Stream generation
|
| 166 |
+
for chunk in model.generate_audio_stream(voice_state, "Long text content..."):
|
| 167 |
+
# Process each chunk as it's generated
|
| 168 |
+
print(f"Generated chunk: {chunk.shape[0]} samples")
|
| 169 |
+
# Could save chunks to file or play in real-time
|
| 170 |
+
```
|
| 171 |
+
|
| 172 |
+
## Advanced Usage
|
| 173 |
+
|
| 174 |
+
### Voice Management
|
| 175 |
+
|
| 176 |
+
```python
|
| 177 |
+
from pocket_tts import TTSModel
|
| 178 |
+
|
| 179 |
+
model = TTSModel.load_model()
|
| 180 |
+
# Preload multiple voices
|
| 181 |
+
voices = {
|
| 182 |
+
"casual": model.get_state_for_audio_prompt("hf://kyutai/tts-voices/alba-mackenna/casual.wav"),
|
| 183 |
+
"funny": model.get_state_for_audio_prompt(
|
| 184 |
+
"https://huggingface.co/kyutai/tts-voices/resolve/main/expresso/ex01-ex02_default_001_channel1_168s.wav"
|
| 185 |
+
),
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
# Generate with different voices
|
| 189 |
+
casual_audio = model.generate_audio(voices["casual"], "Hey there!")
|
| 190 |
+
funny_audio = model.generate_audio(voices["funny"], "Good morning.")
|
| 191 |
+
```
|
| 192 |
+
|
| 193 |
+
### Batch Processing
|
| 194 |
+
|
| 195 |
+
```python
|
| 196 |
+
from pocket_tts import TTSModel
|
| 197 |
+
import scipy.io.wavfile
|
| 198 |
+
import torch
|
| 199 |
+
|
| 200 |
+
model = TTSModel.load_model()
|
| 201 |
+
|
| 202 |
+
voice_state = model.get_state_for_audio_prompt("hf://kyutai/tts-voices/alba-mackenna/casual.wav")
|
| 203 |
+
# Process multiple texts efficiently by re-using the same voice state
|
| 204 |
+
texts = [
|
| 205 |
+
"First sentence to generate.",
|
| 206 |
+
"Second sentence to generate.",
|
| 207 |
+
"Third sentence to generate.",
|
| 208 |
+
]
|
| 209 |
+
|
| 210 |
+
audios = []
|
| 211 |
+
for text in texts:
|
| 212 |
+
audio = model.generate_audio(voice_state, text)
|
| 213 |
+
audios.append(audio)
|
| 214 |
+
|
| 215 |
+
# Concatenate all audio
|
| 216 |
+
full_audio = torch.cat(audios, dim=0)
|
| 217 |
+
scipy.io.wavfile.write("batch_output.wav", model.sample_rate, full_audio.numpy())
|
| 218 |
+
```
|
| 219 |
+
|
| 220 |
+
### Streaming to File
|
| 221 |
+
You can refer to our CLI implementation which can stream audio to a wav file.
|
| 222 |
+
|
| 223 |
+
For more information about the command-line interface, see the [Generate Documentation](generate.md) or [Serve Documentation](serve.md).
|
pocket_tts_openai_server.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import argparse
|
| 3 |
+
import sys
|
| 4 |
+
import logging
|
| 5 |
+
import io
|
| 6 |
+
import time
|
| 7 |
+
import json
|
| 8 |
+
import glob
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from flask import Flask, request, jsonify, send_file, render_template, Response, stream_with_context
|
| 11 |
+
import torch
|
| 12 |
+
import torchaudio
|
| 13 |
+
|
| 14 |
+
# Import pocket-tts
|
| 15 |
+
try:
|
| 16 |
+
from pocket_tts import TTSModel
|
| 17 |
+
except ImportError:
|
| 18 |
+
print("Error: pocket-tts not found. Please install it using 'pip install pocket-tts'.")
|
| 19 |
+
sys.exit(1)
|
| 20 |
+
|
| 21 |
+
from utils import validate_format, convert_audio, write_wav_header
|
| 22 |
+
|
| 23 |
+
# Configure Logging
|
| 24 |
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
| 25 |
+
logger = logging.getLogger("PocketTTS-Server")
|
| 26 |
+
|
| 27 |
+
app = Flask(__name__)
|
| 28 |
+
|
| 29 |
+
# Global Variables
|
| 30 |
+
model = None
|
| 31 |
+
voice_cache = {} # Cache for voice states
|
| 32 |
+
VOICES_DIR = None
|
| 33 |
+
|
| 34 |
+
# --- Helpers ---
|
| 35 |
+
|
| 36 |
+
def get_voice_state(voice_id_or_path):
|
| 37 |
+
"""
|
| 38 |
+
Resolve voice ID to a model state with caching.
|
| 39 |
+
"""
|
| 40 |
+
global voice_cache
|
| 41 |
+
|
| 42 |
+
# Check cache first
|
| 43 |
+
if voice_id_or_path in voice_cache:
|
| 44 |
+
# logger.info(f"Using cached voice state for {voice_id_or_path}")
|
| 45 |
+
return voice_cache[voice_id_or_path]
|
| 46 |
+
|
| 47 |
+
# If path is relative to VOICES_DIR and exists there, resolve it
|
| 48 |
+
if VOICES_DIR:
|
| 49 |
+
possible_path = os.path.join(VOICES_DIR, voice_id_or_path)
|
| 50 |
+
if os.path.exists(possible_path):
|
| 51 |
+
voice_id_or_path = possible_path
|
| 52 |
+
|
| 53 |
+
# Check existence if local path
|
| 54 |
+
if os.path.exists(voice_id_or_path):
|
| 55 |
+
# It's a local file
|
| 56 |
+
logger.info(f"Loading new voice state from file: {voice_id_or_path}")
|
| 57 |
+
state = model.get_state_for_audio_prompt(voice_id_or_path)
|
| 58 |
+
voice_cache[voice_id_or_path] = state
|
| 59 |
+
return state
|
| 60 |
+
|
| 61 |
+
# URL or ID
|
| 62 |
+
try:
|
| 63 |
+
logger.info(f"Loading new voice state from ID/URL: {voice_id_or_path}")
|
| 64 |
+
state = model.get_state_for_audio_prompt(voice_id_or_path)
|
| 65 |
+
voice_cache[voice_id_or_path] = state
|
| 66 |
+
return state
|
| 67 |
+
except Exception as e:
|
| 68 |
+
logger.error(f"Failed to load voice {voice_id_or_path}: {e}")
|
| 69 |
+
raise ValueError(f"Voice '{voice_id_or_path}' could not be loaded.")
|
| 70 |
+
|
| 71 |
+
# --- Routes ---
|
| 72 |
+
|
| 73 |
+
@app.route('/')
|
| 74 |
+
def home():
|
| 75 |
+
"""Serve the simple web interface."""
|
| 76 |
+
return render_template('index.html')
|
| 77 |
+
|
| 78 |
+
@app.route('/v1/voices', methods=['GET'])
|
| 79 |
+
def list_voices():
|
| 80 |
+
"""List available voices: Built-ins + Scanned Directory."""
|
| 81 |
+
|
| 82 |
+
# 1. Built-in defaults
|
| 83 |
+
# User requested specific voices: alba, marius, javert, jean, fantine, cosette, eponine, azelma
|
| 84 |
+
# We map them to potential IDs. Since we don't have exact URLs for all, we assume they are either
|
| 85 |
+
# resolved by pocket-tts or the user has these files/IDs.
|
| 86 |
+
|
| 87 |
+
builtin_map = {
|
| 88 |
+
"alba": "alba",
|
| 89 |
+
"marius": "marius",
|
| 90 |
+
"javert": "javert",
|
| 91 |
+
"jean": "jean",
|
| 92 |
+
"fantine": "fantine",
|
| 93 |
+
"cosette": "cosette",
|
| 94 |
+
"eponine": "eponine",
|
| 95 |
+
"azelma": "azelma"
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
voices = []
|
| 99 |
+
for name_id in ["alba", "marius", "javert", "jean", "fantine", "cosette", "eponine", "azelma"]:
|
| 100 |
+
# If we have a known mapping (like for alba), use it as ID?
|
| 101 |
+
# Or just use the name as ID and let get_voice_state handle it?
|
| 102 |
+
# Specifying ID as the lookup key.
|
| 103 |
+
voices.append({
|
| 104 |
+
"id": builtin_map.get(name_id, name_id),
|
| 105 |
+
"name": name_id.capitalize()
|
| 106 |
+
})
|
| 107 |
+
|
| 108 |
+
# 2. Scan Directory if configured
|
| 109 |
+
if VOICES_DIR and os.path.isdir(VOICES_DIR):
|
| 110 |
+
# Scan for .wav files
|
| 111 |
+
wav_files = glob.glob(os.path.join(VOICES_DIR, "*.wav"))
|
| 112 |
+
for f in wav_files:
|
| 113 |
+
name = os.path.basename(f)
|
| 114 |
+
# ID is the full path so the server can access it, or just filename if we handle resolution
|
| 115 |
+
# Let's use full absolute path for robustness or relative if clean.
|
| 116 |
+
# Using filename is nicer for UI, but 'voice' parameter needs to be resolvable.
|
| 117 |
+
# get_voice_state logic handles checking VOICES_DIR.
|
| 118 |
+
voices.append({
|
| 119 |
+
"id": name, # client sends this, get_voice_state resolves via VOICES_DIR
|
| 120 |
+
"name": f"Local: {name}"
|
| 121 |
+
})
|
| 122 |
+
|
| 123 |
+
return jsonify({
|
| 124 |
+
"object": "list",
|
| 125 |
+
"data": [{"id": v["id"], "name": v["name"], "object": "voice"} for v in voices]
|
| 126 |
+
})
|
| 127 |
+
|
| 128 |
+
@app.route('/v1/audio/speech', methods=['POST'])
|
| 129 |
+
def generate_speech():
|
| 130 |
+
"""OpenAI-compatible speech generation endpoint."""
|
| 131 |
+
data = request.json
|
| 132 |
+
|
| 133 |
+
if not data:
|
| 134 |
+
return jsonify({"error": "Missing JSON body"}), 400
|
| 135 |
+
|
| 136 |
+
text = data.get('input')
|
| 137 |
+
voice = data.get('voice', 'hf://kyutai/tts-voices/alba-mackenna/casual.wav')
|
| 138 |
+
response_format = data.get('response_format', 'wav')
|
| 139 |
+
|
| 140 |
+
# Check stream flag
|
| 141 |
+
stream_request = data.get('stream', False)
|
| 142 |
+
|
| 143 |
+
if not text:
|
| 144 |
+
return jsonify({"error": "Missing 'input' text"}), 400
|
| 145 |
+
|
| 146 |
+
target_format = validate_format(response_format)
|
| 147 |
+
|
| 148 |
+
try:
|
| 149 |
+
# Load Voice (with cache)
|
| 150 |
+
voice_state = get_voice_state(voice)
|
| 151 |
+
|
| 152 |
+
# Determine Generation Mode
|
| 153 |
+
if stream_request or app.config.get('CLI_STREAM_DEFAULT'):
|
| 154 |
+
return stream_audio(voice_state, text, target_format)
|
| 155 |
+
else:
|
| 156 |
+
return generate_file(voice_state, text, target_format)
|
| 157 |
+
|
| 158 |
+
except Exception as e:
|
| 159 |
+
logger.exception("Generation failed")
|
| 160 |
+
return jsonify({"error": str(e)}), 500
|
| 161 |
+
|
| 162 |
+
def generate_file(voice_state, text, fmt):
|
| 163 |
+
"""Generate full audio and return as file."""
|
| 164 |
+
t0 = time.time()
|
| 165 |
+
audio_tensor = model.generate_audio(voice_state, text)
|
| 166 |
+
generation_time = time.time() - t0
|
| 167 |
+
logger.info(f"Generated {len(text)} chars in {generation_time:.2f}s")
|
| 168 |
+
|
| 169 |
+
# Convert
|
| 170 |
+
audio_buffer = convert_audio(audio_tensor, model.sample_rate, fmt)
|
| 171 |
+
|
| 172 |
+
mimetype = f"audio/{fmt}"
|
| 173 |
+
if fmt == 'wav': mimetype = 'audio/wav'
|
| 174 |
+
elif fmt == 'mp3': mimetype = 'audio/mpeg'
|
| 175 |
+
|
| 176 |
+
return send_file(
|
| 177 |
+
audio_buffer,
|
| 178 |
+
mimetype=mimetype,
|
| 179 |
+
as_attachment=True,
|
| 180 |
+
download_name=f"speech.{fmt}"
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
def stream_audio(voice_state, text, fmt):
|
| 184 |
+
"""Stream audio chunks."""
|
| 185 |
+
|
| 186 |
+
def generate():
|
| 187 |
+
stream = model.generate_audio_stream(voice_state, text)
|
| 188 |
+
|
| 189 |
+
for chunk_tensor in stream:
|
| 190 |
+
# Convert to int16 PCM bytes
|
| 191 |
+
# Ensure CPU/correct shape
|
| 192 |
+
if chunk_tensor.is_cuda: chunk_tensor = chunk_tensor.cpu()
|
| 193 |
+
if chunk_tensor.dim() == 1: chunk_tensor = chunk_tensor.unsqueeze(0)
|
| 194 |
+
|
| 195 |
+
# Simple PCM conversion
|
| 196 |
+
c = (chunk_tensor * 32767).clamp(-32768, 32767).to(torch.int16)
|
| 197 |
+
data_bytes = c.numpy().tobytes()
|
| 198 |
+
yield data_bytes
|
| 199 |
+
|
| 200 |
+
def stream_with_header():
|
| 201 |
+
# Valid WAV Header for streaming
|
| 202 |
+
if fmt == 'wav':
|
| 203 |
+
# We assume standard 24kHz mono based on model
|
| 204 |
+
# If model rate varies, we should use model.sample_rate
|
| 205 |
+
yield write_wav_header(model.sample_rate, num_channels=1, bits_per_sample=16, num_frames=0)
|
| 206 |
+
|
| 207 |
+
yield from generate()
|
| 208 |
+
|
| 209 |
+
mimetype = f"audio/{fmt}"
|
| 210 |
+
if fmt == 'wav': mimetype = 'audio/wav'
|
| 211 |
+
|
| 212 |
+
return Response(stream_with_context(stream_with_header()), mimetype=mimetype)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
# --- startup ---
|
| 216 |
+
def main():
|
| 217 |
+
parser = argparse.ArgumentParser(description="Pocket TTS OpenAI Compatible Server")
|
| 218 |
+
parser.add_argument("--model_path", type=str, default=None, help="Path to model file or variant name")
|
| 219 |
+
parser.add_argument("--host", type=str, default="0.0.0.0", help="Host to run server")
|
| 220 |
+
parser.add_argument("--port", type=int, default=5002, help="Port to run server")
|
| 221 |
+
parser.add_argument("--stream", action="store_true", help="Enable streaming by default")
|
| 222 |
+
parser.add_argument("--voices_dir", type=str, default=None, help="Directory containing local voice .wav files")
|
| 223 |
+
|
| 224 |
+
args = parser.parse_args()
|
| 225 |
+
|
| 226 |
+
# Config
|
| 227 |
+
app.config['CLI_STREAM_DEFAULT'] = args.stream
|
| 228 |
+
global VOICES_DIR
|
| 229 |
+
VOICES_DIR = args.voices_dir
|
| 230 |
+
|
| 231 |
+
global model
|
| 232 |
+
logger.info("Loading Pocket TTS Model...")
|
| 233 |
+
|
| 234 |
+
# Use model_path as variant if provided, otherwise default
|
| 235 |
+
if args.model_path:
|
| 236 |
+
logger.info(f"Using custom model variant/path: {args.model_path}")
|
| 237 |
+
model = TTSModel.load_model(variant=args.model_path)
|
| 238 |
+
else:
|
| 239 |
+
model = TTSModel.load_model()
|
| 240 |
+
|
| 241 |
+
logger.info(f"Model loaded. Device: {model.device}, Sample Rate: {model.sample_rate}")
|
| 242 |
+
|
| 243 |
+
if VOICES_DIR:
|
| 244 |
+
logger.info(f"Scanning voices from: {VOICES_DIR}")
|
| 245 |
+
|
| 246 |
+
logger.info(f"Starting server on {args.host}:{args.port}")
|
| 247 |
+
app.run(host=args.host, port=args.port, debug=False, threaded=True)
|
| 248 |
+
|
| 249 |
+
if __name__ == "__main__":
|
| 250 |
+
main()
|
project specifications.txt
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Project Specification: Universal TTS API Wrapper
|
| 2 |
+
1. Project Objective
|
| 3 |
+
Develop a production-ready Flask wrapper for pocket-tts that supports synthesis, voice cloning, and streaming audio, compatible with standard industry API TTS schemas (OpenAI) and includes a simple but elegant preview website that runs on the same host/post as the TTS server endpoint .
|
| 4 |
+
|
| 5 |
+
2. Phase 1: Infrastructure & Configuration
|
| 6 |
+
Implement a robust CLI-driven configuration layer to handle model loading and hardware orchestration.
|
| 7 |
+
|
| 8 |
+
CLI Argument Parser: Support arguments for --model_path, --host (host to run server on), --port (post to run server on), and --stresm (if added, use streaming TTS)
|
| 9 |
+
|
| 10 |
+
3. Phase 2: Core Synthesis Logic
|
| 11 |
+
Develop necessary functions to bridge the API and the model to allow for streaming and non-streaming voice TTS generation.
|
| 12 |
+
|
| 13 |
+
Text Pre-processing: Integrate a text cleaner/tokenizer to handle language-specific normalization.
|
| 14 |
+
|
| 15 |
+
Audio Post-processing:
|
| 16 |
+
|
| 17 |
+
Function to convert raw model output (tensors) into a BytesIO buffer.
|
| 18 |
+
|
| 19 |
+
Use torchaudio to convert raw audio into various formats: mp3, wav, opus, aac, and flac.
|
| 20 |
+
|
| 21 |
+
Voice Cloning/Conditioning: Support accepting a path to a reference audio file for zero-shot cloning. If supported by model, cache latents to avoid having to reprocess wav files for cloning each generarion.
|
| 22 |
+
|
| 23 |
+
4. Phase 3: API Surface Implementation
|
| 24 |
+
Expose the model to the user in two ways:
|
| 25 |
+
|
| 26 |
+
A. Simple web page at root of host/port used for server
|
| 27 |
+
Use pocket-tts-logo.png and develop a simple localhost html and or css web page that is run automatically when running the server and allows the user to test and play output from the built in voices or select a wav file from their file system to clone and provides from default text prompts as well as a free text input field the user can utilize to test the model, and download output wavs through a file selection dislog to a location of their choice.
|
| 28 |
+
|
| 29 |
+
B. OpenAI Compatibility Layer endpoint (/v1/audio/speech)
|
| 30 |
+
Endpoint: Follow OpenAI’s JSON payload schema: { "model": "...", "input": "...", "voice": "...", "response_format": "mp3", "speed": 1.0 }.
|
| 31 |
+
The response format should support the formats noted above
|
| 32 |
+
Speed should be passed to the model if supported by pocket-tts to control how fast the TTS plays
|
| 33 |
+
The "voice" parameter should accept one of the model's built in voice names or the absolute path to a .wav file specified by the user
|
| 34 |
+
Model should always be defaulted to "pocket-tts" if not specified and should have no i,pact on the actual voice generation as this server is only for use with the pocket-tts model
|
| 35 |
+
Logic: Map "voice" strings to internal speaker IDs (for voices supported natively by the model) or paths to cloning .wav files.
|
| 36 |
+
Non-streaming response: If not using streaming via command line argument when running server, generate a non-streaming file response supporting all the audio formats specified above
|
| 37 |
+
Streaming: Implement steaming audio response option at user's discretion using Response(generate_chunks(), mimetype=...).
|
| 38 |
+
|
| 39 |
+
Voices endooint: also include a voices endpoint that will return built in voices and the wav files in a directory for cloning wav files specified by the user as a command line argument when running the server:
|
| 40 |
+
Voices endpoint sample to adapt:
|
| 41 |
+
|
| 42 |
+
@app.route("/v1/voices", methods=["GET"])
|
| 43 |
+
def openai_list_voices():
|
| 44 |
+
"""JSON list of available built-in voice names.
|
| 45 |
+
Not an official OpenAI endpoint; provided for convenience for clients.
|
| 46 |
+
"""
|
| 47 |
+
voices = _get_builtin_voice_names()
|
| 48 |
+
return jsonify(
|
| 49 |
+
{
|
| 50 |
+
"object": "list",
|
| 51 |
+
"data": [{"id": v, "object": "voice"} for v in voices],
|
| 52 |
+
}
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
5. Phase 4: Error Handling: Implement try-except blocks around relevant logic to return 500-level JSON errors instead of crashing the Flask thread.
|
| 56 |
+
|
| 57 |
+
6. Technical Requirements
|
| 58 |
+
Language: Python 3.11
|
| 59 |
+
|
| 60 |
+
Framework: Flask
|
| 61 |
+
|
| 62 |
+
Core Libraries: torch, torchaudio, numpy, whatever else needed
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
flask
|
| 2 |
+
torch
|
| 3 |
+
torchaudio
|
| 4 |
+
pocket-tts
|
| 5 |
+
scipy
|
| 6 |
+
numpy
|
run_pocket_tts_server.bat
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=5002"
|
| 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 Path/Variant (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 |
+
:: If user hits ENTER, use the local folder path
|
| 55 |
+
set "VOICES_DIR=--voices_dir ^"!DEFAULT_VOICES!^""
|
| 56 |
+
) else (
|
| 57 |
+
:: If user types a path, use that instead
|
| 58 |
+
set "VOICES_DIR=--voices_dir ^"%INPUT_VOICES%^""
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
:: 6. Streaming Default
|
| 62 |
+
set "STREAM_ARG="
|
| 63 |
+
set /p "INPUT_STREAM=Enable Streaming by default? (Y/N) [N]: "
|
| 64 |
+
if /i "%INPUT_STREAM%"=="Y" set "STREAM_ARG=--stream"
|
| 65 |
+
|
| 66 |
+
echo.
|
| 67 |
+
echo ========================================================
|
| 68 |
+
echo Starting Pocket TTS Server...
|
| 69 |
+
echo Host: %HOST%
|
| 70 |
+
echo Port: %PORT%
|
| 71 |
+
if defined MODEL_PATH echo Model: %MODEL_PATH%
|
| 72 |
+
if defined VOICES_DIR echo Voices: %VOICES_DIR%
|
| 73 |
+
if defined STREAM_ARG echo Streaming: Enabled
|
| 74 |
+
echo ========================================================
|
| 75 |
+
echo.
|
| 76 |
+
|
| 77 |
+
:: 7. Run Command
|
| 78 |
+
python pocket_tts_openai_server.py --host %HOST% --port %PORT% %MODEL_PATH% %VOICES_DIR% %STREAM_ARG%
|
| 79 |
+
|
| 80 |
+
if %ERRORLEVEL% NEQ 0 (
|
| 81 |
+
echo.
|
| 82 |
+
echo [ERROR] Server exited with error code %ERRORLEVEL%.
|
| 83 |
+
pause
|
| 84 |
+
)
|
static/css/style.css
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
| 11 |
+
--glass-bg: rgba(22, 27, 34, 0.7);
|
| 12 |
+
--glass-border: rgba(48, 54, 61, 0.5);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
* {
|
| 16 |
+
box-sizing: border-box;
|
| 17 |
+
margin: 0;
|
| 18 |
+
padding: 0;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
body {
|
| 22 |
+
font-family: var(--font-family);
|
| 23 |
+
background-color: var(--bg-color);
|
| 24 |
+
color: var(--text-primary);
|
| 25 |
+
display: flex;
|
| 26 |
+
justify-content: center;
|
| 27 |
+
align-items: center;
|
| 28 |
+
min-height: 100vh;
|
| 29 |
+
background-image: radial-gradient(circle at 50% 0%, #1f2530 0%, #0d1117 100%);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
.container {
|
| 33 |
+
width: 100%;
|
| 34 |
+
max-width: 800px;
|
| 35 |
+
padding: 2rem;
|
| 36 |
+
background: var(--glass-bg);
|
| 37 |
+
backdrop-filter: blur(12px);
|
| 38 |
+
border: 1px solid var(--glass-border);
|
| 39 |
+
border-radius: 16px;
|
| 40 |
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
| 41 |
+
animation: fadeIn 0.5s ease-out;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
@keyframes fadeIn {
|
| 45 |
+
from { opacity: 0; transform: translateY(20px); }
|
| 46 |
+
to { opacity: 1; transform: translateY(0); }
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
header {
|
| 50 |
+
text-align: center;
|
| 51 |
+
margin-bottom: 2rem;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
.logo {
|
| 55 |
+
max-width: 120px;
|
| 56 |
+
margin-bottom: 1rem;
|
| 57 |
+
filter: drop-shadow(0 0 10px rgba(88, 166, 255, 0.3));
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
h1 {
|
| 61 |
+
font-size: 2rem;
|
| 62 |
+
font-weight: 700;
|
| 63 |
+
color: var(--text-primary);
|
| 64 |
+
margin-bottom: 0.5rem;
|
| 65 |
+
background: linear-gradient(90deg, #58a6ff, #a371f7);
|
| 66 |
+
-webkit-background-clip: text;
|
| 67 |
+
-webkit-text-fill-color: transparent;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
.subtitle {
|
| 71 |
+
color: var(--text-secondary);
|
| 72 |
+
font-size: 0.95rem;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
.control-group {
|
| 76 |
+
margin-bottom: 1.5rem;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
label {
|
| 80 |
+
display: block;
|
| 81 |
+
margin-bottom: 0.5rem;
|
| 82 |
+
font-weight: 500;
|
| 83 |
+
color: var(--text-secondary);
|
| 84 |
+
font-size: 0.9rem;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
input[type="text"],
|
| 88 |
+
textarea,
|
| 89 |
+
select {
|
| 90 |
+
width: 100%;
|
| 91 |
+
padding: 0.75rem 1rem;
|
| 92 |
+
background-color: var(--input-bg);
|
| 93 |
+
border: 1px solid var(--border-color);
|
| 94 |
+
border-radius: 8px;
|
| 95 |
+
color: var(--text-primary);
|
| 96 |
+
font-family: inherit;
|
| 97 |
+
font-size: 1rem;
|
| 98 |
+
transition: border-color 0.2s, box-shadow 0.2s;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
input[type="file"] {
|
| 102 |
+
width: 100%;
|
| 103 |
+
padding: 0.5rem;
|
| 104 |
+
background-color: var(--input-bg);
|
| 105 |
+
border: 1px dashed var(--border-color);
|
| 106 |
+
border-radius: 8px;
|
| 107 |
+
color: var(--text-secondary);
|
| 108 |
+
cursor: pointer;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
input:focus,
|
| 112 |
+
textarea:focus,
|
| 113 |
+
select:focus {
|
| 114 |
+
outline: none;
|
| 115 |
+
border-color: var(--accent-color);
|
| 116 |
+
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.1);
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
textarea {
|
| 120 |
+
min-height: 120px;
|
| 121 |
+
resize: vertical;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
.btn-primary {
|
| 125 |
+
display: block;
|
| 126 |
+
width: 100%;
|
| 127 |
+
padding: 1rem;
|
| 128 |
+
border: none;
|
| 129 |
+
border-radius: 8px;
|
| 130 |
+
background: linear-gradient(135deg, var(--accent-color), var(--accent-hover));
|
| 131 |
+
color: white;
|
| 132 |
+
font-weight: 600;
|
| 133 |
+
font-size: 1.1rem;
|
| 134 |
+
cursor: pointer;
|
| 135 |
+
transition: transform 0.1s, opacity 0.2s;
|
| 136 |
+
box-shadow: 0 4px 12px rgba(31, 111, 235, 0.3);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.btn-primary:hover {
|
| 140 |
+
opacity: 0.9;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
.btn-primary:active {
|
| 144 |
+
transform: scale(0.98);
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
.btn-primary:disabled {
|
| 148 |
+
background: var(--border-color);
|
| 149 |
+
cursor: not-allowed;
|
| 150 |
+
opacity: 0.7;
|
| 151 |
+
box-shadow: none;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.output-section {
|
| 155 |
+
margin-top: 2rem;
|
| 156 |
+
padding-top: 2rem;
|
| 157 |
+
border-top: 1px solid var(--border-color);
|
| 158 |
+
text-align: center;
|
| 159 |
+
display: none; /* Hidden by default */
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.output-section.active {
|
| 163 |
+
display: block;
|
| 164 |
+
animation: slideDown 0.3s ease-out;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
@keyframes slideDown {
|
| 168 |
+
from { opacity: 0; transform: translateY(-10px); }
|
| 169 |
+
to { opacity: 1; transform: translateY(0); }
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
audio {
|
| 173 |
+
width: 100%;
|
| 174 |
+
margin-bottom: 1rem;
|
| 175 |
+
border-radius: 8px;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
.download-link {
|
| 179 |
+
display: inline-block;
|
| 180 |
+
padding: 0.5rem 1rem;
|
| 181 |
+
color: var(--accent-color);
|
| 182 |
+
text-decoration: none;
|
| 183 |
+
border: 1px solid var(--accent-color);
|
| 184 |
+
border-radius: 6px;
|
| 185 |
+
font-size: 0.9rem;
|
| 186 |
+
transition: all 0.2s;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
.download-link:hover {
|
| 190 |
+
background: rgba(88, 166, 255, 0.1);
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
/* Spinner */
|
| 194 |
+
.spinner {
|
| 195 |
+
display: inline-block;
|
| 196 |
+
width: 1.5rem;
|
| 197 |
+
height: 1.5rem;
|
| 198 |
+
border: 3px solid rgba(255,255,255,0.3);
|
| 199 |
+
border-radius: 50%;
|
| 200 |
+
border-top-color: #fff;
|
| 201 |
+
animation: spin 1s ease-in-out infinite;
|
| 202 |
+
margin-left: 0.5rem;
|
| 203 |
+
vertical-align: middle;
|
| 204 |
+
display: none;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
.btn-primary.loading .spinner {
|
| 208 |
+
display: inline-block;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
.btn-primary.loading span {
|
| 212 |
+
display: none;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
@keyframes spin {
|
| 216 |
+
to { transform: rotate(360deg); }
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
.hidden {
|
| 220 |
+
display: none;
|
| 221 |
+
}
|
static/js/app.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
document.addEventListener('DOMContentLoaded', async () => {
|
| 2 |
+
const voiceSelect = document.getElementById('voice-select');
|
| 3 |
+
const customVoiceGroup = document.getElementById('custom-voice-group');
|
| 4 |
+
const generateBtn = document.getElementById('generate-btn');
|
| 5 |
+
const textInput = document.getElementById('text-input');
|
| 6 |
+
const voiceFile = document.getElementById('voice-file');
|
| 7 |
+
const outputSection = document.getElementById('output-section');
|
| 8 |
+
const audioPlayer = document.getElementById('audio-player');
|
| 9 |
+
const downloadBtn = document.getElementById('download-btn');
|
| 10 |
+
const streamToggle = document.getElementById('stream-toggle');
|
| 11 |
+
|
| 12 |
+
// 1. Load Voices
|
| 13 |
+
async function loadVoices() {
|
| 14 |
+
try {
|
| 15 |
+
const res = await fetch('/v1/voices');
|
| 16 |
+
const data = await res.json();
|
| 17 |
+
|
| 18 |
+
// Clear existing except custom
|
| 19 |
+
voiceSelect.innerHTML = '<option value="custom">Custom (Upload .wav)...</option>';
|
| 20 |
+
|
| 21 |
+
if (data.data) {
|
| 22 |
+
// Add built-ins first
|
| 23 |
+
data.data.forEach(voice => {
|
| 24 |
+
const opt = document.createElement('option');
|
| 25 |
+
opt.value = voice.id;
|
| 26 |
+
opt.textContent = voice.name || voice.id;
|
| 27 |
+
voiceSelect.insertBefore(opt, voiceSelect.firstChild); // Prepend
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
// Select first by default
|
| 31 |
+
if (voiceSelect.options.length > 0) {
|
| 32 |
+
voiceSelect.selectedIndex = 0;
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
} catch (e) {
|
| 36 |
+
console.error("Failed to list voices:", e);
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
await loadVoices();
|
| 41 |
+
|
| 42 |
+
// 2. Handle Voice Selection Change
|
| 43 |
+
voiceSelect.addEventListener('change', (e) => {
|
| 44 |
+
if (e.target.value === 'custom') {
|
| 45 |
+
// Setup for Custom Path
|
| 46 |
+
document.querySelector('#custom-voice-group label').textContent = "Absolute Path to WAV File:";
|
| 47 |
+
voiceFile.type = 'text';
|
| 48 |
+
voiceFile.placeholder = "C:\\path\\to\\voice.wav";
|
| 49 |
+
customVoiceGroup.classList.remove('hidden');
|
| 50 |
+
} else {
|
| 51 |
+
customVoiceGroup.classList.add('hidden');
|
| 52 |
+
}
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
// 3. Generate Logic
|
| 56 |
+
generateBtn.addEventListener('click', async () => {
|
| 57 |
+
const text = textInput.value.trim();
|
| 58 |
+
if (!text) return alert("Please enter text");
|
| 59 |
+
|
| 60 |
+
const stream = streamToggle.checked;
|
| 61 |
+
|
| 62 |
+
let voice = voiceSelect.value;
|
| 63 |
+
if (voice === 'custom') {
|
| 64 |
+
voice = voiceFile.value.trim();
|
| 65 |
+
if (!voice) return alert("Please enter the path to the wave file.");
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
generateBtn.classList.add('loading');
|
| 69 |
+
generateBtn.disabled = true;
|
| 70 |
+
outputSection.classList.remove('active');
|
| 71 |
+
|
| 72 |
+
try {
|
| 73 |
+
const response = await fetch('/v1/audio/speech', {
|
| 74 |
+
method: 'POST',
|
| 75 |
+
headers: { 'Content-Type': 'application/json' },
|
| 76 |
+
body: JSON.stringify({
|
| 77 |
+
model: 'pocket-tts',
|
| 78 |
+
input: text,
|
| 79 |
+
voice: voice,
|
| 80 |
+
response_format: 'wav',
|
| 81 |
+
stream: stream
|
| 82 |
+
})
|
| 83 |
+
});
|
| 84 |
+
|
| 85 |
+
if (!response.ok) {
|
| 86 |
+
const err = await response.json();
|
| 87 |
+
throw new Error(err.error || response.statusText);
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
const blob = await response.blob();
|
| 91 |
+
const url = URL.createObjectURL(blob);
|
| 92 |
+
audioPlayer.src = url;
|
| 93 |
+
downloadBtn.href = url;
|
| 94 |
+
downloadBtn.download = "generated_speech.wav";
|
| 95 |
+
|
| 96 |
+
audioPlayer.play();
|
| 97 |
+
outputSection.classList.add('active');
|
| 98 |
+
|
| 99 |
+
} catch (e) {
|
| 100 |
+
alert("Error generating speech: " + e.message);
|
| 101 |
+
} finally {
|
| 102 |
+
generateBtn.classList.remove('loading');
|
| 103 |
+
generateBtn.disabled = false;
|
| 104 |
+
}
|
| 105 |
+
});
|
| 106 |
+
});
|
templates/index.html
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
| 11 |
+
</head>
|
| 12 |
+
<body>
|
| 13 |
+
|
| 14 |
+
<div class="container">
|
| 15 |
+
<header>
|
| 16 |
+
<img src="/static/images/pocket-tts-logo.png" alt="Pocket TTS Logo" class="logo">
|
| 17 |
+
<h1>Pocket TTS</h1>
|
| 18 |
+
<p class="subtitle">Real-time local speech synthesis & voice cloning</p>
|
| 19 |
+
</header>
|
| 20 |
+
|
| 21 |
+
<div class="content">
|
| 22 |
+
<div class="control-group">
|
| 23 |
+
<label for="text-input">Text Prompt</label>
|
| 24 |
+
<textarea id="text-input" placeholder="Enter text to generate speech...">Hello! I am Pocket TTS, running locally on your machine. I can clone voices and stream audio in real-time.</textarea>
|
| 25 |
+
</div>
|
| 26 |
+
|
| 27 |
+
<div class="control-group">
|
| 28 |
+
<label for="voice-select">Voice Selection</label>
|
| 29 |
+
<select id="voice-select">
|
| 30 |
+
<!-- Populated by JS -->
|
| 31 |
+
<option value="custom">Custom (Upload .wav)...</option>
|
| 32 |
+
</select>
|
| 33 |
+
</div>
|
| 34 |
+
|
| 35 |
+
<div class="control-group hidden" id="custom-voice-group">
|
| 36 |
+
<label for="voice-file">Upload Reference Audio (WAV)</label>
|
| 37 |
+
<input type="file" id="voice-file" accept=".wav">
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<div class="control-group">
|
| 41 |
+
<label for="stream-toggle" style="display:inline-flex; align-items:center; cursor:pointer;">
|
| 42 |
+
<input type="checkbox" id="stream-toggle" style="width:auto; margin-right:8px;"> Enable Streaming Response
|
| 43 |
+
</label>
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
<button id="generate-btn" class="btn-primary">
|
| 47 |
+
<span>Generate Speech</span>
|
| 48 |
+
<div class="spinner"></div>
|
| 49 |
+
</button>
|
| 50 |
+
|
| 51 |
+
<div class="output-section" id="output-section">
|
| 52 |
+
<audio id="audio-player" controls></audio>
|
| 53 |
+
<br>
|
| 54 |
+
<a href="#" id="download-btn" class="download-link">Download Audio</a>
|
| 55 |
+
</div>
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
<script src="/static/js/app.js"></script>
|
| 60 |
+
</body>
|
| 61 |
+
</html>
|
test_server.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import subprocess
|
| 2 |
+
import time
|
| 3 |
+
import requests
|
| 4 |
+
import sys
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
def test_server():
|
| 8 |
+
print("Starting server for testing...")
|
| 9 |
+
# Start server as a subprocess
|
| 10 |
+
process = subprocess.Popen(
|
| 11 |
+
[sys.executable, "pocket_tts_openai_server.py", "--port", "5001"],
|
| 12 |
+
stdout=subprocess.PIPE,
|
| 13 |
+
stderr=subprocess.PIPE,
|
| 14 |
+
text=True
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
try:
|
| 18 |
+
# Wait for valid startup
|
| 19 |
+
print("Waiting for server to start...")
|
| 20 |
+
time.sleep(15) # Give it time to load model
|
| 21 |
+
|
| 22 |
+
base_url = "http://localhost:5001"
|
| 23 |
+
|
| 24 |
+
# 1. Test Home
|
| 25 |
+
try:
|
| 26 |
+
r = requests.get(base_url + "/")
|
| 27 |
+
print(f"GET /: {r.status_code}")
|
| 28 |
+
if r.status_code == 200 and "Pocket TTS" in r.text:
|
| 29 |
+
print("SUCCESS: Home page loaded.")
|
| 30 |
+
else:
|
| 31 |
+
print("FAILURE: Home page check failed.")
|
| 32 |
+
except Exception as e:
|
| 33 |
+
print(f"FAILURE: Could not connect to home page: {e}")
|
| 34 |
+
|
| 35 |
+
# 2. Test Voices List
|
| 36 |
+
try:
|
| 37 |
+
r = requests.get(base_url + "/v1/voices")
|
| 38 |
+
print(f"GET /v1/voices: {r.status_code}")
|
| 39 |
+
if r.status_code == 200:
|
| 40 |
+
data = r.json()
|
| 41 |
+
if data.get("object") == "list" and len(data.get("data", [])) > 0:
|
| 42 |
+
print(f"SUCCESS: Voices list returned {len(data['data'])} voices.")
|
| 43 |
+
else:
|
| 44 |
+
print("FAILURE: Voices list format incorrect.")
|
| 45 |
+
else:
|
| 46 |
+
print("FAILURE: Voices endpoint returned error.")
|
| 47 |
+
except Exception as e:
|
| 48 |
+
print(f"FAILURE: Voices test failed: {e}")
|
| 49 |
+
|
| 50 |
+
# 3. Test Generation (Mock or Real)
|
| 51 |
+
payload = {
|
| 52 |
+
"model": "pocket-tts",
|
| 53 |
+
"input": "Hi",
|
| 54 |
+
"voice": "hf://kyutai/tts-voices/alba-mackenna/casual.wav",
|
| 55 |
+
"response_format": "wav"
|
| 56 |
+
}
|
| 57 |
+
try:
|
| 58 |
+
t0 = time.time()
|
| 59 |
+
r = requests.post(base_url + "/v1/audio/speech", json=payload, timeout=60)
|
| 60 |
+
print(f"POST /v1/audio/speech: {r.status_code} (took {time.time()-t0:.2f}s)")
|
| 61 |
+
if r.status_code == 200:
|
| 62 |
+
print(f"SUCCESS: Audio generated ({len(r.content)} bytes).")
|
| 63 |
+
else:
|
| 64 |
+
print(f"FAILURE: Generation failed: {r.text}")
|
| 65 |
+
except Exception as e:
|
| 66 |
+
print(f"FAILURE: Generation test failed: {e}")
|
| 67 |
+
|
| 68 |
+
finally:
|
| 69 |
+
print("Terminating server...")
|
| 70 |
+
process.terminate()
|
| 71 |
+
try:
|
| 72 |
+
outs, errs = process.communicate(timeout=5)
|
| 73 |
+
# print("Server Output:", outs)
|
| 74 |
+
# print("Server Errors:", errs)
|
| 75 |
+
except:
|
| 76 |
+
process.kill()
|
| 77 |
+
|
| 78 |
+
if __name__ == "__main__":
|
| 79 |
+
test_server()
|
utils.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
import torch
|
| 3 |
+
import torchaudio
|
| 4 |
+
import logging
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
def convert_audio(audio_tensor: torch.Tensor, sample_rate: int, target_format: str = 'wav') -> io.BytesIO:
|
| 9 |
+
"""
|
| 10 |
+
Convert a raw audio tensor to a byte buffer in the specified format.
|
| 11 |
+
|
| 12 |
+
Args:
|
| 13 |
+
audio_tensor (torch.Tensor): The audio waveform (1D or 2D).
|
| 14 |
+
sample_rate (int): The sample rate of the audio.
|
| 15 |
+
target_format (str): The target audio format (mp3, wav, opus, aac, flac).
|
| 16 |
+
|
| 17 |
+
Returns:
|
| 18 |
+
io.BytesIO: Buffer containing the encoded audio data.
|
| 19 |
+
"""
|
| 20 |
+
buffer = io.BytesIO()
|
| 21 |
+
|
| 22 |
+
# Ensure tensor is CPU
|
| 23 |
+
if audio_tensor.is_cuda:
|
| 24 |
+
audio_tensor = audio_tensor.cpu()
|
| 25 |
+
|
| 26 |
+
# Ensure 2D (channels, time)
|
| 27 |
+
if audio_tensor.dim() == 1:
|
| 28 |
+
audio_tensor = audio_tensor.unsqueeze(0)
|
| 29 |
+
|
| 30 |
+
# Validation/Normalization if needed
|
| 31 |
+
# torchaudio.save expects data in range [-1, 1] usually, confirm if normalization needed?
|
| 32 |
+
# PocketTTS output range is typically fine, but clipping is good practice.
|
| 33 |
+
# audio_tensor = torch.clamp(audio_tensor, -1.0, 1.0)
|
| 34 |
+
|
| 35 |
+
try:
|
| 36 |
+
torchaudio.save(buffer, audio_tensor, sample_rate, format=target_format)
|
| 37 |
+
buffer.seek(0)
|
| 38 |
+
return buffer
|
| 39 |
+
except Exception as e:
|
| 40 |
+
logger.error(f"Error converting audio to {target_format}: {e}")
|
| 41 |
+
# Fallback to wav if conversion fails? Or re-raise?
|
| 42 |
+
# For now, let's try to handle common issues or re-raise
|
| 43 |
+
raise e
|
| 44 |
+
|
| 45 |
+
def validate_format(fmt: str) -> str:
|
| 46 |
+
"""Normalize and validate the requested audio format."""
|
| 47 |
+
fmt = fmt.lower()
|
| 48 |
+
valid_formats = {'mp3', 'wav', 'opus', 'aac', 'flac'}
|
| 49 |
+
|
| 50 |
+
if fmt == 'mpeg': # OpenAI sometimes sends 'mpeg' aka mp3
|
| 51 |
+
return 'mp3'
|
| 52 |
+
|
| 53 |
+
if fmt not in valid_formats:
|
| 54 |
+
return 'wav' # Default fallback
|
| 55 |
+
return fmt
|
| 56 |
+
|
| 57 |
+
def write_wav_header(sample_rate: int, num_channels: int = 1, bits_per_sample: int = 16, num_frames: int = 0) -> bytes:
|
| 58 |
+
"""
|
| 59 |
+
Generate a WAV header. If num_frames is 0, set to 0xFFFFFFFF (unknown/large).
|
| 60 |
+
"""
|
| 61 |
+
import struct
|
| 62 |
+
|
| 63 |
+
byte_rate = sample_rate * num_channels * bits_per_sample // 8
|
| 64 |
+
block_align = num_channels * bits_per_sample // 8
|
| 65 |
+
|
| 66 |
+
# Data size: if unknown, max uint32 approximately
|
| 67 |
+
data_size = num_frames * block_align
|
| 68 |
+
if num_frames == 0:
|
| 69 |
+
data_size = 0xFFFFFFFF - 36 # Max possible size remaining
|
| 70 |
+
|
| 71 |
+
chunk_size = 36 + data_size
|
| 72 |
+
|
| 73 |
+
header = io.BytesIO()
|
| 74 |
+
header.write(b'RIFF')
|
| 75 |
+
header.write(struct.pack('<I', chunk_size))
|
| 76 |
+
header.write(b'WAVE')
|
| 77 |
+
header.write(b'fmt ')
|
| 78 |
+
header.write(struct.pack('<I', 16)) # Subchunk1Size (16 for PCM)
|
| 79 |
+
header.write(struct.pack('<H', 1)) # AudioFormat (1 for PCM)
|
| 80 |
+
header.write(struct.pack('<H', num_channels))
|
| 81 |
+
header.write(struct.pack('<I', sample_rate))
|
| 82 |
+
header.write(struct.pack('<I', byte_rate))
|
| 83 |
+
header.write(struct.pack('<H', block_align))
|
| 84 |
+
header.write(struct.pack('<H', bits_per_sample))
|
| 85 |
+
header.write(b'data')
|
| 86 |
+
header.write(struct.pack('<I', data_size))
|
| 87 |
+
|
| 88 |
+
return header.getvalue()
|