diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..551f95a727a008a902b86871fb580a01d12f9e84 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,44 @@ +# Docker build artifacts +.git +.gitignore +.env +*.md +!README.md +*.pyc +__pycache__ +*.pyo +*.pyd +.Python +*.so +.eggs +*.egg-info +*.egg +dist +build +*.spec + +# PyInstaller +*.exe +*.bat + +# IDE +.vscode +.idea +*.swp +*.swo + +# Logs (we mount these as volume) +logs/ + +# Virtual environments +venv/ +.venv/ + +# Test files +tests/ +*.test.py +pytest.ini + +# Frozen requirements (use requirements.txt for Docker) +frozen_requirements.txt +pyinstaller_command.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..b918a8a36cd01294090bf2a769be023105ceca67 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# Docker environment configuration template +# Copy to .env and customize as needed + +# Server settings +POCKET_TTS_PORT=49112 +POCKET_TTS_LOG_LEVEL=INFO +POCKET_TTS_STREAM_DEFAULT=true + +# Model language (requires pocket-tts>=2.0.0) +# Options: english, french_24l, german_24l, portuguese, italian, spanish_24l +# Mutually exclusive with POCKET_TTS_MODEL_PATH +# POCKET_TTS_LANGUAGE=english + +# Enable int8 quantization for lower memory usage and improved speed +# POCKET_TTS_QUANTIZE=false + +# Custom voices directory (mounted to container) +# POCKET_TTS_VOICES_DIR=./my_custom_voices + +# Hugging Face token for voice cloning (optional) +# Get your token from: https://huggingface.co/settings/tokens +# HF_TOKEN=hf_xxxxxxxxxxxxx + +# Writable cache for per-model cloned voice safetensors +# Defaults to /voice_cache. In Docker this is /app/voice_cache backed +# by the `pockettts-voice-cache` named volume. +# POCKET_TTS_VOICE_CACHE_DIR=/path/to/cache diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..bf7b69905117fc4c9ecbbafe7a75557c61737dd9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,160 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +pocket-tts-logo.ico filter=lfs diff=lfs merge=lfs -text +static/images/pocket-tts-logo.png filter=lfs diff=lfs merge=lfs -text +voices/ASEN.wav filter=lfs diff=lfs merge=lfs -text +voices/Aadi.wav filter=lfs diff=lfs merge=lfs -text +voices/AbD.wav filter=lfs diff=lfs merge=lfs -text +voices/Abhinox.wav filter=lfs diff=lfs merge=lfs -text +voices/Abo_Ayman.wav filter=lfs diff=lfs merge=lfs -text +voices/Abob_Malay.wav filter=lfs diff=lfs merge=lfs -text +voices/AgentCobra.wav filter=lfs diff=lfs merge=lfs -text +voices/Ajith.wav filter=lfs diff=lfs merge=lfs -text +voices/Alejandro_espanol_latino.wav filter=lfs diff=lfs merge=lfs -text +voices/Allen.wav filter=lfs diff=lfs merge=lfs -text +voices/AmitNag.wav filter=lfs diff=lfs merge=lfs -text +voices/Andrea.wav filter=lfs diff=lfs merge=lfs -text +voices/Aon.wav filter=lfs diff=lfs merge=lfs -text +voices/Aryobe.wav filter=lfs diff=lfs merge=lfs -text +voices/Bijay.wav filter=lfs diff=lfs merge=lfs -text +voices/Blake.wav filter=lfs diff=lfs merge=lfs -text +voices/Bobby_McFern.wav filter=lfs diff=lfs merge=lfs -text +voices/Breaking_1.wav filter=lfs diff=lfs merge=lfs -text +voices/BrokenHypocrite.wav filter=lfs diff=lfs merge=lfs -text +voices/Butter.wav filter=lfs diff=lfs merge=lfs -text +voices/CPS_001.wav filter=lfs diff=lfs merge=lfs -text +voices/Chujus.wav filter=lfs diff=lfs merge=lfs -text +voices/Darya_khan.wav filter=lfs diff=lfs merge=lfs -text +voices/Deepak.wav filter=lfs diff=lfs merge=lfs -text +voices/Dhruv_Rao.wav filter=lfs diff=lfs merge=lfs -text +voices/Dil.wav filter=lfs diff=lfs merge=lfs -text +voices/Enrique.wav filter=lfs diff=lfs merge=lfs -text +voices/Ernesto_Y.wav filter=lfs diff=lfs merge=lfs -text +voices/Eshan.wav filter=lfs diff=lfs merge=lfs -text +voices/Esteban_Aguirre_Arias.wav filter=lfs diff=lfs merge=lfs -text +voices/Ferdinand.wav filter=lfs diff=lfs merge=lfs -text +voices/FlorDaddy.wav filter=lfs diff=lfs merge=lfs -text +voices/Fred_Mara.wav filter=lfs diff=lfs merge=lfs -text +voices/Giovanne.wav filter=lfs diff=lfs merge=lfs -text +voices/Glenn.wav filter=lfs diff=lfs merge=lfs -text +voices/Goku.wav filter=lfs diff=lfs merge=lfs -text +voices/Haku.wav filter=lfs diff=lfs merge=lfs -text +voices/Hannah.wav filter=lfs diff=lfs merge=lfs -text +voices/Hardik_Clone.wav filter=lfs diff=lfs merge=lfs -text +voices/Hillbilly_Jim.wav filter=lfs diff=lfs merge=lfs -text +voices/Hkl.wav filter=lfs diff=lfs merge=lfs -text +voices/Ilyass_yea.wav filter=lfs diff=lfs merge=lfs -text +voices/Indian_guy.wav filter=lfs diff=lfs merge=lfs -text +voices/Ineedthisnow.wav filter=lfs diff=lfs merge=lfs -text +voices/JJis2123.wav filter=lfs diff=lfs merge=lfs -text +voices/JOSHE.wav filter=lfs diff=lfs merge=lfs -text +voices/James.wav filter=lfs diff=lfs merge=lfs -text +voices/Jaspino.wav filter=lfs diff=lfs merge=lfs -text +voices/Jaw.wav filter=lfs diff=lfs merge=lfs -text +voices/Jeff_Andrew.wav filter=lfs diff=lfs merge=lfs -text +voices/Jeffrey.wav filter=lfs diff=lfs merge=lfs -text +voices/Jeremy_Q.wav filter=lfs diff=lfs merge=lfs -text +voices/Jimmy.wav filter=lfs diff=lfs merge=lfs -text +voices/Joaopedrobil1.wav filter=lfs diff=lfs merge=lfs -text +voices/John_Triguero.wav filter=lfs diff=lfs merge=lfs -text +voices/Karti.wav filter=lfs diff=lfs merge=lfs -text +voices/Koorosh.wav filter=lfs diff=lfs merge=lfs -text +voices/LC.wav filter=lfs diff=lfs merge=lfs -text +voices/L_Roy.wav filter=lfs diff=lfs merge=lfs -text +voices/Lake.wav filter=lfs diff=lfs merge=lfs -text +voices/Lara.wav filter=lfs diff=lfs merge=lfs -text +voices/Latin_Accent.wav filter=lfs diff=lfs merge=lfs -text +voices/Louis.wav filter=lfs diff=lfs merge=lfs -text +voices/Lucas.wav filter=lfs diff=lfs merge=lfs -text +voices/MJDePedro.wav filter=lfs diff=lfs merge=lfs -text +voices/Maisako.wav filter=lfs diff=lfs merge=lfs -text +voices/Manahen.wav filter=lfs diff=lfs merge=lfs -text +voices/Marshal_Indian.wav filter=lfs diff=lfs merge=lfs -text +voices/Midlands_Bedfordshire_Dialect.wav filter=lfs diff=lfs merge=lfs -text +voices/Moses.wav filter=lfs diff=lfs merge=lfs -text +voices/MrHat.wav filter=lfs diff=lfs merge=lfs -text +voices/Mystery_Sir.wav filter=lfs diff=lfs merge=lfs -text +voices/Narrum.wav filter=lfs diff=lfs merge=lfs -text +voices/Nick.wav filter=lfs diff=lfs merge=lfs -text +voices/P0LFR.wav filter=lfs diff=lfs merge=lfs -text +voices/Parthiban.wav filter=lfs diff=lfs merge=lfs -text +voices/Prakash369.wav filter=lfs diff=lfs merge=lfs -text +voices/Puzzle.wav filter=lfs diff=lfs merge=lfs -text +voices/Qasim_Wali_Khan.wav filter=lfs diff=lfs merge=lfs -text +voices/RAJ.wav filter=lfs diff=lfs merge=lfs -text +voices/Rafaelpazv.wav filter=lfs diff=lfs merge=lfs -text +voices/Raj25.wav filter=lfs diff=lfs merge=lfs -text +voices/Ramu.wav filter=lfs diff=lfs merge=lfs -text +voices/Ranjith.wav filter=lfs diff=lfs merge=lfs -text +voices/ReadyOrNotTOC.wav filter=lfs diff=lfs merge=lfs -text +voices/Richard_cuban.wav filter=lfs diff=lfs merge=lfs -text +voices/Roscoe.wav filter=lfs diff=lfs merge=lfs -text +voices/Rup.wav filter=lfs diff=lfs merge=lfs -text +voices/STONE.wav filter=lfs diff=lfs merge=lfs -text +voices/Selfie.wav filter=lfs diff=lfs merge=lfs -text +voices/Sheddy.wav filter=lfs diff=lfs merge=lfs -text +voices/Siddh_Indian.wav filter=lfs diff=lfs merge=lfs -text +voices/Sir_TJ.wav filter=lfs diff=lfs merge=lfs -text +voices/Sp46.wav filter=lfs diff=lfs merge=lfs -text +voices/Sr_Erick.wav filter=lfs diff=lfs merge=lfs -text +voices/Standollars.wav filter=lfs diff=lfs merge=lfs -text +voices/TESLLA.wav filter=lfs diff=lfs merge=lfs -text +voices/TheFin.wav filter=lfs diff=lfs merge=lfs -text +voices/The_Sustainabler.wav filter=lfs diff=lfs merge=lfs -text +voices/The_other_brother.wav filter=lfs diff=lfs merge=lfs -text +voices/Titorium.wav filter=lfs diff=lfs merge=lfs -text +voices/Umair.wav filter=lfs diff=lfs merge=lfs -text +voices/Vexat.wav filter=lfs diff=lfs merge=lfs -text +voices/Victor_Garcia.wav filter=lfs diff=lfs merge=lfs -text +voices/Vivaldi.wav filter=lfs diff=lfs merge=lfs -text +voices/W_A_H.wav filter=lfs diff=lfs merge=lfs -text +voices/Wealthiest.wav filter=lfs diff=lfs merge=lfs -text +voices/WhisperInEar.wav filter=lfs diff=lfs merge=lfs -text +voices/Yesid.wav filter=lfs diff=lfs merge=lfs -text +voices/Youfied.wav filter=lfs diff=lfs merge=lfs -text +voices/Yuush.wav filter=lfs diff=lfs merge=lfs -text +voices/ada.wav filter=lfs diff=lfs merge=lfs -text +voices/aela_voice_sample.wav filter=lfs diff=lfs merge=lfs -text +voices/amazon_box.wav filter=lfs diff=lfs merge=lfs -text +voices/andrea_spanish.wav filter=lfs diff=lfs merge=lfs -text +voices/awais_shah.wav filter=lfs diff=lfs merge=lfs -text +voices/bathri.wav filter=lfs diff=lfs merge=lfs -text +voices/bevi.wav filter=lfs diff=lfs merge=lfs -text +voices/boom.wav filter=lfs diff=lfs merge=lfs -text +voices/cybina.wav filter=lfs diff=lfs merge=lfs -text +voices/david_attenborough.wav filter=lfs diff=lfs merge=lfs -text +voices/default_voice.wav filter=lfs diff=lfs merge=lfs -text +voices/developpeuse_3.wav filter=lfs diff=lfs merge=lfs -text +voices/english_with_german_accent.wav filter=lfs diff=lfs merge=lfs -text +voices/enrique_spanish.wav filter=lfs diff=lfs merge=lfs -text +voices/erihppas.wav filter=lfs diff=lfs merge=lfs -text +voices/ex03_ex01_angry_001_channel1_201s.wav filter=lfs diff=lfs merge=lfs -text +voices/ex03_ex01_calm_001_channel1_1143s.wav filter=lfs diff=lfs merge=lfs -text +voices/ex03_ex01_happy_001_channel1_334s.wav filter=lfs diff=lfs merge=lfs -text +voices/ex04_narration_longform_00001.wav filter=lfs diff=lfs merge=lfs -text +voices/fabieng_enhanced_v2.wav filter=lfs diff=lfs merge=lfs -text +voices/gmaskell92.wav filter=lfs diff=lfs merge=lfs -text +voices/hielos.wav filter=lfs diff=lfs merge=lfs -text +voices/hielos_2.wav filter=lfs diff=lfs merge=lfs -text +voices/james_earl_jones1.wav filter=lfs diff=lfs merge=lfs -text +voices/kbrn1.wav filter=lfs diff=lfs merge=lfs -text +voices/kitt.wav filter=lfs diff=lfs merge=lfs -text +voices/muhtasims_voice.wav filter=lfs diff=lfs merge=lfs -text +voices/nms_suit.wav filter=lfs diff=lfs merge=lfs -text +voices/obama.wav filter=lfs diff=lfs merge=lfs -text +voices/oldNerd.wav filter=lfs diff=lfs merge=lfs -text +voices/oldNerd2.wav filter=lfs diff=lfs merge=lfs -text +voices/oldNerd3.wav filter=lfs diff=lfs merge=lfs -text +voices/ra_XOr.wav filter=lfs diff=lfs merge=lfs -text +voices/rewi.wav filter=lfs diff=lfs merge=lfs -text +voices/robert.wav filter=lfs diff=lfs merge=lfs -text +voices/robert2.wav filter=lfs diff=lfs merge=lfs -text +voices/siddharth_khanna.wav filter=lfs diff=lfs merge=lfs -text +voices/solace.wav filter=lfs diff=lfs merge=lfs -text +voices/stein.wav filter=lfs diff=lfs merge=lfs -text +voices/surazy.wav filter=lfs diff=lfs merge=lfs -text +voices/thepolishdane.wav filter=lfs diff=lfs merge=lfs -text +voices/vinayak.wav filter=lfs diff=lfs merge=lfs -text +voices/willbas.wav filter=lfs diff=lfs merge=lfs -text +voices/zerocool.wav filter=lfs diff=lfs merge=lfs -text diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000000000000000000000000000000000000..394862d3e5bce77fa4da0c367b063ad94bf6a079 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "ms-python.python", + "charliermarsh.ruff", + "ms-python.debugpy" + ] +} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000000000000000000000000000000000000..87959b0b9af1f47d97ae36ebb4c0854a45779552 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,55 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "PocketTTS Server", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/server.py", + "console": "integratedTerminal", + "justMyCode": true, + "env": { + "POCKET_TTS_LOG_LEVEL": "DEBUG", + "POCKET_TTS_PORT": "49112" + }, + "args": [] + }, + { + "name": "PocketTTS Server (Custom Port)", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/server.py", + "console": "integratedTerminal", + "justMyCode": true, + "args": [ + "--port", + "8080", + "--log-level", + "DEBUG" + ] + }, + { + "name": "PocketTTS Server (With Voices Dir)", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/server.py", + "console": "integratedTerminal", + "justMyCode": true, + "args": [ + "--voices-dir", + "${workspaceFolder}/voices", + "--stream", + "--log-level", + "DEBUG" + ] + }, + { + "name": "Python: Current File", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal", + "justMyCode": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000000000000000000000000000000000..92c6eee069aac3758ada4d8921b9d4ca0c1c143a --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.defaultInterpreterPath": "${workspaceFolder}/venv/bin/python", + "[python]": { + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll": "explicit", + "source.organizeImports": "explicit" + }, + "editor.defaultFormatter": "charliermarsh.ruff" + } +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..f4822c98f16108430914ba12e47cf089988fc027 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,111 @@ +# Project Overview + +**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. + +## Why This Exists + +The official `pocket-tts` has a FastAPI server with `/tts` endpoint, but it's **not OpenAI API compatible**. This project adds: + +- `/v1/audio/speech` matching OpenAI's schema +- `/v1/voices` for voice listing +- Docker deployment with voice mounting +- Windows executable distribution + +## Architecture + +``` +server.py # Entry point, CLI, starts Waitress + └── app/__init__.py # Flask app factory + ├── app/routes.py # API endpoints + ├── app/config.py # Environment config + └── app/services/ + ├── tts.py # TTSService: model, voice cache + └── audio.py # Format conversion, streaming +``` + +## Key Files + +| File | Purpose | +| --------------------- | ---------------------------------------------------------------- | +| `server.py` | Entry point, CLI args, Waitress server | +| `app/routes.py` | HTTP endpoints: `/`, `/health`, `/v1/voices`, `/v1/audio/speech` | +| `app/services/tts.py` | Model loading, voice caching, generation | +| `app/config.py` | Environment variables, path resolution | + +## API Endpoints + +| Endpoint | Method | Purpose | +| ------------------ | ------ | ----------------------------------- | +| `/` | GET | Web UI | +| `/health` | GET | Health check for containers | +| `/v1/voices` | GET | List voices | +| `/v1/audio/speech` | POST | Generate speech (OpenAI-compatible) | + +### Speech Request + +```json +{ + "model": "tts-1", + "input": "Text to speak", + "voice": "alba", + "response_format": "mp3", + "stream": false +} +``` + +## CLI Arguments + +| Argument | Env Variable | Default | Purpose | +| --------------------- | --------------------------- | ------- | ------------------ | +| `--host` | `POCKET_TTS_HOST` | `0.0.0.0` | Bind address | +| `--port` | `POCKET_TTS_PORT` | `49112` | Port | +| `--model-path` | `POCKET_TTS_MODEL_PATH` | None | Path to model config file (.yaml) or variant | +| `--language` | `POCKET_TTS_LANGUAGE` | None | Model language (english, french_24l, etc.) | +| `--quantize` | `POCKET_TTS_QUANTIZE` | `false` | Enable int8 quantization for lower memory usage | +| `--voices-dir` | `POCKET_TTS_VOICES_DIR` | None | Custom voices directory | +| `--stream` | `POCKET_TTS_STREAM_DEFAULT`| `false` | Enable streaming by default | +| `--text-preprocess` | `POCKET_TTS_TEXT_PREPROCESS_DEFAULT` | `false` | Enable text preprocessing by default | +| `--log-level` | `POCKET_TTS_LOG_LEVEL` | `INFO` | Log verbosity | + +**Note**: `--language` and `--model-path` are mutually exclusive. Use `--language` to select a built-in language model (requires pocket-tts>=2.0.0). + +## Voice Resolution Order + +1. Built-in names (`alba`, `marius`, etc.) → pass to pocket-tts +2. HuggingFace models (`hf://` URLs) → pass to pocket-tts +3. Files in `POCKET_TTS_VOICES_DIR` +4. Absolute paths +5. Fallback to pocket-tts + +**Security Note**: HTTP/HTTPS URLs are blocked to prevent SSRF attacks. Only `hf://` URLs are allowed for remote models. + +## Development + +```bash +# Install +pip install -r requirements.txt + +# Run with defaults (English model) +python server.py --log-level DEBUG + +# Run with French model and quantization +python server.py --language french_24l --quantize --log-level DEBUG + +# Test +curl http://localhost:49112/health +curl -X POST http://localhost:49112/v1/audio/speech \ + -H "Content-Type: application/json" \ + -d '{"input": "Hello", "voice": "alba"}' -o test.mp3 +``` + +## Code Style + +- Linter/formatter: `ruff` (config in `pyproject.toml`) +- Line length: 100 +- Single quotes + +## Deployment + +- **Python**: `python server.py` +- **Docker**: `docker compose up -d` +- **Windows EXE**: Built via GitHub Actions on release tags diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..201c94951bc780eb458eda2e463f77c307a4f444 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,76 @@ +# Dockerfile for PocketTTS OpenAI-Compatible Server +# Optimized for CPU inference (pocket-tts runs efficiently on CPU) +# Uses CPU-only PyTorch for smaller image size (~700MB vs ~2GB) + +FROM python:3.10-slim AS builder + +# Install build dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +# Create virtual environment +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +# Install Python dependencies (requirements.txt specifies CPU-only PyTorch) +COPY requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r /tmp/requirements.txt + + +# Production image +FROM python:3.10-slim + +# Install runtime dependencies for audio processing +RUN apt-get update && apt-get install -y --no-install-recommends \ + libsndfile1 \ + ffmpeg \ + && rm -rf /var/lib/apt/lists/* \ + && apt-get clean + +# Copy virtual environment from builder +COPY --from=builder /opt/venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +# Create non-root user +RUN useradd --create-home --shell /bin/bash pockettts +WORKDIR /app + +# Copy application code +COPY --chown=pockettts:pockettts app/ ./app/ +COPY --chown=pockettts:pockettts static/ ./static/ +COPY --chown=pockettts:pockettts templates/ ./templates/ +COPY --chown=pockettts:pockettts voices/ ./voices/ +COPY --chown=pockettts:pockettts server.py ./ + +# Create logs directory, and ensure app directory is owned by user +RUN chown pockettts:pockettts /app && mkdir -p /app/logs && chown pockettts:pockettts /app/logs + +# Create HuggingFace cache directory (for volume mount) +RUN mkdir -p /home/pockettts/.cache/huggingface && \ + chown -R pockettts:pockettts /home/pockettts/.cache + +# Create voice cache directory with correct ownership for the named volume +RUN mkdir -p /app/voice_cache && chown pockettts:pockettts /app/voice_cache + +# Switch to non-root user +USER pockettts + +# Environment variables with defaults +ENV POCKET_TTS_HOST=0.0.0.0 \ + POCKET_TTS_PORT=49112 \ + POCKET_TTS_VOICES_DIR=/app/voices \ + POCKET_TTS_LOG_DIR=/app/logs \ + POCKET_TTS_LOG_LEVEL=INFO \ + PYTHONUNBUFFERED=1 + +# Expose port +EXPOSE 49112 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:49112/health')" || exit 1 + +# Run server +CMD ["python", "server.py"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..0802bae7d68ce227df7cfb3de4c8b1397685e1f2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 teddybear082 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PocketTTS-Server.desktop b/PocketTTS-Server.desktop new file mode 100644 index 0000000000000000000000000000000000000000..852a7d7aa1cd8da6bf30bd7005080f92c083d6c8 --- /dev/null +++ b/PocketTTS-Server.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Type=Application +Name=PocketTTS Server +Comment=OpenAI-compatible TTS API server powered by Pocket-TTS +Exec=pocket-tts-server +Icon=PocketTTS-Server +Terminal=true +Categories=Development;AudioVideo; diff --git a/README.md b/README.md index 7a1e71bdf6c8999e523284c566a8b400aa360395..899648f8e216a97c7ffc35984aeeecf140acbd28 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,324 @@ ---- -title: Pocket -emoji: 🏆 -colorFrom: purple -colorTo: pink -sdk: docker -pinned: false ---- - -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# PocketTTS OpenAI-Compatible Server + +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. + +Tested and working fully with [WingmanAI by Shipbit](https://www.wingman-ai.com/). Due to low resource use, can be used for real time local text to speech even while playing intensive video games (even in VR!) with WingmanAI. + +**Key Features:** + +- 🎯 **OpenAI API Compatible** - Works with any OpenAI TTS client +- 🚀 **Real-time Streaming** - Low-latency audio generation +- 🎤 **150+ Community Voices** - Ready-to-use voice library included +- 🎭 **Voice Cloning** - Clone any voice from a short audio sample +- 🐳 **Docker Ready** - One-command deployment +- 💻 **Cross-platform** - Runs on Windows, macOS, and Linux +- ⚡ **CPU Optimized** - No GPU required +- 🎤 **Text pre-processing** - Clean text for words and symbols TTS usually has difficulty with, automatically + +## Quick Start + +### Option 1: Docker (Recommended) + +```bash +# Clone the repository +git clone https://github.com/teddybear082/pocket-tts-openai_streaming_server.git +cd pocket-tts-openai_streaming_server + +# Start the server +docker compose up -d + +# View logs +docker compose logs -f +``` + +The server will be available at `http://localhost:49112` + +**Custom Configuration:** + +```bash +# Change port +POCKET_TTS_PORT=8080 docker compose up -d + +# Use custom voices directory +POCKET_TTS_VOICES_DIR=/path/to/my/voices docker compose up -d +``` + +### Option 2: Python (from source) + +```bash +# Clone the repository +git clone https://github.com/teddybear082/pocket-tts-openai_streaming_server.git +cd pocket-tts-openai_streaming_server + +# Create virtual environment +python -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate + +# Install dependencies +pip install -r requirements.txt + +# Start the server +python server.py +``` + +**Command Line Options:** + +```bash +python server.py --help + +# Custom port and voices +python server.py --port 8080 --voices-dir ./my_voices + +# Enable streaming by default +python server.py --stream + +# Enable text preprocessing +python server.py --text-preprocess +``` + +### Option 3: Windows Executable + +1. Download the latest release from [Releases](https://github.com/teddybear082/pocket-tts-openai_streaming_server/releases) +2. Extract the ZIP file +3. Double-click `PocketTTS-Server.exe` to run with defaults +4. Or run `run_pocket_tts_server_exe.bat` for custom configuration + +## Web Interface + +Open `http://localhost:49112` in your browser to access the built-in web UI: + +- Select from available voices +- Enter text to synthesize +- Listen to generated audio directly + +## API Usage + +### Generate Speech + +**Endpoint:** `POST /v1/audio/speech` + +```bash +curl http://localhost:49112/v1/audio/speech \ + -H "Content-Type: application/json" \ + -d '{ + "model": "tts-1", + "input": "Hello world! This is a test.", + "voice": "alba" + }' \ + --output speech.mp3 +``` + +### Python Client + +```python +from openai import OpenAI + +client = OpenAI( + base_url="http://localhost:49112/v1", + api_key="not-needed" # No authentication required +) + +# Generate and save audio +response = client.audio.speech.create( + model="tts-1", + voice="alba", + input="Hello world! This is a test." +) +response.stream_to_file("output.mp3") + +# Streaming +with client.audio.speech.with_streaming_response.create( + model="tts-1", + voice="alba", + input="This is streaming audio.", + response_format="pcm" +) as response: + for chunk in response.iter_bytes(): + # Process audio chunks in real-time + pass +``` + +### API Reference + +| Endpoint | Method | Description | +| ------------------ | ------ | ---------------------------------------- | +| `/` | GET | Web interface | +| `/health` | GET | Health check for container orchestration | +| `/v1/voices` | GET | List available voices | +| `/v1/audio/speech` | POST | Generate speech audio | + +**Speech Parameters:** + +| Parameter | Type | Required | Default | Description | +| ----------------- | ------- | -------- | ------- | -------------------------------------------------- | +| `model` | string | No | - | Ignored (for OpenAI compatibility) | +| `input` | string | Yes | - | Text to synthesize | +| `voice` | string | No | `alba` | Voice ID (see `/v1/voices`) | +| `response_format` | string | No | `mp3` | Output format: `mp3`, `wav`, `pcm`, `opus`, `aac`, `flac` | +| `stream` | boolean | No | `false` | Enable streaming response | + +## Custom Voices + +### Using Custom Voice Files + +1. **Create a voices directory** with your audio files (`.wav`, `.mp3`, `.flac`) +2. **Configure the server** to use your directory: + + **Docker:** + + ```bash + POCKET_TTS_VOICES_DIR=/path/to/voices docker compose up -d + ``` + + **Python:** + + ```bash + python server.py --voices-dir /path/to/voices + ``` + + **Windows EXE:** + Use the batch launcher and specify the voices directory when prompted. + +3. **Use your voice** by filename: + ```json + { "voice": "my_voice.wav", "input": "Hello!" } + ``` + +### Voice File Guidelines + +- **Duration:** 3-15 seconds of clear speech works best +- **Quality:** Clean audio without background noise +- **Format:** WAV, MP3, or FLAC +- **Tip:** Use [Adobe Podcast Enhance](https://podcast.adobe.com/enhance) to clean noisy samples + +### Built-in Voices + +The following voices are available by default: +`alba`, `marius`, `javert`, `jean`, `fantine`, `cosette`, `eponine`, `azelma` + +The `voices/` directory includes 150+ community-contributed voices. + +## Configuration + +### Environment Variables + +| Variable | Default | Description | +| ------------------------------------| ---------- | -------------------------------------- | +| `POCKET_TTS_HOST` | `0.0.0.0` | Server bind address | +| `POCKET_TTS_PORT` | `49112` | Server port | +| `POCKET_TTS_VOICES_DIR` | `./voices` | Custom voices directory | +| `POCKET_TTS_MODEL_PATH` | - | Custom model path | +| `POCKET_TTS_STREAM_DEFAULT` | `true` | Enable streaming by default | +| `POCKET_TTS_TEXT_PREPROCESS_DEFAULT`| `true` | Enable text preprocessing by default | +| `POCKET_TTS_LOG_LEVEL` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR | +| `POCKET_TTS_LOG_DIR` | `./logs` | Log files directory | +| `HF_TOKEN` | - | Hugging Face token (for voice cloning) | + +### Docker Compose Options + +See [docker-compose.yml](docker-compose.yml) for all available options including: + +- Volume mounts for custom voices +- Resource limits +- Health check configuration +- HuggingFace cache persistence + +## Project Structure + +``` +pocket-tts-openai_streaming_server/ +├── app/ # Application modules +│ ├── __init__.py # Flask app factory +│ ├── config.py # Configuration management +│ ├── logging_config.py # Logging setup +│ ├── routes.py # API endpoints +│ └── services/ # Business logic +│ ├── audio.py # Audio conversion +│ └── tts.py # TTS service +| |-- preprocess.py # Text preprocessor +├── static/ # Web UI assets +├── templates/ # HTML templates +├── voices/ # Voice files +├── server.py # Main entry point +├── Dockerfile # Container build +├── docker-compose.yml # Container orchestration +└── requirements.txt # Python dependencies +``` + +## Development + +### Dependencies + +| File | Purpose | +| ---------------------- | ---------------------------------------------------- | +| `requirements.txt` | Runtime dependencies only (Flask, torch, pocket-tts) | +| `requirements-dev.txt` | Adds dev tools: ruff (linting), pytest (testing) | + +### Running Locally + +```bash +# Install runtime dependencies only +pip install -r requirements.txt + +# Or install with dev tools (recommended for contributors) +pip install -r requirements-dev.txt + +# Run with debug logging +python server.py --log-level DEBUG +``` + +### Linting + +```bash +pip install ruff +ruff check . +ruff format . +``` + +### Building Windows EXE + +```bash +pip install pyinstaller +pyinstaller --onefile --name PocketTTS-Server \ + --add-data "static;static" \ + --add-data "templates;templates" \ + --add-data "voices;voices" \ + --add-data "app;app" \ + server.py +``` + +## Troubleshooting + +### Model Loading Takes Long + +First run downloads the model (~500MB). Subsequent runs use cached model. + +**Docker:** Model cache is persisted in a Docker volume. + +### Voice Cloning Requires HF Token + +For voice cloning, you may need a Hugging Face token: + +1. Get token from https://huggingface.co/settings/tokens +2. Set `HF_TOKEN` environment variable + +### Port Already in Use + +```bash +# Use a different port +python server.py --port 8080 + +# Or with Docker +POCKET_TTS_PORT=8080 docker compose up -d +``` + +## Credits + +- [Pocket-TTS](https://github.com/kyutai-labs/pocket-tts) by Kyutai Labs +- Community voice contributors (see [voices/credits.txt](voices/credits.txt)) + +## License + +This project is licensed under the MIT License - see [LICENSE](LICENSE) for details. + +Pocket-TTS is subject to its own license terms. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..4b6c96ac60ebf385293283e007ff018328f83c4d --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,90 @@ +""" +PocketTTS OpenAI-Compatible Server + +Flask application factory and initialization. +""" + +# Keep in sync with pyproject.toml — used as the version fallback when the +# package isn't installed via pip (e.g. running directly from a clone). +__version__ = '2.5.4' + +from flask import Flask + +from app.config import Config +from app.logging_config import get_logger, setup_logging + + +def create_app(config_overrides: dict = None) -> Flask: + """ + Application factory for creating the Flask app. + + Args: + config_overrides: Optional dictionary of config values to override + + Returns: + Configured Flask application + """ + # Setup logging first + setup_logging() + logger = get_logger() + + # Create Flask app with correct paths + app = Flask( + __name__, + template_folder=Config.get_template_folder(), + static_folder=Config.get_static_folder(), + ) + + # Apply default config + app.config['STREAM_DEFAULT'] = Config.STREAM_DEFAULT + + # Apply overrides + if config_overrides: + app.config.update(config_overrides) + + # Register blueprints + from app.routes import api + + app.register_blueprint(api) + + logger.info('Flask application created') + + return app + + +def init_tts_service( + model_path: str = None, + voices_dir: str = None, + language: str = None, + quantize: bool = False, +) -> None: + """ + Initialize the TTS service with model and voices. + + Args: + model_path: Optional path to model config file + voices_dir: Optional path to voices directory + language: Optional language identifier (e.g., english, french_24l) + quantize: Whether to apply dynamic int8 quantization + """ + from app.services.tts import get_tts_service + + logger = get_logger() + tts = get_tts_service() + + # Load model + tts.load_model(model_path=model_path, language=language, quantize=quantize) + + # Pre-create the voice cache directory (or log warning if not writable) + tts._ensure_cache_dir() + + # Set voices directory + if voices_dir: + tts.set_voices_dir(voices_dir) + else: + # Check for bundled voices + bundle_voices, _ = Config.get_bundle_paths() + if bundle_voices: + tts.set_voices_dir(bundle_voices) + + logger.info('TTS service initialized') diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000000000000000000000000000000000000..625cbae43b2b40cd3e8803426abf2e2a0ae047c1 --- /dev/null +++ b/app/config.py @@ -0,0 +1,131 @@ +""" +Configuration management for PocketTTS OpenAI Server. +Loads settings from environment variables with sensible defaults. +""" + +import os +import sys +from pathlib import Path + + +def get_base_path() -> Path: + """Get the base path for the application, handling PyInstaller frozen state.""" + if getattr(sys, 'frozen', False): + if hasattr(sys, '_MEIPASS'): + # One-file mode + return Path(sys._MEIPASS) + else: + # One-dir mode + return Path(sys.executable).parent + return Path(__file__).parent.parent + + +class Config: + """Application configuration loaded from environment variables.""" + + # Base paths + BASE_PATH = get_base_path() + IS_FROZEN = getattr(sys, 'frozen', False) + + # Server settings + HOST = os.environ.get('POCKET_TTS_HOST', '0.0.0.0') + PORT = int(os.environ.get('POCKET_TTS_PORT', '49112')) + + # Model settings + MODEL_PATH = os.environ.get('POCKET_TTS_MODEL_PATH', None) + LANGUAGE = os.environ.get('POCKET_TTS_LANGUAGE', None) + QUANTIZE = os.environ.get('POCKET_TTS_QUANTIZE', 'false').lower() == 'true' + + # Supported languages (pocket-tts v2.0.0 predefined model YAMLs) + SUPPORTED_LANGUAGES = [ + 'english', # alias for english_2026-04 (default) + 'english_2026-01', + 'english_2026-04', + 'french_24l', # no bare `french` — upstream raises + 'german', + 'german_24l', + 'italian', + 'italian_24l', + 'portuguese', + 'portuguese_24l', + 'spanish', + 'spanish_24l', + ] + + # Canonicalize equivalent model IDs so tagged caches dedupe. + LEGACY_MODEL_ALIASES = { + 'english': 'english_2026-04', + 'english_2026-01': 'english_2026-04', + } + + # Writable voice cache dir for tagged .safetensors clones. + VOICE_CACHE_DIR = os.environ.get( + 'POCKET_TTS_VOICE_CACHE_DIR', + str(BASE_PATH / 'voice_cache'), + ) + + DEFAULT_VOICE = os.environ.get( + 'POCKET_TTS_DEFAULT_VOICE', 'hf://kyutai/tts-voices/alba-mackenna/casual.wav' + ) + + # Voice directory + VOICES_DIR = os.environ.get('POCKET_TTS_VOICES_DIR', None) + + # Streaming default + STREAM_DEFAULT = os.environ.get('POCKET_TTS_STREAM_DEFAULT', 'false').lower() == 'true' + + # Text preprocessing default + TEXT_PREPROCESS_DEFAULT = ( + os.environ.get('POCKET_TTS_TEXT_PREPROCESS_DEFAULT', 'false').lower() == 'true' + ) + + # Docker detection + @staticmethod + def _is_docker() -> bool: + """Detect if running in a Docker container.""" + # Check for .dockerenv file (most reliable) + if os.path.exists('/.dockerenv'): + return True + # Check cgroup for docker/containerd references + try: + with open('/proc/1/cgroup') as f: + return any('docker' in line or 'containerd' in line for line in f) + except (FileNotFoundError, PermissionError): + return False + + IS_DOCKER = _is_docker.__func__() + + # Logging + LOG_LEVEL = os.environ.get('POCKET_TTS_LOG_LEVEL', 'INFO') + LOG_DIR = os.environ.get('POCKET_TTS_LOG_DIR', str(BASE_PATH / 'logs')) + LOG_FILE = os.environ.get('POCKET_TTS_LOG_FILE', 'pocket_tts.log') + LOG_MAX_BYTES = int(os.environ.get('POCKET_TTS_LOG_MAX_BYTES', str(10 * 1024 * 1024))) # 10MB + LOG_BACKUP_COUNT = int(os.environ.get('POCKET_TTS_LOG_BACKUP_COUNT', '5')) + + # Built-in voice mappings (these are resolved by pocket-tts internally) + BUILTIN_VOICES = ['alba', 'marius', 'javert', 'jean', 'fantine', 'cosette', 'eponine', 'azelma'] + + # Supported audio extensions for custom voices + VOICE_EXTENSIONS = ('.wav', '.mp3', '.flac', '.safetensors') + + @classmethod + def get_bundle_paths(cls) -> tuple: + """Get bundled paths for frozen executables.""" + if cls.IS_FROZEN: + voices_dir = cls.BASE_PATH / 'voices' + model_path = cls.BASE_PATH / 'model' / 'b6369a24.yaml' + return ( + str(voices_dir) if voices_dir.is_dir() else None, + str(model_path) if model_path.is_file() else None, + ) + return None, None + + @classmethod + def get_template_folder(cls) -> str: + """Get the templates folder path.""" + return str(cls.BASE_PATH / 'templates') + + @classmethod + def get_static_folder(cls) -> str: + """Get the static files folder path.""" + return str(cls.BASE_PATH / 'static') diff --git a/app/logging_config.py b/app/logging_config.py new file mode 100644 index 0000000000000000000000000000000000000000..222c1b59c20653457140f997b72a2fa6c35119b1 --- /dev/null +++ b/app/logging_config.py @@ -0,0 +1,77 @@ +""" +Logging configuration with file rotation support. +""" + +import logging +import sys +from logging.handlers import RotatingFileHandler +from pathlib import Path + +from app.config import Config + + +def setup_logging(log_level: str = None) -> logging.Logger: + """ + Configure application logging with console and rotating file handlers. + + Args: + log_level: Override log level (default: from Config.LOG_LEVEL) + + Returns: + Configured logger instance + """ + level = getattr(logging, (log_level or Config.LOG_LEVEL).upper(), logging.INFO) + + # Create logger + logger = logging.getLogger('PocketTTS') + logger.setLevel(level) + + # Avoid duplicate handlers + if logger.handlers: + return logger + + # Console handler - simple format + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(level) + console_format = logging.Formatter( + '%(asctime)s - %(levelname)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S' + ) + console_handler.setFormatter(console_format) + logger.addHandler(console_handler) + + # File handler - detailed format with rotation + try: + log_dir = Path(Config.LOG_DIR) + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / Config.LOG_FILE + + file_handler = RotatingFileHandler( + log_path, + maxBytes=Config.LOG_MAX_BYTES, + backupCount=Config.LOG_BACKUP_COUNT, + encoding='utf-8', + ) + file_handler.setLevel(level) + file_format = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - [%(filename)s:%(lineno)d] - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + ) + file_handler.setFormatter(file_format) + logger.addHandler(file_handler) + + except Exception as e: + logger.warning(f'Could not set up file logging: {e}') + + # Suppress noisy third-party loggers + logging.getLogger('werkzeug').setLevel(logging.WARNING) + logging.getLogger('urllib3').setLevel(logging.WARNING) + + return logger + + +def get_logger(name: str = None) -> logging.Logger: + """Get a logger instance, optionally with a child name.""" + base_logger = logging.getLogger('PocketTTS') + if name: + return base_logger.getChild(name) + return base_logger diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000000000000000000000000000000000000..d0e085b57df91948e030f10d062a06c304c22c59 --- /dev/null +++ b/app/routes.py @@ -0,0 +1,340 @@ +""" +Flask routes for the OpenAI-compatible TTS API. +""" + +import time + +from flask import ( + Blueprint, + Response, + jsonify, + render_template, + request, + send_file, + stream_with_context, +) + +from app.config import Config +from app.logging_config import get_logger +from app.services.audio import ( + convert_audio, + get_mime_type, + tensor_to_pcm_bytes, + validate_format, + write_wav_header, +) +from app.services.preprocess import TextPreprocessor +from app.services.tts import get_tts_service +from app.services.versions import get_versions + +logger = get_logger('routes') + +# Create blueprint +api = Blueprint('api', __name__) + +# Create text preprocessor instance, some options changed from defaults +text_preprocessor = TextPreprocessor( + remove_urls=False, + remove_emails=False, + remove_html=True, + remove_hashtags=True, + remove_mentions=False, + remove_punctuation=False, + remove_stopwords=False, + remove_extra_whitespace=False, +) + + +@api.route('/') +def home(): + """Serve the web interface.""" + from app.config import Config + + return render_template( + 'index.html', + is_docker=Config.IS_DOCKER, + versions=get_versions(), + ) + + +@api.route('/health', methods=['GET']) +def health(): + """ + Health check endpoint for container orchestration. + + Returns service status and basic model info. + """ + tts = get_tts_service() + + # Validate a built-in voice quickly + voice_valid, voice_msg = tts.validate_voice('alba') + + return jsonify( + { + 'status': 'healthy' if tts.is_loaded else 'unhealthy', + 'model_loaded': tts.is_loaded, + 'device': tts.device if tts.is_loaded else None, + 'sample_rate': tts.sample_rate if tts.is_loaded else None, + 'voices_dir': tts.voices_dir, + 'voice_check': {'valid': voice_valid, 'message': voice_msg}, + 'active_model': tts._active, + } + ), 200 if tts.is_loaded else 503 + + +@api.route('/v1/voices', methods=['GET']) +def list_voices(): + """ + List available voices. + + Returns OpenAI-compatible voice list format. + """ + tts = get_tts_service() + voices = tts.list_voices() + + return jsonify( + { + 'object': 'list', + 'data': [ + { + 'id': v['id'], + 'name': v['name'], + 'object': 'voice', + 'type': v.get('type', 'builtin'), + } + for v in voices + ], + } + ) + + +@api.route('/v1/model', methods=['GET']) +def get_model(): + """Return the active model state, boot snapshot, and supported languages.""" + tts = get_tts_service() + + active = tts._active or {'source': 'default', 'value': None, 'quantize': False} + boot = tts._boot_active or active + differs = active != boot + model_path_locked = boot.get('source') == 'model_path' + versions = get_versions() + + return jsonify( + { + 'active': active, + 'boot': boot, + 'differs_from_boot': differs, + 'loading': tts._loading, + 'loading_target': getattr(tts, '_loading_target', None), + 'last_error': getattr(tts, '_last_reload_error', None), + 'model_path_locked': model_path_locked, + 'available_languages': list(Config.SUPPORTED_LANGUAGES), + 'server_version': versions['server'], + 'pocket_tts_version': versions['pocket_tts'], + } + ) + + +@api.route('/v1/model', methods=['POST']) +def post_model(): + """Request a runtime model switch. Returns 202; UI polls GET for completion.""" + data = request.json + if not isinstance(data, dict): + return jsonify({'error': 'Request body must be a JSON object'}), 400 + + language = data.get('language') + + # Reject non-bool `quantize` rather than coercing — `bool('false')` is True, + # which would silently enable quantization for any client sending a string. + quantize = False + if 'quantize' in data: + if not isinstance(data['quantize'], bool): + return jsonify({'error': "Field 'quantize' must be a boolean"}), 400 + quantize = data['quantize'] + + if not language: + return jsonify({'error': "Missing required field 'language'"}), 400 + + if language not in Config.SUPPORTED_LANGUAGES: + return jsonify( + { + 'error': f"Unknown language: '{language}'", + 'available': list(Config.SUPPORTED_LANGUAGES), + } + ), 400 + + tts = get_tts_service() + + if tts._boot_active and tts._boot_active.get('source') == 'model_path': + return jsonify( + { + 'error': 'Language switching disabled: server started with --model-path.', + } + ), 403 + + # `reload_model_async` does the atomic check-and-claim, so the 409 race + # window between `if tts._loading` and `start()` is gone. + started = tts.reload_model_async(language=language, quantize=quantize) + if not started: + return jsonify({'error': 'A model reload is already in progress.'}), 409 + + return jsonify( + { + 'status': 'accepted', + 'loading_target': {'value': language, 'quantize': quantize}, + } + ), 202 + + +@api.route('/v1/audio/speech', methods=['POST']) +def generate_speech(): + """ + OpenAI-compatible speech generation endpoint. + + Request body: + model: string (ignored, for compatibility) + input: string (required) - Text to synthesize + voice: string (optional) - Voice ID or path + response_format: string (optional) - Audio format + stream: boolean (optional) - Enable streaming + + Returns: + Audio file or streaming audio response + """ + from flask import current_app + + data = request.json + + if not isinstance(data, dict): + return jsonify({'error': 'Request body must be a JSON object'}), 400 + + text = data.get('input') + if not text: + return jsonify({'error': "Missing 'input' text"}), 400 + + voice = data.get('voice', 'alba') + stream_request = data.get('stream', False) + + response_format = data.get('response_format', 'mp3') + target_format = validate_format(response_format) + + tts = get_tts_service() + + if tts._loading: + return jsonify({'error': 'Model is reloading; retry shortly.'}), 503 + + # Validate voice first + is_valid, msg = tts.validate_voice(voice) + if not is_valid: + available = [v['id'] for v in tts.list_voices()] + return jsonify( + { + 'error': f"Voice '{voice}' not found", + 'available_voices': available[:10], # Limit to first 10 + 'hint': 'Use /v1/voices to see all available voices', + } + ), 400 + + try: + voice_state = tts.get_voice_state(voice) + + # Check if streaming should be used + use_streaming = stream_request or current_app.config.get('STREAM_DEFAULT', False) + + # Streaming supports only PCM/WAV today; fall back to file for other formats. + if use_streaming and target_format not in ('pcm', 'wav'): + logger.warning( + "Streaming format '%s' is not supported; returning full file instead.", + target_format, + ) + use_streaming = False + # Check if text preprocessing should be used + use_text_preprocess = current_app.config.get('TEXT_PREPROCESS_DEFAULT', False) + # Preprocess text + if use_text_preprocess: + # logger.info(f'Preprocessing text: {text}') + text = text_preprocessor.process(text) + # logger.info(f'Preprocessed text: {text}') + if use_streaming: + return _stream_audio(tts, voice_state, text, target_format) + return _generate_file(tts, voice_state, text, target_format) + + except ValueError as e: + msg = str(e) + # Detect the legacy-unlabeled-safetensors mismatch pattern. Re-resolving + # can itself raise (e.g. SSRF protection on http:// URLs); treat any + # failure here as "not a mismatch" and fall through to the generic 400. + try: + resolved = tts._resolve_voice_path(voice) if not tts._loading else '' + except Exception: + resolved = '' + is_legacy_st = resolved.endswith('.safetensors') and not any( + resolved.endswith(f'.{tag}.safetensors') for tag in Config.SUPPORTED_LANGUAGES + ) + mismatch_markers = ('size mismatch', 'Error(s) in loading state_dict', 'shape') + if is_legacy_st and any(m in msg for m in mismatch_markers): + return jsonify( + { + 'error': 'voice_model_mismatch', + 'message': ( + f"Voice '{voice}' appears to have been cloned for a different " + f'model. Upload the original audio (.wav/.mp3/.flac) to ' + f're-clone for the active model, or switch to the model it ' + f'was generated for.' + ), + 'voice': voice, + 'active_model': (tts._active or {}).get('value'), + } + ), 400 + + logger.warning(f'Voice loading failed: {e}') + return jsonify({'error': msg}), 400 + except Exception as e: + logger.exception('Generation failed') + return jsonify({'error': str(e)}), 500 + + +def _generate_file(tts, voice_state, text: str, fmt: str): + """Generate complete audio and return as file.""" + t0 = time.time() + audio_tensor = tts.generate_audio(voice_state, text) + generation_time = time.time() - t0 + + logger.info(f'Generated {len(text)} chars in {generation_time:.2f}s') + + audio_buffer = convert_audio(audio_tensor, tts.sample_rate, fmt) + mimetype = get_mime_type(fmt) + + return send_file( + audio_buffer, mimetype=mimetype, as_attachment=True, download_name=f'speech.{fmt}' + ) + + +def _stream_audio(tts, voice_state, text: str, fmt: str): + """Stream audio chunks.""" + # Normalize streaming format: we always emit PCM bytes, optionally wrapped + # in a WAV container. For non-PCM/WAV formats (e.g. mp3, opus), coerce to + # raw PCM to avoid mismatched content-type vs. payload. + stream_fmt = fmt + if stream_fmt not in ('pcm', 'wav'): + logger.warning( + "Requested streaming format '%s' is not supported for streaming; " + "falling back to 'pcm'.", + stream_fmt, + ) + stream_fmt = 'pcm' + + def generate(): + stream = tts.generate_audio_stream(voice_state, text) + for chunk_tensor in stream: + yield tensor_to_pcm_bytes(chunk_tensor) + + def stream_with_header(): + # Yield WAV header first if streaming as WAV + if stream_fmt == 'wav': + yield write_wav_header(tts.sample_rate, num_channels=1, bits_per_sample=16) + yield from generate() + + mimetype = get_mime_type(stream_fmt) + + return Response(stream_with_context(stream_with_header()), mimetype=mimetype) diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d205ac44c07ff731a0b2ede71474cf377c0858ab --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1,5 @@ +"""Services package.""" + +from app.services.tts import TTSService, get_tts_service + +__all__ = ['TTSService', 'get_tts_service'] diff --git a/app/services/audio.py b/app/services/audio.py new file mode 100644 index 0000000000000000000000000000000000000000..86121d3461973e6cce37cdef41a89a56756a3335 --- /dev/null +++ b/app/services/audio.py @@ -0,0 +1,181 @@ +""" +Audio conversion and streaming utilities. +""" + +import io +import struct + +import torch +import torchaudio + +from app.logging_config import get_logger + +logger = get_logger('audio') + +# Valid audio formats +VALID_FORMATS = {'mp3', 'wav', 'opus', 'aac', 'flac', 'pcm'} + + +def validate_format(fmt: str) -> str: + """ + Normalize and validate the requested audio format. + + Args: + fmt: Requested format string + + Returns: + Validated format string + """ + fmt = fmt.lower() + + # OpenAI sometimes sends 'mpeg' for mp3 + if fmt == 'mpeg': + return 'mp3' + + if fmt not in VALID_FORMATS: + logger.warning(f"Unknown format '{fmt}', falling back to wav") + return 'wav' + + return fmt + + +def convert_audio( + audio_tensor: torch.Tensor, sample_rate: int, target_format: str = 'wav' +) -> io.BytesIO: + """ + Convert a raw audio tensor to a byte buffer in the specified format. + + Args: + audio_tensor: The audio waveform (1D or 2D) + sample_rate: The sample rate of the audio + target_format: The target audio format + + Returns: + Buffer containing the encoded audio data + """ + buffer = io.BytesIO() + + # Ensure tensor is CPU + if audio_tensor.is_cuda: + audio_tensor = audio_tensor.cpu() + + # Ensure 2D (channels, time) + if audio_tensor.dim() == 1: + audio_tensor = audio_tensor.unsqueeze(0) + + # Handle PCM raw bytes (no container) + if target_format == 'pcm': + try: + pcm_bytes = tensor_to_pcm_bytes(audio_tensor) + buffer.write(pcm_bytes) + buffer.seek(0) + return buffer + except Exception as e: + logger.error(f'Error converting audio to PCM: {e}') + raise + + # Map OpenAI format names to torchaudio/backend supported format names + # torchaudio uses 'ogg' as the container for 'opus' + # 'aac' usually requires 'adts' or 'm4a' + actual_format = target_format + if actual_format == 'opus': + actual_format = 'ogg' + elif actual_format == 'aac': + actual_format = 'adts' + + try: + torchaudio.save(buffer, audio_tensor, sample_rate, format=actual_format) + buffer.seek(0) + return buffer + except Exception as e: + logger.error( + f'Error converting audio to {target_format} (backend format: {actual_format}): {e}' + ) + raise + + +def write_wav_header( + sample_rate: int, num_channels: int = 1, bits_per_sample: int = 16, num_frames: int = 0 +) -> bytes: + """ + Generate a WAV header for streaming. + + If num_frames is 0, set to max value (streaming/unknown length). + + Args: + sample_rate: Audio sample rate + num_channels: Number of audio channels + bits_per_sample: Bits per sample + num_frames: Number of frames (0 for unknown/streaming) + + Returns: + WAV header bytes + """ + byte_rate = sample_rate * num_channels * bits_per_sample // 8 + block_align = num_channels * bits_per_sample // 8 + + # Data size: if unknown, max uint32 + data_size = num_frames * block_align + if num_frames == 0: + data_size = 0xFFFFFFFF - 36 + + chunk_size = 36 + data_size + + header = io.BytesIO() + header.write(b'RIFF') + header.write(struct.pack(' bytes: + """ + Convert audio tensor chunk to 16-bit PCM bytes. + + Args: + chunk_tensor: Audio tensor chunk + + Returns: + PCM audio bytes + """ + if chunk_tensor.is_cuda: + chunk_tensor = chunk_tensor.cpu() + + if chunk_tensor.dim() == 1: + chunk_tensor = chunk_tensor.unsqueeze(0) + + # Convert to 16-bit PCM + pcm = (chunk_tensor * 32767).clamp(-32768, 32767).to(torch.int16) + return pcm.numpy().tobytes() + + +def get_mime_type(fmt: str) -> str: + """ + Get the MIME type for an audio format. + + Args: + fmt: Audio format string + + Returns: + MIME type string + """ + mime_types = { + 'wav': 'audio/wav', + 'mp3': 'audio/mpeg', + 'pcm': 'audio/L16', + 'opus': 'audio/opus', + 'aac': 'audio/aac', + 'flac': 'audio/flac', + } + return mime_types.get(fmt, f'audio/{fmt}') diff --git a/app/services/preprocess.py b/app/services/preprocess.py new file mode 100644 index 0000000000000000000000000000000000000000..f9b6f492b440c1cd38ae7855d4c019993a6c280f --- /dev/null +++ b/app/services/preprocess.py @@ -0,0 +1,1093 @@ +""" +Adapted and supplemented from origional at https://github.com/KittenML/KittenTTS/blob/main/kittentts/preprocess.py +See license at: https://github.com/KittenML/KittenTTS/blob/main/LICENSE (Apache 2.0) +""" + +import re +import unicodedata + +# ───────────────────────────────────────────── +# Number → Words conversion +# ───────────────────────────────────────────── + +_ONES = [ + '', + 'one', + 'two', + 'three', + 'four', + 'five', + 'six', + 'seven', + 'eight', + 'nine', + 'ten', + 'eleven', + 'twelve', + 'thirteen', + 'fourteen', + 'fifteen', + 'sixteen', + 'seventeen', + 'eighteen', + 'nineteen', +] +_TENS = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety'] +_SCALE = ['', 'thousand', 'million', 'billion', 'trillion'] + +_ORDINAL_EXCEPTIONS = { + 'one': 'first', + 'two': 'second', + 'three': 'third', + 'four': 'fourth', + 'five': 'fifth', + 'six': 'sixth', + 'seven': 'seventh', + 'eight': 'eighth', + 'nine': 'ninth', + 'twelve': 'twelfth', +} + +_CURRENCY_SYMBOLS = { + '$': 'dollar', + '€': 'euro', + '£': 'pound', + '¥': 'yen', + '₹': 'rupee', + '₩': 'won', + '₿': 'bitcoin', +} + +_CURRENCY_SCALE_MAP = { + 'K': 'thousand', + 'M': 'million', + 'B': 'billion', + 'T': 'trillion', + 'thousand': 'thousand', + 'million': 'million', + 'billion': 'billion', + 'trillion': 'trillion', +} + +_ROMAN = [ + (1000, 'M'), + (900, 'CM'), + (500, 'D'), + (400, 'CD'), + (100, 'C'), + (90, 'XC'), + (50, 'L'), + (40, 'XL'), + (10, 'X'), + (9, 'IX'), + (5, 'V'), + (4, 'IV'), + (1, 'I'), +] +_RE_ROMAN = re.compile(r'\b(M{0,4})(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})\b') + + +def _three_digits_to_words(n: int) -> str: + """Convert a number 0–999 to English words.""" + if n == 0: + return '' + parts = [] + hundreds = n // 100 + remainder = n % 100 + if hundreds: + parts.append(f'{_ONES[hundreds]} hundred') + if remainder < 20: + if remainder: + parts.append(_ONES[remainder]) + else: + tens_word = _TENS[remainder // 10] + ones_word = _ONES[remainder % 10] + parts.append(f'{tens_word}-{ones_word}' if ones_word else tens_word) + return ' '.join(parts) + + +def number_to_words(n: int) -> str: + """ + Convert an integer to its English word representation. + + Examples: + 1200 → "twelve hundred" + 1000 → "one thousand" + 1_000_000 → "one million" + -42 → "negative forty-two" + 0 → "zero" + """ + if not isinstance(n, int): + n = int(n) + if n == 0: + return 'zero' + if n < 0: + return f'negative {number_to_words(-n)}' + + # X00–X999 read as "X hundred" (e.g. 1200 → "twelve hundred") + # Exclude exact multiples of 1000 (1000 → "one thousand", not "ten hundred") + if 100 <= n <= 9999 and n % 100 == 0 and n % 1000 != 0: + hundreds = n // 100 + if hundreds < 20: + return f'{_ONES[hundreds]} hundred' + + parts = [] + for _i, scale in enumerate(_SCALE): + chunk = n % 1000 + if chunk: + chunk_words = _three_digits_to_words(chunk) + parts.append(f'{chunk_words} {scale}'.strip() if scale else chunk_words) + n //= 1000 + if n == 0: + break + + return ' '.join(reversed(parts)) + + +def float_to_words(value, decimal_sep: str = 'point') -> str: + """ + Convert a float (or numeric string) to words, reading decimal digits individually. + Accepts a string to preserve trailing zeros (e.g. "1.50" → "one point five zero"). + + Examples: + 3.14 → "three point one four" + -0.5 → "negative zero point five" + "3.10" → "three point one zero" + 1.007 → "one point zero zero seven" + """ + text = value if isinstance(value, str) else f'{value}' + negative = text.startswith('-') + if negative: + text = text[1:] + + if '.' in text: + int_part, dec_part = text.split('.', 1) + int_words = number_to_words(int(int_part)) if int_part else 'zero' + # Read each decimal digit individually; "0" → "zero" + digit_map = ['zero'] + _ONES[1:] # index 0 → "zero" + dec_words = ' '.join(digit_map[int(d)] for d in dec_part) + result = f'{int_words} {decimal_sep} {dec_words}' + else: + result = number_to_words(int(text)) + + return f'negative {result}' if negative else result + + +def roman_to_int(s: str) -> int: + """Convert a Roman numeral string to an integer.""" + val = {'I': 1, 'V': 5, 'X': 10, 'L': 50, 'C': 100, 'D': 500, 'M': 1000} + result = 0 + prev = 0 + for ch in reversed(s.upper()): + curr = val[ch] + result += curr if curr >= prev else -curr + prev = curr + return result + + +# ───────────────────────────────────────────── +# Regex patterns +# ───────────────────────────────────────────── + +_RE_URL = re.compile(r'https?://\S+|www\.\S+') +_RE_EMAIL = re.compile(r'\b[\w.+-]+@[\w-]+\.[a-z]{2,}\b', re.IGNORECASE) +_RE_HASHTAG = re.compile(r'#\w+') +_RE_MENTION = re.compile(r'@\w+') +_RE_HTML = re.compile(r'<[^>]+>') +_RE_PUNCT = re.compile(r'[^\w\s]') +_RE_SPACES = re.compile(r'\s+') +_RE_AI = re.compile(r'\bAI\b') +_RE_DOT_COM = re.compile(r'\.com\b', re.IGNORECASE) +_RE_PLUS = re.compile(r'\+') +_RE_AMPERSAND = re.compile(r'&') +_RE_AT_SYMBOL = re.compile(r'@') +_RE_NEWLINE = re.compile(r'[\r\n]+') +_RE_TILDE = re.compile(r'~') + +_MONTH_MAP = { + 'Jan': 'January', + 'Feb': 'February', + 'Mar': 'March', + 'Apr': 'April', + 'Jun': 'June', + 'Jul': 'July', + 'Aug': 'August', + 'Sep': 'September', + 'Sept': 'September', + 'Oct': 'October', + 'Nov': 'November', + 'Dec': 'December', +} + +# Regex looks for Title Case months followed by a period or a digit +# We handle "May" separately because it's a common word. +_RE_MONTHS = re.compile(r'\b(Jan|Feb|Mar|Apr|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)\.?\b(?=\s*\d|\s*$)') +_RE_MAY = re.compile(r'\bMay\b(?=\s*\d)') # Only expand May if followed by a number (May 5) + +# Number: do NOT match a leading minus if it is immediately preceded by a letter +# (handles "gpt-3", "gpl-3", "v-2" etc.) +_RE_NUMBER = re.compile(r'(? str: + """ + Handles specific abbreviations before lowercase normalization. + AI -> A.I. + .com -> dot com + """ + # 1. AI to A.I. (Case sensitive) + text = _RE_AI.sub('A.I.', text) + # 2. .com to dot com + text = _RE_DOT_COM.sub(' dot com', text) + return text + + +def expand_symbols(text: str) -> str: + """ + Translates mathematical and connector symbols to words. + """ + text = _RE_PLUS.sub(' plus ', text) + text = _RE_AMPERSAND.sub(' and ', text) + text = _RE_AT_SYMBOL.sub(' at ', text) + return text + + +def _ordinal_suffix(n: int) -> str: + """Return the ordinal word for n (e.g. 1 → 'first', 5 → 'fifth', 21 → 'twenty-first').""" + word = number_to_words(n) + # For hyphenated compounds like "twenty-one", convert only the last part + if '-' in word: + prefix, last = word.rsplit('-', 1) + joiner = '-' + else: + parts = word.rsplit(' ', 1) + prefix, last, joiner = (parts[0], parts[1], ' ') if len(parts) == 2 else ('', parts[0], '') + + # Check exception table + for base, ordinal in _ORDINAL_EXCEPTIONS.items(): + if last == base: + last_ord = ordinal + break + else: + # General rule + if last.endswith('t'): + last_ord = last + 'h' + elif last.endswith('e'): + last_ord = last[:-1] + 'th' + else: + last_ord = last + 'th' + + return f'{prefix}{joiner}{last_ord}' if prefix else last_ord + + +def expand_ordinals(text: str) -> str: + """ + Convert ordinal numbers to words. + + Examples: + "1st place" → "first place" + "2nd floor" → "second floor" + "3rd base" → "third base" + "21st century" → "twenty-first century" + "100th day" → "one hundredth day" + """ + + def _replace(m: re.Match) -> str: + return _ordinal_suffix(int(m.group(1))) + + return _RE_ORDINAL.sub(_replace, text) + + +def expand_percentages(text: str) -> str: + """ + Expand percentage expressions. + + Examples: + "50% off" → "fifty percent off" + "3.5% rate" → "three point five percent rate" + "-2% change" → "negative two percent change" + """ + + def _replace(m: re.Match) -> str: + raw = m.group(1).replace(',', '') + if '.' in raw: + return float_to_words(float(raw)) + ' percent' + return number_to_words(int(raw)) + ' percent' + + return _RE_PERCENT.sub(_replace, text) + + +def expand_newlines(text: str) -> str: + """Change newlines/returns to a period and space for TTS pausing.""" + return _RE_NEWLINE.sub('. ', text) + + +def expand_tilde(text: str) -> str: + """Change ~ to 'about'.""" + return _RE_TILDE.sub('about ', text) + + +def expand_currency(text: str) -> str: + """ + Expand currency amounts, including optional scale suffixes. + + Examples: + "$100" → "one hundred dollars" + "€1,200.50" → "twelve hundred euros and fifty cents" + "£9.99" → "nine pounds and ninety-nine cents" + "$85K" → "eighty five thousand dollars" + "$2.5M" → "two point five million dollars" + """ + + def _replace(m: re.Match) -> str: + symbol = m.group(1) + raw = m.group(2).replace(',', '') + scale_suffix = m.group(3) + unit = _CURRENCY_SYMBOLS.get(symbol, '') + + # Handle Scaled Currency ($17.5 billion or $17.5B) + if scale_suffix: + # Normalize suffix (e.g., 'B' or 'billion' -> 'billion') + scale_word = _CURRENCY_SCALE_MAP.get(scale_suffix.upper(), scale_suffix.lower()) + num = float_to_words(raw) if '.' in raw else number_to_words(int(raw)) + return f'{num} {scale_word} {unit}{"s" if unit else ""}'.strip() + + # Handle Standard Currency ($17.50) + if '.' in raw: + int_part, dec_part = raw.split('.', 1) + dec_val = int(dec_part[:2].ljust(2, '0')) + int_words = number_to_words(int(int_part)) + result = f'{int_words} {unit}s' if unit else int_words + if dec_val: + cents = number_to_words(dec_val) + result += f' and {cents} cent{"s" if dec_val != 1 else ""}' + else: + val = int(raw) + words = number_to_words(val) + result = f'{words} {unit}{"s" if val != 1 and unit else ""}' if unit else words + return result + + return _RE_CURRENCY.sub(_replace, text) + + +def expand_time(text: str) -> str: + """ + Expand time expressions. + + Examples: + "3:30pm" → "three thirty pm" + "14:00" → "fourteen hundred" + "9:05 AM" → "nine oh five am" + "12:00pm" → "twelve pm" + """ + + def _replace(m: re.Match) -> str: + h = int(m.group(1)) + mins = int(m.group(2)) + suffix = (' ' + m.group(4).lower()) if m.group(4) else '' + h_words = number_to_words(h) + if mins == 0: + return f'{h_words} hundred{suffix}' if not m.group(4) else f'{h_words}{suffix}' + elif mins < 10: + return f'{h_words} oh {number_to_words(mins)}{suffix}' + else: + return f'{h_words} {number_to_words(mins)}{suffix}' + + return _RE_TIME.sub(_replace, text) + + +def expand_ranges(text: str) -> str: + """ + Expand numeric ranges. + + Examples: + "10-20 items" → "ten to twenty items" + "pages 100-200" → "pages one hundred to two hundred" + "2020-2024" → "twenty twenty to twenty twenty-four" + """ + + def _replace(m: re.Match) -> str: + lo = number_to_words(int(m.group(1))) + hi = number_to_words(int(m.group(2))) + return f'{lo} to {hi}' + + return _RE_RANGE.sub(_replace, text) + + +def expand_model_names(text: str) -> str: + """ + Normalise version/model names that use letter-hyphen-number patterns, + so the number is not misread as negative. + + Examples: + "GPT-3" → "GPT 3" + "gpt-3.5" → "gpt 3.5" + "GPL-3" → "GPL 3" + "Python-3.10"→ "Python 3.10" + "v2.0" stays as "v2.0" (no hyphen — handled by number replacement) + "IPv6" stays as "IPv6" + """ + return _RE_MODEL_VER.sub(lambda m: f'{m.group(1)} {m.group(2)}', text) + + +def expand_units(text: str) -> str: + """ + Expand common measurement units glued to numbers. + + Examples: + "100km" → "one hundred kilometers" + "50kg" → "fifty kilograms" + "25°C" → "twenty-five degrees Celsius" + "5GB" → "five gigabytes" + """ + _unit_map = { + 'km': 'kilometers', + 'kg': 'kilograms', + 'mg': 'milligrams', + 'ml': 'milliliters', + 'gb': 'gigabytes', + 'mb': 'megabytes', + 'kb': 'kilobytes', + 'tb': 'terabytes', + 'hz': 'hertz', + 'khz': 'kilohertz', + 'mhz': 'megahertz', + 'ghz': 'gigahertz', + 'mph': 'miles per hour', + 'kph': 'kilometers per hour', + 'ms': 'milliseconds', + 'ns': 'nanoseconds', + 'µs': 'microseconds', + '°c': 'degrees Celsius', + 'c°': 'degrees Celsius', + '°f': 'degrees Fahrenheit', + 'f°': 'degrees Fahrenheit', + } + + def _replace(m: re.Match) -> str: + raw = m.group(1) + unit = m.group(2).lower() + expanded = _unit_map.get(unit, m.group(2)) + num = float_to_words(float(raw)) if '.' in raw else number_to_words(int(raw)) + return f'{num} {expanded}' + + return _RE_UNIT.sub(_replace, text) + + +def expand_roman_numerals(text: str, context_words: bool = True) -> str: + """ + Expand Roman numerals that appear as standalone tokens (optionally + only when preceded by a title-like word to avoid false positives). + + Examples: + "World War II" → "World War two" + "Chapter IV" → "Chapter four" + "Louis XIV" → "Louis fourteen" + "mix I with V" → left unchanged (ambiguous single letters) + """ + _TITLE_WORDS = re.compile( + r'\b(war|chapter|part|volume|act|scene|book|section|article|' + r'king|queen|pope|louis|henry|edward|george|william|james|' + r'phase|round|level|stage|class|type|version|episode|season)\b', + re.IGNORECASE, + ) + + def _replace(m: re.Match) -> str: + roman = m.group(0) + if not roman.strip(): + return roman + # Skip single ambiguous letters (I, V, X) unless context present + if len(roman) == 1 and roman in 'IVX': + # Only expand if preceded by a title word + start = m.start() + preceding = text[max(0, start - 30) : start] + if not _TITLE_WORDS.search(preceding): + return roman + try: + val = roman_to_int(roman) + if val == 0: + return roman + return number_to_words(val) + except Exception: + return roman + + return _RE_ROMAN.sub(_replace, text) + + +def normalize_leading_decimals(text: str) -> str: + """ + Normalise bare leading-decimal floats so the number pipeline handles them. + + Examples: + ".5 teaspoons" → "0.5 teaspoons" + "-.25 adjustment" → "-0.25 adjustment" + """ + # Handle -.5 → -0.5 and .5 → 0.5 + text = re.sub(r'(?0.\2', text) + return _RE_LEAD_DEC.sub(r'0.\1', text) + + +def expand_scientific_notation(text: str) -> str: + """ + Expand scientific-notation numbers to spoken form. + + Examples: + "1e-4" → "one times ten to the negative four" + "2.5e10" → "two point five times ten to the ten" + "6.022E23"→ "six point zero two two times ten to the twenty three" + """ + + def _replace(m: re.Match) -> str: + coeff_raw = m.group(1) + exp = int(m.group(2)) + coeff_words = ( + float_to_words(coeff_raw) if '.' in coeff_raw else number_to_words(int(coeff_raw)) + ) + exp_words = number_to_words(abs(exp)) + sign = 'negative ' if exp < 0 else '' + return f'{coeff_words} times ten to the {sign}{exp_words}' + + return _RE_SCI.sub(_replace, text) + + +def expand_scale_suffixes(text: str) -> str: + """ + Expand standalone uppercase scale suffixes attached to numbers. + + Examples: + "7B parameters" → "seven billion parameters" + "340M model" → "three hundred forty million model" + "1.5K salary" → "one point five thousand salary" + "$100K budget" → "$100K budget" (currency handled upstream) + """ + _map = {'K': 'thousand', 'M': 'million', 'B': 'billion', 'T': 'trillion'} + + def _replace(m: re.Match) -> str: + raw = m.group(1) + suffix = m.group(2) + scale_word = _map.get(suffix, suffix) + num = float_to_words(raw) if '.' in raw else number_to_words(int(raw)) + return f'{num} {scale_word}' + + return _RE_SCALE.sub(_replace, text) + + +def expand_fractions(text: str) -> str: + """ + Expand simple numeric fractions. + + Examples: + "1/2 cup" → "one half cup" + "3/4 mile" → "three quarters mile" + "2/3 done" → "two thirds done" + "5/8 inch" → "five eighths inch" + """ + + def _replace(m: re.Match) -> str: + num = int(m.group(1)) + den = int(m.group(2)) + if den == 0: + return m.group() + num_words = number_to_words(num) + if den == 2: + denom_word = 'half' if num == 1 else 'halves' + elif den == 4: + denom_word = 'quarter' if num == 1 else 'quarters' + else: + denom_word = _ordinal_suffix(den) + if num != 1: + denom_word += 's' + return f'{num_words} {denom_word}' + + return _RE_FRACTION.sub(_replace, text) + + +def expand_decades(text: str) -> str: + """ + Expand decade expressions to words. + + Examples: + "the 80s" → "the eighties" + "the 1980s" → "the nineteen eighties" + "the 2020s" → "the twenty twenties" + "'90s music" → "nineties music" + """ + _decade_map = { + 0: 'hundreds', + 1: 'tens', + 2: 'twenties', + 3: 'thirties', + 4: 'forties', + 5: 'fifties', + 6: 'sixties', + 7: 'seventies', + 8: 'eighties', + 9: 'nineties', + } + + def _replace(m: re.Match) -> str: + base = int(m.group(1)) # e.g. 8 for "80s", 198 for "1980s" + decade_digit = base % 10 + decade_word = _decade_map.get(decade_digit, '') + if base < 10: + return decade_word + century_part = base // 10 # e.g. 19 for 198 + return f'{number_to_words(century_part)} {decade_word}' + + return _RE_DECADE.sub(_replace, text) + + +def expand_ip_addresses(text: str) -> str: + """ + Expand IPv4 addresses to spoken digits per octet. + + Examples: + "192.168.1.1" → "one nine two dot one six eight dot one dot one" + "10.0.0.1" → "one zero dot zero dot zero dot one" + """ + _d = { + '0': 'zero', + '1': 'one', + '2': 'two', + '3': 'three', + '4': 'four', + '5': 'five', + '6': 'six', + '7': 'seven', + '8': 'eight', + '9': 'nine', + } + + def _octet(s: str) -> str: + return ' '.join(_d[c] for c in s) + + def _replace(m: re.Match) -> str: + return ' dot '.join(_octet(g) for g in m.groups()) + + return re.sub(r'\b(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\b', _replace, text) + + +def expand_phone_numbers(text: str) -> str: + """ + Expand US phone numbers to spoken digits before range expansion claims the hyphens. + + Examples: + "555-1234" → "five five five one two three four" + "555-123-4567" → "five five five one two three four five six seven" + "1-800-555-0199" → "one eight zero zero five five five zero one nine nine" + """ + _d = { + '0': 'zero', + '1': 'one', + '2': 'two', + '3': 'three', + '4': 'four', + '5': 'five', + '6': 'six', + '7': 'seven', + '8': 'eight', + '9': 'nine', + } + + def _digits(s: str) -> str: + return ' '.join(_d[c] for c in s) + + def _join(*groups) -> str: + return ' '.join(_digits(g) for g in groups) + + # Match longest pattern first to avoid partial matches + # 11-digit: 1-800-555-0199 + text = re.sub( + r'(? str: + """ + Expands Jan, Feb, etc. to January, February. + Only triggers if the abbreviation is likely a date. + """ + + def _replace(m: re.Match) -> str: + return _MONTH_MAP.get(m.group(1), m.group(1)) + + # 1. Standard abbreviations + text = _RE_MONTHS.sub(_replace, text) + + # 2. May (Special case: only if followed by a digit) + text = _RE_MAY.sub('May', text) # Essentially just ensuring it's treated as a word + + return text + + +# ───────────────────────────────────────────── +# Core preprocessing functions +# ───────────────────────────────────────────── + + +def replace_numbers(text: str, replace_floats: bool = True) -> str: + """ + Replace all numeric tokens with their word equivalents. + + Examples: + "There are 1200 students" → "There are twelve hundred students" + "Pi is 3.14" → "Pi is three point one four" + "gpt-3 rocks" → "gpt-3 rocks" (hyphen not treated as minus) + """ + + def _replace(m: re.Match) -> str: + raw = m.group().replace(',', '') + try: + if '.' in raw and replace_floats: + # Pass raw string so trailing zeros are preserved ("1.50" → "one point five zero") + return float_to_words(raw) + else: + return number_to_words(int(float(raw))) + except (ValueError, OverflowError): + return m.group() + + return _RE_NUMBER.sub(_replace, text) + + +def to_lowercase(text: str) -> str: + """Convert text to lowercase.""" + return text.lower() + + +def remove_urls(text: str, replacement: str = '') -> str: + """Remove URLs from text.""" + return _RE_URL.sub(replacement, text).strip() + + +def remove_emails(text: str, replacement: str = '') -> str: + """Remove email addresses from text.""" + return _RE_EMAIL.sub(replacement, text).strip() + + +def remove_html_tags(text: str) -> str: + """Strip HTML tags from text.""" + return _RE_HTML.sub(' ', text) + + +def remove_hashtags(text: str, replacement: str = '') -> str: + """Remove hashtags (e.g. #NLP) from text.""" + return _RE_HASHTAG.sub(replacement, text) + + +def remove_mentions(text: str, replacement: str = '') -> str: + """Remove @mentions from text.""" + return _RE_MENTION.sub(replacement, text) + + +def remove_punctuation(text: str) -> str: + """Remove all punctuation characters.""" + return _RE_PUNCT.sub(' ', text) + + +def remove_extra_whitespace(text: str) -> str: + """Collapse multiple whitespace characters into a single space and strip ends.""" + return _RE_SPACES.sub(' ', text).strip() + + +def normalize_unicode(text: str, form: str = 'NFC') -> str: + """Normalize unicode characters (NFC, NFD, NFKC, or NFKD).""" + return unicodedata.normalize(form, text) + + +def remove_accents(text: str) -> str: + """Remove diacritical marks (accents) from characters.""" + nfkd = unicodedata.normalize('NFD', text) + return ''.join(c for c in nfkd if unicodedata.category(c) != 'Mn') + + +def expand_contractions(text: str) -> str: + """ + Expand common English contractions. + + Examples: + "don't" → "do not" + "they're" → "they are" + "I've" → "I have" + """ + contractions = { + r"\bcan't\b": 'cannot', + r"\bwon't\b": 'will not', + r"\bshan't\b": 'shall not', + r"\bain't\b": 'is not', + r"\blet's\b": 'let us', + r"\b(\w+)n't\b": r'\1 not', + r"\b(\w+)'re\b": r'\1 are', + r"\b(\w+)'ve\b": r'\1 have', + r"\b(\w+)'ll\b": r'\1 will', + r"\b(\w+)'d\b": r'\1 would', + r"\b(\w+)'m\b": r'\1 am', + r"\bit's\b": 'it is', + } + for pattern, replacement in contractions.items(): + text = re.sub(pattern, replacement, text, flags=re.IGNORECASE) + return text + + +def remove_stopwords(text: str, stopwords: set | None = None) -> str: + """ + Remove stopwords from text. + + Args: + stopwords: Set of words to remove. Uses a built-in English set if None. + """ + if stopwords is None: + stopwords = { + 'a', + 'an', + 'the', + 'and', + 'or', + 'but', + 'in', + 'on', + 'at', + 'to', + 'for', + 'of', + 'with', + 'by', + 'from', + 'is', + 'was', + 'are', + 'were', + 'be', + 'been', + 'being', + 'have', + 'has', + 'had', + 'do', + 'does', + 'did', + 'will', + 'would', + 'could', + 'should', + 'may', + 'might', + 'this', + 'that', + 'these', + 'those', + 'it', + 'its', + 'i', + 'me', + 'my', + 'we', + 'our', + 'you', + 'your', + 'he', + 'she', + 'him', + 'her', + 'they', + 'them', + 'their', + } + tokens = text.split() + return ' '.join(t for t in tokens if t.lower() not in stopwords) + + +# ───────────────────────────────────────────── +# Pipeline helper +# ───────────────────────────────────────────── + + +class TextPreprocessor: + """ + Configurable preprocessing pipeline. + + Usage: + pp = TextPreprocessor( + lowercase=True, + replace_numbers=True, + remove_urls=True, + remove_html=True, + remove_punctuation=True, + ) + clean = pp("GPT-3 costs $0.002 per token — 50% cheaper than before!") + # → "gpt three costs zero dollars and zero point two cents per token fifty percent cheaper than before" + """ + + def __init__( + self, + lowercase: bool = True, + replace_numbers: bool = True, + replace_floats: bool = True, + expand_newlines: bool = True, + expand_tilde: bool = True, + expand_abbreviations: bool = True, + expand_symbols: bool = True, + expand_contractions: bool = True, + expand_model_names: bool = True, + expand_ordinals: bool = True, + expand_percentages: bool = True, + expand_currency: bool = True, + expand_time: bool = True, + expand_ranges: bool = True, + expand_units: bool = True, + expand_scale_suffixes: bool = True, + expand_scientific_notation: bool = True, + expand_fractions: bool = True, + expand_decades: bool = True, + expand_phone_numbers: bool = True, + expand_ip_addresses: bool = True, + normalize_leading_decimals: bool = True, + expand_roman_numerals: bool = False, + remove_urls: bool = True, + remove_emails: bool = True, + remove_html: bool = True, + remove_hashtags: bool = False, + remove_mentions: bool = False, + remove_punctuation: bool = True, + remove_stopwords: bool = False, + stopwords: set | None = None, + normalize_unicode: bool = True, + remove_accents: bool = False, + remove_extra_whitespace: bool = True, + ): + self.config = {k: v for k, v in locals().items() if k != 'self'} + self._stopwords = stopwords + + def __call__(self, text: str) -> str: + return self.process(text) + + def process(self, text: str) -> str: + cfg = self.config + if cfg.get('expand_abbreviations'): + text = expand_abbreviations(text) + text = expand_months(text) + if cfg.get('expand_newlines'): + text = expand_newlines(text) + if cfg.get('expand_symbols'): + text = expand_symbols(text) + if cfg.get('expand_tilde'): + text = expand_tilde(text) + if cfg['normalize_unicode']: + text = normalize_unicode(text) + if cfg['remove_html']: + text = remove_html_tags(text) + if cfg['remove_urls']: + text = remove_urls(text) + if cfg['remove_emails']: + text = remove_emails(text) + if cfg['remove_hashtags']: + text = remove_hashtags(text) + if cfg['remove_mentions']: + text = remove_mentions(text) + if cfg['expand_contractions']: + text = expand_contractions(text) + # IP addresses before normalize_leading_decimals (IPs contain dots before digits) + if cfg['expand_ip_addresses']: + text = expand_ip_addresses(text) + # Normalise bare leading decimals early so downstream regexes see "0.5" not ".5" + if cfg['normalize_leading_decimals']: + text = normalize_leading_decimals(text) + # Expand special forms before generic number replacement + if cfg['expand_currency']: + text = expand_currency(text) + if cfg['expand_percentages']: + text = expand_percentages(text) + # Scientific notation before model-name expansion (e.g. "1e-4" contains "e-4") + if cfg['expand_scientific_notation']: + text = expand_scientific_notation(text) + if cfg['expand_time']: + text = expand_time(text) + if cfg['expand_ordinals']: + text = expand_ordinals(text) + if cfg['expand_units']: + text = expand_units(text) + # Scale suffixes after units (units handles "MB"/"GB"; this handles bare "B"/"M") + if cfg['expand_scale_suffixes']: + text = expand_scale_suffixes(text) + if cfg['expand_fractions']: + text = expand_fractions(text) + if cfg['expand_decades']: + text = expand_decades(text) + # Phone numbers before ranges, otherwise NNN-NNNN is treated as a range + if cfg['expand_phone_numbers']: + text = expand_phone_numbers(text) + if cfg['expand_ranges']: + text = expand_ranges(text) + if cfg['expand_model_names']: + text = expand_model_names(text) + if cfg['expand_roman_numerals']: + text = expand_roman_numerals(text) + if cfg['replace_numbers']: + text = replace_numbers(text, replace_floats=cfg['replace_floats']) + if cfg['remove_accents']: + text = remove_accents(text) + if cfg['remove_punctuation']: + text = remove_punctuation(text) + if cfg['lowercase']: + text = to_lowercase(text) + if cfg['remove_stopwords']: + text = remove_stopwords(text, self._stopwords) + if cfg['remove_extra_whitespace']: + text = remove_extra_whitespace(text) + + return text diff --git a/app/services/tts.py b/app/services/tts.py new file mode 100644 index 0000000000000000000000000000000000000000..c94d7994a1425f24cde19bea728899c7cebe91de --- /dev/null +++ b/app/services/tts.py @@ -0,0 +1,516 @@ +""" +TTS Service - handles model loading, voice management, and audio generation. +""" + +import os +import time +from pathlib import Path + +from app.config import Config +from app.logging_config import get_logger + +logger = get_logger('tts') + +# Lazy import pocket_tts to allow for better error handling +TTSModel = None +export_model_state = None + + +def _ensure_pocket_tts(): + """Ensure pocket-tts is imported.""" + global TTSModel, export_model_state + if TTSModel is None: + try: + from pocket_tts import TTSModel as _TTSModel + from pocket_tts.models.tts_model import export_model_state as _export_state + + TTSModel = _TTSModel + export_model_state = _export_state + except ImportError as exc: + raise ImportError('pocket-tts not found. Install with: pip install pocket-tts') from exc + + +class TTSService: + """ + Service class for Text-to-Speech operations. + Manages model loading, voice caching, and audio generation. + """ + + def __init__(self): + import threading + from collections import OrderedDict + + self.model = None + self.voice_cache: OrderedDict = OrderedDict() + self.voices_dir: str | None = None + self._model_loaded = False + + # Concurrency + reload state. + # _lock is held for the duration of model operations (load, generate); + # _state_lock is held only briefly to mutate the loading flag and + # related state, so concurrent reload claims are atomic without + # waiting for in-flight generation to finish. + self._lock = threading.Lock() + self._state_lock = threading.Lock() + self._loading = False # fast-path flag; read without lock, written under _state_lock + self._active: dict | None = None + self._boot_active: dict | None = None + self._loading_target: dict | None = None + self._last_reload_error: str | None = None + + self.cache_dir: Path | None = Path(Config.VOICE_CACHE_DIR) + + def _ensure_cache_dir(self) -> None: + """Create the voice cache directory on first need. Tolerate read-only FS.""" + if self.cache_dir is None: + return + try: + self.cache_dir.mkdir(parents=True, exist_ok=True) + except OSError as e: + logger.warning( + f'Voice cache dir {self.cache_dir} is not writable ({e}); ' + f'cache persistence disabled.' + ) + self.cache_dir = None + + def _save_cloned_state(self, state: dict, audio_path) -> None: + """Persist a freshly-cloned state as ..safetensors.""" + + from app.services.voice_cache import active_model_tag + + self._ensure_cache_dir() + if self.cache_dir is None: + return + + audio_path = Path(audio_path) + tag = active_model_tag((self._active or {}).get('value') or 'english') + target = self.cache_dir / f'{audio_path.stem}.{tag}.safetensors' + # Caching is best-effort: swallow any failure so a broken cache write + # never blocks voice loading. safetensors raises SafetensorError (not + # OSError) on serialization I/O failures, so we catch broadly. Keep + # exc_info=True so unexpected failures stay diagnosable. + try: + export_model_state(state, target) + logger.info(f'Saved cloned voice state to {target}') + except Exception: + logger.warning(f'Could not save voice cache to {target}', exc_info=True) + + @property + def is_loaded(self) -> bool: + """Check if the model is loaded.""" + return self._model_loaded and self.model is not None + + @property + def sample_rate(self) -> int: + """Get the model's sample rate.""" + if self.model: + return self.model.sample_rate + return 24000 # Default pocket-tts sample rate + + @property + def device(self) -> str: + """Get the model's device.""" + if self.model: + return str(self.model.device) + return 'unknown' + + def load_model( + self, + model_path: str | None = None, + language: str | None = None, + quantize: bool = False, + _is_boot: bool = True, + ) -> None: + """ + Load the TTS model. + + Args: + model_path: Optional path to model config file (.yaml) + language: Optional language identifier (e.g., english, french_24l). + Incompatible with model_path. + quantize: If True, apply dynamic int8 quantization to reduce memory. + _is_boot: Internal; True only for the first boot-time load. Controls + whether _boot_active is initialized. + """ + _ensure_pocket_tts() + + logger.info('Loading Pocket TTS model...') + t0 = time.time() + + effective_path = model_path + + if not effective_path: + _, bundle_model = Config.get_bundle_paths() + if bundle_model and os.path.isfile(bundle_model): + effective_path = bundle_model + logger.info(f'Using bundled model: {effective_path}') + + try: + if effective_path: + logger.info(f'Loading model from: {effective_path}') + self.model = TTSModel.load_model(config=effective_path, quantize=quantize) + active = {'source': 'model_path', 'value': effective_path, 'quantize': quantize} + elif language: + logger.info(f'Loading model with language: {language}') + self.model = TTSModel.load_model(language=language, quantize=quantize) + active = {'source': 'language', 'value': language, 'quantize': quantize} + else: + logger.info('Loading default model from HuggingFace...') + self.model = TTSModel.load_model(quantize=quantize) + active = {'source': 'default', 'value': None, 'quantize': quantize} + + self._model_loaded = True + self._active = active + if _is_boot: + self._boot_active = dict(active) + + load_time = time.time() - t0 + logger.info( + f'Model loaded in {load_time:.2f}s. ' + f'Device: {self.device}, Sample Rate: {self.sample_rate}' + ) + + except Exception as e: + logger.error(f'Failed to load model: {e}') + raise + + def _validate_reload(self, language: str) -> None: + """Pre-flight checks shared by sync and async reload paths.""" + if self._boot_active and self._boot_active['source'] == 'model_path': + raise RuntimeError( + 'Cannot switch language: server was started with a custom model_path.' + ) + if language not in Config.SUPPORTED_LANGUAGES: + raise ValueError(f'Unsupported language: {language!r}') + + def _claim_loading(self, language: str, quantize: bool) -> bool: + """Atomically check-and-set the loading flag. + + Returns True if this caller owns the reload slot, False if another + reload is already in progress. Held briefly under _state_lock so + concurrent claims race-free without waiting on the long-held _lock. + """ + with self._state_lock: + if self._loading: + return False + self._loading = True + self._loading_target = {'value': language, 'quantize': quantize} + self._last_reload_error = None + return True + + def _release_loading(self) -> None: + with self._state_lock: + self._loading = False + self._loading_target = None + + def _do_reload(self, language: str, quantize: bool) -> None: + """Perform the actual model swap. Caller must already hold the loading + slot via `_claim_loading`. Restores the previous model on failure.""" + with self._lock: + previous_model = self.model + previous_active = self._active + try: + self.load_model(language=language, quantize=quantize, _is_boot=False) + self.voice_cache.clear() + except Exception: + # Restore previous state on failure so the server remains usable. + self.model = previous_model + self._active = previous_active + raise + + def reload_model(self, language: str, quantize: bool) -> None: + """Reload the model synchronously. + + Validates, atomically claims the reload slot, then performs the swap + while holding the model lock. Pocket-tts v2 `TTSModel` is not + thread-safe so generation is serialized via the same lock. + + Raises: + ValueError: unknown language. + RuntimeError: model_path locked, already loading, or load failure. + """ + self._validate_reload(language) + if not self._claim_loading(language, quantize): + raise RuntimeError('already loading') + try: + self._do_reload(language, quantize) + finally: + self._release_loading() + + def reload_model_async(self, language: str, quantize: bool) -> bool: + """Atomically claim the reload slot and start a worker thread. + + Returns True if the claim succeeded (worker started), False if a + reload was already in progress. Validation errors are still raised + synchronously so the caller can surface them as 400/403. + """ + import threading + + self._validate_reload(language) + if not self._claim_loading(language, quantize): + return False + + def _worker(): + try: + self._do_reload(language, quantize) + except Exception as e: + with self._state_lock: + self._last_reload_error = f'{type(e).__name__}: {e}' + logger.error(f'Reload failed: {self._last_reload_error}') + finally: + self._release_loading() + + threading.Thread(target=_worker, daemon=True, name='tts-reload').start() + return True + + def set_voices_dir(self, voices_dir: str | None) -> None: + """ + Set the directory for custom voice files. + + Args: + voices_dir: Path to directory containing voice files + """ + if voices_dir and os.path.isdir(voices_dir): + self.voices_dir = voices_dir + logger.info(f'Voices directory set to: {voices_dir}') + elif voices_dir: + logger.warning(f'Voices directory not found: {voices_dir}') + self.voices_dir = None + else: + self.voices_dir = None + + def get_voice_state(self, voice_id_or_path: str) -> dict: + """Resolve a voice ID to a cached model state. + + When the resolved path is raw audio, encode it against the active model + and persist the result as ..safetensors in cache_dir. + If a tagged cache exists but its source audio is newer, regenerate. + + Pocket-tts v2 `TTSModel` is not thread-safe, so model invocations are + serialized under `self._lock` (the same lock that protects generation + and reload). + """ + + from app.services.voice_cache import ( + AUDIO_EXTENSIONS, + cache_is_stale, + known_model_tags, + parse_safetensors_name, + ) + + if self._loading: + raise RuntimeError('model reloading') + if not self.is_loaded: + raise RuntimeError('Model not loaded. Call load_model() first.') + + resolved_key = self._resolve_voice_path(voice_id_or_path) + + # Cache hit fast path. The dict can be cleared concurrently by + # `reload_model`, so guard against a KeyError between `in` and access. + if resolved_key in self.voice_cache: + try: + self.voice_cache.move_to_end(resolved_key) + logger.debug(f'Using in-memory voice state for: {resolved_key}') + return self.voice_cache[resolved_key] + except KeyError: + pass # raced with cache clear; fall through and re-encode + + # If resolved to a tagged cache, check staleness against raw-audio source. + # Treat any existing filesystem path as local — relative paths from a + # configured `voices_dir` (common in local dev) must still get the + # truncate=True clone path and disk caching. + resolved_path = ( + Path(resolved_key) + if os.path.isabs(resolved_key) or os.path.exists(resolved_key) + else None + ) + regenerate_from_source: Path | None = None + + if resolved_path and resolved_path.suffix == '.safetensors' and self.voices_dir: + # Use the same parser the cache module uses so stems containing + # dots (e.g. "John.Doe.english_2026-04.safetensors" → "John.Doe") + # are extracted correctly. + stem, _tag = parse_safetensors_name(resolved_path.name, known_model_tags()) + for ext in AUDIO_EXTENSIONS: + source = Path(self.voices_dir) / f'{stem}{ext}' + if cache_is_stale(cache_path=resolved_path, source_path=source): + regenerate_from_source = source + break + + logger.info(f'Loading voice: {resolved_key}') + t0 = time.time() + + try: + with self._lock: + if regenerate_from_source: + logger.info(f'Regenerating stale cache from {regenerate_from_source}') + state = self.model.get_state_for_audio_prompt( + regenerate_from_source, truncate=True + ) + self._save_cloned_state(state, regenerate_from_source) + elif resolved_path and resolved_path.suffix.lower() in AUDIO_EXTENSIONS: + state = self.model.get_state_for_audio_prompt(resolved_path, truncate=True) + self._save_cloned_state(state, resolved_path) + else: + # Pre-made .safetensors OR built-in OR hf:// — let pocket-tts handle it. + state = self.model.get_state_for_audio_prompt(resolved_key) + + # LRU insert (under the lock for consistency with the dict mutation + # in `reload_model.voice_cache.clear()`). + self.voice_cache[resolved_key] = state + if len(self.voice_cache) > 32: + self.voice_cache.popitem(last=False) + + load_time = time.time() - t0 + logger.info(f'Voice loaded in {load_time:.2f}s: {resolved_key}') + return state + + except Exception as e: + logger.error(f"Failed to load voice '{voice_id_or_path}': {e}") + raise ValueError(f"Voice '{voice_id_or_path}' could not be loaded: {e}") from e + + def _resolve_voice_path(self, voice_id_or_path: str) -> str: + """Resolve a voice identifier using per-model cache preference. + + Raises ValueError on unsafe URL schemes (retained from previous behavior). + """ + + from app.services.voice_cache import resolve_voice_path + + # Retain SSRF protection. + if voice_id_or_path.startswith(('http://', 'https://')): + raise ValueError( + f'URL scheme not allowed for security reasons: {voice_id_or_path[:50]}. ' + "Use 'hf://' for HuggingFace models or provide a local file path." + ) + + if voice_id_or_path.startswith('hf://'): + return voice_id_or_path + + # Built-in names pass through untouched (pocket-tts handles resolution). + if voice_id_or_path.lower() in Config.BUILTIN_VOICES: + return voice_id_or_path.lower() + + # Absolute path hit. + if os.path.isabs(voice_id_or_path) and os.path.exists(voice_id_or_path): + return voice_id_or_path + + voices_path = Path(self.voices_dir) if self.voices_dir else None + + # Backwards-compat: accept full filenames (e.g. `emma.wav`) by checking + # for an exact match in cache_dir / voices_dir before falling through + # to stem-based resolution. Without this, `voice_id_or_path` containing + # an extension would never match anything and pocket-tts would receive + # the raw string. + for directory in (self.cache_dir, voices_path): + if directory: + exact = directory / voice_id_or_path + if exact.exists(): + return str(exact) + + active_model = (self._active or {}).get('value') or 'english' + + resolved = resolve_voice_path( + voice_id=voice_id_or_path, + active_model=active_model, + voices_dir=voices_path, + cache_dir=self.cache_dir, + ) + return str(resolved) if isinstance(resolved, Path) else resolved + + def validate_voice(self, voice_id_or_path: str) -> tuple[bool, str]: + """ + Validate if a voice can be loaded (fast check without full loading). + + Args: + voice_id_or_path: Voice identifier + + Returns: + Tuple of (is_valid, message) + """ + # Block unsafe URL schemes first + if voice_id_or_path.startswith(('http://', 'https://')): + return ( + False, + 'HTTP/HTTPS URLs are not allowed for security reasons. Use hf:// for HuggingFace models.', + ) + + try: + resolved = self._resolve_voice_path(voice_id_or_path) + except ValueError as e: + return False, str(e) + + # Built-in voices are always valid + if resolved.lower() in Config.BUILTIN_VOICES: + return True, f'Built-in voice: {resolved}' + + # HuggingFace URLs - assume valid + if resolved.startswith('hf://'): + return True, f'HuggingFace voice: {resolved}' + + # Local file - check existence + if os.path.exists(resolved): + return True, f'Local voice file: {resolved}' + + return False, f'Voice not found: {voice_id_or_path}' + + def generate_audio(self, voice_state: dict, text: str): + """Generate complete audio for given text.""" + import torch # noqa: F401 — kept for return-type doc + + if self._loading: + raise RuntimeError('model reloading') + if not self.is_loaded: + raise RuntimeError('Model not loaded') + + with self._lock: + t0 = time.time() + audio = self.model.generate_audio(voice_state, text) + gen_time = time.time() - t0 + + logger.info(f'Generated {len(text)} chars in {gen_time:.2f}s') + return audio + + def generate_audio_stream(self, voice_state: dict, text: str): + """Generate audio in streaming chunks. Holds the lock for the entire stream.""" + if self._loading: + raise RuntimeError('model reloading') + if not self.is_loaded: + raise RuntimeError('Model not loaded') + + logger.info(f'Starting streaming generation for {len(text)} chars') + with self._lock: + yield from self.model.generate_audio_stream(voice_state, text) + + def list_voices(self) -> list[dict]: + """List built-in voices and custom voices (one entry per stem).""" + + from app.services.voice_cache import list_voice_stems + + voices: list[dict] = [] + + # Built-in voices (sorted). + for name in sorted(Config.BUILTIN_VOICES): + voices.append({'id': name, 'name': name.capitalize(), 'type': 'builtin'}) + + voices_path = Path(self.voices_dir) if self.voices_dir else None + stems = list_voice_stems(voices_dir=voices_path, cache_dir=self.cache_dir) + + for stem in stems: + # Format name: "bobby_mcfern" -> "Bobby Mcfern" + clean_name = stem.replace('_', ' ').replace('-', ' ').title() + voices.append({'id': stem, 'name': clean_name, 'type': 'custom'}) + + return voices + + +# Global service instance +_tts_service: TTSService | None = None + + +def get_tts_service() -> TTSService: + """Get the global TTS service instance.""" + global _tts_service + if _tts_service is None: + _tts_service = TTSService() + return _tts_service diff --git a/app/services/versions.py b/app/services/versions.py new file mode 100644 index 0000000000000000000000000000000000000000..93e92317a2d789d5f5d41eb50044403315466cba --- /dev/null +++ b/app/services/versions.py @@ -0,0 +1,26 @@ +"""Cached version lookup for the web UI.""" + +from functools import lru_cache +from importlib.metadata import PackageNotFoundError, version + +from app import __version__ as _SERVER_VERSION_FALLBACK + + +@lru_cache(maxsize=1) +def get_versions() -> dict[str, str]: + """Return {'server': ..., 'pocket_tts': ...}. + + Falls back to the hardcoded `app.__version__` for the server when the + package isn't installed via pip. `pocket_tts` falls back to 'unknown'. + """ + try: + server = version('pocket-tts-openai-server') + except PackageNotFoundError: + server = _SERVER_VERSION_FALLBACK + + try: + pocket_tts = version('pocket-tts') + except PackageNotFoundError: + pocket_tts = 'unknown' + + return {'server': server, 'pocket_tts': pocket_tts} diff --git a/app/services/voice_cache.py b/app/services/voice_cache.py new file mode 100644 index 0000000000000000000000000000000000000000..2cafeada64750a950921f432998fe7c3a8092b40 --- /dev/null +++ b/app/services/voice_cache.py @@ -0,0 +1,146 @@ +"""Pure filename and path logic for per-model cached voice states. + +Kept separate from tts.py so it can be exercised without loading pocket-tts. +""" + +import re +from pathlib import Path + +from app.config import Config + +# Substituted away when sanitizing path-derived tags. Includes filesystem-illegal +# characters AND `.`, since `parse_safetensors_name` uses `.` to delimit +# ..safetensors — a tag containing a dot would mis-parse. +_TAG_ILLEGAL_CHARS = re.compile(r'[\\/:*?"<>|.]') +_CONFIG_EXTENSIONS = ('.yaml', '.yml', '.json') + + +def active_model_tag(raw_model: str) -> str: + """Normalize a language identifier for use as a filename tag. + + Handles three input shapes: + - plain language identifier (e.g. 'english_2026-04') → unchanged + - aliased identifier (e.g. 'english') → canonical alias target + - custom --model-path value (e.g. r'C:\\models\\english.yaml') → + reduced to the file stem ('english'), since path separators and + Windows drive colons are illegal in filenames and would crash + safetensors serialization (issue #13). + """ + canonical = Config.LEGACY_MODEL_ALIASES.get(raw_model, raw_model) + + # If the value looks like a file path (separators present, or it ends in + # a known config extension), reduce it to a filesystem-safe stem. + if '/' in canonical or '\\' in canonical or canonical.lower().endswith(_CONFIG_EXTENSIONS): + last = canonical.replace('\\', '/').rsplit('/', 1)[-1] + for ext in _CONFIG_EXTENSIONS: + if last.lower().endswith(ext): + last = last[: -len(ext)] + break + canonical = _TAG_ILLEGAL_CHARS.sub('_', last) + + return canonical + + +def known_model_tags() -> set[str]: + """Filename tags we treat as model identifiers during parsing. + + Includes both the raw supported languages and the alias-target canonicals. + """ + return set(Config.SUPPORTED_LANGUAGES) | set(Config.LEGACY_MODEL_ALIASES.values()) + + +def parse_safetensors_name(filename: str, tags: set[str]) -> tuple[str, str | None]: + """Split `stem.model_tag.safetensors` into (stem, tag). + + Returns (stem, None) when the filename is unlabeled or the final segment + is not a recognized tag. + """ + base = Path(filename).stem # strips .safetensors + if '.' in base: + stem, tag = base.rsplit('.', 1) + if tag in tags: + return stem, tag + return base, None + + +def resolve_voice_path( + voice_id: str, + active_model: str, + voices_dir: Path | None, + cache_dir: Path | None, +) -> Path | str: + """Resolve a voice identifier to its on-disk source. + + Preference order (within each directory, the canonical-tagged filename + is checked first; if `active_model` is an alias, the alias-tagged + filename is checked as a fallback so files written under either name + resolve correctly): + 1. cache_dir/..safetensors + 2. cache_dir/..safetensors (alias fallback) + 3. voices_dir/..safetensors + 4. voices_dir/..safetensors (alias fallback) + 5. voices_dir/.{wav,mp3,flac} + 6. voices_dir/.safetensors (legacy unlabeled) + 7. The bare voice_id string — pocket-tts will resolve (e.g. built-ins). + + New caches are always written using the canonical tag (see + `_save_cloned_state` in tts.py), so the alias-tagged paths exist only + for files placed by external tools or by users running an older version. + """ + canonical_tag = active_model_tag(active_model) + candidates = [f'{voice_id}.{canonical_tag}.safetensors'] + if active_model != canonical_tag: + candidates.append(f'{voice_id}.{active_model}.safetensors') + + for directory in (cache_dir, voices_dir): + if not directory: + continue + for name in candidates: + p = directory / name + if p.exists(): + return p + + if voices_dir: + for ext in ('.wav', '.mp3', '.flac'): + p = voices_dir / f'{voice_id}{ext}' + if p.exists(): + return p + + p = voices_dir / f'{voice_id}.safetensors' + if p.exists(): + return p + + return voice_id + + +AUDIO_EXTENSIONS = ('.wav', '.mp3', '.flac') + + +def list_voice_stems( + voices_dir: Path | None, + cache_dir: Path | None, +) -> list[str]: + """Return the unique voice stems present across voices_dir and cache_dir.""" + stems: set[str] = set() + tags = known_model_tags() + + for directory in (voices_dir, cache_dir): + if not directory or not directory.is_dir(): + continue + for ext in AUDIO_EXTENSIONS: + for f in directory.glob(f'*{ext}'): + stems.add(f.stem) + for f in directory.glob('*.safetensors'): + stem, _tag = parse_safetensors_name(f.name, tags) + stems.add(stem) + + return sorted(stems) + + +def cache_is_stale(cache_path: Path, source_path: Path) -> bool: + """True when `source_path` exists and is newer than `cache_path`.""" + if not source_path.exists(): + return False + if not cache_path.exists(): + return False + return source_path.stat().st_mtime > cache_path.stat().st_mtime diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..350f619e9bbbcd07755b025f5dd44bc442e4b59c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,74 @@ +# Docker Compose for PocketTTS OpenAI-Compatible Server +# +# Usage: +# docker compose up -d # Start the server +# docker compose logs -f # View logs +# docker compose down # Stop the server +# +# Custom voices: +# Mount your own voices directory to /app/voices + +services: + pockettts: + build: + context: . + dockerfile: Dockerfile + image: pockettts-openai-server:latest + container_name: pockettts-server + + ports: + - '${POCKET_TTS_PORT:-49112}:49112' + + environment: + - POCKET_TTS_HOST=0.0.0.0 + - POCKET_TTS_PORT=49112 + - POCKET_TTS_VOICES_DIR=/app/voices + - POCKET_TTS_LOG_LEVEL=${POCKET_TTS_LOG_LEVEL:-INFO} + - POCKET_TTS_STREAM_DEFAULT=${POCKET_TTS_STREAM_DEFAULT:-true} + # Model language (e.g., english, french_24l, german_24l, portuguese, italian, spanish_24l) + # Mutually exclusive with POCKET_TTS_MODEL_PATH. Requires pocket-tts>=2.0.0. + - POCKET_TTS_LANGUAGE=${POCKET_TTS_LANGUAGE:-} + # Enable int8 quantization for lower memory usage and improved speed + - POCKET_TTS_QUANTIZE=${POCKET_TTS_QUANTIZE:-false} + - POCKET_TTS_VOICE_CACHE_DIR=/app/voice_cache + # Hugging Face token for voice cloning (optional) + - HF_TOKEN=${HF_TOKEN:-} + + volumes: + # Mount custom voices (optional - overrides bundled voices) + - ${POCKET_TTS_VOICES_DIR:-./voices}:/app/voices:ro + # Persist logs + - ./logs:/app/logs + # Cache HuggingFace models to avoid re-downloading + - pockettts-cache:/home/pockettts/.cache/huggingface + # Writable cache for per-model cloned voice safetensors + - pockettts-voice-cache:/app/voice_cache + + restart: unless-stopped + + # Resource limits (adjust based on your hardware) + deploy: + resources: + limits: + memory: 4G + reservations: + memory: 2G + + healthcheck: + test: + [ + 'CMD', + 'python', + '-c', + "import urllib.request; urllib.request.urlopen('http://localhost:49112/health')", + ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 120s # Model loading takes time + +volumes: + pockettts-cache: + name: pockettts-huggingface-cache + pockettts-voice-cache: + name: pockettts-voice-cache diff --git a/logs/.gitkeep b/logs/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..a7374515992c0dd09117200e06bf15124459057d --- /dev/null +++ b/logs/.gitkeep @@ -0,0 +1 @@ +# Keep this directory in git (logs are gitignored but directory needed) diff --git a/pocket-tts-logo.ico b/pocket-tts-logo.ico new file mode 100644 index 0000000000000000000000000000000000000000..a9e4d782674a285ade071999d444b317fa9e6e2f --- /dev/null +++ b/pocket-tts-logo.ico @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b8a5758b82e1232b760e23535b364fdd500ec65172eedd70a3212d0681bd7f97 +size 147902 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..ca24c0692c2fe2e3783bf6594fd1f148de644da8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,40 @@ +[project] +description = "OpenAI-compatible TTS API server powered by Pocket-TTS" +license = {text = "MIT"} +name = "pocket-tts-openai-server" +readme = "README.md" +requires-python = ">=3.10" +version = "2.5.4" + +dependencies = [ + "flask>=3.0.0", + "waitress>=3.0.0", + "pocket-tts>=2.0.0", + "torch>=2.0.0,<=2.8.0", + "torchaudio>=2.0.0,<=2.8.0", + "torchao==0.13.0", + "scipy>=1.10.0", + "numpy>=1.24.0", + "soundfile>=0.12.0", +] + +[project.optional-dependencies] +build = ["pyinstaller"] +dev = ["ruff", "pytest"] + +[project.scripts] +pocket-tts-server = "server:main" + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +ignore = ["E501", "B008", "B904"] +select = ["E", "W", "F", "I", "B", "C4", "UP"] + +[tool.ruff.lint.isort] +known-first-party = ["app"] + +[tool.ruff.format] +quote-style = "single" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000000000000000000000000000000000000..8afb608e973a4ad6eafa7047d4ad174b6bd845c5 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +testpaths = tests +python_files = test_*.py +addopts = -v --tb=short diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000000000000000000000000000000000000..62e6142a9116475f9dc5d256f29129dd0d63d4db --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,10 @@ +# Development dependencies +# Install with: pip install -r requirements-dev.txt + +-r requirements.txt + +# Linting and formatting +ruff + +# Testing +pytest diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..e31c090fbe1f7dda1ddb48eec2fbf85b266dae6e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,15 @@ +# CPU-only PyTorch (smaller, faster install - this project is CPU-only) +--extra-index-url https://download.pytorch.org/whl/cpu + +# Core dependencies +flask>=3.0.0 +waitress>=3.0.0 +pocket-tts>=2.1.0 + +# Audio processing +torch>=2.0.0,<=2.8.0 +torchaudio>=2.0.0,<=2.8.0 +torchao==0.13.0 +scipy>=1.10.0 +numpy>=1.24.0 +soundfile>=0.12.0 diff --git a/run_pocket_tts_server.bat b/run_pocket_tts_server.bat new file mode 100644 index 0000000000000000000000000000000000000000..1f5487c35da52e5c2b29434ce7b45080fa901382 --- /dev/null +++ b/run_pocket_tts_server.bat @@ -0,0 +1,102 @@ +@echo off +setlocal EnableDelayedExpansion + +title Pocket TTS Server Launcher + +echo. +echo ======================================================== +echo Pocket TTS OpenAI Streaming Server Launcher +echo ======================================================== +echo. +:: 0. Hugging Face Authentication +set "HF_TOKEN=your_token_here_if_you_want_to_hardcode_it" +if "%HF_TOKEN%"=="your_token_here_if_you_want_to_hardcode_it" ( + set /p "HF_TOKEN=Enter Hugging Face Token (leave blank if already logged in): " +) + +if not "%HF_TOKEN%"=="" ( + echo [INFO] Setting Hugging Face Token... + set "HF_TOKEN=%HF_TOKEN%" +) + +:: 1. Activate Virtual Environment +if exist "venv\Scripts\activate.bat" ( + echo [INFO] Activating virtual environment... + call venv\Scripts\activate.bat +) else ( + echo [WARNING] venv not found at .\venv. Attempting to run with system python... +) + +echo. +echo Please configure the server (Press ENTER to use defaults): +echo. + +:: 2. Host +set "HOST=0.0.0.0" +set /p "INPUT_HOST=Host IP [%HOST%]: " +if not "%INPUT_HOST%"=="" set "HOST=%INPUT_HOST%" + +:: 3. Port +set "PORT=49112" +set /p "INPUT_PORT=Port [%PORT%]: " +if not "%INPUT_PORT%"=="" set "PORT=%INPUT_PORT%" + +:: 4. Model Path +set "MODEL_PATH=" +set /p "INPUT_MODEL=Model Config Path (.yaml)/Variant Name (Optional, default=built-in): " +if not "%INPUT_MODEL%"=="" set "MODEL_PATH=--model-path ^"%INPUT_MODEL%^"" + +:: 5. Voices Directory +set "DEFAULT_VOICES=%~dp0voices" +set /p "INPUT_VOICES=Voices Directory [%DEFAULT_VOICES%]: " + +if "!INPUT_VOICES!"=="" ( + set "VOICES_DIR_ARG=--voices-dir "!DEFAULT_VOICES!"" +) else ( + set "VOICES_DIR_ARG=--voices-dir "!INPUT_VOICES!"" +) + +:: 6. Streaming Default +:: Changed: Defaults to ON. Only unsets if the user types 'N'. +set "STREAM_ARG=--stream" +set /p "INPUT_STREAM=Enable Streaming? (Y/N) [Y]: " +if /i "%INPUT_STREAM%"=="N" set "STREAM_ARG=" + +:: 7. Text Preprocessing Default +:: Defaults to ON. Only unsets if the user types 'N'. +set "TEXT_PREPROCESS_ARG=--text-preprocess" +set /p "INPUT_PREPROCESS=Enable Text Preprocessing? (Y/N) [Y]: " +if /i "%INPUT_PREPROCESS%"=="N" set "TEXT_PREPROCESS_ARG=" + +:: 8. Language +set "LANGUAGE_ARG=" +set /p "INPUT_LANGUAGE=Language (english, french_24l, german_24l, portuguese, italian, spanish_24l - leave blank for default): " +if not "%INPUT_LANGUAGE%"=="" set "LANGUAGE_ARG=--language %INPUT_LANGUAGE%" + +:: 9. Quantization +set "QUANTIZE_ARG=" +set /p "INPUT_QUANTIZE=Enable int8 Quantization? (Y/N) [N]: " +if /i "%INPUT_QUANTIZE%"=="Y" set "QUANTIZE_ARG=--quantize" + +echo. +echo ======================================================== +echo Starting Pocket TTS Server... +echo Host: %HOST% +echo Port: %PORT% +if defined MODEL_PATH echo Model: %MODEL_PATH% +if defined VOICES_DIR echo Voices: %VOICES_DIR% +if defined STREAM_ARG echo Streaming: Enabled +if defined TEXT_PREPROCESS_ARG echo Text Preprocessing: Enabled +if defined LANGUAGE_ARG echo Language: %INPUT_LANGUAGE% +if defined QUANTIZE_ARG echo Quantization: Enabled +echo ======================================================== +echo. + +:: 10. Run Command +python server.py --host %HOST% --port %PORT% %MODEL_PATH% %VOICES_DIR_ARG% %STREAM_ARG% %TEXT_PREPROCESS_ARG% %LANGUAGE_ARG% %QUANTIZE_ARG% + +if %ERRORLEVEL% NEQ 0 ( + echo. + echo [ERROR] Server exited with error code %ERRORLEVEL%. + pause +) diff --git a/run_pocket_tts_server.sh b/run_pocket_tts_server.sh new file mode 100644 index 0000000000000000000000000000000000000000..2ee50253c2deb296aa73cbf3284266448c0049fa --- /dev/null +++ b/run_pocket_tts_server.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# Pocket TTS Server Launcher (Linux/macOS) +# This script provides an interactive configuration menu before starting the server. + +set -euo pipefail + +# Resolve the script directory (handles symlinks) +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# Banner +echo "" +echo "========================================" +echo " Pocket TTS OpenAI Streaming Server" +echo "========================================" +echo "" + +# --- 0. Hugging Face Token --- +HF_TOKEN="${HF_TOKEN:-}" +if [ -z "$HF_TOKEN" ]; then + read -r -p "Hugging Face Token (leave blank if already logged in): " INPUT_HF + if [ -n "$INPUT_HF" ]; then + HF_TOKEN="$INPUT_HF" + export HF_TOKEN + echo "[INFO] Hugging Face Token set." + fi +fi + +# --- 1. Activate Virtual Environment --- +if [ -d "venv" ]; then + echo "[INFO] Activating virtual environment..." + source venv/bin/activate +else + echo "[WARNING] 'venv' not found. Using system Python..." +fi + +echo "" +echo "Configure the server (press Enter to accept defaults):" +echo "" + +# --- 2. Host --- +read -r -p "Host IP [0.0.0.0]: " INPUT_HOST +HOST="${INPUT_HOST:-0.0.0.0}" + +# --- 3. Port --- +read -r -p "Port [49112]: " INPUT_PORT +PORT="${INPUT_PORT:-49112}" +if ! [[ "$PORT" =~ ^[0-9]+$ ]]; then + echo "[ERROR] Port must be a number." + exit 1 +fi + +# --- 4. Model Path --- +echo "Model Config Path (.yaml) or variant name (leave blank for built-in):" +read -r INPUT_MODEL +MODEL_ARG=() +if [ -n "$INPUT_MODEL" ]; then + MODEL_ARG+=("--model-path" "$INPUT_MODEL") +fi + +# --- 5. Voices Directory --- +DEFAULT_VOICES="$SCRIPT_DIR/voices" +[ ! -d "$DEFAULT_VOICES" ] && DEFAULT_VOICES="None" + +read -r -p "Voices Directory [$DEFAULT_VOICES]: " INPUT_VOICES +VOICES_ARG=() +if [ -n "$INPUT_VOICES" ] && [ "$INPUT_VOICES" != "None" ]; then + VOICES_ARG+=("--voices-dir" "$INPUT_VOICES") +fi + +# --- 6. Streaming --- +read -r -p "Enable Streaming? (y/n) [Y]: " INPUT_STREAM +STREAM_ARG=() +if [ "${INPUT_STREAM,,}" != "n" ]; then + STREAM_ARG+=("--stream") +fi + +# --- 7. Text Preprocessing --- +read -r -p "Enable Text Preprocessing? (y/n) [Y]: " INPUT_PREPROCESS +PREPROCESS_ARG=() +if [ "${INPUT_PREPROCESS,,}" != "n" ]; then + PREPROCESS_ARG+=("--text-preprocess") +fi + +# --- 8. Language --- +echo "Language (english, french_24l, german_24l, portuguese, italian, spanish_24l - leave blank for default):" +read -r INPUT_LANGUAGE +LANGUAGE_ARG=() +if [ -n "$INPUT_LANGUAGE" ]; then + LANGUAGE_ARG+=("--language" "$INPUT_LANGUAGE") +fi + +# --- 9. Quantization --- +read -r -p "Enable int8 Quantization? (y/n) [N]: " INPUT_QUANTIZE +QUANTIZE_ARG=() +if [ "${INPUT_QUANTIZE,,}" = "y" ]; then + QUANTIZE_ARG+=("--quantize") +fi + +# --- Summary --- +echo "" +echo "========================================" +echo "Starting Pocket TTS Server..." +echo " Host : $HOST" +echo " Port : $PORT" +[ -n "$INPUT_MODEL" ] && echo " Model : $INPUT_MODEL" +[ -n "$INPUT_VOICES" ] && echo " Voices : $INPUT_VOICES" +echo " Streaming : $([ "${INPUT_STREAM,,}" = "n" ] && echo "Disabled" || echo "Enabled")" +echo " Preprocessing: $([ "${INPUT_PREPROCESS,,}" = "n" ] && echo "Disabled" || echo "Enabled")" +[ -n "$INPUT_LANGUAGE" ] && echo " Language : $INPUT_LANGUAGE" +[ "${INPUT_QUANTIZE,,}" = "y" ] && echo " Quantization : Enabled" +echo "========================================" +echo "" + +# --- Run --- +python3 server.py \ + --host "$HOST" \ + --port "$PORT" \ + "${MODEL_ARG[@]+"${MODEL_ARG[@]}"}" \ + "${VOICES_ARG[@]+"${VOICES_ARG[@]}"}" \ + "${STREAM_ARG[@]+"${STREAM_ARG[@]}"}" \ + "${PREPROCESS_ARG[@]+"${PREPROCESS_ARG[@]}"}" \ + "${LANGUAGE_ARG[@]+"${LANGUAGE_ARG[@]}"}" \ + "${QUANTIZE_ARG[@]+"${QUANTIZE_ARG[@]}"}" + +EXIT_CODE=$? + +if [ $EXIT_CODE -ne 0 ]; then + echo "" + echo "[ERROR] Server exited with error code $EXIT_CODE." +fi +exit $EXIT_CODE \ No newline at end of file diff --git a/run_pocket_tts_server_exe.bat b/run_pocket_tts_server_exe.bat new file mode 100644 index 0000000000000000000000000000000000000000..82f868478ee2270f4543b817095648ee6b01ef7a --- /dev/null +++ b/run_pocket_tts_server_exe.bat @@ -0,0 +1,100 @@ +@echo off +setlocal EnableDelayedExpansion + +title Pocket TTS Server Launcher (EXE) + +echo. +echo ======================================================== +echo Pocket TTS OpenAI Streaming Server Launcher +echo (Standalone EXE) +echo ======================================================== +echo. + +:: 0. Hugging Face Authentication +set "HF_TOKEN=your_token_here_if_you_want_to_hardcode_it" +if "%HF_TOKEN%"=="your_token_here_if_you_want_to_hardcode_it" ( + set /p "HF_TOKEN=Enter Hugging Face Token (leave blank if already logged in): " +) + +if not "%HF_TOKEN%"=="" ( + echo [INFO] Setting Hugging Face Token... + set "HF_TOKEN=%HF_TOKEN%" +) + +echo. +echo Please configure the server (Press ENTER to use defaults): +echo. + +:: 1. Host +set "HOST=0.0.0.0" +set /p "INPUT_HOST=Host IP [%HOST%]: " +if not "%INPUT_HOST%"=="" set "HOST=%INPUT_HOST%" + +:: 2. Port +set "PORT=49112" +set /p "INPUT_PORT=Port [%PORT%]: " +if not "%INPUT_PORT%"=="" set "PORT=%INPUT_PORT%" + +:: 3. Model Path +set "MODEL_PATH=" +set /p "INPUT_MODEL=Model Config Path (.yaml)/Variant Name (Optional, default=built-in): " +if not "%INPUT_MODEL%"=="" set "MODEL_PATH=--model-path ^"%INPUT_MODEL%^"" + +:: 4. Voices Directory +:: Changed: Now remains empty if the user hits ENTER. +set "VOICES_DIR=" +set /p "INPUT_VOICES=Voices Directory (Optional, leave blank to skip): " + +if not "!INPUT_VOICES!"=="" ( + set "VOICES_DIR=--voices-dir "!INPUT_VOICES!"" +) + +:: 5. Streaming Default +:: Changed: Defaults to ON. Only unsets if the user types 'N'. +set "STREAM_ARG=--stream" +set /p "INPUT_STREAM=Enable Streaming? (Y/N) [Y]: " +if /i "%INPUT_STREAM%"=="N" set "STREAM_ARG=" + +:: 6. Text Preprocessing Default +:: Defaults to ON. Only unsets if the user types 'N'. +set "TEXT_PREPROCESS_ARG=--text-preprocess" +set /p "INPUT_PREPROCESS=Enable Text Preprocessing? (Y/N) [Y]: " +if /i "%INPUT_PREPROCESS%"=="N" set "TEXT_PREPROCESS_ARG=" + +:: 7. Language +set "LANGUAGE_ARG=" +set /p "INPUT_LANGUAGE=Language (english, french_24l, german_24l, portuguese, italian, spanish_24l - leave blank for default): " +if not "%INPUT_LANGUAGE%"=="" set "LANGUAGE_ARG=--language %INPUT_LANGUAGE%" + +:: 8. Quantization +set "QUANTIZE_ARG=" +set /p "INPUT_QUANTIZE=Enable int8 Quantization? (Y/N) [N]: " +if /i "%INPUT_QUANTIZE%"=="Y" set "QUANTIZE_ARG=--quantize" + +echo. +echo ======================================================== +echo Starting Pocket TTS Server (EXE)... +echo Host: %HOST% +echo Port: %PORT% +if defined MODEL_PATH echo Model: %MODEL_PATH% +if defined VOICES_DIR (echo Voices: %VOICES_DIR%) else (echo Voices: Default/None) +if defined STREAM_ARG (echo Streaming: Enabled) else (echo Streaming: Disabled) +if defined TEXT_PREPROCESS_ARG (echo Text Preprocessing: Enabled) else (echo Text Preprocessing: Disabled) +if defined LANGUAGE_ARG echo Language: %INPUT_LANGUAGE% +if defined QUANTIZE_ARG echo Quantization: Enabled +echo ======================================================== +echo. + +:: 9. Run Command +if exist "%~dp0PocketTTS-Server.exe" ( + "%~dp0PocketTTS-Server.exe" --host %HOST% --port %PORT% %MODEL_PATH% %VOICES_DIR% %STREAM_ARG% %TEXT_PREPROCESS_ARG% %LANGUAGE_ARG% %QUANTIZE_ARG% +) else ( + echo [ERROR] PocketTTS-Server.exe not found in the current directory. + echo Please make sure the executable is located in: %~dp0 +) + +if %ERRORLEVEL% NEQ 0 ( + echo. + echo [ERROR] Server exited with error code %ERRORLEVEL%. + pause +) \ No newline at end of file diff --git a/server.py b/server.py new file mode 100644 index 0000000000000000000000000000000000000000..a20d7176e60e803a77b7d8976f75ee92ca1e9403 --- /dev/null +++ b/server.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +""" +PocketTTS OpenAI-Compatible Server + +A drop-in replacement for OpenAI's TTS API using the pocket-tts model. +Supports streaming, custom voices, and runs on CPU. + +Usage: + python server.py [OPTIONS] + + # Or with environment variables: + POCKET_TTS_PORT=8080 python server.py +""" + +import argparse +import os +import sys + +from app import create_app, init_tts_service +from app.config import Config +from app.logging_config import get_logger + + +def parse_args(): + """Parse command line arguments.""" + parser = argparse.ArgumentParser( + description='PocketTTS OpenAI-Compatible Server', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Start with defaults + python server.py + + # Custom port and voices directory + python server.py --port 8080 --voices-dir ./my_voices + + # Load French model with quantization + python server.py --language french_24l --quantize + +Environment Variables: + POCKET_TTS_HOST Server host (default: 0.0.0.0) + POCKET_TTS_PORT Server port (default: 49112) + POCKET_TTS_MODEL_PATH Path to model config file + POCKET_TTS_LANGUAGE Model language (e.g., english, french_24l) + POCKET_TTS_QUANTIZE Enable int8 quantization (default: false) + POCKET_TTS_VOICES_DIR Path to voices directory + POCKET_TTS_STREAM_DEFAULT Enable streaming by default + POCKET_TTS_TEXT_PREPROCESS_DEFAULT Enable text preprocessing by default + POCKET_TTS_LOG_DIR Log directory path + """, + ) + + parser.add_argument( + '--host', type=str, default=Config.HOST, help=f'Host to bind to (default: {Config.HOST})' + ) + parser.add_argument( + '--port', type=int, default=Config.PORT, help=f'Port to listen on (default: {Config.PORT})' + ) + parser.add_argument( + '--model-path', + type=str, + default=Config.MODEL_PATH, + dest='model_path', + help='Path to model config file (.yaml) or variant name', + ) + parser.add_argument( + '--voices-dir', + type=str, + default=Config.VOICES_DIR, + dest='voices_dir', + help='Directory containing voice files', + ) + parser.add_argument( + '--stream', + action='store_true', + default=Config.STREAM_DEFAULT, + help='Enable streaming by default for all requests', + ) + parser.add_argument( + '--text-preprocess', + action='store_true', + default=Config.TEXT_PREPROCESS_DEFAULT, + help='Enable text preprocessing for all requests', + ) + parser.add_argument( + '--language', + type=str, + default=Config.LANGUAGE, + dest='language', + help='Model language (e.g., english, french_24l, german_24l, portuguese, italian, spanish_24l). Incompatible with --model-path.', + ) + parser.add_argument( + '--quantize', + action='store_true', + default=Config.QUANTIZE, + help='Apply dynamic int8 quantization to reduce memory usage and improve speed.', + ) + parser.add_argument( + '--log-level', + type=str, + default=Config.LOG_LEVEL, + choices=['DEBUG', 'INFO', 'WARNING', 'ERROR'], + dest='log_level', + help='Logging level', + ) + + return parser.parse_args() + + +def main(): + """Main entry point.""" + args = parse_args() + + # Update config from args (environment takes precedence via Config class) + os.environ.setdefault('POCKET_TTS_LOG_LEVEL', args.log_level) + + # Create app + app = create_app( + {'STREAM_DEFAULT': args.stream, 'TEXT_PREPROCESS_DEFAULT': args.text_preprocess} + ) + + logger = get_logger() + + # Validate mutually exclusive options + if args.language and args.model_path: + logger.error('--language and --model-path are mutually exclusive. Use one or the other.') + sys.exit(1) + + # Validate --language against supported list (prevents cryptic pocket-tts errors). + if args.language and args.language not in Config.SUPPORTED_LANGUAGES: + logger.error( + f"Unknown language '{args.language}'. " + f'Supported: {", ".join(Config.SUPPORTED_LANGUAGES)}' + ) + sys.exit(1) + + # Initialize TTS service + try: + init_tts_service( + model_path=args.model_path, + voices_dir=args.voices_dir, + language=args.language, + quantize=args.quantize, + ) + except Exception as e: + logger.error(f'Failed to initialize TTS service: {e}') + sys.exit(1) + + # Start server with Waitress (production WSGI server) + try: + from waitress import serve + + logger.info(f'Starting PocketTTS server on http://{args.host}:{args.port}') + logger.info('Press Ctrl+C to stop') + + serve(app, host=args.host, port=args.port, threads=4, url_scheme='http') + + except ImportError: + logger.warning('Waitress not installed, falling back to Flask dev server') + logger.warning('Install waitress for production: pip install waitress') + app.run(host=args.host, port=args.port, debug=False, threaded=True) + + +if __name__ == '__main__': + main() diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000000000000000000000000000000000000..de4acef228d55846f3d210270fea5d1eb2488337 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,819 @@ +:root { + --bg-color: #0d1117; + --card-bg: #161b22; + --accent-color: #58a6ff; + --accent-hover: #1f6feb; + --text-primary: #c9d1d9; + --text-secondary: #8b949e; + --border-color: #30363d; + --input-bg: #0d1117; + --font-family: + 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, + sans-serif; + --glass-bg: rgba(22, 27, 34, 0.7); + --glass-border: rgba(48, 54, 61, 0.5); +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: var(--font-family); + background-color: var(--bg-color); + color: var(--text-primary); + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + background-image: radial-gradient(circle at 50% 0%, #1f2530 0%, #0d1117 100%); +} + +.container { + width: 100%; + max-width: 800px; + padding: 2rem; + background: var(--glass-bg); + backdrop-filter: blur(12px); + border: 1px solid var(--glass-border); + border-radius: 16px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + animation: fadeIn 0.5s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +header { + text-align: center; + margin-bottom: 2rem; +} + +.logo { + max-width: 120px; + margin-bottom: 1rem; + filter: drop-shadow(0 0 10px rgba(88, 166, 255, 0.3)); +} + +h1 { + font-size: 2rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 0.5rem; + background: linear-gradient(90deg, #58a6ff, #a371f7); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.subtitle { + color: var(--text-secondary); + font-size: 0.95rem; +} + +.control-group { + margin-bottom: 1.5rem; + position: relative; +} + +label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: var(--text-secondary); + font-size: 0.9rem; +} + +input[type='text'], +textarea, +select { + width: 100%; + padding: 0.75rem 1rem; + background-color: var(--input-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-primary); + font-family: inherit; + font-size: 1rem; + transition: + border-color 0.2s, + box-shadow 0.2s; +} + +/* Replace native select chevron with a custom one positioned right next to + the content area instead of floating at the far edge of the box. + `!important` on background-image is needed because #format-select has an + inline `background: var(--input-bg)` that would otherwise reset the image. */ +select { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='none' stroke='%238b949e' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M1 1l5 5 5-5'/%3E%3C/svg%3E") !important; + background-repeat: no-repeat !important; + background-position: right 14px center !important; + background-size: 12px 8px !important; + padding-right: 36px !important; +} + +input[type='file'] { + width: 100%; + padding: 0.5rem; + background-color: var(--input-bg); + border: 1px dashed var(--border-color); + border-radius: 8px; + color: var(--text-secondary); + cursor: pointer; +} + +input:focus, +textarea:focus, +select:focus { + outline: none; + border-color: var(--accent-color); + box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.1); +} + +textarea { + min-height: 120px; + resize: vertical; +} + +.input-with-action { + position: relative; + display: flex; + align-items: center; +} + +.input-with-action input[type='text'] { + width: 100%; + padding-right: 2.5rem; +} + +.clear-btn { + position: absolute; + right: 0.5rem; + top: 50%; + transform: translateY(-50%); + width: 1.8rem; + height: 1.8rem; + padding: 0; + background: transparent; + color: var(--text-secondary); + border: none; + border-radius: 50%; + font-size: 0.85rem; + font-weight: 600; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: + background-color 0.2s, + color 0.2s; +} + +.clear-btn:hover { + color: var(--text-primary); + background-color: rgba(255, 255, 255, 0.1); +} + +.clear-btn:disabled { + cursor: default; + opacity: 0; + pointer-events: none; +} + +.voice-list { + position: absolute; + top: 100%; + left: 0; + width: 100%; + z-index: 100; + list-style: none; + margin-top: 4px; + padding: 0; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + max-height: 250px; + overflow-y: auto; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6); + display: none; /* Changed from just being in flow */ +} + +.voice-list.show { + display: block; +} + +.voice-list li { + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} +.voice-list li:last-child { + border-bottom: none; +} + +.voice-list-item { + width: 100%; + text-align: left; + background: transparent; + border: none; + border-radius: 0; + color: var(--text-primary); + padding: 0.75rem 1rem; + font-size: 0.95rem; + cursor: pointer; + transition: background-color 0.1s; + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 0.5rem; +} + +.voice-list-item .voice-info { + display: flex; + flex-direction: column; + gap: 2px; + overflow: hidden; +} + +.voice-list-item .voice-name { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.voice-list-item .voice-sub { + font-size: 0.75rem; + color: var(--text-secondary); + font-family: 'SF Mono', 'Consolas', monospace; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + opacity: 0.7; +} + +.voice-list-item span.voice-badge { + font-size: 0.7rem; + font-weight: 600; + padding: 2px 8px; + border-radius: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + white-space: nowrap; +} + +.voice-list-item span.voice-badge.badge-builtin { + color: var(--text-secondary); + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.voice-list-item span.voice-badge.badge-custom { + color: #7ee787; /* Greenish for custom */ + background: rgba(46, 160, 67, 0.15); + border: 1px solid rgba(46, 160, 67, 0.2); +} + +.voice-list-item:hover, +.voice-list-item:focus { + outline: none; + background: rgba(88, 166, 255, 0.15); + color: var(--accent-color); +} + +.voice-list-empty { + color: var(--text-secondary); + padding: 1rem; + text-align: center; + font-size: 0.9rem; +} + +.btn-primary { + display: block; + width: 100%; + padding: 1rem; + border: none; + border-radius: 8px; + background: linear-gradient(135deg, var(--accent-color), var(--accent-hover)); + color: white; + font-weight: 600; + font-size: 1.1rem; + cursor: pointer; + transition: + transform 0.1s, + opacity 0.2s; + box-shadow: 0 4px 12px rgba(31, 111, 235, 0.3); +} + +.btn-primary:hover { + opacity: 0.9; +} + +.btn-primary:active { + transform: scale(0.98); +} + +.btn-primary:disabled { + background: var(--border-color); + cursor: not-allowed; + opacity: 0.7; + box-shadow: none; +} + +.output-section { + margin-top: 2rem; + padding-top: 2rem; + border-top: 1px solid var(--border-color); + text-align: center; + display: none; /* Hidden by default */ +} + +.output-section.active { + display: block; + animation: slideDown 0.3s ease-out; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +audio { + width: 100%; + margin-bottom: 1rem; + border-radius: 8px; +} + +.download-link { + display: inline-block; + padding: 0.5rem 1rem; + color: var(--accent-color); + text-decoration: none; + border: 1px solid var(--accent-color); + border-radius: 6px; + font-size: 0.9rem; + transition: all 0.2s; +} + +.download-link:hover { + background: rgba(88, 166, 255, 0.1); +} + +/* Spinner */ +.spinner { + display: inline-block; + width: 1.5rem; + height: 1.5rem; + border: 3px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + border-top-color: #fff; + animation: spin 1s ease-in-out infinite; + margin-left: 0.5rem; + vertical-align: middle; + display: none; +} + +.btn-primary.loading .spinner { + display: inline-block; +} + +.btn-primary.loading span { + display: none; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.hidden { + display: none; +} + +/* API Documentation Styles */ +.api-docs { + margin-top: 3rem; + padding-top: 2rem; + border-top: 1px solid var(--border-color); +} + +.api-docs h2 { + font-size: 1.5rem; + margin-bottom: 0.5rem; + color: var(--text-primary); +} + +.api-intro { + color: var(--text-secondary); + margin-bottom: 2rem; + font-size: 0.95rem; +} + +.endpoint { + background: var(--input-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 1.5rem; + margin-bottom: 1.5rem; +} + +.endpoint-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; +} + +.method { + padding: 0.25rem 0.75rem; + border-radius: 4px; + font-weight: 600; + font-size: 0.8rem; + text-transform: uppercase; +} + +.method.get { + background: rgba(46, 160, 67, 0.2); + color: #3fb950; + border: 1px solid rgba(46, 160, 67, 0.4); +} + +.method.post { + background: rgba(88, 166, 255, 0.2); + color: #58a6ff; + border: 1px solid rgba(88, 166, 255, 0.4); +} + +.path { + font-family: 'SF Mono', 'Consolas', monospace; + font-size: 1rem; + color: var(--text-primary); + background: none; + padding: 0; +} + +.endpoint-desc { + color: var(--text-secondary); + margin-bottom: 1rem; + font-size: 0.9rem; +} + +.params-section, +.example-section, +.response-example, +.error-section { + margin-top: 1rem; +} + +.params-section strong, +.example-section strong, +.response-example strong, +.error-section strong { + display: block; + color: var(--text-secondary); + font-size: 0.85rem; + margin-bottom: 0.5rem; +} + +.params-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; + margin-top: 0.5rem; +} + +.params-table th, +.params-table td { + padding: 0.6rem 0.75rem; + text-align: left; + border-bottom: 1px solid var(--border-color); +} + +.params-table th { + color: var(--text-secondary); + font-weight: 500; + background: rgba(0, 0, 0, 0.2); +} + +.params-table td { + color: var(--text-primary); +} + +.params-table code { + background: rgba(88, 166, 255, 0.1); + color: var(--accent-color); + padding: 0.15rem 0.4rem; + border-radius: 4px; + font-size: 0.85em; +} + +.api-docs pre { + background: rgba(0, 0, 0, 0.3); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 1rem; + overflow-x: auto; + font-family: 'SF Mono', 'Consolas', monospace; + font-size: 0.85rem; + color: var(--text-primary); + line-height: 1.5; +} + +.voices-reference { + margin-top: 2rem; + padding: 1.5rem; + background: var(--input-bg); + border: 1px solid var(--border-color); + border-radius: 12px; +} + +.voices-reference h3 { + font-size: 1.1rem; + margin-bottom: 0.5rem; + color: var(--text-primary); +} + +.voices-reference p { + color: var(--text-secondary); + font-size: 0.9rem; + margin-bottom: 1rem; +} + +.voice-chips { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.voice-chip { + background: rgba(88, 166, 255, 0.15); + color: var(--accent-color); + padding: 0.4rem 0.8rem; + border-radius: 20px; + font-size: 0.85rem; + font-family: 'SF Mono', 'Consolas', monospace; + border: 1px solid rgba(88, 166, 255, 0.3); +} + +.voice-note { + font-size: 0.85rem; + color: var(--text-secondary); + margin-top: 1rem; + margin-bottom: 0; +} + +.voice-note a { + color: var(--accent-color); + text-decoration: none; +} + +.voice-note a:hover { + text-decoration: underline; +} + +/* ID Helper */ +.voice-id-helper { + margin-top: 0.5rem; + font-size: 0.8rem; + color: var(--text-secondary); + font-family: 'SF Mono', 'Consolas', monospace; + display: flex; + align-items: center; + padding-left: 2px; + animation: fadeIn 0.3s ease-out; +} + +.voice-id-helper span { + color: var(--accent-color); + margin-left: 0.5rem; + background: rgba(88, 166, 255, 0.1); + padding: 2px 6px; + border-radius: 4px; + user-select: all; + cursor: text; +} + +.copy-btn { + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 4px; + margin-left: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: + background-color 0.2s, + color 0.2s; +} + +.copy-btn:hover { + background: rgba(255, 255, 255, 0.1); + color: var(--text-primary); +} + +.copy-btn:active { + transform: translateY(1px); +} + +.version-strip { + color: var(--text-secondary); + font-size: 0.8em; + margin-top: 4px; + margin-bottom: 0; +} + +.version-strip a { + color: var(--text-secondary); + text-decoration: none; + border-bottom: 1px dotted currentColor; +} + +.version-strip a:hover { + color: var(--text-color); +} + +.model-settings { + margin-bottom: 20px; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--input-bg); +} + +.model-settings-summary { + padding: 10px 14px; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + font-weight: 500; + list-style: none; +} + +.model-settings-summary::-webkit-details-marker { + display: none; +} + +.disclosure-caret { + display: inline-block; + transition: transform 0.15s ease; + color: var(--text-secondary); +} + +.model-settings[open] .disclosure-caret { + transform: rotate(90deg); +} + +.summary-label #active-model-label { + font-family: monospace; + color: var(--text-color); +} + +.model-badge { + font-size: 0.75em; + padding: 2px 6px; + border-radius: 3px; + background: var(--border-color); + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.session-badge { + background: #b45309; + color: #fff; +} + +.loading-indicator { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.9em; + color: var(--text-secondary); +} + +.spinner-small { + width: 12px; + height: 12px; + border: 2px solid var(--border-color); + border-top-color: var(--text-color); + border-radius: 50%; + display: inline-block; + animation: spin 0.8s linear infinite; +} + +.model-settings-body { + padding: 14px 14px 14px 14px; + border-top: 1px solid var(--border-color); +} + +/* HTML [hidden] is otherwise overridden by display: inline-flex etc. */ +.model-settings [hidden] { + display: none !important; +} + +.btn-secondary { + padding: 8px 18px; + margin-top: 14px; + border: 1px solid var(--border-color); + border-radius: 6px; + background: transparent; + color: var(--text-color); + font-weight: 500; + cursor: pointer; + transition: + background 0.15s, + border-color 0.15s, + opacity 0.15s; +} + +.btn-secondary:hover:not(:disabled) { + background: var(--input-bg); + border-color: var(--accent-color); +} + +.btn-secondary:active:not(:disabled) { + transform: translateY(1px); +} + +.btn-secondary:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.inline-label { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; +} + +.info-banner { + padding: 10px 12px; + border-radius: 4px; + margin-top: 14px; + font-size: 0.9em; + line-height: 1.4; +} + +.info-banner code { + font-family: monospace; + background: rgba(255,255,255,0.06); + padding: 1px 4px; + border-radius: 2px; +} + +.info-banner.info { + background: rgba(59,130,246,0.08); + border-left: 3px solid #3b82f6; + color: var(--text-color); +} + +.info-banner.warning { + background: rgba(234,179,8,0.08); + border-left: 3px solid #eab308; + color: var(--text-color); +} + +.info-banner.error { + background: rgba(239,68,68,0.08); + border-left: 3px solid #ef4444; + color: var(--text-color); +} + +/* Responsive adjustments */ +@media (max-width: 600px) { + .params-table { + font-size: 0.75rem; + } + + .params-table th, + .params-table td { + padding: 0.4rem 0.5rem; + } + + .endpoint-header { + flex-wrap: wrap; + } +} diff --git a/static/images/pocket-tts-logo.png b/static/images/pocket-tts-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..22201d660a96cb4853eb40e363bd0e128e3e3289 --- /dev/null +++ b/static/images/pocket-tts-logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f674a22e3728493d14e045999963b9326bf513424cce1d8f7ac45c8aff6225ca +size 178742 diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000000000000000000000000000000000000..5068fc3fedb6d0be6594fa2c0eeaa2e5eac4a38a --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,680 @@ +document.addEventListener('DOMContentLoaded', async () => { + const voiceInput = document.getElementById('voice-input'); + const voiceList = document.getElementById('voice-list'); + const voiceClearBtn = document.getElementById('voice-clear-btn'); + const customVoiceGroup = document.getElementById('custom-voice-group'); + const generateBtn = document.getElementById('generate-btn'); + const textInput = document.getElementById('text-input'); + const voiceFile = document.getElementById('voice-file'); + const outputSection = document.getElementById('output-section'); + const audioPlayer = document.getElementById('audio-player'); + const downloadBtn = document.getElementById('download-btn'); + const streamToggle = document.getElementById('stream-toggle'); + const formatSelect = document.getElementById('format-select'); + + let availableVoices = []; + let selectedVoiceId = null; // The actual value used for generation + + // Format & Streaming Logic + function updateStreamingAvailability() { + const fmt = formatSelect.value; + // Server only supports streaming for PCM and WAV currently + const supportsStreaming = ['wav', 'pcm'].includes(fmt); + const infoLabel = document.getElementById('format-info'); + + if (supportsStreaming) { + streamToggle.disabled = false; + streamToggle.parentElement.title = ''; + + if (fmt === 'pcm') { + infoLabel.textContent = + "Streaming is available for Raw PCM. Note: This format creates a specialized raw stream that will not play in the browser's audio player."; + } else { + // WAV + infoLabel.textContent = + 'Streaming is available for WAV. The server streams audio chunks for lower latency.'; + } + } else { + streamToggle.disabled = true; + streamToggle.checked = false; + streamToggle.parentElement.title = + 'Streaming is only available for WAV and PCM formats'; + + if (fmt === 'mp3') { + infoLabel.textContent = + 'Streaming is not available for MP3 (Server limitation). A full file will be generated and played.'; + } else if (['opus', 'aac', 'flac'].includes(fmt)) { + infoLabel.textContent = `Streaming is not available for ${fmt.toUpperCase()}. A full file will be generated and played.`; + } else { + infoLabel.textContent = 'Streaming is not available for this format.'; + } + } + } + + formatSelect.addEventListener('change', updateStreamingAvailability); + // Initialize state + updateStreamingAvailability(); + + // 1. Load Voices + async function loadVoices() { + try { + const res = await fetch('/v1/voices'); + const data = await res.json(); + availableVoices = []; + + if (data.data) { + data.data.forEach((voice) => { + availableVoices.push({ + id: voice.id, + label: voice.name || voice.id, + display: voice.name || voice.id, // For search + type: voice.type || 'builtin', + }); + }); + + // Custom option + availableVoices.push({ + id: 'custom', + label: 'Custom Voice', + display: 'Custom (Upload .wav, .mp3, .flac)...', + type: 'manual', + }); + + // Default selection: Prefer first non-custom voice + const defaultVoice = availableVoices.find((v) => v.id !== 'custom'); + if (defaultVoice) { + selectVoice(defaultVoice.id, false); + } + } + } catch (e) { + console.error('Failed to list voices:', e); + } + } + + // 2. Core Search & Selection Logic + + function selectVoice(id, closeList = true) { + const voice = availableVoices.find((v) => v.id === id); + if (!voice) return; + + selectedVoiceId = voice.id; + voiceInput.value = voice.label; // Display nice name + + // Update ID Display helper + const idDisplay = document.getElementById('voice-id-display'); + if (idDisplay) { + if (id !== 'custom') { + const idSpan = idDisplay.querySelector('.voice-id-text'); + if (idSpan) { + // Clean extension from ID for cleaner display/copying + const cleanId = voice.id.replace( + /\.(wav|mp3|flac|safetensors)$/i, + '', + ); + idSpan.textContent = cleanId; + } + idDisplay.classList.remove('hidden'); + } else { + idDisplay.classList.add('hidden'); + } + } + + // Handle UI state + voiceClearBtn.disabled = false; + if (closeList) hideVoiceList(); + + // Handle Custom + if (id === 'custom') { + const isDocker = window.POCKET_TTS_CONFIG?.isDocker || false; + if (isDocker) { + alert('Custom voices are not available in Docker mode.'); + // Fallback to the first non-custom voice if available + const fallbackVoice = availableVoices.find((v) => v.id !== 'custom'); + if (fallbackVoice) { + selectVoice(fallbackVoice.id || ''); + } else { + // No valid fallback; clear selection and hide custom UI + selectedVoiceId = null; + voiceInput.value = ''; + voiceClearBtn.disabled = true; + customVoiceGroup.classList.add('hidden'); + } + return; + } + customVoiceGroup.classList.remove('hidden'); + document.querySelector('#custom-voice-group label').textContent = + 'Absolute Path to Audio File:'; + voiceFile.type = 'text'; + voiceFile.placeholder = 'C:\\path\\to\\voice.wav'; + } else { + customVoiceGroup.classList.add('hidden'); + } + } + + function renderVoiceList(filterText = '') { + const normalizedFilter = filterText.trim().toLowerCase(); + const fragment = document.createDocumentFragment(); + + let matchCount = 0; + let firstMatchId = null; + + const isDocker = window.POCKET_TTS_CONFIG?.isDocker || false; + const filtered = availableVoices.filter((v) => { + if (v.id === 'custom' && isDocker) return false; + if (!normalizedFilter) return true; + return ( + v.id.toLowerCase().includes(normalizedFilter) || + v.label.toLowerCase().includes(normalizedFilter) || + (v.display && v.display.toLowerCase().includes(normalizedFilter)) + ); + }); + + voiceList.innerHTML = ''; + + if (filtered.length === 0) { + const emptyItem = document.createElement('li'); + emptyItem.className = 'voice-list-empty'; + emptyItem.textContent = 'No matching voices'; + voiceList.appendChild(emptyItem); + } else { + filtered.forEach((voice) => { + matchCount++; + if (matchCount === 1) firstMatchId = voice.id; + + const item = document.createElement('li'); + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'voice-list-item'; + btn.dataset.voiceId = voice.id; + + // Better content structure + const infoDiv = document.createElement('div'); + infoDiv.className = 'voice-info'; + + const nameSpan = document.createElement('span'); + nameSpan.className = 'voice-name'; + nameSpan.textContent = voice.display || voice.label; + + const subSpan = document.createElement('span'); + subSpan.className = 'voice-sub'; + if (voice.id === 'custom') { + subSpan.textContent = ''; // No ID for the upload button itself + } else { + subSpan.textContent = voice.id; + } + + infoDiv.appendChild(nameSpan); + if (subSpan.textContent) infoDiv.appendChild(subSpan); + + const badgeSpan = document.createElement('span'); + badgeSpan.className = 'voice-badge'; + + // Format badge text: "builtin" -> "Default", "custom" -> "Custom" + let badgeText = 'Default'; + if (voice.type === 'custom') badgeText = 'Custom'; + if (voice.type === 'manual') badgeText = 'Upload'; + + badgeSpan.textContent = badgeText; + + // Add specific class for styling if needed + badgeSpan.classList.add( + voice.type === 'builtin' ? 'badge-builtin' : 'badge-custom', + ); + + btn.appendChild(infoDiv); + btn.appendChild(badgeSpan); + + item.appendChild(btn); + fragment.appendChild(item); + }); + voiceList.appendChild(fragment); + } + + return { count: matchCount, firstId: firstMatchId }; + } + + function showVoiceList() { + voiceList.classList.add('show'); + renderVoiceList( + voiceInput.value === getSelectedVoiceLabel() ? '' : voiceInput.value, + ); + } + + function hideVoiceList() { + // Small delay to allow click events to propagate + setTimeout(() => { + voiceList.classList.remove('show'); + }, 150); + } + + function getSelectedVoiceLabel() { + const v = availableVoices.find((v) => v.id === selectedVoiceId); + return v ? v.label : ''; + } + + // Smart Input Handling + voiceInput.addEventListener('focus', () => { + // On focus, if the input value matches the current selection, wipe it to allow fresh search? + // Or keep it? Standard combobox keeps it but selects text. + // Let's select text so user can type over immediately. + voiceInput.select(); + showVoiceList(); + }); + + voiceInput.addEventListener('input', () => { + voiceClearBtn.disabled = voiceInput.value.length === 0; + // If user types, we conceptually deselect until they pick or we auto-match + // But strictly clearing selectedVoiceId might be annoying if they just made a typo. + // Let's keep selectedVoiceId as fallback, but filter. + renderVoiceList(voiceInput.value); + voiceList.classList.add('show'); + }); + + voiceInput.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + voiceInput.value = getSelectedVoiceLabel(); + hideVoiceList(); + voiceInput.blur(); + } else if (e.key === 'Enter') { + e.preventDefault(); + // Auto-select if 1 result + const { count, firstId } = renderVoiceList(voiceInput.value); + if (count === 1 && firstId) { + selectVoice(firstId); + voiceInput.blur(); + } else if (count > 0 && firstId) { + // If multiple, maybe select first? Or do nothing? + // User asked: "If I filter so much that there is just 1 result, I still have to select it" + // implies standard Enter behavior triggers selection of top result usually. + selectVoice(firstId); + voiceInput.blur(); + } + } + }); + + // Handle Blur: Auto-select if logic dictates + voiceInput.addEventListener('blur', () => { + // Delay logic slightly to allow Click to happen first + setTimeout(() => { + if (!document.activeElement.classList.contains('voice-list-item')) { + // Validate: Is text a partial match for exactly one voice? + const val = voiceInput.value.trim(); + if (!val) { + // Cleared -> maybe clear selection? Or revert? + // Let's revert to last selected for safety unless user explicitly cleared? + // If they cleared, they probably want to clear. + // But we need a voice to generate? + // Let's revert if empty. + voiceInput.value = getSelectedVoiceLabel(); + hideVoiceList(); + return; + } + + // If the text matches the currently selected label, do nothing + if (val === getSelectedVoiceLabel()) { + hideVoiceList(); + return; + } + + // Try to find a match + // 1. Exact Name Match + const exact = availableVoices.find( + (v) => + v.label.toLowerCase() === val.toLowerCase() || + v.id.toLowerCase() === val.toLowerCase(), + ); + if (exact) { + selectVoice(exact.id); + } else { + // 2. Single Filter Match + const { count, firstId } = renderVoiceList(val); + if (count === 1) { + selectVoice(firstId); + } else { + // 3. No clean match (0 or >1). Revert to last valid. + // User said "get an error because the value from the search field is taken" + // So passing the raw text is bad. We must force valid selection. + voiceInput.value = getSelectedVoiceLabel(); + } + } + hideVoiceList(); + } + }, 200); + }); + + // List Click Handling + voiceList.addEventListener('mousedown', (e) => { + // Use mousedown to trigger before blur + const btn = e.target.closest('.voice-list-item'); + if (btn) { + const id = btn.dataset.voiceId; + selectVoice(id); + } + }); + + voiceClearBtn.addEventListener('mousedown', (e) => { + e.preventDefault(); // Prevent blur on input + selectedVoiceId = null; + voiceInput.value = ''; + voiceInput.focus(); + renderVoiceList(''); + showVoiceList(); + voiceClearBtn.disabled = true; + + const idDisplay = document.getElementById('voice-id-display'); + if (idDisplay) idDisplay.classList.add('hidden'); + }); + + // Copy Button Logic + const copyBtn = document.getElementById('voice-id-copy-btn'); + if (copyBtn) { + copyBtn.addEventListener('click', async () => { + const idText = document.querySelector('.voice-id-text')?.textContent; + if (idText) { + try { + await navigator.clipboard.writeText(idText); + const originalHTML = copyBtn.innerHTML; + // Show checkmark + copyBtn.innerHTML = ``; + copyBtn.classList.add('copied'); + + setTimeout(() => { + copyBtn.innerHTML = originalHTML; + copyBtn.classList.remove('copied'); + }, 1500); + } catch (err) { + console.error('Failed to copy: ', err); + // Fallback for non-secure contexts (optional but good for localhost sometimes) + const input = document.createElement('textarea'); + input.value = idText; + document.body.appendChild(input); + input.select(); + document.execCommand('copy'); + document.body.removeChild(input); + } + } + }); + } + + // 3. Generate Logic + generateBtn.addEventListener('click', async () => { + const text = textInput.value.trim(); + if (!text) return alert('Please enter text'); + + // Use the ID, not the Input Value + let voice = selectedVoiceId; + + // Fallback: If for some reason ID is null but text exists (shouldn't happen with our blur logic), try to resolve + if (!voice) { + // Try to find by name from input + const val = voiceInput.value.trim(); + const isDocker = window.POCKET_TTS_CONFIG?.isDocker || false; + const match = availableVoices.find( + (v) => + (v.label === val || v.id === val) && + // In Docker mode, do not allow resolving the special "custom" voice + !(isDocker && v.id === 'custom'), + ); + if (match) voice = match.id; + } + + if (!voice) return alert('Please choose a valid voice from the list'); + + const isDocker = window.POCKET_TTS_CONFIG?.isDocker || false; + if (isDocker && voice === 'custom') { + return alert( + 'The custom voice is not available in Docker mode. Please choose another voice.', + ); + } + + if (voice === 'custom') { + voice = voiceFile.value.trim(); + if (!voice) return alert('Please enter the path to the voice file.'); + } + + // ... rest of generation logic ... + const stream = streamToggle.checked; + const fmt = formatSelect.value; + + generateBtn.classList.add('loading'); + generateBtn.disabled = true; + outputSection.classList.remove('active'); + + try { + const response = await fetch('/v1/audio/speech', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'pocket-tts', + input: text, + voice: voice, + response_format: fmt, + stream: stream, + }), + }); + + if (!response.ok) { + const err = await response.json(); + throw new Error(err.error || response.statusText); + } + + // Currently we always fetch the full blob and play it once ready. + // The `stream` flag is still sent to the server, but client playback + // uses a single blob path for robustness. + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + audioPlayer.src = url; + downloadBtn.href = url; + downloadBtn.download = `generated_speech.${fmt}`; + + // PCM raw audio usually won't play in standard