Shackless commited on
Commit
d2ee1c3
·
1 Parent(s): e5bb4ba

restructure and dockerize

Browse files
.dockerignore ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Docker build artifacts
2
+ .git
3
+ .gitignore
4
+ .env
5
+ *.md
6
+ !README.md
7
+ *.pyc
8
+ __pycache__
9
+ *.pyo
10
+ *.pyd
11
+ .Python
12
+ *.so
13
+ .eggs
14
+ *.egg-info
15
+ *.egg
16
+ dist
17
+ build
18
+ *.spec
19
+
20
+ # PyInstaller
21
+ *.exe
22
+ *.bat
23
+
24
+ # IDE
25
+ .vscode
26
+ .idea
27
+ *.swp
28
+ *.swo
29
+
30
+ # Logs (we mount these as volume)
31
+ logs/
32
+
33
+ # Virtual environments
34
+ venv/
35
+ .venv/
36
+
37
+ # Test files
38
+ tests/
39
+ *.test.py
40
+ pytest.ini
41
+
42
+ # Frozen requirements (use requirements.txt for Docker)
43
+ frozen_requirements.txt
44
+ pyinstaller_command.txt
.env.example ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Docker environment configuration template
2
+ # Copy to .env and customize as needed
3
+
4
+ # Server settings
5
+ POCKET_TTS_PORT=49112
6
+ POCKET_TTS_LOG_LEVEL=INFO
7
+ POCKET_TTS_STREAM_DEFAULT=true
8
+
9
+ # Custom voices directory (mounted to container)
10
+ # POCKET_TTS_VOICES_DIR=./my_custom_voices
11
+
12
+ # Hugging Face token for voice cloning (optional)
13
+ # Get your token from: https://huggingface.co/settings/tokens
14
+ # HF_TOKEN=hf_xxxxxxxxxxxxx
.github/workflows/build-exe.yml ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Build Windows Executable
2
+ # Triggered on version tags (e.g., v1.0.0)
3
+
4
+ name: Build Windows EXE
5
+
6
+ on:
7
+ push:
8
+ tags:
9
+ - 'v*'
10
+ workflow_dispatch: # Allow manual trigger
11
+
12
+ jobs:
13
+ build:
14
+ runs-on: windows-latest
15
+
16
+ steps:
17
+ - name: Checkout code
18
+ uses: actions/checkout@v4
19
+
20
+ - name: Set up Python
21
+ uses: actions/setup-python@v5
22
+ with:
23
+ python-version: '3.10'
24
+ cache: 'pip'
25
+
26
+ - name: Install dependencies
27
+ run: |
28
+ python -m pip install --upgrade pip
29
+ pip install -r requirements.txt
30
+ pip install pyinstaller
31
+
32
+ - name: Build executable
33
+ run: |
34
+ pyinstaller --noconfirm --clean --onefile ^
35
+ --name "PocketTTS-Server" ^
36
+ --add-data "static;static" ^
37
+ --add-data "templates;templates" ^
38
+ --add-data "voices;voices" ^
39
+ --add-data "app;app" ^
40
+ --collect-all "pocket_tts" ^
41
+ --hidden-import "waitress" ^
42
+ --hidden-import "engineio.async_drivers.threading" ^
43
+ server.py
44
+ shell: cmd
45
+
46
+ - name: Create release package
47
+ run: |
48
+ mkdir release
49
+ copy dist\PocketTTS-Server.exe release\
50
+ copy run_pocket_tts_server_exe.bat release\
51
+ copy README.md release\
52
+ copy LICENSE release\
53
+ shell: cmd
54
+
55
+ - name: Upload artifact
56
+ uses: actions/upload-artifact@v4
57
+ with:
58
+ name: PocketTTS-Server-Windows
59
+ path: release/
60
+ retention-days: 30
61
+
62
+ - name: Create GitHub Release
63
+ if: startsWith(github.ref, 'refs/tags/')
64
+ uses: softprops/action-gh-release@v1
65
+ with:
66
+ files: |
67
+ release/PocketTTS-Server.exe
68
+ release/run_pocket_tts_server_exe.bat
69
+ release/README.md
70
+ draft: false
71
+ prerelease: false
72
+ generate_release_notes: true
73
+ env:
74
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
.github/workflows/ci.yml ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Continuous Integration - Linting and Testing
2
+
3
+ name: CI
4
+
5
+ on:
6
+ push:
7
+ branches:
8
+ - main
9
+ pull_request:
10
+ branches:
11
+ - main
12
+
13
+ jobs:
14
+ lint:
15
+ runs-on: ubuntu-latest
16
+
17
+ steps:
18
+ - name: Checkout code
19
+ uses: actions/checkout@v4
20
+
21
+ - name: Set up Python
22
+ uses: actions/setup-python@v5
23
+ with:
24
+ python-version: '3.10'
25
+
26
+ - name: Install ruff
27
+ run: pip install ruff
28
+
29
+ - name: Run ruff linter
30
+ run: ruff check .
31
+
32
+ - name: Run ruff formatter check
33
+ run: ruff format --check .
34
+
35
+ test:
36
+ runs-on: ubuntu-latest
37
+ needs: lint
38
+
39
+ steps:
40
+ - name: Checkout code
41
+ uses: actions/checkout@v4
42
+
43
+ - name: Set up Python
44
+ uses: actions/setup-python@v5
45
+ with:
46
+ python-version: '3.10'
47
+ cache: 'pip'
48
+
49
+ - name: Install dependencies
50
+ run: |
51
+ python -m pip install --upgrade pip
52
+ pip install -r requirements.txt
53
+ pip install pytest pytest-cov
54
+
55
+ - name: Run import test
56
+ run: |
57
+ python -c "from app import create_app; print('Import test passed')"
58
+
59
+ # Note: Full TTS tests require the model which is too large for CI
60
+ # Add more tests as the project grows
.github/workflows/docker.yml ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Build and Push Docker Image
2
+
3
+ name: Docker Build
4
+
5
+ on:
6
+ push:
7
+ branches:
8
+ - main
9
+ tags:
10
+ - 'v*'
11
+ pull_request:
12
+ branches:
13
+ - main
14
+ workflow_dispatch:
15
+
16
+ env:
17
+ REGISTRY: ghcr.io
18
+ IMAGE_NAME: ${{ github.repository }}
19
+
20
+ jobs:
21
+ build:
22
+ runs-on: ubuntu-latest
23
+ permissions:
24
+ contents: read
25
+ packages: write
26
+
27
+ steps:
28
+ - name: Checkout code
29
+ uses: actions/checkout@v4
30
+
31
+ - name: Set up Docker Buildx
32
+ uses: docker/setup-buildx-action@v3
33
+
34
+ - name: Log in to Container Registry
35
+ if: github.event_name != 'pull_request'
36
+ uses: docker/login-action@v3
37
+ with:
38
+ registry: ${{ env.REGISTRY }}
39
+ username: ${{ github.actor }}
40
+ password: ${{ secrets.GITHUB_TOKEN }}
41
+
42
+ - name: Extract metadata
43
+ id: meta
44
+ uses: docker/metadata-action@v5
45
+ with:
46
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
47
+ tags: |
48
+ type=ref,event=branch
49
+ type=ref,event=pr
50
+ type=semver,pattern={{version}}
51
+ type=semver,pattern={{major}}.{{minor}}
52
+ type=sha
53
+
54
+ - name: Build and push
55
+ uses: docker/build-push-action@v5
56
+ with:
57
+ context: .
58
+ push: ${{ github.event_name != 'pull_request' }}
59
+ tags: ${{ steps.meta.outputs.tags }}
60
+ labels: ${{ steps.meta.outputs.labels }}
61
+ cache-from: type=gha
62
+ cache-to: type=gha,mode=max
63
+ platforms: linux/amd64
.gitignore CHANGED
@@ -3,6 +3,9 @@ __pycache__/
3
  *.py[cod]
4
  *$py.class
5
 
 
 
 
6
  # C extensions
7
  *.so
8
 
@@ -174,8 +177,8 @@ cython_debug/
174
  # PyPI configuration file
175
  .pypirc
176
 
177
- # Cursor
178
- # Cursor is an AI-powered code editor.`.cursorignore` specifies files/directories to
179
  # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
180
  # refer to https://docs.cursor.com/context/ignore-files
181
  .cursorignore
 
3
  *.py[cod]
4
  *$py.class
5
 
