Shackless commited on
Commit
ab7cd03
·
2 Parent(s): e5bb4ba731e410

implement v2 with docker support

Browse files

This pull request represents a major version 2.0 refactoring of the Pocket-TTS OpenAI-compatible server. The PR restructures the monolithic codebase into a modular application architecture, adds comprehensive Docker support, improves logging and configuration management, and updates the port from 5002 to 49112.

Changes:

Complete architectural refactor from single-file (pocket_tts_openai_server.py, utils.py) to modular app/ package structure with separate services, routes, and configuration modules
Added Docker deployment with multi-stage builds, health checks, and docker-compose orchestration
Implemented comprehensive logging with rotation support
Added CI/CD workflows for linting, Docker builds, and Windows executable generation
Enhanced documentation with updated README, new AGENTS.md for developers, and inline API documentation in the web UI
Changed default port from 5002 to 49112

.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,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 .
.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,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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. Built-in names (`alba`, `marius`, etc.) → pass to pocket-tts
69
+ 2. HuggingFace models (`hf://` URLs) → pass to pocket-tts
70
+ 3. Files in `POCKET_TTS_VOICES_DIR`
71
+ 4. Absolute paths
72
+ 5. Fallback to pocket-tts
73
+
74
+ **Security Note**: HTTP/HTTPS URLs are blocked to prevent SSRF attacks. Only `hf://` URLs are allowed for remote models.
75
+
76
+ ## Development
77
+
78
+ ```bash
79
+ # Install
80
+ pip install -r requirements.txt
81
+
82
+ # Run
83
+ python server.py --log-level DEBUG
84
+
85
+ # Test
86
+ curl http://localhost:49112/health
87
+ curl -X POST http://localhost:49112/v1/audio/speech \
88
+ -H "Content-Type: application/json" \
89
+ -d '{"input": "Hello", "voice": "alba"}' -o test.mp3
90
+ ```
91
+
92
+ ## Code Style
93
+
94
+ - Linter/formatter: `ruff` (config in `pyproject.toml`)
95
+ - Line length: 100
96
+ - Single quotes
97
+
98
+ ## Deployment
99
+
100
+ - **Python**: `python server.py`
101
+ - **Docker**: `docker compose up -d`
102
+ - **Windows EXE**: Built via GitHub Actions on release tags
Dockerfile ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dockerfile for PocketTTS OpenAI-Compatible Server
2
+ # Optimized for CPU inference (pocket-tts runs efficiently on CPU)
3
+ # Uses CPU-only PyTorch for smaller image size (~700MB vs ~2GB)
4
+
5
+ FROM python:3.10-slim AS builder
6
+
7
+ # Install build dependencies
8
+ RUN apt-get update && apt-get install -y --no-install-recommends \
9
+ build-essential \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
12
+ # Create virtual environment
13
+ RUN python -m venv /opt/venv
14
+ ENV PATH="/opt/venv/bin:$PATH"
15
+
16
+ # Install Python dependencies (requirements.txt specifies CPU-only PyTorch)
17
+ COPY requirements.txt /tmp/requirements.txt
18
+ RUN pip install --no-cache-dir --upgrade pip && \
19
+ pip install --no-cache-dir -r /tmp/requirements.txt
20
+
21
+
22
+ # Production image
23
+ FROM python:3.10-slim
24
+
25
+ # Install runtime dependencies for audio processing
26
+ RUN apt-get update && apt-get install -y --no-install-recommends \
27
+ libsndfile1 \
28
+ ffmpeg \
29
+ && rm -rf /var/lib/apt/lists/* \
30
+ && apt-get clean
31
+
32
+ # Copy virtual environment from builder
33
+ COPY --from=builder /opt/venv /opt/venv
34
+ ENV PATH="/opt/venv/bin:$PATH"
35
+
36
+ # Create non-root user
37
+ RUN useradd --create-home --shell /bin/bash pockettts
38
+ WORKDIR /app
39
+
40
+ # Copy application code
41
+ COPY --chown=pockettts:pockettts app/ ./app/
42
+ COPY --chown=pockettts:pockettts static/ ./static/
43
+ COPY --chown=pockettts:pockettts templates/ ./templates/
44
+ COPY --chown=pockettts:pockettts voices/ ./voices/
45
+ COPY --chown=pockettts:pockettts server.py ./
46
+
47
+ # Create logs directory
48
+ RUN mkdir -p /app/logs && chown pockettts:pockettts /app/logs
49
+
50
+ # Create HuggingFace cache directory (for volume mount)
51
+ RUN mkdir -p /home/pockettts/.cache/huggingface && \
52
+ chown -R pockettts:pockettts /home/pockettts/.cache
53
+
54
+ # Switch to non-root user
55
+ USER pockettts
56
+
57
+ # Environment variables with defaults
58
+ ENV POCKET_TTS_HOST=0.0.0.0 \
59
+ POCKET_TTS_PORT=49112 \
60
+ POCKET_TTS_VOICES_DIR=/app/voices \
61
+ POCKET_TTS_LOG_DIR=/app/logs \
62
+ POCKET_TTS_LOG_LEVEL=INFO \
63
+ PYTHONUNBUFFERED=1
64
+
65
+ # Expose port
66
+ EXPOSE 49112
67
+
68
+ # Health check
69
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
70
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:49112/health')" || exit 1
71
+
72
+ # Run server
73
+ 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,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # Docker detection
47
+ @staticmethod
48
+ def _is_docker() -> bool:
49
+ """Detect if running in a Docker container."""
50
+ # Check for .dockerenv file (most reliable)
51
+ if os.path.exists('/.dockerenv'):
52
+ return True
53
+ # Check cgroup for docker/containerd references
54
+ try:
55
+ with open('/proc/1/cgroup') as f:
56
+ return any('docker' in line or 'containerd' in line for line in f)
57
+ except (FileNotFoundError, PermissionError):
58
+ return False
59
+
60
+ IS_DOCKER = _is_docker.__func__()
61
+
62
+ # Logging
63
+ LOG_LEVEL = os.environ.get('POCKET_TTS_LOG_LEVEL', 'INFO')
64
+ LOG_DIR = os.environ.get('POCKET_TTS_LOG_DIR', str(BASE_PATH / 'logs'))
65
+ LOG_FILE = os.environ.get('POCKET_TTS_LOG_FILE', 'pocket_tts.log')
66
+ LOG_MAX_BYTES = int(os.environ.get('POCKET_TTS_LOG_MAX_BYTES', str(10 * 1024 * 1024))) # 10MB
67
+ LOG_BACKUP_COUNT = int(os.environ.get('POCKET_TTS_LOG_BACKUP_COUNT', '5'))
68
+
69
+ # Built-in voice mappings (these are resolved by pocket-tts internally)
70
+ BUILTIN_VOICES = ['alba', 'marius', 'javert', 'jean', 'fantine', 'cosette', 'eponine', 'azelma']
71
+
72
+ # Supported audio extensions for custom voices
73
+ VOICE_EXTENSIONS = ('.wav', '.mp3', '.flac', '.safetensors')
74
+
75
+ @classmethod
76
+ def get_bundle_paths(cls) -> tuple:
77
+ """Get bundled paths for frozen executables."""
78
+ if cls.IS_FROZEN:
79
+ voices_dir = cls.BASE_PATH / 'voices'
80
+ model_path = cls.BASE_PATH / 'model' / 'b6369a24.yaml'
81
+ return (
82
+ str(voices_dir) if voices_dir.is_dir() else None,
83
+ str(model_path) if model_path.is_file() else None,
84
+ )
85
+ return None, None
86
+
87
+ @classmethod
88
+ def get_template_folder(cls) -> str:
89
+ """Get the templates folder path."""
90
+ return str(cls.BASE_PATH / 'templates')
91
+
92
+ @classmethod
93
+ def get_static_folder(cls) -> str:
94
+ """Get the static files folder path."""
95
+ 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,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ from app.config import Config
37
+
38
+ return render_template('index.html', is_docker=Config.IS_DOCKER)
39
+
40
+
41
+ @api.route('/health', methods=['GET'])
42
+ def health():
43
+ """
44
+ Health check endpoint for container orchestration.
45
+
46
+ Returns service status and basic model info.
47
+ """
48
+ tts = get_tts_service()
49
+
50
+ # Validate a built-in voice quickly
51
+ voice_valid, voice_msg = tts.validate_voice('alba')
52
+
53
+ return jsonify(
54
+ {
55
+ 'status': 'healthy' if tts.is_loaded else 'unhealthy',
56
+ 'model_loaded': tts.is_loaded,
57
+ 'device': tts.device if tts.is_loaded else None,
58
+ 'sample_rate': tts.sample_rate if tts.is_loaded else None,
59
+ 'voices_dir': tts.voices_dir,
60
+ 'voice_check': {'valid': voice_valid, 'message': voice_msg},
61
+ }
62
+ ), 200 if tts.is_loaded else 503
63
+
64
+
65
+ @api.route('/v1/voices', methods=['GET'])
66
+ def list_voices():
67
+ """
68
+ List available voices.
69
+
70
+ Returns OpenAI-compatible voice list format.
71
+ """
72
+ tts = get_tts_service()
73
+ voices = tts.list_voices()
74
+
75
+ return jsonify(
76
+ {
77
+ 'object': 'list',
78
+ 'data': [{'id': v['id'], 'name': v['name'], 'object': 'voice'} for v in voices],
79
+ }
80
+ )
81
+
82
+
83
+ @api.route('/v1/audio/speech', methods=['POST'])
84
+ def generate_speech():
85
+ """
86
+ OpenAI-compatible speech generation endpoint.
87
+
88
+ Request body:
89
+ model: string (ignored, for compatibility)
90
+ input: string (required) - Text to synthesize
91
+ voice: string (optional) - Voice ID or path
92
+ response_format: string (optional) - Audio format
93
+ stream: boolean (optional) - Enable streaming
94
+
95
+ Returns:
96
+ Audio file or streaming audio response
97
+ """
98
+ from flask import current_app
99
+
100
+ data = request.json
101
+
102
+ if not data:
103
+ return jsonify({'error': 'Missing JSON body'}), 400
104
+
105
+ text = data.get('input')
106
+ if not text:
107
+ return jsonify({'error': "Missing 'input' text"}), 400
108
+
109
+ voice = data.get('voice', 'alba')
110
+ stream_request = data.get('stream', False)
111
+
112
+ # Determine format based on streaming
113
+ if stream_request:
114
+ response_format = data.get('response_format', 'pcm')
115
+ else:
116
+ response_format = data.get('response_format', 'mp3')
117
+
118
+ target_format = validate_format(response_format)
119
+
120
+ tts = get_tts_service()
121
+
122
+ # Validate voice first
123
+ is_valid, msg = tts.validate_voice(voice)
124
+ if not is_valid:
125
+ available = [v['id'] for v in tts.list_voices()]
126
+ return jsonify(
127
+ {
128
+ 'error': f"Voice '{voice}' not found",
129
+ 'available_voices': available[:10], # Limit to first 10
130
+ 'hint': 'Use /v1/voices to see all available voices',
131
+ }
132
+ ), 400
133
+
134
+ try:
135
+ voice_state = tts.get_voice_state(voice)
136
+
137
+ # Check if streaming should be used
138
+ use_streaming = stream_request or current_app.config.get('STREAM_DEFAULT', False)
139
+
140
+ if use_streaming:
141
+ return _stream_audio(tts, voice_state, text, target_format)
142
+ else:
143
+ return _generate_file(tts, voice_state, text, target_format)
144
+
145
+ except ValueError as e:
146
+ logger.warning(f'Voice loading failed: {e}')
147
+ return jsonify({'error': str(e)}), 400
148
+ except Exception as e:
149
+ logger.exception('Generation failed')
150
+ return jsonify({'error': str(e)}), 500
151
+
152
+
153
+ def _generate_file(tts, voice_state, text: str, fmt: str):
154
+ """Generate complete audio and return as file."""
155
+ t0 = time.time()
156
+ audio_tensor = tts.generate_audio(voice_state, text)
157
+ generation_time = time.time() - t0
158
+
159
+ logger.info(f'Generated {len(text)} chars in {generation_time:.2f}s')
160
+
161
+ audio_buffer = convert_audio(audio_tensor, tts.sample_rate, fmt)
162
+ mimetype = get_mime_type(fmt)
163
+
164
+ return send_file(
165
+ audio_buffer, mimetype=mimetype, as_attachment=True, download_name=f'speech.{fmt}'
166
+ )
167
+
168
+
169
+ def _stream_audio(tts, voice_state, text: str, fmt: str):
170
+ """Stream audio chunks."""
171
+ # Normalize streaming format: we always emit PCM bytes, optionally wrapped
172
+ # in a WAV container. For non-PCM/WAV formats (e.g. mp3, opus), coerce to
173
+ # raw PCM to avoid mismatched content-type vs. payload.
174
+ stream_fmt = fmt
175
+ if stream_fmt not in ('pcm', 'wav'):
176
+ logger.warning(
177
+ "Requested streaming format '%s' is not supported for streaming; "
178
+ "falling back to 'pcm'.",
179
+ stream_fmt,
180
+ )
181
+ stream_fmt = 'pcm'
182
+
183
+ def generate():
184
+ stream = tts.generate_audio_stream(voice_state, text)
185
+ for chunk_tensor in stream:
186
+ yield tensor_to_pcm_bytes(chunk_tensor)
187
+
188
+ def stream_with_header():
189
+ # Yield WAV header first if streaming as WAV
190
+ if stream_fmt == 'wav':
191
+ yield write_wav_header(tts.sample_rate, num_channels=1, bits_per_sample=16)
192
+ yield from generate()
193
+
194
+ mimetype = get_mime_type(stream_fmt)
195
+
196
+ 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,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ Raises:
171
+ ValueError: If unsafe URL scheme is used
172
+ """
173
+ # Block potentially dangerous URL schemes (SSRF protection)
174
+ if voice_id_or_path.startswith(('http://', 'https://')):
175
+ raise ValueError(
176
+ f'URL scheme not allowed for security reasons: {voice_id_or_path[:50]}. '
177
+ "Use 'hf://' for HuggingFace models or provide a local file path."
178
+ )
179
+
180
+ # Allow HuggingFace URLs
181
+ if voice_id_or_path.startswith('hf://'):
182
+ return voice_id_or_path
183
+
184
+ # Check if it's a built-in voice
185
+ if voice_id_or_path.lower() in Config.BUILTIN_VOICES:
186
+ return voice_id_or_path.lower()
187
+
188
+ # Check voices directory
189
+ if self.voices_dir:
190
+ for ext in Config.VOICE_EXTENSIONS:
191
+ # Try exact match first
192
+ possible_path = os.path.join(self.voices_dir, voice_id_or_path)
193
+ if os.path.exists(possible_path):
194
+ return os.path.abspath(possible_path)
195
+
196
+ # Try with extension
197
+ if not voice_id_or_path.endswith(ext):
198
+ possible_path = os.path.join(self.voices_dir, voice_id_or_path + ext)
199
+ if os.path.exists(possible_path):
200
+ return os.path.abspath(possible_path)
201
+
202
+ # Check if it's an absolute path that exists
203
+ if os.path.isabs(voice_id_or_path) and os.path.exists(voice_id_or_path):
204
+ return voice_id_or_path
205
+
206
+ # Return as-is, let pocket-tts handle it
207
+ return voice_id_or_path
208
+
209
+ def validate_voice(self, voice_id_or_path: str) -> tuple[bool, str]:
210
+ """
211
+ Validate if a voice can be loaded (fast check without full loading).
212
+
213
+ Args:
214
+ voice_id_or_path: Voice identifier
215
+
216
+ Returns:
217
+ Tuple of (is_valid, message)
218
+ """
219
+ # Block unsafe URL schemes first
220
+ if voice_id_or_path.startswith(('http://', 'https://')):
221
+ return (
222
+ False,
223
+ 'HTTP/HTTPS URLs are not allowed for security reasons. Use hf:// for HuggingFace models.',
224
+ )
225
+
226
+ try:
227
+ resolved = self._resolve_voice_path(voice_id_or_path)
228
+ except ValueError as e:
229
+ return False, str(e)
230
+
231
+ # Built-in voices are always valid
232
+ if resolved.lower() in Config.BUILTIN_VOICES:
233
+ return True, f'Built-in voice: {resolved}'
234
+
235
+ # HuggingFace URLs - assume valid
236
+ if resolved.startswith('hf://'):
237
+ return True, f'HuggingFace voice: {resolved}'
238
+
239
+ # Local file - check existence
240
+ if os.path.exists(resolved):
241
+ return True, f'Local voice file: {resolved}'
242
+
243
+ return False, f'Voice not found: {voice_id_or_path}'
244
+
245
+ def generate_audio(self, voice_state: dict, text: str) -> torch.Tensor:
246
+ """
247
+ Generate complete audio for given text.
248
+
249
+ Args:
250
+ voice_state: Model state from get_voice_state()
251
+ text: Text to synthesize
252
+
253
+ Returns:
254
+ Audio tensor
255
+ """
256
+ if not self.is_loaded:
257
+ raise RuntimeError('Model not loaded')
258
+
259
+ t0 = time.time()
260
+ audio = self.model.generate_audio(voice_state, text)
261
+ gen_time = time.time() - t0
262
+
263
+ logger.info(f'Generated {len(text)} chars in {gen_time:.2f}s')
264
+ return audio
265
+
266
+ def generate_audio_stream(self, voice_state: dict, text: str) -> Iterator[torch.Tensor]:
267
+ """
268
+ Generate audio in streaming chunks.
269
+
270
+ Args:
271
+ voice_state: Model state from get_voice_state()
272
+ text: Text to synthesize
273
+
274
+ Yields:
275
+ Audio tensor chunks
276
+ """
277
+ if not self.is_loaded:
278
+ raise RuntimeError('Model not loaded')
279
+
280
+ logger.info(f'Starting streaming generation for {len(text)} chars')
281
+ yield from self.model.generate_audio_stream(voice_state, text)
282
+
283
+ def list_voices(self) -> list[dict]:
284
+ """
285
+ List all available voices.
286
+
287
+ Returns:
288
+ List of voice dictionaries with 'id' and 'name' keys
289
+ """
290
+ voices = []
291
+
292
+ # Built-in voices
293
+ for voice in Config.BUILTIN_VOICES:
294
+ voices.append({'id': voice, 'name': voice.capitalize(), 'type': 'builtin'})
295
+
296
+ # Custom voices from directory
297
+ if self.voices_dir and os.path.isdir(self.voices_dir):
298
+ for ext in Config.VOICE_EXTENSIONS:
299
+ pattern = f'*{ext}'
300
+ voice_dir = Path(self.voices_dir)
301
+ for voice_file in voice_dir.glob(pattern):
302
+ voices.append(
303
+ {
304
+ 'id': voice_file.name,
305
+ 'name': f'Custom: {voice_file.stem}',
306
+ 'type': 'custom',
307
+ }
308
+ )
309
+
310
+ return voices
311
+
312
+
313
+ # Global service instance
314
+ _tts_service: TTSService | None = None
315
+
316
+
317
+ def get_tts_service() -> TTSService:
318
+ """Get the global TTS service instance."""
319
+ global _tts_service
320
+ if _tts_service is None:
321
+ _tts_service = TTSService()
322
+ 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,14 @@
1
- flask
2
- torch<=2.8.0
3
- torchaudio<=2.8.0
4
- pocket-tts
5
- scipy
6
- numpy
7
- soundfile
 
 
 
 
 
 
 
 
1
+ # CPU-only PyTorch (smaller, faster install - this project is CPU-only)
2
+ --extra-index-url https://download.pytorch.org/whl/cpu
3
+
4
+ # Core dependencies
5
+ flask>=3.0.0
6
+ waitress>=3.0.0
7
+ pocket-tts>=1.0.0
8
+
9
+ # Audio processing
10
+ torch>=2.0.0,<=2.8.0
11
+ torchaudio>=2.0.0,<=2.8.0
12
+ scipy>=1.10.0
13
+ numpy>=1.24.0
14
+ 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()
static/css/style.css CHANGED
@@ -219,3 +219,201 @@ audio {
219
  .hidden {
220
  display: none;
221
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  .hidden {
220
  display: none;
221
  }
222
+
223
+ /* API Documentation Styles */
224
+ .api-docs {
225
+ margin-top: 3rem;
226
+ padding-top: 2rem;
227
+ border-top: 1px solid var(--border-color);
228
+ }
229
+
230
+ .api-docs h2 {
231
+ font-size: 1.5rem;
232
+ margin-bottom: 0.5rem;
233
+ color: var(--text-primary);
234
+ }
235
+
236
+ .api-intro {
237
+ color: var(--text-secondary);
238
+ margin-bottom: 2rem;
239
+ font-size: 0.95rem;
240
+ }
241
+
242
+ .endpoint {
243
+ background: var(--input-bg);
244
+ border: 1px solid var(--border-color);
245
+ border-radius: 12px;
246
+ padding: 1.5rem;
247
+ margin-bottom: 1.5rem;
248
+ }
249
+
250
+ .endpoint-header {
251
+ display: flex;
252
+ align-items: center;
253
+ gap: 1rem;
254
+ margin-bottom: 1rem;
255
+ }
256
+
257
+ .method {
258
+ padding: 0.25rem 0.75rem;
259
+ border-radius: 4px;
260
+ font-weight: 600;
261
+ font-size: 0.8rem;
262
+ text-transform: uppercase;
263
+ }
264
+
265
+ .method.get {
266
+ background: rgba(46, 160, 67, 0.2);
267
+ color: #3fb950;
268
+ border: 1px solid rgba(46, 160, 67, 0.4);
269
+ }
270
+
271
+ .method.post {
272
+ background: rgba(88, 166, 255, 0.2);
273
+ color: #58a6ff;
274
+ border: 1px solid rgba(88, 166, 255, 0.4);
275
+ }
276
+
277
+ .path {
278
+ font-family: 'SF Mono', 'Consolas', monospace;
279
+ font-size: 1rem;
280
+ color: var(--text-primary);
281
+ background: none;
282
+ padding: 0;
283
+ }
284
+
285
+ .endpoint-desc {
286
+ color: var(--text-secondary);
287
+ margin-bottom: 1rem;
288
+ font-size: 0.9rem;
289
+ }
290
+
291
+ .params-section,
292
+ .example-section,
293
+ .response-example,
294
+ .error-section {
295
+ margin-top: 1rem;
296
+ }
297
+
298
+ .params-section strong,
299
+ .example-section strong,
300
+ .response-example strong,
301
+ .error-section strong {
302
+ display: block;
303
+ color: var(--text-secondary);
304
+ font-size: 0.85rem;
305
+ margin-bottom: 0.5rem;
306
+ }
307
+
308
+ .params-table {
309
+ width: 100%;
310
+ border-collapse: collapse;
311
+ font-size: 0.85rem;
312
+ margin-top: 0.5rem;
313
+ }
314
+
315
+ .params-table th,
316
+ .params-table td {
317
+ padding: 0.6rem 0.75rem;
318
+ text-align: left;
319
+ border-bottom: 1px solid var(--border-color);
320
+ }
321
+
322
+ .params-table th {
323
+ color: var(--text-secondary);
324
+ font-weight: 500;
325
+ background: rgba(0, 0, 0, 0.2);
326
+ }
327
+
328
+ .params-table td {
329
+ color: var(--text-primary);
330
+ }
331
+
332
+ .params-table code {
333
+ background: rgba(88, 166, 255, 0.1);
334
+ color: var(--accent-color);
335
+ padding: 0.15rem 0.4rem;
336
+ border-radius: 4px;
337
+ font-size: 0.85em;
338
+ }
339
+
340
+ .api-docs pre {
341
+ background: rgba(0, 0, 0, 0.3);
342
+ border: 1px solid var(--border-color);
343
+ border-radius: 8px;
344
+ padding: 1rem;
345
+ overflow-x: auto;
346
+ font-family: 'SF Mono', 'Consolas', monospace;
347
+ font-size: 0.85rem;
348
+ color: var(--text-primary);
349
+ line-height: 1.5;
350
+ }
351
+
352
+ .voices-reference {
353
+ margin-top: 2rem;
354
+ padding: 1.5rem;
355
+ background: var(--input-bg);
356
+ border: 1px solid var(--border-color);
357
+ border-radius: 12px;
358
+ }
359
+
360
+ .voices-reference h3 {
361
+ font-size: 1.1rem;
362
+ margin-bottom: 0.5rem;
363
+ color: var(--text-primary);
364
+ }
365
+
366
+ .voices-reference p {
367
+ color: var(--text-secondary);
368
+ font-size: 0.9rem;
369
+ margin-bottom: 1rem;
370
+ }
371
+
372
+ .voice-chips {
373
+ display: flex;
374
+ flex-wrap: wrap;
375
+ gap: 0.5rem;
376
+ margin-bottom: 1rem;
377
+ }
378
+
379
+ .voice-chip {
380
+ background: rgba(88, 166, 255, 0.15);
381
+ color: var(--accent-color);
382
+ padding: 0.4rem 0.8rem;
383
+ border-radius: 20px;
384
+ font-size: 0.85rem;
385
+ font-family: 'SF Mono', 'Consolas', monospace;
386
+ border: 1px solid rgba(88, 166, 255, 0.3);
387
+ }
388
+
389
+ .voice-note {
390
+ font-size: 0.85rem;
391
+ color: var(--text-secondary);
392
+ margin-top: 1rem;
393
+ margin-bottom: 0;
394
+ }
395
+
396
+ .voice-note a {
397
+ color: var(--accent-color);
398
+ text-decoration: none;
399
+ }
400
+
401
+ .voice-note a:hover {
402
+ text-decoration: underline;
403
+ }
404
+
405
+ /* Responsive adjustments */
406
+ @media (max-width: 600px) {
407
+ .params-table {
408
+ font-size: 0.75rem;
409
+ }
410
+
411
+ .params-table th,
412
+ .params-table td {
413
+ padding: 0.4rem 0.5rem;
414
+ }
415
+
416
+ .endpoint-header {
417
+ flex-wrap: wrap;
418
+ }
419
+ }
static/js/app.js CHANGED
@@ -1,106 +1,120 @@
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, .mp3, .flac)...</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 Audio 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 voice 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
  });
 
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
19
+ voiceSelect.innerHTML = '';
20
+
21
+ if (data.data) {
22
+ // Add voices in order (built-in first, then custom)
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.appendChild(opt);
28
+ });
29
+
30
+ // Add custom upload option at the end
31
+ const customOpt = document.createElement('option');
32
+ customOpt.value = 'custom';
33
+ customOpt.textContent = 'Custom (Upload .wav, .mp3, .flac)...';
34
+ voiceSelect.appendChild(customOpt);
35
+
36
+ // Select first (alba) by default
37
+ if (voiceSelect.options.length > 0) {
38
+ voiceSelect.selectedIndex = 0;
39
+ }
40
+ }
41
+ } catch (e) {
42
+ console.error('Failed to list voices:', e);
43
+ }
44
+ }
45
+
46
+ await loadVoices();
47
+
48
+ // 2. Handle Voice Selection Change (hide custom option in Docker mode)
49
+ const isDocker = window.POCKET_TTS_CONFIG?.isDocker || false;
50
+
51
+ // Remove the "Custom" option when running in Docker (paths won't work)
52
+ if (isDocker) {
53
+ const customOption = voiceSelect.querySelector('option[value="custom"]');
54
+ if (customOption) customOption.remove();
55
+ }
56
+
57
+ voiceSelect.addEventListener('change', (e) => {
58
+ if (e.target.value === 'custom' && !isDocker) {
59
+ // Setup for Custom Path (only for native/exe mode)
60
+ document.querySelector('#custom-voice-group label').textContent =
61
+ 'Absolute Path to Audio File:';
62
+ voiceFile.type = 'text';
63
+ voiceFile.placeholder = 'C:\\path\\to\\voice.wav';
64
+ customVoiceGroup.classList.remove('hidden');
65
+ } else {
66
+ customVoiceGroup.classList.add('hidden');
67
+ }
68
+ });
69
+
70
+ // 3. Generate Logic
71
+ generateBtn.addEventListener('click', async () => {
72
+ const text = textInput.value.trim();
73
+ if (!text) return alert('Please enter text');
74
+
75
+ const stream = streamToggle.checked;
76
+
77
+ let voice = voiceSelect.value;
78
+ if (voice === 'custom') {
79
+ voice = voiceFile.value.trim();
80
+ if (!voice) return alert('Please enter the path to the voice file.');
81
+ }
82
+
83
+ generateBtn.classList.add('loading');
84
+ generateBtn.disabled = true;
85
+ outputSection.classList.remove('active');
86
+
87
+ try {
88
+ const response = await fetch('/v1/audio/speech', {
89
+ method: 'POST',
90
+ headers: { 'Content-Type': 'application/json' },
91
+ body: JSON.stringify({
92
+ model: 'pocket-tts',
93
+ input: text,
94
+ voice: voice,
95
+ response_format: 'wav',
96
+ stream: stream,
97
+ }),
98
+ });
99
+
100
+ if (!response.ok) {
101
+ const err = await response.json();
102
+ throw new Error(err.error || response.statusText);
103
+ }
104
+
105
+ const blob = await response.blob();
106
+ const url = URL.createObjectURL(blob);
107
+ audioPlayer.src = url;
108
+ downloadBtn.href = url;
109
+ downloadBtn.download = 'generated_speech.wav';
110
+
111
+ audioPlayer.play();
112
+ outputSection.classList.add('active');
113
+ } catch (e) {
114
+ alert('Error generating speech: ' + e.message);
115
+ } finally {
116
+ generateBtn.classList.remove('loading');
117
+ generateBtn.disabled = false;
118
+ }
119
+ });
120
  });
