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