6
+ # Logs
7
+ logs/*.log
8
+
9
  # C extensions
10
  *.so
11
 
 
177
  # PyPI configuration file
178
  .pypirc
179
 
180
+ # Cursor
181
+ # Cursor is an AI-powered code editor.`.cursorignore` specifies files/directories to
182
  # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
183
  # refer to https://docs.cursor.com/context/ignore-files
184
  .cursorignore
.vscode/extensions.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "recommendations": [
3
+ "ms-python.python",
4
+ "charliermarsh.ruff",
5
+ "ms-python.debugpy"
6
+ ]
7
+ }
.vscode/launch.json ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": "0.2.0",
3
+ "configurations": [
4
+ {
5
+ "name": "PocketTTS Server",
6
+ "type": "debugpy",
7
+ "request": "launch",
8
+ "program": "${workspaceFolder}/server.py",
9
+ "console": "integratedTerminal",
10
+ "justMyCode": true,
11
+ "env": {
12
+ "POCKET_TTS_LOG_LEVEL": "DEBUG",
13
+ "POCKET_TTS_PORT": "49112"
14
+ },
15
+ "args": []
16
+ },
17
+ {
18
+ "name": "PocketTTS Server (Custom Port)",
19
+ "type": "debugpy",
20
+ "request": "launch",
21
+ "program": "${workspaceFolder}/server.py",
22
+ "console": "integratedTerminal",
23
+ "justMyCode": true,
24
+ "args": [
25
+ "--port",
26
+ "8080",
27
+ "--log-level",
28
+ "DEBUG"
29
+ ]
30
+ },
31
+ {
32
+ "name": "PocketTTS Server (With Voices Dir)",
33
+ "type": "debugpy",
34
+ "request": "launch",
35
+ "program": "${workspaceFolder}/server.py",
36
+ "console": "integratedTerminal",
37
+ "justMyCode": true,
38
+ "args": [
39
+ "--voices-dir",
40
+ "${workspaceFolder}/voices",
41
+ "--stream",
42
+ "--log-level",
43
+ "DEBUG"
44
+ ]
45
+ },
46
+ {
47
+ "name": "Python: Current File",
48
+ "type": "debugpy",
49
+ "request": "launch",
50
+ "program": "${file}",
51
+ "console": "integratedTerminal",
52
+ "justMyCode": true
53
+ }
54
+ ]
55
+ }
.vscode/settings.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "python.defaultInterpreterPath": "${workspaceFolder}/venv/bin/python",
3
+ "[python]": {
4
+ "editor.formatOnSave": true,
5
+ "editor.codeActionsOnSave": {
6
+ "source.fixAll": "explicit",
7
+ "source.organizeImports": "explicit"
8
+ },
9
+ "editor.defaultFormatter": "charliermarsh.ruff"
10
+ }
11
+ }
AGENTS.md ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Project Overview
2
+
3
+ **PocketTTS OpenAI-Compatible Server** wraps [pocket-tts](https://github.com/kyutai-labs/pocket-tts) to provide OpenAI-compatible TTS endpoints. Any OpenAI TTS client can use this for local, CPU-based text-to-speech.
4
+
5
+ ## Why This Exists
6
+
7
+ The official `pocket-tts` has a FastAPI server with `/tts` endpoint, but it's **not OpenAI API compatible**. This project adds:
8
+
9
+ - `/v1/audio/speech` matching OpenAI's schema
10
+ - `/v1/voices` for voice listing
11
+ - Docker deployment with voice mounting
12
+ - Windows executable distribution
13
+
14
+ ## Architecture
15
+
16
+ ```
17
+ server.py # Entry point, CLI, starts Waitress
18
+ └── app/__init__.py # Flask app factory
19
+ ├── app/routes.py # API endpoints
20
+ ├── app/config.py # Environment config
21
+ └── app/services/
22
+ ├── tts.py # TTSService: model, voice cache
23
+ └── audio.py # Format conversion, streaming
24
+ ```
25
+
26
+ ## Key Files
27
+
28
+ | File | Purpose |
29
+ | --------------------- | ---------------------------------------------------------------- |
30
+ | `server.py` | Entry point, CLI args, Waitress server |
31
+ | `app/routes.py` | HTTP endpoints: `/`, `/health`, `/v1/voices`, `/v1/audio/speech` |
32
+ | `app/services/tts.py` | Model loading, voice caching, generation |
33
+ | `app/config.py` | Environment variables, path resolution |
34
+
35
+ ## API Endpoints
36
+
37
+ | Endpoint | Method | Purpose |
38
+ | ------------------ | ------ | ----------------------------------- |
39
+ | `/` | GET | Web UI |
40
+ | `/health` | GET | Health check for containers |
41
+ | `/v1/voices` | GET | List voices |
42
+ | `/v1/audio/speech` | POST | Generate speech (OpenAI-compatible) |
43
+
44
+ ### Speech Request
45
+
46
+ ```json
47
+ {
48
+ "model": "tts-1",
49
+ "input": "Text to speak",
50
+ "voice": "alba",
51
+ "response_format": "mp3",
52
+ "stream": false
53
+ }
54
+ ```
55
+
56
+ ## Configuration (Environment Variables)
57
+
58
+ | Variable | Default | Purpose |
59
+ | --------------------------- | --------- | ------------------ |
60
+ | `POCKET_TTS_HOST` | `0.0.0.0` | Bind address |
61
+ | `POCKET_TTS_PORT` | `49112` | Port |
62
+ | `POCKET_TTS_VOICES_DIR` | None | Custom voices path |
63
+ | `POCKET_TTS_STREAM_DEFAULT` | `true` | Default streaming |
64
+ | `POCKET_TTS_LOG_LEVEL` | `INFO` | Log verbosity |
65
+
66
+ ## Voice Resolution Order
67
+
68
+ 1. URLs (`http://`, `https://`, `hf://`) → pass to pocket-tts
69
+ 2. Built-in names (`alba`, `marius`, etc.) → pass to pocket-tts
70
+ 3. Files in `POCKET_TTS_VOICES_DIR`
71
+ 4. Absolute paths
72
+ 5. Fallback to pocket-tts
73
+
74
+ ## Development
75
+
76
+ ```bash
77
+ # Install
78
+ pip install -r requirements.txt
79
+
80
+ # Run
81
+ python server.py --log-level DEBUG
82
+
83
+ # Test
84
+ curl http://localhost:49112/health
85
+ curl -X POST http://localhost:49112/v1/audio/speech \
86
+ -H "Content-Type: application/json" \
87
+ -d '{"input": "Hello", "voice": "alba"}' -o test.mp3
88
+ ```
89
+
90
+ ## Code Style
91
+
92
+ - Linter/formatter: `ruff` (config in `ruff.toml`)
93
+ - Line length: 100
94
+ - Single quotes
95
+
96
+ ## Deployment
97
+
98
+ - **Python**: `python server.py`
99
+ - **Docker**: `docker compose up -d`
100
+ - **Windows EXE**: Built via GitHub Actions on release tags
Dockerfile ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dockerfile for PocketTTS OpenAI-Compatible Server
2
+ # Optimized for CPU inference (pocket-tts runs efficiently on CPU)
3
+
4
+ FROM python:3.10-slim AS builder
5
+
6
+ # Install build dependencies
7
+ RUN apt-get update && apt-get install -y --no-install-recommends \
8
+ build-essential \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ # Create virtual environment
12
+ RUN python -m venv /opt/venv
13
+ ENV PATH="/opt/venv/bin:$PATH"
14
+
15
+ # Install Python dependencies
16
+ COPY requirements.txt /tmp/requirements.txt
17
+ RUN pip install --no-cache-dir --upgrade pip && \
18
+ pip install --no-cache-dir -r /tmp/requirements.txt
19
+
20
+
21
+ # Production image
22
+ FROM python:3.10-slim
23
+
24
+ # Install runtime dependencies for audio processing
25
+ RUN apt-get update && apt-get install -y --no-install-recommends \
26
+ libsndfile1 \
27
+ ffmpeg \
28
+ && rm -rf /var/lib/apt/lists/* \
29
+ && apt-get clean
30
+
31
+ # Copy virtual environment from builder
32
+ COPY --from=builder /opt/venv /opt/venv
33
+ ENV PATH="/opt/venv/bin:$PATH"
34
+
35
+ # Create non-root user
36
+ RUN useradd --create-home --shell /bin/bash pockettts
37
+ WORKDIR /app
38
+
39
+ # Copy application code
40
+ COPY --chown=pockettts:pockettts app/ ./app/
41
+ COPY --chown=pockettts:pockettts static/ ./static/
42
+ COPY --chown=pockettts:pockettts templates/ ./templates/
43
+ COPY --chown=pockettts:pockettts voices/ ./voices/
44
+ COPY --chown=pockettts:pockettts server.py ./
45
+
46
+ # Create logs directory
47
+ RUN mkdir -p /app/logs && chown pockettts:pockettts /app/logs
48
+
49
+ # Switch to non-root user
50
+ USER pockettts
51
+
52
+ # Environment variables with defaults
53
+ ENV POCKET_TTS_HOST=0.0.0.0 \
54
+ POCKET_TTS_PORT=49112 \
55
+ POCKET_TTS_VOICES_DIR=/app/voices \
56
+ POCKET_TTS_LOG_DIR=/app/logs \
57
+ POCKET_TTS_LOG_LEVEL=INFO \
58
+ PYTHONUNBUFFERED=1
59
+
60
+ # Expose port
61
+ EXPOSE 49112
62
+
63
+ # Health check
64
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
65
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:49112/health')" || exit 1
66
+
67
+ # Run server
68
+ CMD ["python", "server.py"]
README.md CHANGED
@@ -1,152 +1,316 @@
1
- # Pocket-TTS OpenAI Streaming Server
2
 
3
- This project implements an [OpenAI-compatible API](https://platform.openai.com/docs/api-reference/audio/createSpeech) for the **Pocket-TTS** text-to-speech model. It supports real-time streaming, high-quality voice synthesis, and easy management of custom voices.
4
 
5
- Pocket-TTS Github repo: https://github.com/kyutai-labs/pocket-tts
6
 
7
- Pocket-TTS Huggingface: https://huggingface.co/kyutai/pocket-tts
 
 
 
 
 
 
8
 
9
- ## Features
10
 
11
- - **OpenAI API Compatibility**: Drop-in replacement for `tts-1` endpoints.
12
- - **Streaming Support**: Real-time audio generation with low latency.
13
- - **Web Interface**: Simple built-in UI to test voices and generation.
14
- - **Custom Voices**: Easy addition of new voices by dragging and dropping `.wav` files.
15
- - **Flexible Configuration**: Run via command line or an interactive batch launcher.
16
- - **Windows EXE included**: If you want to use defaults (host: 0.0.0.0, port: 5002, streaming enabled, local model file and built-in voices) just double click on .exe and you're up and running!
17
 
18
- ## Prerequisites
19
-
20
- - **Python 3.10+**: Ensure Python is installed and added to your system PATH.
21
-
22
- ## EXE Installation and Usage (Windows only)
23
-
24
- - Download latest release .zip from https://github.com/teddybear082/pocket-tts-openai_streaming_server/releases
25
- - Unzip to location on your computer that is not protected, like C://Pocket-TTS-Server/
26
- - To use all defaults for server (host: 0.0.0.0, port: 5002, streaming enabled, local model file and built-in voices) just double click on .exe and you're up and running!
27
- - To be able to input custom arguments for any of those, run the run_pocket_tts_server_exe.bat instead and follow the UI prompts.
28
- - To use the WebUI, navigate to the host and port you set in the .bat file, by default: http://localhost:5002
29
- - Supports cloning .wav, .mp3. and .flac files. To try out custom cloning, use the WebUI and in the voice list select the last option to upload a custom file and insert the path to the .wav, .mp3, or .flac with the voice you want to clone.
30
-
31
- ## Python Installation
32
-
33
- 1. **Clone or Download** this repository to your local machine.
34
-
35
- 2. **Install Dependencies**:
36
- It is recommended to use a virtual environment.
37
-
38
- ```bash
39
- # Create virtual environment
40
- python -m venv venv
41
 
42
- # Activate virtual environment
43
- # Windows:
44
- venv\Scripts\activate
45
- # Linux/Mac:
46
- source venv/bin/activate
47
 
48
- # Install requirements
49
- pip install -r requirements.txt
50
- ```
51
 
52
- *Note: If you do not have `requirements.txt`, you will need at least:*
53
- ```bash
54
- pip install flask pocket-tts torch torchaudio
55
- ```
56
 
57
- 3. **Hugging Face Login (If required)**:
58
- Until a workaround is found, to enable voice cloning of .wavs you will need to insert your hugging face token when prompted in the .bat file.
59
 
60
- ## Python Usage
 
 
61
 
62
- ### Method 1: Interactive Launcher (Windows)
 
 
63
 
64
- Double-click `run_pocket_tts_server.bat`. This interactive script handles the setup:
65
 
66
- 1. **Hugging Face Login**: It may ask for your HF Token if not already set (leave blank if voice cloning not needed).
67
- 2. **Environment**: Automatically looks for and activates your `venv`.
68
- 3. **Configuration**: detailed prompts allow you to customize the run:
69
- - **Host/Port**: Defaults to `0.0.0.0:5002`.
70
- - **Model Path**: Press ENTER for the default, or specify a custom path/variant.
71
- - **Voices Directory**: Defaults to `voices/` in the script's folder.
72
- - **Streaming**: Option to enable streaming by default for all requests.
73
 
74
- ### Method 2: Command Line
 
 
75
 
76
- Activate your virtual environment and run:
 
77
 
78
- ```bash
79
- python pocket_tts_openai_server.py --host 0.0.0.0 --port 5002 --stream
80
  ```
81
 
82
- **Arguments:**
83
- - `--host`: Host IP to bind to (default: `0.0.0.0`).
84
- - `--port`: Port to listen on (default: `5002`).
85
- - `--model_path`: Path to a local model file or a specific Hugging Face model variant.
86
- - `--voices_dir`: Directory to scan for custom voice `.wav` files (default: `voices/` in the project root if created, or as specified).
87
- - `--stream`: Enable streaming by default for all requests.
88
 
89
- ## Web Interface
 
90
 
91
- Once the server is running, open your web browser and go to:
 
92
 
93
- > **http://localhost:5002** (or your configured host:port)
 
 
94
 
95
- From here you can:
96
- - Select available voices (Built-in or Custom).
97
- - Type text to generate speech.
98
- - Listen to the output directly in the browser.
99
 
100
- ## Custom Voices
 
 
 
101
 
102
- You can easily add your own voices to the server.
103
-
104
- 1. **Create a folder** for your voices (e.g., `voices/`).
105
- 2. **Add Audio Files**: Place short, clear `.wav` files (3-10 seconds ideal) of the target speaker in this folder.
106
- 3. **Restart/Configure Server**:
107
- - If using the **Batch Launcher**, specify the full path to your `voices` folder when prompted.
108
- - If using **CLI**, add `--voices_dir "path/to/voices"`.
109
 
110
- **Naming**: The filename (e.g., `my_voice.wav`) will become the Voice ID.
111
- - Example Voice ID: `my_voice.wav`
112
- - In the Request: `"voice": "my_voice.wav"`
113
 
114
- ### Built-in Voices
115
- The server comes with mappings for several default Pocket TTS voices:
116
- `alba`, `marius`, `javert`, `jean`, `fantine`, `cosette`, `eponine`, `azelma`.
117
 
118
  ## API Usage
119
 
120
- You can use this server with any OpenAI-compatible client.
121
 
122
- **Endpoint**: `POST /v1/audio/speech`
123
 
124
- **Example Request (cURL)**:
125
  ```bash
126
- curl http://localhost:5002/v1/audio/speech \
127
  -H "Content-Type: application/json" \
128
  -d '{
129
  "model": "tts-1",
130
- "input": "The quick brown fox jumps over the lazy dog.",
131
  "voice": "alba"
132
  }' \
133
- --output speech.wav
134
  ```
135
 
136
- **Python (openai-python)**:
 
137
  ```python
138
  from openai import OpenAI
139
 
140
  client = OpenAI(
141
- base_url="http://localhost:5002/v1",
142
- api_key="not-needed"
143
  )
144
 
 
145
  response = client.audio.speech.create(
146
  model="tts-1",
147
  voice="alba",
148
- input="Hello world! This is a test of Pocket TTS."
149
  )
 
150
 
151
- response.stream_to_file("output.wav")
 
 
 
 
 
 
 
 
 
152
  ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PocketTTS OpenAI-Compatible Server
2
 
3
+ An OpenAI-compatible Text-to-Speech API server powered by [Pocket-TTS](https://github.com/kyutai-labs/pocket-tts). Drop-in replacement for OpenAI's TTS API with support for streaming, custom voices, and voice cloning.
4
 
5
+ **Key Features:**
6
 
7
+ - 🎯 **OpenAI API Compatible** - Works with any OpenAI TTS client
8
+ - 🚀 **Real-time Streaming** - Low-latency audio generation
9
+ - 🎤 **150+ Community Voices** - Ready-to-use voice library included
10
+ - 🎭 **Voice Cloning** - Clone any voice from a short audio sample
11
+ - 🐳 **Docker Ready** - One-command deployment
12
+ - 💻 **Cross-platform** - Runs on Windows, macOS, and Linux
13
+ - ⚡ **CPU Optimized** - No GPU required
14
 
15
+ ## Quick Start
16
 
17
+ ### Option 1: Docker (Recommended)
 
 
 
 
 
18
 
19
+ ```bash
20
+ # Clone the repository
21
+ git clone https://github.com/teddybear082/pocket-tts-openai_streaming_server.git
22
+ cd pocket-tts-openai_streaming_server
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
+ # Start the server
25
+ docker compose up -d
 
 
 
26
 
27
+ # View logs
28
+ docker compose logs -f
29
+ ```
30
 
31
+ The server will be available at `http://localhost:49112`
 
 
 
32
 
33
+ **Custom Configuration:**
 
34
 
35
+ ```bash
36
+ # Change port
37
+ POCKET_TTS_PORT=8080 docker compose up -d
38
 
39
+ # Use custom voices directory
40
+ POCKET_TTS_VOICES_DIR=/path/to/my/voices docker compose up -d
41
+ ```
42
 
43
+ ### Option 2: Python (from source)
44
 
45
+ ```bash
46
+ # Clone the repository
47
+ git clone https://github.com/teddybear082/pocket-tts-openai_streaming_server.git
48
+ cd pocket-tts-openai_streaming_server
 
 
 
49
 
50
+ # Create virtual environment
51
+ python -m venv venv
52
+ source venv/bin/activate # On Windows: venv\Scripts\activate
53
 
54
+ # Install dependencies
55
+ pip install -r requirements.txt
56
 
57
+ # Start the server
58
+ python server.py
59
  ```
60
 
61
+ **Command Line Options:**
 
 
 
 
 
62
 
63
+ ```bash
64
+ python server.py --help
65
 
66
+ # Custom port and voices
67
+ python server.py --port 8080 --voices-dir ./my_voices
68
 
69
+ # Enable streaming by default
70
+ python server.py --stream
71
+ ```
72
 
73
+ ### Option 3: Windows Executable
 
 
 
74
 
75
+ 1. Download the latest release from [Releases](https://github.com/teddybear082/pocket-tts-openai_streaming_server/releases)
76
+ 2. Extract the ZIP file
77
+ 3. Double-click `PocketTTS-Server.exe` to run with defaults
78
+ 4. Or run `run_pocket_tts_server_exe.bat` for custom configuration
79
 
80
+ ## Web Interface
 
 
 
 
 
 
81
 
82
+ Open `http://localhost:49112` in your browser to access the built-in web UI:
 
 
83
 
84
+ - Select from available voices
85
+ - Enter text to synthesize
86
+ - Listen to generated audio directly
87
 
88
  ## API Usage
89
 
90
+ ### Generate Speech
91
 
92
+ **Endpoint:** `POST /v1/audio/speech`
93
 
 
94
  ```bash
95
+ curl http://localhost:49112/v1/audio/speech \
96
  -H "Content-Type: application/json" \
97
  -d '{
98
  "model": "tts-1",
99
+ "input": "Hello world! This is a test.",
100
  "voice": "alba"
101
  }' \
102
+ --output speech.mp3
103
  ```
104
 
105
+ ### Python Client
106
+
107
  ```python
108
  from openai import OpenAI
109
 
110
  client = OpenAI(
111
+ base_url="http://localhost:49112/v1",
112
+ api_key="not-needed" # No authentication required
113
  )
114
 
115
+ # Generate and save audio
116
  response = client.audio.speech.create(
117
  model="tts-1",
118
  voice="alba",
119
+ input="Hello world! This is a test."
120
  )
121
+ response.stream_to_file("output.mp3")
122
 
123
+ # Streaming
124
+ with client.audio.speech.with_streaming_response.create(
125
+ model="tts-1",
126
+ voice="alba",
127
+ input="This is streaming audio.",
128
+ response_format="pcm"
129
+ ) as response:
130
+ for chunk in response.iter_bytes():
131
+ # Process audio chunks in real-time
132
+ pass
133
  ```
134
+
135
+ ### API Reference
136
+
137
+ | Endpoint | Method | Description |
138
+ | ------------------ | ------ | ---------------------------------------- |
139
+ | `/` | GET | Web interface |
140
+ | `/health` | GET | Health check for container orchestration |
141
+ | `/v1/voices` | GET | List available voices |
142
+ | `/v1/audio/speech` | POST | Generate speech audio |
143
+
144
+ **Speech Parameters:**
145
+
146
+ | Parameter | Type | Required | Default | Description |
147
+ | ----------------- | ------- | -------- | ------- | -------------------------------------------------- |
148
+ | `model` | string | No | - | Ignored (for OpenAI compatibility) |
149
+ | `input` | string | Yes | - | Text to synthesize |
150
+ | `voice` | string | No | `alba` | Voice ID (see `/v1/voices`) |
151
+ | `response_format` | string | No | `mp3` | Output format: `mp3`, `wav`, `pcm`, `opus`, `flac` |
152
+ | `stream` | boolean | No | `false` | Enable streaming response |
153
+
154
+ ## Custom Voices
155
+
156
+ ### Using Custom Voice Files
157
+
158
+ 1. **Create a voices directory** with your audio files (`.wav`, `.mp3`, `.flac`)
159
+ 2. **Configure the server** to use your directory:
160
+
161
+ **Docker:**
162
+
163
+ ```bash
164
+ POCKET_TTS_VOICES_DIR=/path/to/voices docker compose up -d
165
+ ```
166
+
167
+ **Python:**
168
+
169
+ ```bash
170
+ python server.py --voices-dir /path/to/voices
171
+ ```
172
+
173
+ **Windows EXE:**
174
+ Use the batch launcher and specify the voices directory when prompted.
175
+
176
+ 3. **Use your voice** by filename:
177
+ ```json
178
+ { "voice": "my_voice.wav", "input": "Hello!" }
179
+ ```
180
+
181
+ ### Voice File Guidelines
182
+
183
+ - **Duration:** 3-10 seconds of clear speech works best
184
+ - **Quality:** Clean audio without background noise
185
+ - **Format:** WAV, MP3, or FLAC
186
+ - **Tip:** Use [Adobe Podcast Enhance](https://podcast.adobe.com/enhance) to clean noisy samples
187
+
188
+ ### Built-in Voices
189
+
190
+ The following voices are available by default:
191
+ `alba`, `marius`, `javert`, `jean`, `fantine`, `cosette`, `eponine`, `azelma`
192
+
193
+ The `voices/` directory includes 150+ community-contributed voices.
194
+
195
+ ## Configuration
196
+
197
+ ### Environment Variables
198
+
199
+ | Variable | Default | Description |
200
+ | --------------------------- | ---------- | -------------------------------------- |
201
+ | `POCKET_TTS_HOST` | `0.0.0.0` | Server bind address |
202
+ | `POCKET_TTS_PORT` | `49112` | Server port |
203
+ | `POCKET_TTS_VOICES_DIR` | `./voices` | Custom voices directory |
204
+ | `POCKET_TTS_MODEL_PATH` | - | Custom model path |
205
+ | `POCKET_TTS_STREAM_DEFAULT` | `true` | Enable streaming by default |
206
+ | `POCKET_TTS_LOG_LEVEL` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR |
207
+ | `POCKET_TTS_LOG_DIR` | `./logs` | Log files directory |
208
+ | `HF_TOKEN` | - | Hugging Face token (for voice cloning) |
209
+
210
+ ### Docker Compose Options
211
+
212
+ See [docker-compose.yml](docker-compose.yml) for all available options including:
213
+
214
+ - Volume mounts for custom voices
215
+ - Resource limits
216
+ - Health check configuration
217
+ - HuggingFace cache persistence
218
+
219
+ ## Project Structure
220
+
221
+ ```
222
+ pocket-tts-openai_streaming_server/
223
+ ├── app/ # Application modules
224
+ │ ├── __init__.py # Flask app factory
225
+ │ ├── config.py # Configuration management
226
+ │ ├── logging_config.py # Logging setup
227
+ │ ├── routes.py # API endpoints
228
+ │ └── services/ # Business logic
229
+ │ ├── audio.py # Audio conversion
230
+ │ └── tts.py # TTS service
231
+ ├── static/ # Web UI assets
232
+ ├── templates/ # HTML templates
233
+ ├── voices/ # Voice files
234
+ ├── server.py # Main entry point
235
+ ├── Dockerfile # Container build
236
+ ├── docker-compose.yml # Container orchestration
237
+ └── requirements.txt # Python dependencies
238
+ ```
239
+
240
+ ## Development
241
+
242
+ ### Dependencies
243
+
244
+ | File | Purpose |
245
+ | ---------------------- | ---------------------------------------------------- |
246
+ | `requirements.txt` | Runtime dependencies only (Flask, torch, pocket-tts) |
247
+ | `requirements-dev.txt` | Adds dev tools: ruff (linting), pytest (testing) |
248
+
249
+ ### Running Locally
250
+
251
+ ```bash
252
+ # Install runtime dependencies only
253
+ pip install -r requirements.txt
254
+
255
+ # Or install with dev tools (recommended for contributors)
256
+ pip install -r requirements-dev.txt
257
+
258
+ # Run with debug logging
259
+ python server.py --log-level DEBUG
260
+ ```
261
+
262
+ ### Linting
263
+
264
+ ```bash
265
+ pip install ruff
266
+ ruff check .
267
+ ruff format .
268
+ ```
269
+
270
+ ### Building Windows EXE
271
+
272
+ ```bash
273
+ pip install pyinstaller
274
+ pyinstaller --onefile --name PocketTTS-Server \
275
+ --add-data "static;static" \
276
+ --add-data "templates;templates" \
277
+ --add-data "voices;voices" \
278
+ --add-data "app;app" \
279
+ server.py
280
+ ```
281
+
282
+ ## Troubleshooting
283
+
284
+ ### Model Loading Takes Long
285
+
286
+ First run downloads the model (~500MB). Subsequent runs use cached model.
287
+
288
+ **Docker:** Model cache is persisted in a Docker volume.
289
+
290
+ ### Voice Cloning Requires HF Token
291
+
292
+ For voice cloning, you may need a Hugging Face token:
293
+
294
+ 1. Get token from https://huggingface.co/settings/tokens
295
+ 2. Set `HF_TOKEN` environment variable
296
+
297
+ ### Port Already in Use
298
+
299
+ ```bash
300
+ # Use a different port
301
+ python server.py --port 8080
302
+
303
+ # Or with Docker
304
+ POCKET_TTS_PORT=8080 docker compose up -d
305
+ ```
306
+
307
+ ## Credits
308
+
309
+ - [Pocket-TTS](https://github.com/kyutai-labs/pocket-tts) by Kyutai Labs
310
+ - Community voice contributors (see [voices/credits.txt](voices/credits.txt))
311
+
312
+ ## License
313
+
314
+ This project is licensed under the MIT License - see [LICENSE](LICENSE) for details.
315
+
316
+ Pocket-TTS is subject to its own license terms.
app/__init__.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PocketTTS OpenAI-Compatible Server
3
+
4
+ Flask application factory and initialization.
5
+ """
6
+
7
+ from flask import Flask
8
+
9
+ from app.config import Config
10
+ from app.logging_config import get_logger, setup_logging
11
+
12
+
13
+ def create_app(config_overrides: dict = None) -> Flask:
14
+ """
15
+ Application factory for creating the Flask app.
16
+
17
+ Args:
18
+ config_overrides: Optional dictionary of config values to override
19
+
20
+ Returns:
21
+ Configured Flask application
22
+ """
23
+ # Setup logging first
24
+ setup_logging()
25
+ logger = get_logger()
26
+
27
+ # Create Flask app with correct paths
28
+ app = Flask(
29
+ __name__,
30
+ template_folder=Config.get_template_folder(),
31
+ static_folder=Config.get_static_folder(),
32
+ )
33
+
34
+ # Apply default config
35
+ app.config['STREAM_DEFAULT'] = Config.STREAM_DEFAULT
36
+
37
+ # Apply overrides
38
+ if config_overrides:
39
+ app.config.update(config_overrides)
40
+
41
+ # Register blueprints
42
+ from app.routes import api
43
+
44
+ app.register_blueprint(api)
45
+
46
+ logger.info('Flask application created')
47
+
48
+ return app
49
+
50
+
51
+ def init_tts_service(model_path: str = None, voices_dir: str = None) -> None:
52
+ """
53
+ Initialize the TTS service with model and voices.
54
+
55
+ Args:
56
+ model_path: Optional path to model file
57
+ voices_dir: Optional path to voices directory
58
+ """
59
+ from app.services.tts import get_tts_service
60
+
61
+ logger = get_logger()
62
+ tts = get_tts_service()
63
+
64
+ # Load model
65
+ tts.load_model(model_path)
66
+
67
+ # Set voices directory
68
+ if voices_dir:
69
+ tts.set_voices_dir(voices_dir)
70
+ else:
71
+ # Check for bundled voices
72
+ bundle_voices, _ = Config.get_bundle_paths()
73
+ if bundle_voices:
74
+ tts.set_voices_dir(bundle_voices)
75
+
76
+ logger.info('TTS service initialized')
app/config.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Configuration management for PocketTTS OpenAI Server.
3
+ Loads settings from environment variables with sensible defaults.
4
+ """
5
+
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+
11
+ def get_base_path() -> Path:
12
+ """Get the base path for the application, handling PyInstaller frozen state."""
13
+ if getattr(sys, 'frozen', False):
14
+ if hasattr(sys, '_MEIPASS'):
15
+ # One-file mode
16
+ return Path(sys._MEIPASS)
17
+ else:
18
+ # One-dir mode
19
+ return Path(sys.executable).parent
20
+ return Path(__file__).parent.parent
21
+
22
+
23
+ class Config:
24
+ """Application configuration loaded from environment variables."""
25
+
26
+ # Base paths
27
+ BASE_PATH = get_base_path()
28
+ IS_FROZEN = getattr(sys, 'frozen', False)
29
+
30
+ # Server settings
31
+ HOST = os.environ.get('POCKET_TTS_HOST', '0.0.0.0')
32
+ PORT = int(os.environ.get('POCKET_TTS_PORT', '49112'))
33
+
34
+ # Model settings
35
+ MODEL_PATH = os.environ.get('POCKET_TTS_MODEL_PATH', None)
36
+ DEFAULT_VOICE = os.environ.get(
37
+ 'POCKET_TTS_DEFAULT_VOICE', 'hf://kyutai/tts-voices/alba-mackenna/casual.wav'
38
+ )
39
+
40
+ # Voice directory
41
+ VOICES_DIR = os.environ.get('POCKET_TTS_VOICES_DIR', None)
42
+
43
+ # Streaming default
44
+ STREAM_DEFAULT = os.environ.get('POCKET_TTS_STREAM_DEFAULT', 'true').lower() == 'true'
45
+
46
+ # Logging
47
+ LOG_LEVEL = os.environ.get('POCKET_TTS_LOG_LEVEL', 'INFO')
48
+ LOG_DIR = os.environ.get('POCKET_TTS_LOG_DIR', str(BASE_PATH / 'logs'))
49
+ LOG_FILE = os.environ.get('POCKET_TTS_LOG_FILE', 'pocket_tts.log')
50
+ LOG_MAX_BYTES = int(os.environ.get('POCKET_TTS_LOG_MAX_BYTES', str(10 * 1024 * 1024))) # 10MB
51
+ LOG_BACKUP_COUNT = int(os.environ.get('POCKET_TTS_LOG_BACKUP_COUNT', '5'))
52
+
53
+ # Built-in voice mappings (these are resolved by pocket-tts internally)
54
+ BUILTIN_VOICES = ['alba', 'marius', 'javert', 'jean', 'fantine', 'cosette', 'eponine', 'azelma']
55
+
56
+ # Supported audio extensions for custom voices
57
+ VOICE_EXTENSIONS = ('.wav', '.mp3', '.flac', '.safetensors')
58
+
59
+ @classmethod
60
+ def get_bundle_paths(cls) -> tuple:
61
+ """Get bundled paths for frozen executables."""
62
+ if cls.IS_FROZEN:
63
+ voices_dir = cls.BASE_PATH / 'voices'
64
+ model_path = cls.BASE_PATH / 'model' / 'b6369a24.yaml'
65
+ return (
66
+ str(voices_dir) if voices_dir.is_dir() else None,
67
+ str(model_path) if model_path.is_file() else None,
68
+ )
69
+ return None, None
70
+
71
+ @classmethod
72
+ def get_template_folder(cls) -> str:
73
+ """Get the templates folder path."""
74
+ return str(cls.BASE_PATH / 'templates')
75
+
76
+ @classmethod
77
+ def get_static_folder(cls) -> str:
78
+ """Get the static files folder path."""
79
+ return str(cls.BASE_PATH / 'static')
app/logging_config.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Logging configuration with file rotation support.
3
+ """
4
+
5
+ import logging
6
+ import sys
7
+ from logging.handlers import RotatingFileHandler
8
+ from pathlib import Path
9
+
10
+ from app.config import Config
11
+
12
+
13
+ def setup_logging(log_level: str = None) -> logging.Logger:
14
+ """
15
+ Configure application logging with console and rotating file handlers.
16
+
17
+ Args:
18
+ log_level: Override log level (default: from Config.LOG_LEVEL)
19
+
20
+ Returns:
21
+ Configured logger instance
22
+ """
23
+ level = getattr(logging, (log_level or Config.LOG_LEVEL).upper(), logging.INFO)
24
+
25
+ # Create logger
26
+ logger = logging.getLogger('PocketTTS')
27
+ logger.setLevel(level)
28
+
29
+ # Avoid duplicate handlers
30
+ if logger.handlers:
31
+ return logger
32
+
33
+ # Console handler - simple format
34
+ console_handler = logging.StreamHandler(sys.stdout)
35
+ console_handler.setLevel(level)
36
+ console_format = logging.Formatter(
37
+ '%(asctime)s - %(levelname)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S'
38
+ )
39
+ console_handler.setFormatter(console_format)
40
+ logger.addHandler(console_handler)
41
+
42
+ # File handler - detailed format with rotation
43
+ try:
44
+ log_dir = Path(Config.LOG_DIR)
45
+ log_dir.mkdir(parents=True, exist_ok=True)
46
+ log_path = log_dir / Config.LOG_FILE
47
+
48
+ file_handler = RotatingFileHandler(
49
+ log_path,
50
+ maxBytes=Config.LOG_MAX_BYTES,
51
+ backupCount=Config.LOG_BACKUP_COUNT,
52
+ encoding='utf-8',
53
+ )
54
+ file_handler.setLevel(level)
55
+ file_format = logging.Formatter(
56
+ '%(asctime)s - %(name)s - %(levelname)s - [%(filename)s:%(lineno)d] - %(message)s',
57
+ datefmt='%Y-%m-%d %H:%M:%S',
58
+ )
59
+ file_handler.setFormatter(file_format)
60
+ logger.addHandler(file_handler)
61
+
62
+ except Exception as e:
63
+ logger.warning(f'Could not set up file logging: {e}')
64
+
65
+ # Suppress noisy third-party loggers
66
+ logging.getLogger('werkzeug').setLevel(logging.WARNING)
67
+ logging.getLogger('urllib3').setLevel(logging.WARNING)
68
+
69
+ return logger
70
+
71
+
72
+ def get_logger(name: str = None) -> logging.Logger:
73
+ """Get a logger instance, optionally with a child name."""
74
+ base_logger = logging.getLogger('PocketTTS')
75
+ if name:
76
+ return base_logger.getChild(name)
77
+ return base_logger
app/routes.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Flask routes for the OpenAI-compatible TTS API.
3
+ """
4
+
5
+ import time
6
+
7
+ from flask import (
8
+ Blueprint,
9
+ Response,
10
+ jsonify,
11
+ render_template,
12
+ request,
13
+ send_file,
14
+ stream_with_context,
15
+ )
16
+
17
+ from app.logging_config import get_logger
18
+ from app.services.audio import (
19
+ convert_audio,
20
+ get_mime_type,
21
+ tensor_to_pcm_bytes,
22
+ validate_format,
23
+ write_wav_header,
24
+ )
25
+ from app.services.tts import get_tts_service
26
+
27
+ logger = get_logger('routes')
28
+
29
+ # Create blueprint
30
+ api = Blueprint('api', __name__)
31
+
32
+
33
+ @api.route('/')
34
+ def home():
35
+ """Serve the web interface."""
36
+ return render_template('index.html')
37
+
38
+
39
+ @api.route('/health', methods=['GET'])
40
+ def health():
41
+ """
42
+ Health check endpoint for container orchestration.
43
+
44
+ Returns service status and basic model info.
45
+ """
46
+ tts = get_tts_service()
47
+
48
+ # Validate a built-in voice quickly
49
+ voice_valid, voice_msg = tts.validate_voice('alba')
50
+
51
+ return jsonify(
52
+ {
53
+ 'status': 'healthy' if tts.is_loaded else 'unhealthy',
54
+ 'model_loaded': tts.is_loaded,
55
+ 'device': tts.device if tts.is_loaded else None,
56
+ 'sample_rate': tts.sample_rate if tts.is_loaded else None,
57
+ 'voices_dir': tts.voices_dir,
58
+ 'voice_check': {'valid': voice_valid, 'message': voice_msg},
59
+ }
60
+ ), 200 if tts.is_loaded else 503
61
+
62
+
63
+ @api.route('/v1/voices', methods=['GET'])
64
+ def list_voices():
65
+ """
66
+ List available voices.
67
+
68
+ Returns OpenAI-compatible voice list format.
69
+ """
70
+ tts = get_tts_service()
71
+ voices = tts.list_voices()
72
+
73
+ return jsonify(
74
+ {
75
+ 'object': 'list',
76
+ 'data': [{'id': v['id'], 'name': v['name'], 'object': 'voice'} for v in voices],
77
+ }
78
+ )
79
+
80
+
81
+ @api.route('/v1/audio/speech', methods=['POST'])
82
+ def generate_speech():
83
+ """
84
+ OpenAI-compatible speech generation endpoint.
85
+
86
+ Request body:
87
+ model: string (ignored, for compatibility)
88
+ input: string (required) - Text to synthesize
89
+ voice: string (optional) - Voice ID or path
90
+ response_format: string (optional) - Audio format
91
+ stream: boolean (optional) - Enable streaming
92
+
93
+ Returns:
94
+ Audio file or streaming audio response
95
+ """
96
+ from flask import current_app
97
+
98
+ data = request.json
99
+
100
+ if not data:
101
+ return jsonify({'error': 'Missing JSON body'}), 400
102
+
103
+ text = data.get('input')
104
+ if not text:
105
+ return jsonify({'error': "Missing 'input' text"}), 400
106
+
107
+ voice = data.get('voice', 'alba')
108
+ stream_request = data.get('stream', False)
109
+
110
+ # Determine format based on streaming
111
+ if stream_request:
112
+ response_format = data.get('response_format', 'pcm')
113
+ else:
114
+ response_format = data.get('response_format', 'mp3')
115
+
116
+ target_format = validate_format(response_format)
117
+
118
+ tts = get_tts_service()
119
+
120
+ # Validate voice first
121
+ is_valid, msg = tts.validate_voice(voice)
122
+ if not is_valid:
123
+ available = [v['id'] for v in tts.list_voices()]
124
+ return jsonify(
125
+ {
126
+ 'error': f"Voice '{voice}' not found",
127
+ 'available_voices': available[:10], # Limit to first 10
128
+ 'hint': 'Use /v1/voices to see all available voices',
129
+ }
130
+ ), 400
131
+
132
+ try:
133
+ voice_state = tts.get_voice_state(voice)
134
+
135
+ # Check if streaming should be used
136
+ use_streaming = stream_request or current_app.config.get('STREAM_DEFAULT', False)
137
+
138
+ if use_streaming:
139
+ return _stream_audio(tts, voice_state, text, target_format)
140
+ else:
141
+ return _generate_file(tts, voice_state, text, target_format)
142
+
143
+ except ValueError as e:
144
+ logger.warning(f'Voice loading failed: {e}')
145
+ return jsonify({'error': str(e)}), 400
146
+ except Exception as e:
147
+ logger.exception('Generation failed')
148
+ return jsonify({'error': str(e)}), 500
149
+
150
+
151
+ def _generate_file(tts, voice_state, text: str, fmt: str):
152
+ """Generate complete audio and return as file."""
153
+ t0 = time.time()
154
+ audio_tensor = tts.generate_audio(voice_state, text)
155
+ generation_time = time.time() - t0
156
+
157
+ logger.info(f'Generated {len(text)} chars in {generation_time:.2f}s')
158
+
159
+ audio_buffer = convert_audio(audio_tensor, tts.sample_rate, fmt)
160
+ mimetype = get_mime_type(fmt)
161
+
162
+ return send_file(
163
+ audio_buffer, mimetype=mimetype, as_attachment=True, download_name=f'speech.{fmt}'
164
+ )
165
+
166
+
167
+ def _stream_audio(tts, voice_state, text: str, fmt: str):
168
+ """Stream audio chunks."""
169
+
170
+ def generate():
171
+ stream = tts.generate_audio_stream(voice_state, text)
172
+ for chunk_tensor in stream:
173
+ yield tensor_to_pcm_bytes(chunk_tensor)
174
+
175
+ def stream_with_header():
176
+ # Yield WAV header first if format is WAV
177
+ if fmt == 'wav':
178
+ yield write_wav_header(tts.sample_rate, num_channels=1, bits_per_sample=16)
179
+ yield from generate()
180
+
181
+ mimetype = get_mime_type(fmt)
182
+
183
+ return Response(stream_with_context(stream_with_header()), mimetype=mimetype)
app/services/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Services package."""
2
+
3
+ from app.services.tts import TTSService, get_tts_service
4
+
5
+ __all__ = ['TTSService', 'get_tts_service']
utils.py → app/services/audio.py RENAMED
@@ -1,81 +1,110 @@
 
 
 
 
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', 'pcm'}
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))
@@ -84,5 +113,47 @@ def write_wav_header(sample_rate: int, num_channels: int = 1, bits_per_sample: i
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()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Audio conversion and streaming utilities.
3
+ """
4
+
5
  import io
6
+ import struct
7
+
8
  import torch
9
  import torchaudio
 
10
 
11
+ from app.logging_config import get_logger
12
+
13
+ logger = get_logger('audio')
14
+
15
+ # Valid audio formats
16
+ VALID_FORMATS = {'mp3', 'wav', 'opus', 'aac', 'flac', 'pcm'}
17
+
18
+
19
+ def validate_format(fmt: str) -> str:
20
+ """
21
+ Normalize and validate the requested audio format.
22
+
23
+ Args:
24
+ fmt: Requested format string
25
+
26
+ Returns:
27
+ Validated format string
28
+ """
29
+ fmt = fmt.lower()
30
+
31
+ # OpenAI sometimes sends 'mpeg' for mp3
32
+ if fmt == 'mpeg':
33
+ return 'mp3'
34
+
35
+ if fmt not in VALID_FORMATS:
36
+ logger.warning(f"Unknown format '{fmt}', falling back to wav")
37
+ return 'wav'
38
 
39
+ return fmt
40
+
41
+
42
+ def convert_audio(
43
+ audio_tensor: torch.Tensor, sample_rate: int, target_format: str = 'wav'
44
+ ) -> io.BytesIO:
45
  """
46
  Convert a raw audio tensor to a byte buffer in the specified format.
47
+
48
  Args:
49
+ audio_tensor: The audio waveform (1D or 2D)
50
+ sample_rate: The sample rate of the audio
51
+ target_format: The target audio format
52
+
53
  Returns:
54
+ Buffer containing the encoded audio data
55
  """
56
  buffer = io.BytesIO()
57
+
58
  # Ensure tensor is CPU
59
  if audio_tensor.is_cuda:
60
  audio_tensor = audio_tensor.cpu()
61
+
62
  # Ensure 2D (channels, time)
63
  if audio_tensor.dim() == 1:
64
  audio_tensor = audio_tensor.unsqueeze(0)
 
 
 
 
 
65
 
66
  try:
67
  torchaudio.save(buffer, audio_tensor, sample_rate, format=target_format)
68
  buffer.seek(0)
69
  return buffer
70
  except Exception as e:
71
+ logger.error(f'Error converting audio to {target_format}: {e}')
72
+ raise
 
 
73
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
+ def write_wav_header(
76
+ sample_rate: int, num_channels: int = 1, bits_per_sample: int = 16, num_frames: int = 0
77
+ ) -> bytes:
78
  """
79
+ Generate a WAV header for streaming.
80
+
81
+ If num_frames is 0, set to max value (streaming/unknown length).
82
+
83
+ Args:
84
+ sample_rate: Audio sample rate
85
+ num_channels: Number of audio channels
86
+ bits_per_sample: Bits per sample
87
+ num_frames: Number of frames (0 for unknown/streaming)
88
+
89
+ Returns:
90
+ WAV header bytes
91
  """
 
 
92
  byte_rate = sample_rate * num_channels * bits_per_sample // 8
93
  block_align = num_channels * bits_per_sample // 8
94
+
95
+ # Data size: if unknown, max uint32
96
  data_size = num_frames * block_align
97
  if num_frames == 0:
98
+ data_size = 0xFFFFFFFF - 36
99
+
100
  chunk_size = 36 + data_size
101
+
102
  header = io.BytesIO()
103
  header.write(b'RIFF')
104
  header.write(struct.pack('<I', chunk_size))
105
  header.write(b'WAVE')
106
  header.write(b'fmt ')
107
+ header.write(struct.pack('<I', 16)) # Subchunk1Size (16 for PCM)
108
  header.write(struct.pack('<H', 1)) # AudioFormat (1 for PCM)
109
  header.write(struct.pack('<H', num_channels))
110
  header.write(struct.pack('<I', sample_rate))
 
113
  header.write(struct.pack('<H', bits_per_sample))
114
  header.write(b'data')
115
  header.write(struct.pack('<I', data_size))
116
+
117
  return header.getvalue()
118
+
119
+
120
+ def tensor_to_pcm_bytes(chunk_tensor: torch.Tensor) -> bytes:
121
+ """
122
+ Convert audio tensor chunk to 16-bit PCM bytes.
123
+
124
+ Args:
125
+ chunk_tensor: Audio tensor chunk
126
+
127
+ Returns:
128
+ PCM audio bytes
129
+ """
130
+ if chunk_tensor.is_cuda:
131
+ chunk_tensor = chunk_tensor.cpu()
132
+
133
+ if chunk_tensor.dim() == 1:
134
+ chunk_tensor = chunk_tensor.unsqueeze(0)
135
+
136
+ # Convert to 16-bit PCM
137
+ pcm = (chunk_tensor * 32767).clamp(-32768, 32767).to(torch.int16)
138
+ return pcm.numpy().tobytes()
139
+
140
+
141
+ def get_mime_type(fmt: str) -> str:
142
+ """
143
+ Get the MIME type for an audio format.
144
+
145
+ Args:
146
+ fmt: Audio format string
147
+
148
+ Returns:
149
+ MIME type string
150
+ """
151
+ mime_types = {
152
+ 'wav': 'audio/wav',
153
+ 'mp3': 'audio/mpeg',
154
+ 'pcm': 'audio/L16',
155
+ 'opus': 'audio/opus',
156
+ 'aac': 'audio/aac',
157
+ 'flac': 'audio/flac',
158
+ }
159
+ return mime_types.get(fmt, f'audio/{fmt}')
app/services/tts.py ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TTS Service - handles model loading, voice management, and audio generation.
3
+ """
4
+
5
+ import os
6
+ import time
7
+ from collections.abc import Iterator
8
+ from pathlib import Path
9
+
10
+ import torch
11
+
12
+ from app.config import Config
13
+ from app.logging_config import get_logger
14
+
15
+ logger = get_logger('tts')
16
+
17
+ # Lazy import pocket_tts to allow for better error handling
18
+ TTSModel = None
19
+
20
+
21
+ def _ensure_pocket_tts():
22
+ """Ensure pocket-tts is imported."""
23
+ global TTSModel
24
+ if TTSModel is None:
25
+ try:
26
+ from pocket_tts import TTSModel as _TTSModel
27
+
28
+ TTSModel = _TTSModel
29
+ except ImportError as exc:
30
+ raise ImportError('pocket-tts not found. Install with: pip install pocket-tts') from exc
31
+
32
+
33
+ class TTSService:
34
+ """
35
+ Service class for Text-to-Speech operations.
36
+ Manages model loading, voice caching, and audio generation.
37
+ """
38
+
39
+ def __init__(self):
40
+ self.model = None
41
+ self.voice_cache: dict = {}
42
+ self.voices_dir: str | None = None
43
+ self._model_loaded = False
44
+
45
+ @property
46
+ def is_loaded(self) -> bool:
47
+ """Check if the model is loaded."""
48
+ return self._model_loaded and self.model is not None
49
+
50
+ @property
51
+ def sample_rate(self) -> int:
52
+ """Get the model's sample rate."""
53
+ if self.model:
54
+ return self.model.sample_rate
55
+ return 24000 # Default pocket-tts sample rate
56
+
57
+ @property
58
+ def device(self) -> str:
59
+ """Get the model's device."""
60
+ if self.model:
61
+ return str(self.model.device)
62
+ return 'unknown'
63
+
64
+ def load_model(self, model_path: str | None = None) -> None:
65
+ """
66
+ Load the TTS model.
67
+
68
+ Args:
69
+ model_path: Optional path to model file or variant name
70
+ """
71
+ _ensure_pocket_tts()
72
+
73
+ logger.info('Loading Pocket TTS model...')
74
+ t0 = time.time()
75
+
76
+ # Determine model path
77
+ effective_path = model_path
78
+
79
+ if not effective_path:
80
+ # Check for bundled model in frozen executable
81
+ _, bundle_model = Config.get_bundle_paths()
82
+ if bundle_model and os.path.isfile(bundle_model):
83
+ effective_path = bundle_model
84
+ logger.info(f'Using bundled model: {effective_path}')
85
+
86
+ try:
87
+ if effective_path:
88
+ logger.info(f'Loading model from: {effective_path}')
89
+ self.model = TTSModel.load_model(variant=effective_path)
90
+ else:
91
+ logger.info('Loading default model from HuggingFace...')
92
+ self.model = TTSModel.load_model()
93
+
94
+ self._model_loaded = True
95
+ load_time = time.time() - t0
96
+ logger.info(
97
+ f'Model loaded in {load_time:.2f}s. '
98
+ f'Device: {self.device}, Sample Rate: {self.sample_rate}'
99
+ )
100
+
101
+ except Exception as e:
102
+ logger.error(f'Failed to load model: {e}')
103
+ raise
104
+
105
+ def set_voices_dir(self, voices_dir: str | None) -> None:
106
+ """
107
+ Set the directory for custom voice files.
108
+
109
+ Args:
110
+ voices_dir: Path to directory containing voice files
111
+ """
112
+ if voices_dir and os.path.isdir(voices_dir):
113
+ self.voices_dir = voices_dir
114
+ logger.info(f'Voices directory set to: {voices_dir}')
115
+ elif voices_dir:
116
+ logger.warning(f'Voices directory not found: {voices_dir}')
117
+ self.voices_dir = None
118
+ else:
119
+ self.voices_dir = None
120
+
121
+ def get_voice_state(self, voice_id_or_path: str) -> dict:
122
+ """
123
+ Resolve voice ID to a model state with caching.
124
+
125
+ Args:
126
+ voice_id_or_path: Voice identifier (name, file path, or URL)
127
+
128
+ Returns:
129
+ Model state dictionary for the voice
130
+
131
+ Raises:
132
+ ValueError: If voice cannot be loaded
133
+ """
134
+ if not self.is_loaded:
135
+ raise RuntimeError('Model not loaded. Call load_model() first.')
136
+
137
+ # Resolve the voice path
138
+ resolved_key = self._resolve_voice_path(voice_id_or_path)
139
+
140
+ # Check cache
141
+ if resolved_key in self.voice_cache:
142
+ logger.debug(f'Using cached voice state for: {resolved_key}')
143
+ return self.voice_cache[resolved_key]
144
+
145
+ # Load voice
146
+ logger.info(f'Loading voice: {resolved_key}')
147
+ t0 = time.time()
148
+
149
+ try:
150
+ state = self.model.get_state_for_audio_prompt(resolved_key)
151
+ self.voice_cache[resolved_key] = state
152
+ load_time = time.time() - t0
153
+ logger.info(f'Voice loaded in {load_time:.2f}s: {resolved_key}')
154
+ return state
155
+
156
+ except Exception as e:
157
+ logger.error(f"Failed to load voice '{voice_id_or_path}': {e}")
158
+ raise ValueError(f"Voice '{voice_id_or_path}' could not be loaded: {e}") from e
159
+
160
+ def _resolve_voice_path(self, voice_id_or_path: str) -> str:
161
+ """
162
+ Resolve a voice identifier to its actual path or ID.
163
+
164
+ Args:
165
+ voice_id_or_path: Voice identifier
166
+
167
+ Returns:
168
+ Resolved path or identifier
169
+ """
170
+ # Check if it's a URL or HF path - pass through
171
+ if voice_id_or_path.startswith(('http://', 'https://', 'hf://')):
172
+ return voice_id_or_path
173
+
174
+ # Check if it's a built-in voice
175
+ if voice_id_or_path.lower() in Config.BUILTIN_VOICES:
176
+ return voice_id_or_path.lower()
177
+
178
+ # Check voices directory
179
+ if self.voices_dir:
180
+ for ext in Config.VOICE_EXTENSIONS:
181
+ # Try exact match first
182
+ possible_path = os.path.join(self.voices_dir, voice_id_or_path)
183
+ if os.path.exists(possible_path):
184
+ return os.path.abspath(possible_path)
185
+
186
+ # Try with extension
187
+ if not voice_id_or_path.endswith(ext):
188
+ possible_path = os.path.join(self.voices_dir, voice_id_or_path + ext)
189
+ if os.path.exists(possible_path):
190
+ return os.path.abspath(possible_path)
191
+
192
+ # Check if it's an absolute path that exists
193
+ if os.path.isabs(voice_id_or_path) and os.path.exists(voice_id_or_path):
194
+ return voice_id_or_path
195
+
196
+ # Return as-is, let pocket-tts handle it
197
+ return voice_id_or_path
198
+
199
+ def validate_voice(self, voice_id_or_path: str) -> tuple[bool, str]:
200
+ """
201
+ Validate if a voice can be loaded (fast check without full loading).
202
+
203
+ Args:
204
+ voice_id_or_path: Voice identifier
205
+
206
+ Returns:
207
+ Tuple of (is_valid, message)
208
+ """
209
+ resolved = self._resolve_voice_path(voice_id_or_path)
210
+
211
+ # Built-in voices are always valid
212
+ if resolved.lower() in Config.BUILTIN_VOICES:
213
+ return True, f'Built-in voice: {resolved}'
214
+
215
+ # URLs - assume valid (we can't check without making a request)
216
+ if resolved.startswith(('http://', 'https://', 'hf://')):
217
+ return True, f'Remote voice: {resolved}'
218
+
219
+ # Local file - check existence
220
+ if os.path.exists(resolved):
221
+ return True, f'Local voice file: {resolved}'
222
+
223
+ return False, f'Voice not found: {voice_id_or_path}'
224
+
225
+ def generate_audio(self, voice_state: dict, text: str) -> torch.Tensor:
226
+ """
227
+ Generate complete audio for given text.
228
+
229
+ Args:
230
+ voice_state: Model state from get_voice_state()
231
+ text: Text to synthesize
232
+
233
+ Returns:
234
+ Audio tensor
235
+ """
236
+ if not self.is_loaded:
237
+ raise RuntimeError('Model not loaded')
238
+
239
+ t0 = time.time()
240
+ audio = self.model.generate_audio(voice_state, text)
241
+ gen_time = time.time() - t0
242
+
243
+ logger.info(f'Generated {len(text)} chars in {gen_time:.2f}s')
244
+ return audio
245
+
246
+ def generate_audio_stream(self, voice_state: dict, text: str) -> Iterator[torch.Tensor]:
247
+ """
248
+ Generate audio in streaming chunks.
249
+
250
+ Args:
251
+ voice_state: Model state from get_voice_state()
252
+ text: Text to synthesize
253
+
254
+ Yields:
255
+ Audio tensor chunks
256
+ """
257
+ if not self.is_loaded:
258
+ raise RuntimeError('Model not loaded')
259
+
260
+ logger.info(f'Starting streaming generation for {len(text)} chars')
261
+ yield from self.model.generate_audio_stream(voice_state, text)
262
+
263
+ def list_voices(self) -> list[dict]:
264
+ """
265
+ List all available voices.
266
+
267
+ Returns:
268
+ List of voice dictionaries with 'id' and 'name' keys
269
+ """
270
+ voices = []
271
+
272
+ # Built-in voices
273
+ for voice in Config.BUILTIN_VOICES:
274
+ voices.append({'id': voice, 'name': voice.capitalize(), 'type': 'builtin'})
275
+
276
+ # Custom voices from directory
277
+ if self.voices_dir and os.path.isdir(self.voices_dir):
278
+ for ext in Config.VOICE_EXTENSIONS:
279
+ pattern = f'*{ext}'
280
+ voice_dir = Path(self.voices_dir)
281
+ for voice_file in voice_dir.glob(pattern):
282
+ voices.append(
283
+ {
284
+ 'id': voice_file.name,
285
+ 'name': f'Custom: {voice_file.stem}',
286
+ 'type': 'custom',
287
+ }
288
+ )
289
+
290
+ return voices
291
+
292
+
293
+ # Global service instance
294
+ _tts_service: TTSService | None = None
295
+
296
+
297
+ def get_tts_service() -> TTSService:
298
+ """Get the global TTS service instance."""
299
+ global _tts_service
300
+ if _tts_service is None:
301
+ _tts_service = TTSService()
302
+ return _tts_service
docker-compose.yml ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Docker Compose for PocketTTS OpenAI-Compatible Server
2
+ #
3
+ # Usage:
4
+ # docker compose up -d # Start the server
5
+ # docker compose logs -f # View logs
6
+ # docker compose down # Stop the server
7
+ #
8
+ # Custom voices:
9
+ # Mount your own voices directory to /app/voices
10
+
11
+ services:
12
+ pockettts:
13
+ build:
14
+ context: .
15
+ dockerfile: Dockerfile
16
+ image: pockettts-openai-server:latest
17
+ container_name: pockettts-server
18
+
19
+ ports:
20
+ - '${POCKET_TTS_PORT:-49112}:49112'
21
+
22
+ environment:
23
+ - POCKET_TTS_HOST=0.0.0.0
24
+ - POCKET_TTS_PORT=49112
25
+ - POCKET_TTS_VOICES_DIR=/app/voices
26
+ - POCKET_TTS_LOG_LEVEL=${POCKET_TTS_LOG_LEVEL:-INFO}
27
+ - POCKET_TTS_STREAM_DEFAULT=${POCKET_TTS_STREAM_DEFAULT:-true}
28
+ # Hugging Face token for voice cloning (optional)
29
+ - HF_TOKEN=${HF_TOKEN:-}
30
+
31
+ volumes:
32
+ # Mount custom voices (optional - overrides bundled voices)
33
+ - ${POCKET_TTS_VOICES_DIR:-./voices}:/app/voices:ro
34
+ # Persist logs
35
+ - ./logs:/app/logs
36
+ # Cache HuggingFace models to avoid re-downloading
37
+ - pockettts-cache:/home/pockettts/.cache/huggingface
38
+
39
+ restart: unless-stopped
40
+
41
+ # Resource limits (adjust based on your hardware)
42
+ deploy:
43
+ resources:
44
+ limits:
45
+ memory: 4G
46
+ reservations:
47
+ memory: 2G
48
+
49
+ healthcheck:
50
+ test:
51
+ [
52
+ 'CMD',
53
+ 'python',
54
+ '-c',
55
+ "import urllib.request; urllib.request.urlopen('http://localhost:49112/health')",
56
+ ]
57
+ interval: 30s
58
+ timeout: 10s
59
+ retries: 3
60
+ start_period: 120s # Model loading takes time
61
+
62
+ volumes:
63
+ pockettts-cache:
64
+ name: pockettts-huggingface-cache
frozen_requirements.txt DELETED
Binary file (2.24 kB)
 
logs/.gitkeep ADDED
@@ -0,0 +1 @@
 
 
1
+ # Keep this directory in git (logs are gitignored but directory needed)
pocket_tts_openai_server.py DELETED
@@ -1,303 +0,0 @@
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
- # --- PyInstaller Support ---
35
- if getattr(sys, 'frozen', False):
36
- # If the application is run as a bundle, the PyInstaller bootloader
37
- # extends the sys module by a flag frozen=True.
38
- if hasattr(sys, '_MEIPASS'):
39
- # One-file mode
40
- base_path = sys._MEIPASS
41
- else:
42
- # One-dir mode
43
- base_path = os.path.dirname(os.path.abspath(sys.executable))
44
-
45
- template_folder = os.path.join(base_path, 'templates')
46
- static_folder = os.path.join(base_path, 'static')
47
- app = Flask(__name__, template_folder=template_folder, static_folder=static_folder)
48
-
49
- # Default Voices Dir when frozen (bundled)
50
- # We will assume 'voices' is bundled into the root of the executable
51
- BUNDLE_VOICES_DIR = os.path.join(base_path, 'voices')
52
- BUNDLE_MODEL_PATH = os.path.join(base_path, 'model', 'b6369a24.yaml')
53
- else:
54
- app = Flask(__name__)
55
- base_path = os.path.dirname(os.path.abspath(__file__))
56
- BUNDLE_VOICES_DIR = None
57
- BUNDLE_MODEL_PATH = None
58
-
59
- # --- Helpers ---
60
- def get_voice_state(voice_id_or_path):
61
- """
62
- Resolve voice ID to a model state with caching.
63
- """
64
- global voice_cache
65
-
66
- # 1. Normalize/Resolve the ID to its final path/form first
67
- resolved_key = voice_id_or_path
68
- if VOICES_DIR:
69
- possible_path = os.path.join(VOICES_DIR, voice_id_or_path)
70
- if os.path.exists(possible_path):
71
- resolved_key = os.path.abspath(possible_path) # Use absolute path for consistency
72
- elif os.path.exists(voice_id_or_path):
73
- resolved_key = os.path.abspath(voice_id_or_path)
74
-
75
- # 2. Check cache using the resolved key
76
- if resolved_key in voice_cache:
77
- logger.info(f"Using cached voice state for {resolved_key}")
78
- return voice_cache[resolved_key]
79
-
80
- # 3. If not cached, load it
81
- try:
82
- if os.path.exists(resolved_key):
83
- logger.info(f"Loading new voice state from file: {resolved_key}")
84
- else:
85
- logger.info(f"Loading new voice state from ID/URL: {resolved_key}")
86
-
87
- state = model.get_state_for_audio_prompt(resolved_key)
88
-
89
- # 4. Store in cache using the same resolved key
90
- voice_cache[resolved_key] = state
91
- return state
92
-
93
- except Exception as e:
94
- logger.error(f"Failed to load voice {resolved_key}: {e}")
95
- raise ValueError(f"Voice '{voice_id_or_path}' could not be loaded.")
96
-
97
- # --- Routes ---
98
-
99
- @app.route('/')
100
- def home():
101
- """Serve the simple web interface."""
102
- return render_template('index.html')
103
-
104
- @app.route('/v1/voices', methods=['GET'])
105
- def list_voices():
106
- """List available voices: Built-ins + Scanned Directory."""
107
-
108
- # 1. Built-in defaults
109
- # User requested specific voices: alba, marius, javert, jean, fantine, cosette, eponine, azelma
110
- # We map them to potential IDs. Since we don't have exact URLs for all, we assume they are either
111
- # resolved by pocket-tts or the user has these files/IDs.
112
-
113
- builtin_map = {
114
- "alba": "alba",
115
- "marius": "marius",
116
- "javert": "javert",
117
- "jean": "jean",
118
- "fantine": "fantine",
119
- "cosette": "cosette",
120
- "eponine": "eponine",
121
- "azelma": "azelma"
122
- }
123
-
124
- voices = []
125
- for name_id in ["alba", "marius", "javert", "jean", "fantine", "cosette", "eponine", "azelma"]:
126
- # If we have a known mapping (like for alba), use it as ID?
127
- # Or just use the name as ID and let get_voice_state handle it?
128
- # Specifying ID as the lookup key.
129
- voices.append({
130
- "id": builtin_map.get(name_id, name_id),
131
- "name": name_id.capitalize()
132
- })
133
-
134
- # 2. Scan Directory if configured
135
- if VOICES_DIR and os.path.isdir(VOICES_DIR):
136
- # Define supported extensions
137
- extensions = ("*.wav", "*.mp3", "*.flac")
138
-
139
- # Gather all matching files
140
- audio_files = []
141
- for ext in extensions:
142
- audio_files.extend(glob.glob(os.path.join(VOICES_DIR, ext)))
143
-
144
- for f in audio_files:
145
- name = os.path.basename(f)
146
- # ID is the full path so the server can access it, or just filename if we handle resolution
147
- # Using filename for UI consistency as per original logic
148
- voices.append({
149
- "id": name,
150
- "name": f"Local: {name}"
151
- })
152
-
153
- return jsonify({
154
- "object": "list",
155
- "data": [{"id": v["id"], "name": v["name"], "object": "voice"} for v in voices]
156
- })
157
-
158
- @app.route('/v1/audio/speech', methods=['POST'])
159
- def generate_speech():
160
- """OpenAI-compatible speech generation endpoint."""
161
- data = request.json
162
-
163
- if not data:
164
- return jsonify({"error": "Missing JSON body"}), 400
165
-
166
- text = data.get('input')
167
- voice = data.get('voice', 'hf://kyutai/tts-voices/alba-mackenna/casual.wav')
168
-
169
- # Check stream flag
170
- stream_request = data.get('stream', False)
171
-
172
- if stream_request:
173
- response_format = data.get('response_format', 'pcm')
174
- else:
175
- response_format = data.get('response_format', 'mp3')
176
-
177
- if not text:
178
- return jsonify({"error": "Missing 'input' text"}), 400
179
-
180
- target_format = validate_format(response_format)
181
-
182
- try:
183
- # Load Voice (with cache)
184
- voice_state = get_voice_state(voice)
185
-
186
- # Determine Generation Mode
187
- if stream_request or app.config.get('CLI_STREAM_DEFAULT'):
188
- return stream_audio(voice_state, text, target_format)
189
- else:
190
- return generate_file(voice_state, text, target_format)
191
-
192
- except Exception as e:
193
- logger.exception("Generation failed")
194
- return jsonify({"error": str(e)}), 500
195
-
196
- def generate_file(voice_state, text, fmt):
197
- """Generate full audio and return as file."""
198
- t0 = time.time()
199
- audio_tensor = model.generate_audio(voice_state, text)
200
- generation_time = time.time() - t0
201
- logger.info(f"Generated {len(text)} chars in {generation_time:.2f}s")
202
-
203
- # Convert
204
- audio_buffer = convert_audio(audio_tensor, model.sample_rate, fmt)
205
-
206
- mimetype = f"audio/{fmt}"
207
- if fmt == 'wav': mimetype = 'audio/wav'
208
- elif fmt == 'mp3': mimetype = 'audio/mpeg'
209
-
210
- return send_file(
211
- audio_buffer,
212
- mimetype=mimetype,
213
- as_attachment=True,
214
- download_name=f"speech.{fmt}"
215
- )
216
-
217
- def stream_audio(voice_state, text, fmt):
218
- """Stream audio chunks as WAV or raw PCM."""
219
-
220
- def generate():
221
- # pocket-tts native streaming
222
- stream = model.generate_audio_stream(voice_state, text)
223
-
224
- for chunk_tensor in stream:
225
- if chunk_tensor.is_cuda:
226
- chunk_tensor = chunk_tensor.cpu()
227
- if chunk_tensor.dim() == 1:
228
- chunk_tensor = chunk_tensor.unsqueeze(0)
229
-
230
- # Convert to 16-bit PCM
231
- c = (chunk_tensor * 32767).clamp(-32768, 32767).to(torch.int16)
232
- yield c.numpy().tobytes()
233
-
234
- def stream_with_header():
235
- # ONLY yield header if format is WAV
236
- if fmt == 'wav':
237
- yield write_wav_header(model.sample_rate, num_channels=1, bits_per_sample=16, num_frames=0)
238
-
239
- yield from generate()
240
-
241
- # Match the MIME types
242
- if fmt == 'pcm':
243
- mimetype = "audio/L16"
244
- elif fmt == 'wav':
245
- mimetype = "audio/wav"
246
- else:
247
- mimetype = f"audio/{fmt}"
248
-
249
- return Response(stream_with_context(stream_with_header()), mimetype=mimetype)
250
-
251
-
252
- # --- startup ---
253
- def main():
254
- parser = argparse.ArgumentParser(description="Pocket TTS OpenAI Compatible Server")
255
- parser.add_argument("--model_path", type=str, default=None, help="Path to model file or variant name")
256
- parser.add_argument("--host", type=str, default="0.0.0.0", help="Host to run server")
257
- parser.add_argument("--port", type=int, default=5002, help="Port to run server")
258
- parser.add_argument("--stream", action="store_true", help="Enable streaming by default")
259
- parser.add_argument("--voices_dir", type=str, default=None, help="Directory containing local voice .wav, .mp3 or .flac files")
260
-
261
- args = parser.parse_args()
262
-
263
- # Config
264
- app.config['CLI_STREAM_DEFAULT'] = args.stream
265
- global VOICES_DIR
266
- if args.voices_dir:
267
- VOICES_DIR = args.voices_dir
268
- elif getattr(sys, 'frozen', False) and BUNDLE_VOICES_DIR and os.path.isdir(BUNDLE_VOICES_DIR):
269
- VOICES_DIR = BUNDLE_VOICES_DIR
270
- else:
271
- VOICES_DIR = None
272
-
273
- global model
274
- logger.info("Loading Pocket TTS Model...")
275
-
276
- # Use model_path as variant if provided, otherwise default
277
- if args.model_path:
278
- logger.info(f"Using custom model variant/path: {args.model_path}")
279
- model = TTSModel.load_model(variant=args.model_path)
280
- elif getattr(sys, 'frozen', False):
281
- # Check if model is bundled in 'model' dir
282
- if os.path.isfile(BUNDLE_MODEL_PATH):
283
- logger.info(f"Using bundled model from: {BUNDLE_MODEL_PATH}")
284
- try:
285
- model = TTSModel.load_model(variant=BUNDLE_MODEL_PATH)
286
- except Exception as e:
287
- logging.error(f"Error trying to load models bundled with .exe: {e}. Returning to default model load.")
288
- model = TTSModel.load_model()
289
- else:
290
- model = TTSModel.load_model()
291
- else:
292
- model = TTSModel.load_model()
293
-
294
- logger.info(f"Model loaded. Device: {model.device}, Sample Rate: {model.sample_rate}")
295
-
296
- if VOICES_DIR:
297
- logger.info(f"Scanning voices from: {VOICES_DIR}")
298
-
299
- logger.info(f"Starting server on {args.host}:{args.port}")
300
- app.run(host=args.host, port=args.port, debug=False, threaded=True)
301
-
302
- if __name__ == "__main__":
303
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
pyinstaller_command.txt DELETED
@@ -1,15 +0,0 @@
1
- pyinstaller --noconfirm --clean --onefile --icon=NONE ^
2
- --name "PocketTTS-Server" ^
3
- --add-data "static;static" ^
4
- --add-data "templates;templates" ^
5
- --add-data "voices;voices" ^
6
- --add-data "model;model" ^
7
- --collect-all "pocket_tts" ^
8
- --hidden-import "engineio.async_drivers.threading" ^
9
- --hidden-import "flask" ^
10
- --hidden-import "torch" ^
11
- --hidden-import "torchaudio" ^
12
- --hidden-import "soundfile" ^
13
- pocket_tts_openai_server.py
14
-
15
- pyinstaller --noconfirm --clean --onefile --icon=pocket-tts-logo.ico --name "PocketTTS-Server" --add-data "utils.py;utils.py" --add-data "static;static" --add-data "model;model" --add-data "templates;templates" --add-data "voices;voices" --collect-all "pocket_tts" --hidden-import "engineio.async_drivers.threading" --hidden-import "flask" --hidden-import "torch" --hidden-import "torchaudio" --hidden-import "soundfile" pocket_tts_openai_server.py
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
pyproject.toml ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ description = "OpenAI-compatible TTS API server powered by Pocket-TTS"
3
+ license = {text = "MIT"}
4
+ name = "pocket-tts-openai-server"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ version = "2.0.0"
8
+
9
+ dependencies = [
10
+ "flask>=3.0.0",
11
+ "waitress>=3.0.0",
12
+ "pocket-tts>=1.0.0",
13
+ "torch>=2.0.0,<=2.8.0",
14
+ "torchaudio>=2.0.0,<=2.8.0",
15
+ "scipy>=1.10.0",
16
+ "numpy>=1.24.0",
17
+ "soundfile>=0.12.0",
18
+ ]
19
+
20
+ [project.optional-dependencies]
21
+ build = ["pyinstaller"]
22
+ dev = ["ruff", "pytest"]
23
+
24
+ [project.scripts]
25
+ pocket-tts-server = "server:main"
26
+
27
+ [tool.ruff]
28
+ line-length = 100
29
+ target-version = "py310"
30
+
31
+ [tool.ruff.lint]
32
+ ignore = ["E501", "B008", "B904"]
33
+ select = ["E", "W", "F", "I", "B", "C4", "UP"]
34
+
35
+ [tool.ruff.lint.isort]
36
+ known-first-party = ["app"]
37
+
38
+ [tool.ruff.format]
39
+ quote-style = "single"
requirements-dev.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # Development dependencies
2
+ # Install with: pip install -r requirements-dev.txt
3
+
4
+ -r requirements.txt
5
+
6
+ # Linting and formatting
7
+ ruff
8
+
9
+ # Testing
10
+ pytest
requirements.txt CHANGED
@@ -1,7 +1,11 @@
1
- flask
2
- torch<=2.8.0
3
- torchaudio<=2.8.0
4
- pocket-tts
5
- scipy
6
- numpy
7
- soundfile
 
 
 
 
 
1
+ # Core dependencies
2
+ flask>=3.0.0
3
+ waitress>=3.0.0
4
+ pocket-tts>=1.0.0
5
+
6
+ # Audio processing
7
+ torch>=2.0.0,<=2.8.0
8
+ torchaudio>=2.0.0,<=2.8.0
9
+ scipy>=1.10.0
10
+ numpy>=1.24.0
11
+ soundfile>=0.12.0
run_pocket_tts_server.bat CHANGED
@@ -37,7 +37,7 @@ 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
 
 
37
  if not "%INPUT_HOST%"=="" set "HOST=%INPUT_HOST%"
38
 
39
  :: 3. Port
40
+ set "PORT=49112"
41
  set /p "INPUT_PORT=Port [%PORT%]: "
42
  if not "%INPUT_PORT%"=="" set "PORT=%INPUT_PORT%"
43
 
run_pocket_tts_server_exe.bat CHANGED
@@ -31,7 +31,7 @@ set /p "INPUT_HOST=Host IP [%HOST%]: "
31
  if not "%INPUT_HOST%"=="" set "HOST=%INPUT_HOST%"
32
 
33
  :: 2. Port
34
- set "PORT=5002"
35
  set /p "INPUT_PORT=Port [%PORT%]: "
36
  if not "%INPUT_PORT%"=="" set "PORT=%INPUT_PORT%"
37
 
 
31
  if not "%INPUT_HOST%"=="" set "HOST=%INPUT_HOST%"
32
 
33
  :: 2. Port
34
+ set "PORT=49112"
35
  set /p "INPUT_PORT=Port [%PORT%]: "
36
  if not "%INPUT_PORT%"=="" set "PORT=%INPUT_PORT%"
37
 
server.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ PocketTTS OpenAI-Compatible Server
4
+
5
+ A drop-in replacement for OpenAI's TTS API using the pocket-tts model.
6
+ Supports streaming, custom voices, and runs on CPU.
7
+
8
+ Usage:
9
+ python server.py [OPTIONS]
10
+
11
+ # Or with environment variables:
12
+ POCKET_TTS_PORT=8080 python server.py
13
+ """
14
+
15
+ import argparse
16
+ import os
17
+ import sys
18
+
19
+ from app import create_app, init_tts_service
20
+ from app.config import Config
21
+ from app.logging_config import get_logger
22
+
23
+
24
+ def parse_args():
25
+ """Parse command line arguments."""
26
+ parser = argparse.ArgumentParser(
27
+ description='PocketTTS OpenAI-Compatible Server',
28
+ formatter_class=argparse.RawDescriptionHelpFormatter,
29
+ epilog="""
30
+ Examples:
31
+ # Start with defaults
32
+ python server.py
33
+
34
+ # Custom port and voices directory
35
+ python server.py --port 8080 --voices-dir ./my_voices
36
+
37
+ # Enable streaming by default
38
+ python server.py --stream
39
+
40
+ Environment Variables:
41
+ POCKET_TTS_HOST Server host (default: 0.0.0.0)
42
+ POCKET_TTS_PORT Server port (default: 49112)
43
+ POCKET_TTS_MODEL_PATH Path to model file
44
+ POCKET_TTS_VOICES_DIR Path to voices directory
45
+ POCKET_TTS_STREAM_DEFAULT Enable streaming by default
46
+ POCKET_TTS_LOG_DIR Log directory path
47
+ """,
48
+ )
49
+
50
+ parser.add_argument(
51
+ '--host', type=str, default=Config.HOST, help=f'Host to bind to (default: {Config.HOST})'
52
+ )
53
+ parser.add_argument(
54
+ '--port', type=int, default=Config.PORT, help=f'Port to listen on (default: {Config.PORT})'
55
+ )
56
+ parser.add_argument(
57
+ '--model-path',
58
+ type=str,
59
+ default=Config.MODEL_PATH,
60
+ dest='model_path',
61
+ help='Path to model file or variant name',
62
+ )
63
+ parser.add_argument(
64
+ '--voices-dir',
65
+ type=str,
66
+ default=Config.VOICES_DIR,
67
+ dest='voices_dir',
68
+ help='Directory containing voice files',
69
+ )
70
+ parser.add_argument(
71
+ '--stream',
72
+ action='store_true',
73
+ default=Config.STREAM_DEFAULT,
74
+ help='Enable streaming by default for all requests',
75
+ )
76
+ parser.add_argument(
77
+ '--log-level',
78
+ type=str,
79
+ default=Config.LOG_LEVEL,
80
+ choices=['DEBUG', 'INFO', 'WARNING', 'ERROR'],
81
+ dest='log_level',
82
+ help='Logging level',
83
+ )
84
+
85
+ return parser.parse_args()
86
+
87
+
88
+ def main():
89
+ """Main entry point."""
90
+ args = parse_args()
91
+
92
+ # Update config from args (environment takes precedence via Config class)
93
+ os.environ.setdefault('POCKET_TTS_LOG_LEVEL', args.log_level)
94
+
95
+ # Create app
96
+ app = create_app({'STREAM_DEFAULT': args.stream})
97
+
98
+ logger = get_logger()
99
+
100
+ # Initialize TTS service
101
+ try:
102
+ init_tts_service(model_path=args.model_path, voices_dir=args.voices_dir)
103
+ except Exception as e:
104
+ logger.error(f'Failed to initialize TTS service: {e}')
105
+ sys.exit(1)
106
+
107
+ # Start server with Waitress (production WSGI server)
108
+ try:
109
+ from waitress import serve
110
+
111
+ logger.info(f'Starting PocketTTS server on http://{args.host}:{args.port}')
112
+ logger.info('Press Ctrl+C to stop')
113
+
114
+ serve(app, host=args.host, port=args.port, threads=4, url_scheme='http')
115
+
116
+ except ImportError:
117
+ logger.warning('Waitress not installed, falling back to Flask dev server')
118
+ logger.warning('Install waitress for production: pip install waitress')
119
+ app.run(host=args.host, port=args.port, debug=False, threaded=True)
120
+
121
+
122
+ if __name__ == '__main__':
123
+ main()