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