teddybear082 commited on
Commit
aec4421
·
0 Parent(s):

Initial commit

Browse files
.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()