templates/index.html CHANGED
@@ -1,61 +1,294 @@
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, .mp3, or .flac)...</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, MP3, FLAC)</label>
37
- <input type="file" id="voice-file" accept=".wav,.mp3,.flac">
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>
 
1
+ <!doctype html>
2
  <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Pocket TTS - Local Streaming</title>
7
+ <link rel="stylesheet" href="/static/css/style.css" />
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
+ <link
11
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
12
+ rel="stylesheet"
13
+ />
14
+ </head>
15
+ <body>
16
+ <script>
17
+ // Pass server config to JavaScript
18
+ window.POCKET_TTS_CONFIG = {
19
+ isDocker: {{ 'true' if is_docker else 'false' }}
20
+ };
21
+ </script>
22
+
23
+ <div class="container">
24
+ <header>
25
+ <img
26
+ src="/static/images/pocket-tts-logo.png"
27
+ alt="Pocket TTS Logo"
28
+ class="logo"
29
+ />
30
+ <h1>Pocket TTS</h1>
31
+ <p class="subtitle">Real-time local speech synthesis & voice cloning</p>
32
+ </header>
33
+
34
+ <div class="content">
35
+ <div class="control-group">
36
+ <label for="text-input">Text Prompt</label>
37
+ <textarea
38
+ id="text-input"
39
+ placeholder="Enter text to generate speech..."
40
+ >
41
+ Hello! I am Pocket TTS, running locally on your machine. I can clone voices and stream audio in real-time.</textarea
42
+ >
43
+ </div>
44
+
45
+ <div class="control-group">
46
+ <label for="voice-select">Voice Selection</label>
47
+ <select id="voice-select">
48
+ <!-- Populated by JS -->
49
+ <option value="custom">
50
+ Custom (Upload .wav, .mp3, or .flac)...
51
+ </option>
52
+ </select>
53
+ </div>
54
+
55
+ <div class="control-group hidden" id="custom-voice-group">
56
+ <label for="voice-file"
57
+ >Upload Reference Audio (WAV, MP3, FLAC)</label
58
+ >
59
+ <input type="file" id="voice-file" accept=".wav,.mp3,.flac" />
60
+ </div>
61
+
62
+ <div class="control-group">
63
+ <label
64
+ for="stream-toggle"
65
+ style="display: inline-flex; align-items: center; cursor: pointer"
66
+ >
67
+ <input
68
+ type="checkbox"
69
+ id="stream-toggle"
70
+ checked
71
+ style="width: auto; margin-right: 8px"
72
+ />
73
+ Enable Streaming Response
74
+ </label>
75
+ </div>
76
+
77
+ <button id="generate-btn" class="btn-primary">
78
+ <span>Generate Speech</span>
79
+ <div class="spinner"></div>
80
+ </button>
81
+
82
+ <div class="output-section" id="output-section">
83
+ <audio id="audio-player" controls></audio>
84
+ <br />
85
+ <a href="#" id="download-btn" class="download-link">Download Audio</a>
86
+ </div>
87
+ </div>
88
+
89
+ <!-- API Documentation -->
90
+ <div class="api-docs">
91
+ <h2>API Documentation</h2>
92
+ <p class="api-intro">
93
+ OpenAI-compatible TTS API. Use any OpenAI TTS client by pointing it to
94
+ this server.
95
+ </p>
96
+
97
+ <!-- Health Endpoint -->
98
+ <div class="endpoint">
99
+ <div class="endpoint-header">
100
+ <span class="method get">GET</span>
101
+ <code class="path">/health</code>
102
+ </div>
103
+ <p class="endpoint-desc">
104
+ Health check endpoint for container orchestration and monitoring.
105
+ </p>
106
+ <div class="response-example">
107
+ <strong>Response:</strong>
108
+ <pre>
109
+ {
110
+ "status": "healthy",
111
+ "model_loaded": true,
112
+ "device": "cpu",
113
+ "sample_rate": 24000
114
+ }</pre
115
+ >
116
+ </div>
117
+ </div>
118
+
119
+ <!-- List Voices Endpoint -->
120
+ <div class="endpoint">
121
+ <div class="endpoint-header">
122
+ <span class="method get">GET</span>
123
+ <code class="path">/v1/voices</code>
124
+ </div>
125
+ <p class="endpoint-desc">
126
+ List all available voices (built-in and custom).
127
+ </p>
128
+ <div class="response-example">
129
+ <strong>Response:</strong>
130
+ <pre>
131
+ {
132
+ "object": "list",
133
+ "data": [
134
+ {"id": "alba", "name": "Alba", "object": "voice"},
135
+ {"id": "marius", "name": "Marius", "object": "voice"}
136
+ ]
137
+ }</pre
138
+ >
139
+ </div>
140
+ </div>
141
+
142
+ <!-- Generate Speech Endpoint -->
143
+ <div class="endpoint">
144
+ <div class="endpoint-header">
145
+ <span class="method post">POST</span>
146
+ <code class="path">/v1/audio/speech</code>
147
+ </div>
148
+ <p class="endpoint-desc">
149
+ Generate speech audio from text. OpenAI-compatible endpoint.
150
+ </p>
151
+
152
+ <div class="params-section">
153
+ <strong>Request Body (JSON):</strong>
154
+ <table class="params-table">
155
+ <thead>
156
+ <tr>
157
+ <th>Parameter</th>
158
+ <th>Type</th>
159
+ <th>Required</th>
160
+ <th>Default</th>
161
+ <th>Description</th>
162
+ </tr>
163
+ </thead>
164
+ <tbody>
165
+ <tr>
166
+ <td><code>input</code></td>
167
+ <td>string</td>
168
+ <td>✓</td>
169
+ <td>-</td>
170
+ <td>The text to generate speech from (max 4096 chars)</td>
171
+ </tr>
172
+ <tr>
173
+ <td><code>voice</code></td>
174
+ <td>string</td>
175
+ <td></td>
176
+ <td><code>alba</code></td>
177
+ <td>
178
+ Voice ID, filename, or URL. See <code>/v1/voices</code>
179
+ </td>
180
+ </tr>
181
+ <tr>
182
+ <td><code>model</code></td>
183
+ <td>string</td>
184
+ <td></td>
185
+ <td>-</td>
186
+ <td>Ignored (for OpenAI compatibility)</td>
187
+ </tr>
188
+ <tr>
189
+ <td><code>response_format</code></td>
190
+ <td>string</td>
191
+ <td></td>
192
+ <td><code>mp3</code></td>
193
+ <td>
194
+ Audio format: <code>mp3</code>, <code>wav</code>,
195
+ <code>pcm</code>, <code>opus</code>, <code>flac</code>
196
+ </td>
197
+ </tr>
198
+ <tr>
199
+ <td><code>stream</code></td>
200
+ <td>boolean</td>
201
+ <td></td>
202
+ <td><code>true</code></td>
203
+ <td>Enable streaming response for real-time playback</td>
204
+ </tr>
205
+ <!-- <tr>
206
+ <td><code>speed</code></td>
207
+ <td>number</td>
208
+ <td></td>
209
+ <td><code>1.0</code></td>
210
+ <td>Playback speed (0.25 to 4.0)</td>
211
+ </tr> -->
212
+ </tbody>
213
+ </table>
214
+ </div>
215
+
216
+ <div class="example-section">
217
+ <strong>Example Request (curl):</strong>
218
+ <pre>
219
+ curl -X POST http://localhost:49112/v1/audio/speech \
220
+ -H "Content-Type: application/json" \
221
+ -d '{
222
+ "input": "Hello world!",
223
+ "voice": "alba",
224
+ "response_format": "mp3"
225
+ }' \
226
+ --output speech.mp3</pre
227
+ >
228
+ </div>
229
+
230
+ <div class="example-section">
231
+ <strong>Example (Python with OpenAI client):</strong>
232
+ <pre>
233
+ from openai import OpenAI
234
+
235
+ client = OpenAI(
236
+ base_url="http://localhost:49112/v1",
237
+ api_key="not-needed"
238
+ )
239
+
240
+ response = client.audio.speech.create(
241
+ model="tts-1",
242
+ voice="alba",
243
+ input="Hello world!"
244
+ )
245
+ response.stream_to_file("output.mp3")</pre
246
+ >
247
+ </div>
248
+
249
+ <div class="response-example">
250
+ <strong>Response:</strong>
251
+ <p>Audio file in the requested format (binary stream).</p>
252
+ <p>
253
+ <strong>Content-Type:</strong> <code>audio/mpeg</code>,
254
+ <code>audio/wav</code>, <code>audio/opus</code>, etc.
255
+ </p>
256
+ </div>
257
+
258
+ <div class="error-section">
259
+ <strong>Error Response:</strong>
260
+ <pre>
261
+ {
262
+ "error": "Missing required field: input"
263
+ }</pre
264
+ >
265
+ </div>
266
+ </div>
267
+
268
+ <!-- Built-in Voices Reference -->
269
+ <div class="voices-reference">
270
+ <h3>Built-in Voices</h3>
271
+ <p>These voices work without authentication:</p>
272
+ <div class="voice-chips">
273
+ <span class="voice-chip">alba</span>
274
+ <span class="voice-chip">marius</span>
275
+ <span class="voice-chip">javert</span>
276
+ <span class="voice-chip">jean</span>
277
+ <span class="voice-chip">fantine</span>
278
+ <span class="voice-chip">cosette</span>
279
+ <span class="voice-chip">eponine</span>
280
+ <span class="voice-chip">azelma</span>
281
+ </div>
282
+ <p class="voice-note">
283
+ Custom voice cloning requires a
284
+ <a href="https://huggingface.co/kyutai/pocket-tts" target="_blank"
285
+ >HuggingFace token</a
286
+ >.
287
+ </p>
288
+ </div>
289
+ </div>
290
+ </div>
291
+
292
+ <script src="/static/js/app.js"></script>
293
+ </body>
294
  </html>