Compare commits

...

4 Commits

Author SHA1 Message Date
91887ae296 feat: Add XTTS v2 support, refactor Docker/GPU infra, and improve Piper engine
- Add XTTS v2 configuration to .env.example
- Refactor Dockerfile to multi-stage build with CUDA 12.1 support
- Update Makefile with Kokoro and XTTS test environment targets
- Refactor Piper engine (app/engines/piper.py) to use python module execution
- Add comprehensive documentation for Kokoro and XTTS plans
- Add helper scripts and patches for build process
2025-12-13 11:37:58 +01:00
d6d1fe9d23 docs: Update documentation for OpenAI API and XTTS support
- Updated README.md to include XTTS engine details and model setup instructions.
- Added section on OpenAI API compatibility in README.md.
- Updated API_DOCUMENTATION.md to include the new POST /v1/audio/speech endpoint.
2025-12-09 14:37:40 +01:00
7a2c6bb209 test: Add comprehensive tests for OpenAI-compatible TTS endpoint
- Refactor existing tests to use a shared mock engine fixture.
- Add tests for unsupported response formats.
- Add tests for engine synthesis failures.
- Add tests for 'engine:model_id' parsing in the 'model' parameter.
- Add tests for different audio response formats (opus, flac).
2025-12-09 12:53:07 +01:00
fff0252d52 feat: Add OpenAI-compatible TTS endpoint and engines
- Implements POST /v1/audio/speech endpoint (OpenAI API compatible).
- Integrates Kokoro and XTTS engines (including dependencies and implementations).
- Updates main application to register new engines and router.
- Adds unit tests for OpenAI compatibility.
- Updates requirements.txt for new engines.
2025-12-09 12:45:17 +01:00
30 changed files with 3042 additions and 49 deletions

View File

@ -32,3 +32,7 @@ docker-compose.yml
# Local environment settings
.env
# Exclude unused large models
app/models/chattts/
app/models/f5-tts-voices/

View File

@ -1,6 +1,6 @@
# Comma-separated list of engines to activate.
# Available options (potentially): piper, styletts, f5_tts, chattts
ACTIVE_ENGINES='["piper", "styletts"]'
# Available options (potentially): piper, styletts, f5_tts, chattts, kokoro, xtts
ACTIVE_ENGINES='["piper", "styletts", "xtts"]'
# Server configuration
HOST=0.0.0.0
@ -9,3 +9,12 @@ PORT=8000
# Piper Engine Configuration
PIPER_TIMEOUT_SECONDS=30
FFMPEG_TIMEOUT_SECONDS=60
# Kokoro Engine Configuration
KOKORO_DEVICE=cuda # cuda or cpu
KOKORO_TIMEOUT_SECONDS=30
# XTTS Engine Configuration
XTTS_ACCEPT_LICENSE=false # Set to true to accept the Coqui XTTS license
XTTS_DEVICE=cpu # cuda or cpu
VOICES_DIR=app/asset/voices # Directory where reference audio files for voice cloning are stored

7
.env.kokoro-test Normal file
View File

@ -0,0 +1,7 @@
ACTIVE_ENGINES='["piper", "kokoro"]'
HOST=0.0.0.0
PORT=8000
# Kokoro Engine Configuration
KOKORO_DEVICE=cuda
KOKORO_TIMEOUT_SECONDS=30

13
.env.xtts-test Normal file
View File

@ -0,0 +1,13 @@
LOG_LEVEL=DEBUG
ACTIVE_ENGINES='["piper", "kokoro", "xtts"]'
XTTS_ACCEPT_LICENSE=true
HOST=0.0.0.0
PORT=8000
# Kokoro Engine Configuration
KOKORO_DEVICE=cuda
KOKORO_TIMEOUT_SECONDS=30
VOICES_DIR=/home/appuser/app/asset/voices

35
AGENTS.md Normal file
View File

@ -0,0 +1,35 @@
# Repository Guidelines
## Project Structure & Module Organization
- `app/` FastAPI service code: `main.py` app factory, `engines/` TTS backends (Piper, Kokoro, XTTS, F5, StyleTTS, ChatTTS), `utils/` helpers (text chunking, audio concat, cache keys), `models/` default model assets.
- `app/asset/` runtime assets and audio cache (overridable via `AUDIO_CACHE_DIR`); `asset/` holds Docker/runtime assets mounted into the container.
- `tests/` pytest suite with async API coverage and fixtures that redirect cache paths.
- `docs/` reference material; `scripts/` helper scripts (health checks, setup); `Dockerfile`, `docker-compose*.yml`, and `Makefile` drive builds and orchestration.
## Build, Test, and Development Commands
- `make dev-up` Build from local source and start the stack; auto-picks a free host port.
- `make up` Run using the latest registry image; `make down` stops Compose services; `make logs` tails the app container.
- `make test` Run pytest with coverage over `app/` and `tests/`; honors `.venv` if present.
- Local uvicorn run (outside Docker): `uvicorn app.main:app --reload --host 0.0.0.0 --port 8000`.
## Coding Style & Naming Conventions
- Python code should follow PEP 8 with 4-space indentation and explicit type hints where meaningful; keep functions small and async-aware for I/O.
- Prefer FastAPI dependency injection patterns and pydantic models for request/response validation.
- Tests, fixtures, and helpers use `test_*.py` naming; keep fixtures in `tests/conftest.py`.
- Configuration lives in `app/config.py` via pydantic settings; read values from `.env` instead of hardcoding.
## Testing Guidelines
- Use `pytest`/`pytest-asyncio`; mark async tests with `@pytest.mark.asyncio`.
- Ensure Piper models required by tests exist under `app/models/piper/` (see README instructions) or skip conditionally as in existing tests.
- When adding engines, supply health checks (`healthcheck`), model/voice listing, and synthesis tests mirroring `tests/test_api.py`.
- Keep coverage broad on `/tts`, `/engines`, `/models`, `/health`, and error paths (invalid engine/model/speaker).
## Commit & Pull Request Guidelines
- Follow the existing conventional prefix style (`feat:`, `fix:`, `docs:`, etc.) as seen in git history.
- Commits should be scoped and descriptive; avoid bundling unrelated changes.
- PRs should include: purpose and behavior summary, key test commands run (e.g., `make test`), notes on model/config prerequisites, and screenshots/log snippets only when behavior is user-visible.
## Security & Configuration Tips
- Copy `.env.example` to `.env` and avoid committing secrets or model paths tied to personal systems.
- Validate engine activation lists via `ACTIVE_ENGINES` and ensure cache directories (`AUDIO_CACHE_DIR`) are writable before running locally or in containers.
- Large model files live outside the image; mount them via Compose volumes and confirm licenses (e.g., XTTS `XTTS_ACCEPT_LICENSE`).

269
API_DOCUMENTATION.md Normal file
View File

@ -0,0 +1,269 @@
# AudioEngineHub API Documentation
This document provides detailed information on how to interact with the AudioEngineHub API. AudioEngineHub is a local-first, modular multi-engine Text-to-Speech (TTS) server designed for homelabs and automation.
The API is built using FastAPI, which automatically generates OpenAPI (Swagger) documentation. If the AudioEngineHub service is running, you can typically access the interactive API documentation at `/docs` (e.g., `http://localhost:8000/docs`) and the OpenAPI specification JSON at `/openapi.json` (e.g., `http://localhost:8000/openapi.json`).
## Endpoints
---
### `POST /v1/audio/speech` (OpenAI Compatible)
A drop-in replacement for the [OpenAI Text-to-Speech API](https://platform.openai.com/docs/api-reference/audio/createSpeech). Synthesizes audio and streams the binary response directly.
* **HTTP Method:** `POST`
* **Description:** Allows integration with existing tools and libraries designed for OpenAI's TTS.
* **Request Body:**
* `model`: (string, required) The ID of the model/engine.
* Standard OpenAI IDs: `tts-1`, `tts-1-hd` (mapped to the first active local engine, e.g., Kokoro or XTTS).
* Local Engine IDs: `kokoro`, `xtts`, `piper`, `kokoro:en-us`, `xtts:v2`.
* `input`: (string, required) The text to generate audio for.
* `voice`: (string, required) The voice to use (maps to local `speaker`).
* `response_format`: (string, optional) `mp3`, `opus`, `aac`, `flac`, `wav`, `pcm`. Defaults to `mp3`.
* `speed`: (number, optional) Speed of generated audio (0.25 to 4.0). *Currently ignored by most local engines.*
* **Response:** Binary audio stream (content-type corresponds to `response_format`).
---
### `POST /tts`
Synthesizes text to speech using a specified engine and model.
* **HTTP Method:** `POST`
* **Description:** The main endpoint to synthesize text to speech. It accepts a `TTSRequest` body and can return audio either as a downloadable file or as a base64 encoded string.
* **Query Parameters:**
* `as_base64`:
* **Type:** `boolean`
* **Description:** If `true`, the audio will be returned as a base64 encoded string within a JSON response. If `false` (default), a JSON response with an `audio_url` for direct download will be returned.
* **Required:** No (default: `false`)
* **Request Body (`TTSRequest`):**
* **Description:** Defines the parameters for the text-to-speech synthesis.
* **Fields:**
* `text`:
* **Type:** `string`
* **Description:** The text to be synthesized.
* **Required:** Yes
* `engine`:
* **Type:** `string`
* **Description:** The name of the TTS engine to use (e.g., "piper", "kokoro").
* **Required:** Yes
* `model`:
* **Type:** `string`
* **Description:** The specific model to use within the chosen engine.
* **Required:** No (default: `null`)
* `speaker`:
* **Type:** `string`
**Description:** The speaker/voice to use for synthesis, if supported by the model/engine.
* **Required:** No (default: `null`)
* `format`:
* **Type:** `string`
* **Description:** The desired output audio format (e.g., "ogg", "wav", "mp3").
* **Required:** No (default: "ogg")
* `chunking`:
* **Type:** `boolean`
* **Description:** If `true`, the input text will be chunked into smaller pieces for synthesis, then concatenated. This can help with very long texts or engines with text length limitations.
* **Required:** No (default: `false`)
* **Responses:**
* **`200 OK` (Audio URL):**
```json
{
"engine": "piper",
"model": "en_GB-vctk-medium",
"speaker": "p225",
"format": "ogg",
"audio_url": "/audio/tts_some_unique_id.ogg",
"cached": true,
"chunking": false,
"message": "Audio served from cache. Download from audio_url"
}
```
* **Description:** Returned when `as_base64` is `false`. Contains a URL to download the synthesized audio file. `cached` indicates if the audio was served from the cache.
* **`200 OK` (Base64 Audio):**
```json
{
"engine": "piper",
"model": "en_GB-vctk-medium",
"speaker": "p225",
"format": "ogg",
"audio_base64": "data:audio/ogg;base64,...",
"chunking": false,
"message": "Audio from synth, base64 included"
}
```
* **Description:** Returned when `as_base64` is `true`. Contains the base64 encoded audio data directly in the response.
* **`400 Bad Request`:**
* **Description:** Returned if the provided model or speaker is not found for the selected engine, or if other input validation fails.
* **Example Body:** `{"detail": "Model 'invalid_model' not found for engine 'piper'. Available models: [...]"}`
* **`404 Not Found`:**
* **Description:** Returned if the specified engine is not found.
* **Example Body:** `{"detail": "Engine 'nonexistent_engine' not found."}`
* **`503 Service Unavailable`:**
* **Description:** Returned if the specified engine is not available or its health check fails.
* **Example Body:** `{"detail": "Engine 'piper' is not available. Status: initializing"}`
* **`500 Internal Server Error`:**
* **Description:** Returned if an unexpected error occurs during synthesis.
* **Example Body:** `{"detail": "Error during synthesis: some error message"}`
---
### `GET /audio/{filename}`
Retrieves a synthesized audio file from the cache.
* **HTTP Method:** `GET`
* **Description:** This endpoint allows direct download of audio files that were previously synthesized and cached. The `audio_url` provided by the `/tts` endpoint will typically point to this endpoint.
* **Path Parameters:**
* `filename`:
* **Type:** `string`
* **Description:** The full filename of the audio file to retrieve (e.g., `tts_some_unique_id.ogg`).
* **Required:** Yes
* **Query Parameters:** None
* **Request Body:** None
* **Responses:**
* **`200 OK` (Audio File):**
* **Content-Type:** `audio/wav`, `audio/ogg`, or `audio/mpeg` (depending on file extension)
* **Description:** The raw audio file bytes.
* **`404 Not Found`:**
* **Description:** Returned if the specified audio file does not exist in the cache.
* **Example Body:** `{"detail": "Audio file not found"}`
---
### `GET /engines`
Lists the currently active TTS engines and their health status.
* **HTTP Method:** `GET`
* **Description:** Returns a dictionary where keys are the names of the active engines and values are their respective health statuses.
* **Query Parameters:** None
* **Request Body:** None
* **Responses:**
* **`200 OK`:**
```json
{
"piper": {
"status": "ok",
"detail": "Engine is ready."
},
"kokoro": {
"status": "initializing",
"detail": "Models are loading..."
},
"styletts": {
"status": "ok",
"detail": "Engine is ready."
}
}
```
* **Description:** A JSON object detailing the health status of each active engine.
---
### `GET /models`
Lists the available models for each active TTS engine.
* **HTTP Method:** `GET`
* **Description:** Returns a dictionary where keys are engine names and values are lists of models available for that engine.
* **Query Parameters:** None
* **Request Body:** None
* **Responses:**
* **`200 OK`:**
```json
{
"piper": [
"en_US-kristin-medium",
"de_DE-thorsten-high",
"en_GB-vctk-medium"
],
"kokoro": [
"en-US-Standard-A",
"en-GB-Standard-B",
"ja-JP-Standard-C"
],
"styletts": [
"default"
]
}
```
* **Description:** A JSON object detailing the models available for each active engine. If an engine encounters an error while listing models, an "error" field will be present for that engine.
---
### `GET /speakers`
Lists the available speakers (voices) for a given engine and optionally a specific model.
* **HTTP Method:** `GET`
* **Description:** Retrieves a list of available speakers for a specified TTS engine. If a model is also specified, it will return speakers specific to that model.
* **Query Parameters:**
* `engine`:
* **Type:** `string`
* **Description:** The name of the TTS engine (e.g., "piper", "kokoro").
* **Required:** Yes
* `model`:
* **Type:** `string`
* **Description:** The specific model to query speakers for.
* **Required:** No
* **Request Body:** None
* **Responses:**
* **`200 OK`:**
```json
{
"engine": "piper",
"model": "en_GB-vctk-medium",
"speakers": [
"p225",
"p226",
"p227"
]
}
```
* **Description:** A JSON object containing the engine, model (if provided), and a list of available speakers.
* **`404 Not Found`:**
* **Description:** Returned if the specified engine is not found.
* **Example Body:** `{"detail": "Engine 'nonexistent_engine' not found."}`
---
### `GET /version`
Retrieves the current API version.
* **HTTP Method:** `GET`
* **Description:** Returns the version string of the AudioEngineHub API.
* **Query Parameters:** None
* **Request Body:** None
* **Responses:**
* **`200 OK`:**
```json
{
"version": "0.3.0"
}
```
* **Description:** A JSON object containing the API version.
---
### `GET /health`
Checks the health status of the API and all loaded engines.
* **HTTP Method:** `GET`
* **Description:** Provides an overview of the system's health, including the status of the API itself and each active TTS engine.
* **Query Parameters:** None
* **Request Body:** None
* **Responses:**
* **`200 OK`:**
```json
{
"status": {
"piper": "ok",
"kokoro": "ok",
"styletts": "ok"
},
"detail": "API and engines loaded"
}
```
* **Description:** A JSON object indicating the overall status (`detail`) and the individual health status of each active engine.

View File

@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
AudioEngineHub is a local-first, modular, multi-engine Text-to-Speech (TTS) server built with FastAPI. It provides a unified API to interact with various TTS engines (Piper, StyleTTS, ChatTTS, F5-TTS) with caching, audio format conversion, and Docker deployment support.
AudioEngineHub is a local-first, modular, multi-engine Text-to-Speech (TTS) server built with FastAPI. It provides a unified API to interact with various TTS engines (Piper, Kokoro, StyleTTS, ChatTTS, F5-TTS) with caching, audio format conversion, and Docker deployment support.
## Architecture
@ -14,7 +14,7 @@ AudioEngineHub is a local-first, modular, multi-engine Text-to-Speech (TTS) serv
- Uses `create_app()` factory pattern to dynamically load engines based on configuration
- Maintains `app.ENGINE_REGISTRY` dict mapping engine names to instantiated engine objects
- Engines are loaded from `settings.ACTIVE_ENGINES` at startup
- All available engines defined in `ALL_ENGINES` dict (lines 34-39)
- All available engines defined in `ALL_ENGINES` dict (lines 35-41)
- Startup event checks for models directory availability with retry logic (lines 192-213)
**Engine Architecture (`app/engines/`)**
@ -206,12 +206,80 @@ Engine implementations use absolute path `/models/piper/` not relative paths. Th
**Currently Working:**
- `piper`: Functional, uses real Piper TTS executable with ONNX models
- `kokoro`: Fully functional, uses Kokoro-82M TTS library with 54 voices across 8 languages
**Dummy Implementations (for testing only):**
- `styletts`: Returns hardcoded model/voice lists, generates dummy audio
- `chattts`: Dummy implementation
- `f5-tts`: Implemented but may be inactive by default
### Kokoro Engine (`app/engines/kokoro.py`)
**Overview:**
- Uses `kokoro` Python library (KPipeline) for high-performance TTS synthesis
- 82M parameter model delivering ~90× real-time performance on RTX 3090 Ti
- 54 voices across 8 languages: EN-US, EN-GB, FR, ES, JA, ZH, IT, PT, HI, KO
- Outputs 24kHz audio natively (WAV), converts to OGG/MP3 via ffmpeg
- Model size: ~200MB per language, auto-downloaded from Hugging Face on first use
- Voice metadata: `app/engines/kokoro_voices.py` contains all 54 voices with metadata
**Architecture:**
```python
class KokoroEngine(TTSEngineBase):
def __init__(self):
# GPU/CPU detection via settings.KOKORO_DEVICE
# Pipeline instances cached per language code
async def synthesize(text, speaker, model, fmt):
# Uses KPipeline for synthesis (24kHz native output)
# Format conversion via ffmpeg (_run_ffmpeg_blocking)
# Returns temp file path to generated audio
def list_models(self):
# Returns 10 language models:
# kokoro-en-us, kokoro-en-gb, kokoro-fr, kokoro-es,
# kokoro-ja, kokoro-zh, kokoro-it, kokoro-pt, kokoro-hi, kokoro-ko
def list_voices(self, model=None):
# Returns all 54 voices or filtered by language
# Uses get_voices_for_model() from kokoro_voices.py
```
**Language Code Mapping:**
The engine maps model names to Kokoro's internal language codes:
- `kokoro-en-us` → `'a'` (American English)
- `kokoro-en-gb` → `'b'` (British English)
- `kokoro-fr` → `'fr'` (French)
- `kokoro-es` → `'es'` (Spanish)
- `kokoro-ja` → `'ja'` (Japanese)
- `kokoro-zh` → `'zh'` (Chinese)
- `kokoro-it` → `'it'` (Italian)
- `kokoro-pt` → `'pt'` (Portuguese)
- `kokoro-hi` → `'hi'` (Hindi)
- `kokoro-ko` → `'ko'` (Korean)
**Voice Organization (`app/engines/kokoro_voices.py`):**
- All 54 voices documented with metadata (gender, language, description)
- Naming convention: `{language}{gender}_{name}` (e.g., `af_bella`, `am_adam`)
- Popular voices: `af_bella`, `af_sarah`, `af_sky`, `am_adam`, `am_michael`
- Helper functions: `get_voices_for_model()`, `get_voice_info()`
**Model Download & Caching:**
- Models auto-download from Hugging Face on first synthesis
- Cached in `~/.cache/huggingface/` (inside container)
- First synthesis may take 30-60s due to download + compilation
- Subsequent syntheses are fast (~90× real-time on GPU)
**Configuration (`app/config.py`):**
- `KOKORO_DEVICE`: "cuda" or "cpu" (default: "cuda")
- `KOKORO_TIMEOUT_SECONDS`: Synthesis timeout (default: 30)
**Error Handling:**
- Applies all bug fixes from Piper engine (timeouts, temp file cleanup, logging)
- Graceful GPU fallback if CUDA unavailable
- Voice validation before synthesis
- Comprehensive error logging with context
### Debugging Tips
**Port Conflicts**: The Makefile automatically finds free ports starting from 8000. Run with `sudo make dev-up` for most reliable port detection.

View File

@ -1,42 +1,56 @@
# Stage 1: Builder
FROM python:3.11 as builder
# Stage 1: Builder (for heavy Python dependencies including CUDA-enabled PyTorch)
FROM nvidia/cuda:12.1.0-devel-ubuntu22.04 as builder
WORKDIR /opt/venv
# Create a virtual environment and install dependencies
COPY requirements.txt .
RUN python -m venv . && . /opt/venv/bin/activate && pip install --no-cache-dir -r requirements.txt
# Install Python 3.11 and venv in the builder stage
RUN apt-get update && apt-get install -y python3.11 python3.11-venv
# Stage 2: Runner (The final image)
# Create a virtual environment and install Python dependencies
COPY requirements.txt .
COPY patches/ ./patches/
RUN python3.11 -m venv . && . /opt/venv/bin/activate && pip install --no-cache-dir -r requirements.txt && \
chmod +x ./patches/fix-misaki-espeak.sh && ./patches/fix-misaki-espeak.sh
# Stage 2: Runner (The final slim image)
FROM python:3.11-slim
# Install system dependencies needed at runtime
# ffmpeg is required for audio conversion
# espeak-ng is required for Kokoro TTS phonemization
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
espeak-ng \
&& rm -rf /var/lib/apt/lists/*
# Create an unprivileged user
RUN useradd --create-home --shell /bin/bash appuser
# Set working directory for the application code
WORKDIR /home/appuser
# Copy the virtual environment from the builder stage
# Copy the entire virtual environment from the builder stage
COPY --from=builder /opt/venv /opt/venv
# Add venv to PATH and PYTHONPATH so packages and scripts work correctly
ENV PATH="/opt/venv/bin:$PATH"
ENV PYTHONPATH="/opt/venv/lib/python3.11/site-packages:$PYTHONPATH"
# Create espeak-ng-data symlink for misaki compatibility
RUN mkdir -p /home/runner/work/espeakng-loader/espeakng-loader/espeak-ng/_dynamic/share/ && \
ln -sf /usr/lib/x86_64-linux-gnu/espeak-ng-data /home/runner/work/espeakng-loader/espeakng-loader/espeak-ng/_dynamic/share/espeak-ng-data
# Copy the application code into the container
COPY app/ ./app
# Set the PATH to include the virtual environment's bin directory
ENV PATH="/opt/venv/bin:$PATH"
# Expose the application port
EXPOSE 8000
# Run as the unprivileged user
USER appuser
# Command to run the application using gunicorn for production
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", "-w", "2", "-b", "0.0.0.0:8000", "app.main:app"]
# Command to run the application using uvicorn for development with auto-reloading
CMD ["python3.11", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "debug"]

View File

@ -0,0 +1,492 @@
# Kokoro TTS Engine Implementation Plan
## Overview
Implement Kokoro-82M as a new TTS engine in AudioEngineHub. Kokoro is a lightweight, high-performance open-weight TTS model with 82 million parameters that delivers quality comparable to models 5-15× its size.
## Background Research
### Key Features
- **Size**: 82 million parameters (extremely lightweight)
- **Performance**: ~210× real-time on RTX 4090, ~90× real-time on RTX 3090 Ti
- **Quality**: Took first place in TTS Spaces Arena, outperforming XTTS v2 (467M) and MetaVoice (1.2B)
- **Audio**: 24kHz high-fidelity output
- **License**: Apache 2.0 (open-source, commercial use allowed)
- **Languages**: 8 languages (English US/UK, French, Spanish, Japanese, Chinese, Italian, Portuguese, Hindi, Korean)
- **Voices**: 54 voices available
### Sources
- [Kokoro-82M Hugging Face](https://huggingface.co/hexgrad/Kokoro-82M)
- [Kokoro TTS Official Site](https://kokorotts.net/)
- [VOICES.md - Complete Voice List](https://huggingface.co/hexgrad/Kokoro-82M/blob/main/VOICES.md)
- [GitHub Repository](https://github.com/hexgrad/kokoro)
- [Analytics Vidhya Article](https://www.analyticsvidhya.com/blog/2025/01/kokoro-82m/)
---
## Implementation Strategy
### Phase 1: Research & Setup
#### 1.1 Model Investigation
- [x] Research Kokoro TTS architecture and capabilities
- [x] Identify Python library: `kokoro>=0.9.2`
- [x] Document voice list (54 voices across 8 languages)
- [ ] Test Kokoro locally to understand API
#### 1.2 Dependency Analysis
**Required packages:**
```bash
kokoro>=0.9.2
soundfile
phonemizer
torch
transformers
scipy
munch
```
**System dependencies:**
```bash
espeak-ng # Required for phonemization
```
---
### Phase 2: Engine Implementation
#### 2.1 Create `app/engines/kokoro.py`
**Architecture:**
```python
class KokoroEngine(TTSEngineBase):
def __init__(self):
# Initialize Kokoro pipeline
# Handle GPU/CPU detection
# Load model from Hugging Face
async def synthesize(text, speaker, model, fmt):
# Generate audio using KPipeline
# Handle voice selection
# Convert to requested format (wav/ogg/mp3)
# Return temp file path
def list_models(self):
# Return available language models
# Options: 'a' (American English), 'b' (British English), etc.
def list_voices(self, model):
# Return 54 available voices
# Filter by language if model specified
def healthcheck(self):
# Check if kokoro library is available
# Verify model is loaded
# Return status
async def selftest(self):
# Run quick synthesis test
# Verify audio generation works
```
**Key Implementation Details:**
1. **Model Selection:**
- Kokoro uses `lang_code` parameter (e.g., 'a' = American English, 'b' = British English)
- Map this to "models" concept in our API
- Models: `kokoro-en-us`, `kokoro-en-gb`, `kokoro-fr`, `kokoro-es`, `kokoro-ja`, `kokoro-zh`, `kokoro-it`, `kokoro-pt`, `kokoro-hi`, `kokoro-ko`
2. **Voice Selection:**
- 54 voices available (see VOICES.md)
- Popular voices: `af_alloy`, `af_bella`, `af_sarah`, `af_sky`, `af_nova`, etc.
- Each voice has quality grade and language support
3. **Audio Generation:**
```python
from kokoro import KPipeline
pipeline = KPipeline(lang_code='a')
generator = pipeline(text, voice='af_heart')
for gs, ps, audio in generator:
# audio is numpy array at 24kHz
# Save to temp file
```
4. **Format Conversion:**
- Native output: 24kHz WAV
- Use ffmpeg (already available) for OGG/MP3 conversion
- Reuse `_run_ffmpeg_blocking()` pattern from Piper engine
5. **Error Handling:**
- Apply all bug fixes from Piper engine (timeouts, temp file cleanup, logging)
- Handle GPU out-of-memory gracefully (fallback to CPU)
- Validate voice exists before synthesis
---
### Phase 3: Docker Integration
#### 3.1 Update Dockerfile
**Add dependencies to `Dockerfile`:**
```dockerfile
# Install espeak-ng for Kokoro phonemization
RUN apt-get update && \
apt-get install -y --no-install-recommends espeak-ng && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Install Kokoro Python dependencies
RUN pip install --no-cache-dir \
kokoro>=0.9.2 \
soundfile \
phonemizer \
torch \
transformers \
scipy \
munch
```
#### 3.2 Model Download Strategy
**Options:**
**Option A: Download on first use (lazy loading)**
- Models auto-download from Hugging Face (~200MB per model)
- Advantage: No pre-download needed
- Disadvantage: First synthesis will be slow
**Option B: Pre-download in Docker build**
```dockerfile
# Pre-download Kokoro model during build
RUN python3 -c "from kokoro import KPipeline; KPipeline(lang_code='a')"
```
**Option C: Volume mount like Piper**
```yaml
# docker-compose.yml
volumes:
- ./models/kokoro:/home/appuser/.cache/huggingface
```
**Recommendation**: Option A for MVP, Option C for production
---
### Phase 4: Configuration
#### 4.1 Update `app/config.py`
```python
class Settings(BaseSettings):
# ... existing settings ...
# Kokoro Engine Configuration
KOKORO_DEVICE: str = "cuda" # or "cpu"
KOKORO_TIMEOUT_SECONDS: int = 30
KOKORO_DEFAULT_LANG: str = "a" # American English
```
#### 4.2 Update `.env.example`
```bash
# Kokoro Engine Configuration
KOKORO_DEVICE=cuda # cuda or cpu
KOKORO_TIMEOUT_SECONDS=30
KOKORO_DEFAULT_LANG=a
```
#### 4.3 Update `app/main.py`
```python
ALL_ENGINES = {
"piper": PiperEngine,
"styletts": StyleTTSEngine,
"chattts": ChatTTSEngine,
"f5-tts": F5TTSEngine,
"kokoro": KokoroEngine, # Add this
}
```
---
### Phase 5: Voice Metadata
#### 5.1 Create `app/engines/kokoro_voices.py`
Store voice metadata for better UX:
```python
KOKORO_VOICES = {
"af_alloy": {
"gender": "F",
"language": ["en-us"],
"quality": "high",
"description": "Clear, professional female voice"
},
"af_bella": {
"gender": "F",
"language": ["en-us", "en-gb"],
"quality": "high",
"description": "Warm, expressive female voice"
},
# ... all 54 voices
}
```
Download full voice list from: https://huggingface.co/hexgrad/Kokoro-82M/blob/main/VOICES.md
---
### Phase 6: Testing
#### 6.1 Unit Tests
Create `tests/test_kokoro_engine.py`:
```python
import pytest
from app.engines.kokoro import KokoroEngine
@pytest.mark.asyncio
async def test_kokoro_synthesis():
engine = KokoroEngine()
audio_path = await engine.synthesize(
"Hello, this is Kokoro TTS.",
speaker="af_bella",
model="kokoro-en-us",
fmt="wav"
)
assert os.path.exists(audio_path)
assert os.path.getsize(audio_path) > 0
def test_kokoro_list_voices():
engine = KokoroEngine()
voices = engine.list_voices("kokoro-en-us")
assert len(voices) > 0
assert "af_bella" in voices
def test_kokoro_healthcheck():
engine = KokoroEngine()
health = engine.healthcheck()
assert health["status"] in ["ok", "not_available"]
```
#### 6.2 Integration Tests
```bash
# Test via API
curl -X POST http://localhost:8000/tts \
-H "Content-Type: application/json" \
-d '{
"text": "Hello from Kokoro TTS!",
"engine": "kokoro",
"model": "kokoro-en-us",
"speaker": "af_bella",
"format": "ogg"
}'
```
#### 6.3 Performance Benchmarks
Test synthesis speed:
- Short text (10 words): Target <0.5s on RTX 3090
- Medium text (100 words): Target <2s on RTX 3090
- Long text (1000 words): Target <15s on RTX 3090
---
### Phase 7: Documentation
#### 7.1 Update README.md
Add Kokoro to supported engines:
```markdown
## Supported TTS Engines
- **Piper** - Fast, lightweight, ONNX-based TTS
- **Kokoro** - 82M parameter high-quality TTS (NEW!)
- **StyleTTS** - Expressive style-based TTS (planned)
- **ChatTTS** - Conversational TTS (planned)
- **F5-TTS** - Advanced flow-based TTS (planned)
```
#### 7.2 Update CLAUDE.md
Add Kokoro engine details:
```markdown
### Kokoro Engine (`app/engines/kokoro.py`)
- Uses `kokoro` Python library (KPipeline)
- 54 voices across 8 languages
- Outputs 24kHz audio natively
- Extremely fast (~90x real-time on consumer GPU)
- Model size: 82M parameters (~200MB download)
```
#### 7.3 Create Kokoro Usage Guide
Create `docs/kokoro-guide.md`:
- Voice selection guide
- Language support matrix
- Performance optimization tips
- GPU vs CPU mode comparison
- Troubleshooting common issues
---
## Implementation Checklist
### Phase 1: Research & Setup
- [x] Research Kokoro capabilities
- [x] Document API and dependencies
- [ ] Test Kokoro locally outside Docker
### Phase 2: Engine Implementation
- [ ] Create `app/engines/kokoro.py`
- [ ] Implement `synthesize()` method
- [ ] Implement `list_models()` method
- [ ] Implement `list_voices()` method
- [ ] Implement `healthcheck()` method
- [ ] Implement `selftest()` method
- [ ] Create `app/engines/kokoro_voices.py` metadata file
### Phase 3: Docker Integration
- [ ] Update Dockerfile with dependencies
- [ ] Add espeak-ng system package
- [ ] Add Kokoro Python packages
- [ ] Test Docker build
- [ ] Verify GPU access in container
### Phase 4: Configuration
- [ ] Update `app/config.py` with Kokoro settings
- [ ] Update `.env.example` with Kokoro variables
- [ ] Add KokoroEngine to `app/main.py` ALL_ENGINES
- [ ] Update active engines in `.env`
### Phase 5: Testing
- [ ] Create unit tests
- [ ] Test synthesis with various voices
- [ ] Test format conversion (wav/ogg/mp3)
- [ ] Test caching behavior
- [ ] Performance benchmarks
- [ ] Memory usage profiling
### Phase 6: Documentation
- [ ] Update README.md
- [ ] Update CLAUDE.md
- [ ] Create Kokoro usage guide
- [ ] Document voice selection
- [ ] Add troubleshooting section
### Phase 7: Deployment
- [ ] Test in development environment
- [ ] Rebuild Docker image
- [ ] Update docker-compose.yml if needed
- [ ] Test API endpoints
- [ ] Commit changes to git
- [ ] Tag release
---
## Key Decisions
### 1. Model Organization
**Decision**: Map Kokoro lang_codes to model names
- `kokoro-en-us` → lang_code='a'
- `kokoro-en-gb` → lang_code='b'
- etc.
**Rationale**: Maintains consistency with existing API structure
### 2. Voice Naming
**Decision**: Use Kokoro's native voice names (e.g., `af_bella`)
**Rationale**:
- Avoids confusion with remapping
- Documented in official VOICES.md
- Users can reference official docs
### 3. GPU Support
**Decision**: Support both GPU and CPU with fallback
**Rationale**:
- GPU provides 90x real-time performance
- CPU fallback ensures it works on all systems
- Configurable via KOKORO_DEVICE env var
### 4. Model Download Strategy
**Decision**: Lazy loading on first use
**Rationale**:
- Smaller Docker image
- Only download models that are actually used
- Can switch to pre-download later if needed
---
## Potential Issues & Solutions
### Issue 1: Large Model Download
**Problem**: Model is ~200MB per language
**Solution**:
- Lazy loading (download on first use)
- Cache in Docker volume
- Document expected download time
### Issue 2: GPU Memory Usage
**Problem**: May require 2-4GB VRAM
**Solution**:
- Implement memory monitoring
- Graceful fallback to CPU
- Document GPU requirements
### Issue 3: First Synthesis Slow
**Problem**: Model loading + compilation takes time
**Solution**:
- Warm up during healthcheck
- Keep model loaded in memory
- Document expected first-run delay
### Issue 4: Voice Compatibility
**Problem**: Not all voices work with all languages
**Solution**:
- Validate voice-language compatibility
- Return clear error messages
- Document voice language support
---
## Success Criteria
- [ ] Kokoro engine passes all unit tests
- [ ] API endpoints work correctly (`/tts`, `/models`, `/speakers`)
- [ ] Audio quality matches expected output
- [ ] Synthesis speed: >50x real-time on RTX 3090
- [ ] Memory usage: <4GB VRAM for single synthesis
- [ ] Cache versioning works correctly
- [ ] No temp file leaks
- [ ] No hung processes
- [ ] Documentation complete and accurate
- [ ] Voice wizard integration works
---
## Timeline Estimate
**Total**: 8-12 hours
- **Phase 1 (Research)**: ✅ Complete (2 hours)
- **Phase 2 (Implementation)**: 3-4 hours
- **Phase 3 (Docker)**: 1-2 hours
- **Phase 4 (Config)**: 0.5 hours
- **Phase 5 (Testing)**: 2-3 hours
- **Phase 6 (Documentation)**: 1-2 hours
- **Phase 7 (Deployment)**: 1 hour
---
## References
- [Kokoro-82M Hugging Face](https://huggingface.co/hexgrad/Kokoro-82M)
- [Kokoro GitHub Repository](https://github.com/hexgrad/kokoro)
- [VOICES.md - Complete Voice List](https://huggingface.co/hexgrad/Kokoro-82M/blob/main/VOICES.md)
- [Kokoro TTS Official Website](https://kokorotts.net/)
- [Kokoro-82M Analytics Vidhya Article](https://www.analyticsvidhya.com/blog/2025/01/kokoro-82m/)
- [Kokoro TTS Live Demo](https://huggingface.co/spaces/hexgrad/Kokoro-TTS)

View File

@ -0,0 +1,536 @@
# Kokoro TTS Implementation Status
**Date Started**: 2025-12-05
**Last Updated**: 2025-12-05 00:48 CET
**Status**: 🟡 In Progress (Phase 2 & 3 partially complete)
---
## Quick Summary
We are implementing Kokoro-82M TTS engine as a new engine in AudioEngineHub. Kokoro is a lightweight 82M parameter model that delivers quality comparable to models 5-15× its size, with ~90× real-time performance on consumer GPUs.
**Progress**: ~40% complete
- ✅ Phase 1: Research & Planning (100%)
- ✅ Phase 2: Engine Implementation (100%)
- 🟡 Phase 3: Docker Integration (50%)
- ⏳ Phase 4: Configuration (0%)
- ⏳ Phase 5: Testing (0%)
- ⏳ Phase 6: Documentation (0%)
---
## What We've Completed
### ✅ Phase 1: Research & Planning
- [x] Researched Kokoro TTS capabilities and architecture
- [x] Identified Python library (`kokoro>=0.9.2`)
- [x] Documented all 54 voices across 8 languages
- [x] Created comprehensive implementation plan in `KOKORO_IMPLEMENTATION_PLAN.md`
**Key Findings:**
- Kokoro uses 82M parameters (extremely lightweight)
- Performance: ~90× real-time on RTX 3090 Ti, ~210× on RTX 4090
- Output: 24kHz high-fidelity audio
- License: Apache 2.0 (open-source, commercial use allowed)
- 54 voices across 8 languages (EN-US, EN-GB, JA, ZH, ES, FR, HI, IT, PT)
### ✅ Phase 2: Engine Implementation (Complete)
- [x] **Created `app/engines/kokoro.py`** - Full engine implementation with:
- Complete `KokoroEngine` class implementing `TTSEngineBase`
- `synthesize()` method with all bug fixes from Piper (timeouts, cleanup, logging)
- GPU/CPU support with automatic fallback
- Language code mapping (10 models: kokoro-en-us, kokoro-en-gb, etc.)
- Format conversion (WAV/OGG/MP3) via ffmpeg
- Comprehensive error handling and logging
- Model pipeline caching for performance
- [x] **Created `app/engines/kokoro_voices.py`** - Voice metadata with:
- Complete list of all 54 Kokoro voices
- Voice metadata (gender, language, description)
- Language-based voice filtering
- Helper functions: `get_voices_for_model()`, `get_voice_info()`
**File Locations:**
- `/home/stephan/Projekte/KI/AudioEngineHub/app/engines/kokoro.py` (316 lines)
- `/home/stephan/Projekte/KI/AudioEngineHub/app/engines/kokoro_voices.py` (222 lines)
### 🟡 Phase 3: Docker Integration (50% Complete)
- [x] **Updated `Dockerfile`** - Added espeak-ng system dependency
- Line 17: Added `espeak-ng` to apt-get install
- [ ] **Update `requirements.txt`** - Need to add Kokoro Python dependencies
- **NEXT STEP**: Add these lines to requirements.txt:
```
# Kokoro TTS Engine
kokoro>=0.9.2
soundfile
phonemizer
scipy
munch
```
---
## What's Left To Do
### ⏳ Phase 4: Configuration (Not Started)
#### 1. Update `app/config.py`
Add Kokoro configuration settings:
```python
class Settings(BaseSettings):
# ... existing settings ...
# Kokoro Engine Configuration
KOKORO_DEVICE: str = "cuda" # or "cpu"
KOKORO_TIMEOUT_SECONDS: int = 30
```
**File**: `/home/stephan/Projekte/KI/AudioEngineHub/app/config.py`
**Location**: Add after line 25 (after FFMPEG_TIMEOUT_SECONDS)
#### 2. Update `app/main.py`
Register Kokoro engine in the engine registry:
```python
from app.engines.kokoro import KokoroEngine # Add this import
ALL_ENGINES = {
"piper": PiperEngine,
"styletts": StyleTTSEngine,
"chattts": ChatTTSEngine,
"f5-tts": F5TTSEngine,
"kokoro": KokoroEngine, # Add this line
}
```
**File**: `/home/stephan/Projekte/KI/AudioEngineHub/app/main.py`
**Location**: Line 28 (import), Line 38 (registry)
#### 3. Update `.env.example`
Add Kokoro configuration documentation:
```bash
# Kokoro Engine Configuration
KOKORO_DEVICE=cuda # cuda or cpu
KOKORO_TIMEOUT_SECONDS=30
```
**File**: `/home/stephan/Projekte/KI/AudioEngineHub/.env.example`
**Location**: Add after FFMPEG_TIMEOUT_SECONDS
#### 4. Optional: Update `.env`
To enable Kokoro by default:
```bash
ACTIVE_ENGINES='["piper", "kokoro"]'
```
---
### ⏳ Phase 5: Testing (Not Started)
#### 1. Test Import and Basic Functionality
```bash
# Test engine loads without errors
python3 -c "from app.engines.kokoro import KokoroEngine; print(KokoroEngine().healthcheck())"
```
Expected output:
```json
{
"status": "not_available", # OK if kokoro not installed yet
"engine": "kokoro",
"library_available": false,
"error": "Kokoro library not installed..."
}
```
#### 2. Install Kokoro Dependencies (Outside Docker First)
```bash
# Install espeak-ng
sudo apt-get install espeak-ng
# Install Python packages
pip install kokoro>=0.9.2 soundfile phonemizer scipy munch
```
#### 3. Test Synthesis Locally
```bash
# Run engine selftest
cd /home/stephan/Projekte/KI/AudioEngineHub
python3 app/engines/kokoro.py
```
Expected output:
```
Healthcheck: {'status': 'ok', 'engine': 'kokoro', ...}
Models: ['kokoro-en-us', 'kokoro-en-gb', ...]
Voices: ['af_alloy', 'af_aoede', 'af_bella', ...] ...
Selftest: {'selftest': True, 'models': [...], 'voices_count': 54}
```
#### 4. Test via API
```bash
# Start development server
make dev-up
# Test API endpoint
curl -X POST http://localhost:8000/tts \
-H "Content-Type: application/json" \
-d '{"text":"Hello from Kokoro!","engine":"kokoro","model":"kokoro-en-us","speaker":"af_bella","format":"ogg"}'
```
#### 5. Test with Voice Wizard
```bash
# Launch voice wizard and test preview with Kokoro voices
cd /home/stephan/Games/University/tts
./scripts/voice-assignment-wizard.sh
```
---
### ⏳ Phase 6: Documentation (Not Started)
#### 1. Update `README.md`
Add Kokoro to supported engines list:
```markdown
## Supported TTS Engines
- **Piper** - Fast, lightweight, ONNX-based TTS with 100+ voices
- **Kokoro** - 82M parameter high-quality TTS with 54 voices across 8 languages 🆕
- **StyleTTS** - Expressive style-based TTS (coming soon)
- **ChatTTS** - Conversational TTS (coming soon)
- **F5-TTS** - Advanced flow-based TTS (coming soon)
```
#### 2. Update `CLAUDE.md`
Add Kokoro engine section:
```markdown
### Kokoro Engine (`app/engines/kokoro.py`)
- **Library**: `kokoro` Python package (KPipeline)
- **Models**: 10 language models (en-us, en-gb, ja, zh, es, fr, hi, it, pt, ko)
- **Voices**: 54 voices with gender and language metadata
- **Output**: 24kHz audio natively (WAV), converts to OGG/MP3 via ffmpeg
- **Performance**: ~90× real-time on RTX 3090 Ti
- **Model Size**: 82M parameters (~200MB download per language)
- **Voice Metadata**: `app/engines/kokoro_voices.py` contains all 54 voices with metadata
```
#### 3. Create Usage Guide (Optional)
Create `docs/kokoro-usage.md` with:
- Voice selection guide
- Language support matrix
- Performance tips (GPU vs CPU)
- Troubleshooting common issues
---
## Implementation Checklist
### Phase 1: Research ✅
- [x] Research Kokoro capabilities
- [x] Document API and dependencies
- [x] Create implementation plan
### Phase 2: Engine Implementation ✅
- [x] Create `app/engines/kokoro.py`
- [x] Implement `synthesize()` method
- [x] Implement `list_models()` method
- [x] Implement `list_voices()` method
- [x] Implement `healthcheck()` method
- [x] Implement `selftest()` method
- [x] Create `app/engines/kokoro_voices.py`
### Phase 3: Docker Integration 🟡
- [x] Update Dockerfile (added espeak-ng)
- [ ] **→ NEXT: Update requirements.txt (add Kokoro packages)**
- [ ] Test Docker build
- [ ] Verify GPU access in container (if available)
### Phase 4: Configuration ⏳
- [ ] Update `app/config.py` with Kokoro settings
- [ ] Update `.env.example` with Kokoro variables
- [ ] Add KokoroEngine import to `app/main.py`
- [ ] Add Kokoro to ALL_ENGINES in `app/main.py`
- [ ] Optional: Update `.env` to enable Kokoro
### Phase 5: Testing ⏳
- [ ] Test engine imports without errors
- [ ] Install Kokoro dependencies locally
- [ ] Run selftest (`python3 app/engines/kokoro.py`)
- [ ] Test synthesis via API
- [ ] Test with voice wizard
- [ ] Verify caching works correctly
- [ ] Check for temp file leaks
- [ ] Performance benchmarks
### Phase 6: Documentation ⏳
- [ ] Update README.md
- [ ] Update CLAUDE.md
- [ ] Optional: Create Kokoro usage guide
- [ ] Document voice selection
- [ ] Add troubleshooting section
### Phase 7: Deployment ⏳
- [ ] Rebuild Docker image
- [ ] Test in development environment
- [ ] Commit changes to git
- [ ] Push to remote
- [ ] Tag release (optional)
---
## Files Created/Modified
### ✅ Created Files
1. **`KOKORO_IMPLEMENTATION_PLAN.md`** - Complete implementation plan (280 lines)
2. **`app/engines/kokoro.py`** - Kokoro engine implementation (316 lines)
3. **`app/engines/kokoro_voices.py`** - Voice metadata (222 lines)
4. **`KOKORO_IMPLEMENTATION_STATUS.md`** - This file
### ✅ Modified Files
1. **`Dockerfile`** - Added espeak-ng dependency (line 17)
### ⏳ Files To Modify
1. **`requirements.txt`** - Add Kokoro Python packages
2. **`app/config.py`** - Add Kokoro configuration
3. **`app/main.py`** - Register Kokoro engine
4. **`.env.example`** - Document Kokoro config
5. **`README.md`** - Add Kokoro to engines list
6. **`CLAUDE.md`** - Add Kokoro engine details
---
## Next Session: Action Plan
### Step 1: Complete Docker Integration (5 minutes)
```bash
cd /home/stephan/Projekte/KI/AudioEngineHub
# Add to requirements.txt (after line 11):
cat >> requirements.txt << 'EOF'
# Kokoro TTS Engine
kokoro>=0.9.2
soundfile
phonemizer
scipy
munch
EOF
```
### Step 2: Complete Configuration (10 minutes)
1. Edit `app/config.py` - add KOKORO_DEVICE and KOKORO_TIMEOUT_SECONDS
2. Edit `app/main.py` - import KokoroEngine and add to ALL_ENGINES
3. Edit `.env.example` - document new config options
### Step 3: Test Locally (15 minutes)
1. Install dependencies: `sudo apt-get install espeak-ng && pip install kokoro>=0.9.2 soundfile phonemizer scipy munch`
2. Run selftest: `python3 app/engines/kokoro.py`
3. Start server: `make dev-up` (will rebuild Docker image)
4. Test API synthesis with Kokoro
5. Test voice wizard preview
### Step 4: Documentation (10 minutes)
1. Update README.md with Kokoro
2. Update CLAUDE.md with Kokoro engine details
3. Commit all changes to git
### Total Time Remaining: ~40 minutes
---
## Key Technical Details
### Language Code Mapping
```python
KOKORO_LANG_CODES = {
"kokoro-en-us": "a", # American English
"kokoro-en-gb": "b", # British English
"kokoro-fr": "fr", # French
"kokoro-es": "es", # Spanish
"kokoro-ja": "ja", # Japanese
"kokoro-zh": "zh", # Chinese
"kokoro-it": "it", # Italian
"kokoro-pt": "pt", # Portuguese
"kokoro-hi": "hi", # Hindi
"kokoro-ko": "ko", # Korean
}
```
### Voice Organization
- **54 total voices** across 8 languages
- **Naming convention**: `{language}{gender}_{name}`
- `af_` = American Female
- `am_` = American Male
- `bf_` = British Female
- `bm_` = British Male
- `jf_/jm_` = Japanese F/M
- `zf_/zm_` = Chinese F/M
- etc.
- **Most popular**: af_bella, af_sarah, af_sky, am_adam, am_michael
### API Usage Example
```json
{
"text": "Hello from Kokoro TTS!",
"engine": "kokoro",
"model": "kokoro-en-us",
"speaker": "af_bella",
"format": "ogg"
}
```
---
## Known Issues & Considerations
### 1. Model Download on First Use
- **Issue**: First synthesis will be slow (~30-60s) due to model download
- **Size**: ~200MB per language model
- **Location**: Models cached in `~/.cache/huggingface/`
- **Solution**: Expected behavior, document in README
### 2. GPU vs CPU Performance
- **GPU**: ~90× real-time (RTX 3090 Ti)
- **CPU**: ~5-10× real-time (estimated)
- **Fallback**: Code supports both, configurable via KOKORO_DEVICE env var
### 3. Voice-Language Compatibility
- **Issue**: Not all voices work with all languages
- **Solution**: `kokoro_voices.py` filters voices by language
- **API**: `/speakers?engine=kokoro&model=kokoro-en-us` returns only compatible voices
### 4. Dependencies
- **espeak-ng**: Required system package for phonemization
- **soundfile**: Required for WAV file I/O
- **phonemizer**: Required for text-to-phoneme conversion
- **scipy, munch**: Required by Kokoro library
---
## Testing Commands Reference
### Local Testing (Outside Docker)
```bash
# Install system dependency
sudo apt-get install espeak-ng
# Install Python packages
pip install kokoro>=0.9.2 soundfile phonemizer scipy munch
# Test engine
cd /home/stephan/Projekte/KI/AudioEngineHub
python3 app/engines/kokoro.py
# Test voice metadata
python3 -c "from app.engines.kokoro_voices import ALL_VOICES, get_voices_for_model; print(f'Total voices: {len(ALL_VOICES)}'); print(f'EN-US voices: {get_voices_for_model(\"kokoro-en-us\")}')"
```
### Docker Testing
```bash
# Rebuild and start
make down
make dev-up
# Check logs
make logs
# Health check
make health-check
# Test synthesis
curl -X POST http://localhost:8000/tts \
-H "Content-Type: application/json" \
-d '{"text":"Testing Kokoro TTS engine","engine":"kokoro","model":"kokoro-en-us","speaker":"af_bella","format":"ogg"}' | jq
```
### Voice Wizard Testing
```bash
cd /home/stephan/Games/University/tts
./scripts/voice-assignment-wizard.sh
# Should show Kokoro engine in dropdown
# Should list 54 voices (or subset by language)
# Preview should work with all Kokoro voices
```
---
## Success Criteria
Before marking this implementation as complete, verify:
- [ ] Kokoro engine passes healthcheck
- [ ] All 10 language models are listed in `/models` endpoint
- [ ] 54 voices are accessible via `/speakers` endpoint
- [ ] Voice filtering by model/language works correctly
- [ ] Synthesis produces valid audio files
- [ ] Synthesis speed: >50× real-time on GPU (if available)
- [ ] Format conversion works (WAV/OGG/MP3)
- [ ] Cache versioning works correctly
- [ ] No temp file leaks after synthesis
- [ ] No hung processes under load
- [ ] Voice wizard integration works
- [ ] Documentation is complete and accurate
---
## Resources & References
### Official Documentation
- [Kokoro-82M Hugging Face](https://huggingface.co/hexgrad/Kokoro-82M)
- [VOICES.md - Complete Voice List](https://huggingface.co/hexgrad/Kokoro-82M/blob/main/VOICES.md)
- [Kokoro GitHub Repository](https://github.com/hexgrad/kokoro)
- [Kokoro Official Website](https://kokorotts.net/)
### Technical Articles
- [Analytics Vidhya - Kokoro-82M Review](https://www.analyticsvidhya.com/blog/2025/01/kokoro-82m/)
### Installation Guides
- [Kokoro-82M Installation Instructions](https://huggingface.co/hexgrad/Kokoro-82M#installation)
- [Kokoro Live Demo](https://huggingface.co/spaces/hexgrad/Kokoro-TTS)
---
## Timeline & Estimates
| Phase | Status | Time Spent | Remaining | Total |
|-------|--------|------------|-----------|-------|
| Phase 1: Research | ✅ Complete | 2h | 0h | 2h |
| Phase 2: Implementation | ✅ Complete | 2h | 0h | 2h |
| Phase 3: Docker | 🟡 50% | 0.5h | 0.5h | 1h |
| Phase 4: Configuration | ⏳ Pending | 0h | 0.5h | 0.5h |
| Phase 5: Testing | ⏳ Pending | 0h | 1h | 1h |
| Phase 6: Documentation | ⏳ Pending | 0h | 0.5h | 0.5h |
| **Total** | **40% Complete** | **4.5h** | **2.5h** | **7h** |
**Original Estimate**: 8-12 hours
**Current Progress**: ~5 hours spent, ~2-3 hours remaining
**On Track**: Yes, ahead of schedule
---
## Questions for User (Next Session)
1. **GPU Availability**: Do you have a CUDA-capable GPU? This affects:
- Performance expectations (90× vs 5× real-time)
- Default KOKORO_DEVICE setting (cuda vs cpu)
2. **Language Priority**: Which languages do you need most?
- Only EN-US model will be tested initially
- Other languages can be tested on demand
3. **Docker vs Local**: Prefer testing locally first or directly in Docker?
- Local testing is faster for iteration
- Docker testing validates full deployment
4. **Voice Wizard**: Should we integrate Kokoro metadata into the voice wizard UI?
- Could show language, gender, description for each voice
- Requires changes to audioengine_client.py
---
**End of Status Report**
Last updated: 2025-12-05 00:48 CET
Next session: Continue with Step 1 (Complete Docker Integration)

134
Makefile
View File

@ -36,7 +36,7 @@ endef
.PHONY: build
build:
@echo "Building Docker image: $(IMAGE_NAME):$(TAG)"
docker build -t $(IMAGE_NAME):$(TAG) .
docker build --no-cache -t $(IMAGE_NAME):$(TAG) .
.PHONY: run
run:
@ -92,6 +92,54 @@ down:
export TAG=$(TAG) && \
docker compose down
# --- Test Environment Commands ---
.PHONY: kokoro-up
kokoro-up:
@echo "Starting Kokoro test environment on port 8001..."
export IMAGE_NAME=$(IMAGE_NAME) && \
export REGISTRY=$(REGISTRY) && \
export USERNAME=$(USERNAME) && \
export TAG=dev-kokoro-v5 && \
docker compose -f docker-compose.kokoro-test.yml up -d
.PHONY: kokoro-down
kokoro-down:
@echo "Stopping Kokoro test environment..."
export IMAGE_NAME=$(IMAGE_NAME) && \
export REGISTRY=$(REGISTRY) && \
export USERNAME=$(USERNAME) && \
export TAG=dev-kokoro-v5 && \
docker compose -f docker-compose.kokoro-test.yml down
.PHONY: kokoro-logs
kokoro-logs:
@echo "Showing logs for Kokoro test container..."
docker logs -f audio-engine-hub_kokoro_test
.PHONY: xtts-up
xtts-up:
@echo "Starting XTTS test environment on port 8002..."
export IMAGE_NAME=$(IMAGE_NAME) && \
export REGISTRY=$(REGISTRY) && \
export USERNAME=$(USERNAME) && \
export TAG=dev-xtts && \
docker compose -f docker-compose.xtts-test.yml up -d
.PHONY: xtts-down
xtts-down:
@echo "Stopping XTTS test environment..."
export IMAGE_NAME=$(IMAGE_NAME) && \
export REGISTRY=$(REGISTRY) && \
export USERNAME=$(USERNAME) && \
export TAG=dev-xtts && \
docker compose -f docker-compose.xtts-test.yml down
.PHONY: xtts-logs
xtts-logs:
@echo "Showing logs for XTTS test container..."
docker logs -f audio-engine-hub_xtts_test
# --- Image Management ---
.PHONY: pull
@ -109,6 +157,40 @@ push: build tag
@echo "Pushing image $(REGISTRY)/$(USERNAME)/$(IMAGE_NAME):$(TAG) to registry..."
docker push $(REGISTRY)/$(USERNAME)/$(IMAGE_NAME):$(TAG)
# --- Tagged Image Management ---
.PHONY: build-kokoro
build-kokoro:
@echo "Building Kokoro test image: $(IMAGE_NAME):dev-kokoro-v5"
docker build --no-cache -t $(IMAGE_NAME):dev-kokoro-v5 .
docker tag $(IMAGE_NAME):dev-kokoro-v5 $(REGISTRY)/$(USERNAME)/$(IMAGE_NAME):dev-kokoro-v5
.PHONY: push-kokoro
push-kokoro: build-kokoro
@echo "Pushing Kokoro test image to registry..."
docker push $(REGISTRY)/$(USERNAME)/$(IMAGE_NAME):dev-kokoro-v5
.PHONY: pull-kokoro
pull-kokoro:
@echo "Pulling Kokoro test image from registry..."
docker pull $(REGISTRY)/$(USERNAME)/$(IMAGE_NAME):dev-kokoro-v5
.PHONY: build-xtts
build-xtts:
@echo "Building XTTS test image: $(IMAGE_NAME):dev-xtts"
docker build --no-cache -t $(IMAGE_NAME):dev-xtts .
docker tag $(IMAGE_NAME):dev-xtts $(REGISTRY)/$(USERNAME)/$(IMAGE_NAME):dev-xtts
.PHONY: push-xtts
push-xtts: build-xtts
@echo "Pushing XTTS test image to registry..."
docker push $(REGISTRY)/$(USERNAME)/$(IMAGE_NAME):dev-xtts
.PHONY: pull-xtts
pull-xtts:
@echo "Pulling XTTS test image from registry..."
docker pull $(REGISTRY)/$(USERNAME)/$(IMAGE_NAME):dev-xtts
.PHONY: test
test:
@if [ -d ".venv" ]; then \
@ -148,20 +230,42 @@ clean:
.PHONY: help
help:
@echo "Available commands:"
@echo " build - Build the Docker image"
@echo " run - Run the Docker container (single app, no Traefik)"
@echo " stop - Stop and remove the Docker container"
@echo " logs - Follow the logs of the container"
@echo " shell - Get a shell inside the running container"
@echo " up - Start the dev environment by pulling image from registry"
@echo " dev-up - Start the dev environment by building image locally"
@echo " down - Stop the dev environment with docker-compose"
@echo " pull - Pull the Docker image from the registry"
@echo " tag - Tag the image for a registry"
@echo " push - Push the image to a registry (after tagging)"
@echo " test - Run the pytest test suite"
@echo ""
@echo "Main Deployment:"
@echo " build - Build the Docker image"
@echo " run - Run the Docker container (single app, no Traefik)"
@echo " stop - Stop and remove the Docker container"
@echo " logs - Follow the logs of the container"
@echo " shell - Get a shell inside the running container"
@echo " up - Start the dev environment by pulling image from registry"
@echo " dev-up - Start the dev environment by building image locally"
@echo " down - Stop the dev environment with docker-compose"
@echo ""
@echo "Test Environments:"
@echo " kokoro-up - Start Kokoro test environment (port 8001)"
@echo " kokoro-down - Stop Kokoro test environment"
@echo " kokoro-logs - View Kokoro test container logs"
@echo " xtts-up - Start XTTS test environment (port 8002)"
@echo " xtts-down - Stop XTTS test environment"
@echo " xtts-logs - View XTTS test container logs"
@echo ""
@echo "Image Management (main 'latest' tag):"
@echo " pull - Pull the Docker image from the registry"
@echo " tag - Tag the image for a registry"
@echo " push - Push the image to a registry (after tagging)"
@echo ""
@echo "Image Management (test tags):"
@echo " build-kokoro - Build Kokoro test image (dev-kokoro-v5 tag)"
@echo " push-kokoro - Build and push Kokoro test image to registry"
@echo " pull-kokoro - Pull Kokoro test image from registry"
@echo " build-xtts - Build XTTS test image (dev-xtts tag)"
@echo " push-xtts - Build and push XTTS test image to registry"
@echo " pull-xtts - Pull XTTS test image from registry"
@echo ""
@echo "Testing & Maintenance:"
@echo " test - Run the pytest test suite"
@echo " health-check - Run a sanity check on the deployed container"
@echo " clean - Clean up unused containers and images"
@echo " help - Show this help message"
@echo " clean - Clean up unused containers and images"
@echo " help - Show this help message"
.DEFAULT_GOAL := help

View File

@ -11,6 +11,23 @@ AudioEngineHub is a local-first, modular, multi-engine Text-to-Speech (TTS) serv
- **Automatic Port Finding:** Automatically finds and uses a free port when building locally.
- **Container Registry Support:** Pre-configured to push to and pull from a container registry.
## Supported TTS Engines
- **Piper** - Fast, lightweight ONNX-based TTS with 100+ voices across multiple languages
- **Kokoro** - High-performance 82M parameter TTS with 54 voices across 8 languages (EN-US, EN-GB, JA, ZH, ES, FR, HI, IT, PT, KO). Delivers ~90× real-time performance on consumer GPUs
- **XTTS (Coqui)** - State-of-the-art voice cloning and multilingual TTS. Supports 17 languages and instant voice cloning with a 6-second audio reference.
- **StyleTTS** - Expressive style-based TTS (placeholder implementation)
- **ChatTTS** - Conversational TTS (placeholder implementation)
- **F5-TTS** - Advanced flow-based TTS (planned)
## OpenAI API Compatibility
AudioEngineHub provides an OpenAI-compatible endpoint at `/v1/audio/speech`. This allows you to use it as a drop-in replacement for OpenAI's TTS service in any application or library (like LangChain, AutoGen, or the official OpenAI Python client).
- **Endpoint:** `POST /v1/audio/speech`
- **Supported Models:** `tts-1`, `tts-1-hd` (mapped to active local engines), or specific engine names like `kokoro`, `xtts`.
- **Supported Voices:** Maps the OpenAI `voice` parameter to the local engine's speaker.
## Getting Started
This guide covers local development. For information on using the container registry, see the "Container Registry" section below.
@ -57,6 +74,46 @@ AudioEngineHub/
The `styletts` engine is currently a placeholder (dummy implementation) and does not require external model downloads at this time. Its `list_models()` method provides hardcoded model names.
#### Kokoro Models
The Kokoro engine automatically downloads models from Hugging Face on first use (lazy loading). No manual download is required.
**Model Details:**
- **Source:** [Kokoro-82M on Hugging Face](https://huggingface.co/hexgrad/Kokoro-82M)
- **Size:** ~200MB per language model
- **Cache Location:** Models are cached in `~/.cache/huggingface/` inside the container
- **First Synthesis:** May take 30-60 seconds due to model download and compilation
- **Languages:** 8 languages available (EN-US, EN-GB, FR, ES, JA, ZH, IT, PT, HI, KO)
- **Voices:** 54 high-quality voices across all languages
- **GPU Support:** Automatically uses CUDA if available, falls back to CPU
- **Performance:** ~90× real-time on RTX 3090 Ti, ~210× on RTX 4090
**Configuration:**
```bash
# In .env file
KOKORO_DEVICE=cuda # or "cpu" for CPU-only systems
KOKORO_TIMEOUT_SECONDS=30
ACTIVE_ENGINES='["piper", "kokoro"]' # Enable Kokoro
```
#### XTTS Models (Coqui)
The XTTS v2 model is downloaded automatically on first use.
**Important:** You **must** explicitly accept the Coqui Public Model License to use this engine.
**Configuration:**
1. **License:** Set `XTTS_ACCEPT_LICENSE=true` in your `.env` file.
2. **Voice Cloning:** Place your reference audio files (e.g., `my_voice.wav`) in `app/asset/voices/`. The filename (without extension) becomes the `speaker` ID.
3. **Hardware:** CUDA (NVIDIA GPU) is highly recommended for reasonable inference speeds.
```bash
# In .env file
XTTS_DEVICE=cuda # or "cpu" (slow!)
XTTS_ACCEPT_LICENSE=true
ACTIVE_ENGINES='["piper", "xtts"]'
```
### Local Development Setup
1. **Clone the repository:**

View File

@ -0,0 +1,40 @@
# Coqui XTTS v2 Implementation Plan
## Objective
Implement Coqui XTTS v2 (`tts_models/multilingual/multi-dataset/xtts_v2`) as a new engine in AudioEngineHub.
## 1. Dependencies (`requirements.txt`)
- Add `TTS` (Coqui TTS).
- **Note:** This is a heavy library. We will add it to `requirements.txt`.
- **Potential Conflict:** `TTS` often requires specific `torch` versions. We need to ensure it plays nicely with `f5-tts` (which also uses torch) and `kokoro`.
## 2. Configuration (`app/config.py` & `.env`)
- `XTTS_DEVICE`: "cuda" or "cpu" (default: "cuda")
- `XTTS_ACCEPT_LICENSE`: "true" (required to use the model)
- `XTTS_MODEL_VERSION`: "v2.0.2" (or "main" for latest)
## 3. Engine Implementation (`app/engines/xtts.py`)
- **Class:** `XTTSEngine` (inherits `TTSEngineBase`)
- **Init:**
- Load `TTS` API.
- Download/Load model: `tts_models/multilingual/multi-dataset/xtts_v2`.
- Handle license agreement.
- **Synthesize:**
- Inputs: `text`, `speaker` (voice cloning reference), `language`.
- **Voice Cloning:** The `speaker` argument will be interpreted as a filename in `app/asset/voices/` (or a default provided sample).
- **Language:** XTTS supports 17 languages. We will map them (e.g., "en", "de", "fr").
- **List Models:** Return `['xtts_v2']`.
- **List Voices:** Scan `app/asset/voices/` for `.wav` files to use as reference speakers.
## 4. System Updates
- **`app/main.py`**: Register `xtts` in `ALL_ENGINES`.
- **`Dockerfile`**: Ensure system dependencies (already have `ffmpeg` and `espeak-ng`, which are good).
## 5. Directory Structure
- `app/asset/voices/`: Directory to store reference audio files for cloning.
## Action Plan
1. Update `requirements.txt`.
2. Create `app/engines/xtts.py`.
3. Update `app/config.py`.
4. Update `app/main.py`.

View File

@ -19,11 +19,22 @@ class Settings(BaseSettings):
ACTIVE_ENGINES: Set[str] = {"piper"}
ASSET_DIR: str = "app/asset"
AUDIO_CACHE_DIR: str = "app/asset/audio"
MODELS_DIR: str = "app/models"
LOG_LEVEL: str = "INFO" # Added log level setting
# Piper Engine Timeouts (in seconds)
PIPER_TIMEOUT_SECONDS: int = 30
FFMPEG_TIMEOUT_SECONDS: int = 60
# Kokoro Engine Configuration
KOKORO_DEVICE: str = "cuda" # or "cpu"
KOKORO_TIMEOUT_SECONDS: int = 30
# Coqui XTTS Engine Configuration
XTTS_DEVICE: str = "cuda" # or "cpu"
XTTS_ACCEPT_LICENSE: bool = False # User must opt-in
VOICES_DIR: str = "app/asset/voices" # Directory for reference speaker wavs
model_config = SettingsConfigDict(env_file=".env", env_file_encoding='utf-8')
settings = Settings()

338
app/engines/kokoro.py Normal file
View File

@ -0,0 +1,338 @@
"""
NovaAi – TTS-Engine-Hub
engines/kokoro.py
Version: v0.1.0
Description:
Kokoro TTS engine adapter: 82M parameter high-quality TTS model.
Synthesizes 24kHz audio using Kokoro library, converts to OGG/MP3 via ffmpeg if needed.
Supports 54 voices across 8 languages with GPU acceleration.
Author: Claude Code (Anthropic)
Date: 2025-12-05
"""
import asyncio
import subprocess
import tempfile
import os
import shutil
import logging
from typing import Optional, List
from .engine_base import TTSEngineBase
from app.config import settings
import ffmpeg
logger = logging.getLogger(__name__)
# Language code mapping for Kokoro
KOKORO_LANG_CODES = {
"kokoro-en-us": "a", # American English
"kokoro-en-gb": "b", # British English
"kokoro-fr": "fr", # French
"kokoro-es": "es", # Spanish
"kokoro-ja": "ja", # Japanese
"kokoro-zh": "zh", # Chinese
"kokoro-it": "it", # Italian
"kokoro-pt": "pt", # Portuguese
"kokoro-hi": "hi", # Hindi
"kokoro-ko": "ko", # Korean
}
# Import voice metadata
from .kokoro_voices import ALL_VOICES, get_voices_for_model, get_voice_info
class KokoroEngine(TTSEngineBase):
def __init__(self):
self.kokoro_available = False
self.pipeline = None
self.current_lang = None
self.ffmpeg_executable = shutil.which("ffmpeg")
self.device = getattr(settings, "KOKORO_DEVICE", "cuda")
self.timeout = getattr(settings, "KOKORO_TIMEOUT_SECONDS", 30)
# Try to import and initialize Kokoro
try:
from kokoro import KPipeline
self.KPipeline = KPipeline
self.kokoro_available = True
logger.info("Kokoro TTS library loaded successfully")
except ImportError as e:
logger.warning(f"Kokoro TTS library not available: {e}")
self.kokoro_available = False
def _get_pipeline(self, lang_code: str):
"""Get or create pipeline for specific language."""
if not self.kokoro_available:
raise RuntimeError("Kokoro library not installed. Install with: pip install kokoro>=0.9.2")
# Reuse pipeline if same language
if self.pipeline is not None and self.current_lang == lang_code:
return self.pipeline
# Create new pipeline for language
try:
logger.info(f"Loading Kokoro pipeline for language code: {lang_code}")
self.pipeline = self.KPipeline(lang_code=lang_code)
self.current_lang = lang_code
return self.pipeline
except Exception as e:
logger.error(f"Failed to load Kokoro pipeline: {e}")
raise RuntimeError(f"Failed to load Kokoro pipeline for {lang_code}: {e}")
def _run_ffmpeg_blocking(self, input_path: str, output_path: str):
"""
Wrapper for blocking ffmpeg call with error capture.
Reused from Piper engine implementation.
"""
try:
stdout, stderr = (
ffmpeg
.input(input_path)
.output(output_path)
.run(overwrite_output=True, capture_stdout=True, capture_stderr=True)
)
if stderr:
logger.debug(f"FFmpeg output: {stderr.decode('utf-8', errors='replace')}")
except ffmpeg.Error as e:
stderr_output = e.stderr.decode('utf-8', errors='replace') if e.stderr else "No error output"
logger.error(f"FFmpeg conversion failed: {input_path} -> {output_path}. Error: {stderr_output}")
raise RuntimeError(
f"FFmpeg conversion failed: {input_path} -> {output_path}. "
f"Error: {stderr_output}"
)
async def synthesize(self, text: str, speaker: str = None, model: str = None, fmt: str = "ogg") -> str:
"""
Synthesize speech from text using Kokoro TTS.
Applies all bug fixes from Piper engine:
- Timeout protection
- Comprehensive temp file cleanup
- FFmpeg error capture
- Enhanced logging
"""
# Validation
if not self.kokoro_available:
raise RuntimeError("Kokoro library not installed. Install with: pip install kokoro>=0.9.2 soundfile")
if not model:
model = "kokoro-en-us" # Default to American English
if model not in KOKORO_LANG_CODES:
raise ValueError(
f"Model '{model}' not supported. Available models: {list(KOKORO_LANG_CODES.keys())}"
)
if not speaker:
speaker = "af_bella" # Default voice
if speaker not in ALL_VOICES:
logger.warning(
f"Voice '{speaker}' not in known voice list. Attempting anyway. "
f"Known voices: {ALL_VOICES[:10]}..."
)
# Get language code
lang_code = KOKORO_LANG_CODES[model]
# Track temp files for cleanup
temp_files_to_cleanup = []
try:
# Get pipeline for language
pipeline = await asyncio.to_thread(self._get_pipeline, lang_code)
# Create WAV temp file
fd, output_wav_path = tempfile.mkstemp(suffix=".wav", prefix="kokoro_")
os.close(fd)
temp_files_to_cleanup.append(output_wav_path)
# Log synthesis details
text_preview = text[:100] + "..." if len(text) > 100 else text
logger.debug(f"Kokoro synthesis: model={model}, voice={speaker}, text_len={len(text)}")
logger.debug(f"Text preview: {text_preview}")
# Generate audio with timeout
try:
audio_data = await asyncio.wait_for(
asyncio.to_thread(self._synthesize_audio, pipeline, text, speaker),
timeout=self.timeout
)
except asyncio.TimeoutError:
logger.error(
f"Kokoro synthesis timed out after {self.timeout}s. "
f"Model: {model}, Voice: {speaker}, Text length: {len(text)}"
)
raise RuntimeError(
f"Kokoro synthesis timed out after {self.timeout}s. "
f"Text length: {len(text)} chars"
)
# Save audio to WAV file
import soundfile as sf
await asyncio.to_thread(sf.write, output_wav_path, audio_data, 24000)
# Verify output created
if not os.path.exists(output_wav_path) or os.path.getsize(output_wav_path) == 0:
raise RuntimeError("Kokoro synthesis failed: output file not created or empty")
logger.info(
f"Kokoro synthesis succeeded: {len(text)} chars -> "
f"{os.path.getsize(output_wav_path)} bytes. Model: {model}, Voice: {speaker}"
)
# Return WAV if requested
fmt = (fmt or "ogg").lower()
if fmt == "wav":
temp_files_to_cleanup.remove(output_wav_path)
return output_wav_path
# FFmpeg conversion
if not self.ffmpeg_executable:
raise RuntimeError("ffmpeg not found, cannot convert audio format.")
# Create converted file temp path
fd_conv, output_other_path = tempfile.mkstemp(suffix=f'.{fmt}', prefix="kokoro_conv_")
os.close(fd_conv)
temp_files_to_cleanup.append(output_other_path)
logger.debug(f"Converting WAV to {fmt}: {output_wav_path} -> {output_other_path}")
# Convert with timeout
try:
await asyncio.wait_for(
asyncio.to_thread(self._run_ffmpeg_blocking, output_wav_path, output_other_path),
timeout=60 # FFmpeg timeout
)
except asyncio.TimeoutError:
logger.error(
f"FFmpeg conversion timed out after 60s. "
f"Input size: {os.path.getsize(output_wav_path)} bytes"
)
raise RuntimeError(
f"FFmpeg conversion timed out after 60s. "
f"Input size: {os.path.getsize(output_wav_path)} bytes"
)
# Verify conversion succeeded
if not os.path.exists(output_other_path) or os.path.getsize(output_other_path) == 0:
raise RuntimeError("FFmpeg conversion failed: output file not created or empty")
logger.info(
f"FFmpeg conversion succeeded: {os.path.getsize(output_wav_path)} bytes (WAV) -> "
f"{os.path.getsize(output_other_path)} bytes ({fmt})"
)
# Success! Remove converted file from cleanup (we're returning it)
temp_files_to_cleanup.remove(output_other_path)
return output_other_path
finally:
# Cleanup all temp files
for temp_file in temp_files_to_cleanup:
try:
if os.path.exists(temp_file):
os.remove(temp_file)
logger.debug(f"Cleaned up temp file: {temp_file}")
except Exception as e:
logger.warning(f"Failed to cleanup temp file {temp_file}: {e}")
def _synthesize_audio(self, pipeline, text: str, voice: str):
"""
Blocking synthesis function (runs in thread).
Generates audio using Kokoro pipeline.
"""
import numpy as np
# Generate audio using pipeline
generator = pipeline(text, voice=voice)
# Collect audio chunks
audio_chunks = []
for gs, ps, audio in generator:
audio_chunks.append(audio)
# Concatenate all chunks
if not audio_chunks:
raise RuntimeError("Kokoro generated no audio chunks")
full_audio = np.concatenate(audio_chunks)
return full_audio
def list_models(self) -> List[str]:
"""Return available Kokoro language models."""
return list(KOKORO_LANG_CODES.keys())
def list_voices(self, model: str = None) -> List[str]:
"""Return available Kokoro voices, optionally filtered by model/language."""
if model and model in KOKORO_LANG_CODES:
# Return voices for specific language
return sorted(get_voices_for_model(model))
else:
# Return all voices
return sorted(ALL_VOICES)
def healthcheck(self):
"""Return health/status info for Kokoro engine."""
status = "ok" if self.kokoro_available else "not_available"
details = {
"status": status,
"engine": "kokoro",
"library_available": self.kokoro_available,
"device": self.device if self.kokoro_available else None,
}
if not self.kokoro_available:
details["error"] = "Kokoro library not installed. Install with: pip install kokoro>=0.9.2 soundfile"
return details
async def selftest(self):
"""Run self-test to verify Kokoro is working."""
if not self.kokoro_available:
return {
"selftest": False,
"error": "Kokoro library not installed",
"engine": "kokoro"
}
try:
# Test synthesis with default model and voice
test_text = "This is a Kokoro selftest."
audio_file = await self.synthesize(
test_text,
speaker="af_bella",
model="kokoro-en-us",
fmt="wav"
)
selftest_passed = os.path.exists(audio_file) and os.path.getsize(audio_file) > 0
if selftest_passed:
os.remove(audio_file)
return {
"selftest": selftest_passed,
"models": self.list_models(),
"voices_count": len(self.list_voices()),
"engine": "kokoro"
}
except Exception as e:
return {
"selftest": False,
"error": str(e),
"engine": "kokoro"
}
if __name__ == "__main__":
async def main():
engine = KokoroEngine()
print("Healthcheck:", engine.healthcheck())
print("Models:", engine.list_models())
print("Voices:", engine.list_voices()[:10], "...")
print("Selftest:", await engine.selftest())
asyncio.run(main())

View File

@ -0,0 +1,205 @@
"""
NovaAi – TTS-Engine-Hub
engines/kokoro_voices.py
Version: v0.1.0
Description:
Voice metadata for Kokoro TTS engine.
Complete list of 54 voices across 8 languages with metadata.
Source: https://huggingface.co/hexgrad/Kokoro-82M/blob/main/VOICES.md
Author: Claude Code (Anthropic)
Date: 2025-12-05
"""
# Complete list of all 54 Kokoro voices
ALL_VOICES = [
# American English (20 voices)
'af_heart', 'af_alloy', 'af_aoede', 'af_bella', 'af_jessica', 'af_kore',
'af_nicole', 'af_nova', 'af_river', 'af_sarah', 'af_sky',
'am_adam', 'am_echo', 'am_eric', 'am_fenrir', 'am_liam', 'am_michael',
'am_onyx', 'am_puck', 'am_santa',
# British English (8 voices)
'bf_alice', 'bf_emma', 'bf_isabella', 'bf_lily',
'bm_daniel', 'bm_fable', 'bm_george', 'bm_lewis',
# Japanese (5 voices)
'jf_alpha', 'jf_gongitsune', 'jf_nezumi', 'jf_tebukuro',
'jm_kumo',
# Mandarin Chinese (8 voices)
'zf_xiaobei', 'zf_xiaoni', 'zf_xiaoxiao', 'zf_xiaoyi',
'zm_yunjian', 'zm_yunxi', 'zm_yunxia', 'zm_yunyang',
# Spanish (3 voices)
'ef_dora', 'em_alex', 'em_santa',
# French (1 voice)
'ff_siwis',
# Hindi (4 voices)
'hf_alpha', 'hf_beta', 'hm_omega', 'hm_psi',
# Italian (2 voices)
'if_sara', 'im_nicola',
# Brazilian Portuguese (3 voices)
'pf_dora', 'pm_alex', 'pm_santa',
]
# Voice metadata with gender and language information
VOICE_METADATA = {
# American English - Female
'af_heart': {'gender': 'F', 'language': 'en-us', 'description': 'Clear, warm female voice'},
'af_alloy': {'gender': 'F', 'language': 'en-us', 'description': 'Professional female voice'},
'af_aoede': {'gender': 'F', 'language': 'en-us', 'description': 'Expressive female voice'},
'af_bella': {'gender': 'F', 'language': 'en-us', 'description': 'Warm, friendly female voice'},
'af_jessica': {'gender': 'F', 'language': 'en-us', 'description': 'Natural female voice'},
'af_kore': {'gender': 'F', 'language': 'en-us', 'description': 'Energetic female voice'},
'af_nicole': {'gender': 'F', 'language': 'en-us', 'description': 'Smooth female voice'},
'af_nova': {'gender': 'F', 'language': 'en-us', 'description': 'Bright female voice'},
'af_river': {'gender': 'F', 'language': 'en-us', 'description': 'Calm female voice'},
'af_sarah': {'gender': 'F', 'language': 'en-us', 'description': 'Professional female voice'},
'af_sky': {'gender': 'F', 'language': 'en-us', 'description': 'Cheerful female voice'},
# American English - Male
'am_adam': {'gender': 'M', 'language': 'en-us', 'description': 'Deep male voice'},
'am_echo': {'gender': 'M', 'language': 'en-us', 'description': 'Resonant male voice'},
'am_eric': {'gender': 'M', 'language': 'en-us', 'description': 'Professional male voice'},
'am_fenrir': {'gender': 'M', 'language': 'en-us', 'description': 'Strong male voice'},
'am_liam': {'gender': 'M', 'language': 'en-us', 'description': 'Friendly male voice'},
'am_michael': {'gender': 'M', 'language': 'en-us', 'description': 'Clear male voice'},
'am_onyx': {'gender': 'M', 'language': 'en-us', 'description': 'Smooth male voice'},
'am_puck': {'gender': 'M', 'language': 'en-us', 'description': 'Playful male voice'},
'am_santa': {'gender': 'M', 'language': 'en-us', 'description': 'Warm, jolly male voice'},
# British English - Female
'bf_alice': {'gender': 'F', 'language': 'en-gb', 'description': 'British female voice'},
'bf_emma': {'gender': 'F', 'language': 'en-gb', 'description': 'British female voice'},
'bf_isabella': {'gender': 'F', 'language': 'en-gb', 'description': 'British female voice'},
'bf_lily': {'gender': 'F', 'language': 'en-gb', 'description': 'British female voice'},
# British English - Male
'bm_daniel': {'gender': 'M', 'language': 'en-gb', 'description': 'British male voice'},
'bm_fable': {'gender': 'M', 'language': 'en-gb', 'description': 'British male voice'},
'bm_george': {'gender': 'M', 'language': 'en-gb', 'description': 'British male voice'},
'bm_lewis': {'gender': 'M', 'language': 'en-gb', 'description': 'British male voice'},
# Japanese - Female
'jf_alpha': {'gender': 'F', 'language': 'ja', 'description': 'Japanese female voice'},
'jf_gongitsune': {'gender': 'F', 'language': 'ja', 'description': 'Japanese female voice'},
'jf_nezumi': {'gender': 'F', 'language': 'ja', 'description': 'Japanese female voice'},
'jf_tebukuro': {'gender': 'F', 'language': 'ja', 'description': 'Japanese female voice'},
# Japanese - Male
'jm_kumo': {'gender': 'M', 'language': 'ja', 'description': 'Japanese male voice'},
# Mandarin Chinese - Female
'zf_xiaobei': {'gender': 'F', 'language': 'zh', 'description': 'Chinese female voice'},
'zf_xiaoni': {'gender': 'F', 'language': 'zh', 'description': 'Chinese female voice'},
'zf_xiaoxiao': {'gender': 'F', 'language': 'zh', 'description': 'Chinese female voice'},
'zf_xiaoyi': {'gender': 'F', 'language': 'zh', 'description': 'Chinese female voice'},
# Mandarin Chinese - Male
'zm_yunjian': {'gender': 'M', 'language': 'zh', 'description': 'Chinese male voice'},
'zm_yunxi': {'gender': 'M', 'language': 'zh', 'description': 'Chinese male voice'},
'zm_yunxia': {'gender': 'M', 'language': 'zh', 'description': 'Chinese male voice'},
'zm_yunyang': {'gender': 'M', 'language': 'zh', 'description': 'Chinese male voice'},
# Spanish - Female
'ef_dora': {'gender': 'F', 'language': 'es', 'description': 'Spanish female voice'},
# Spanish - Male
'em_alex': {'gender': 'M', 'language': 'es', 'description': 'Spanish male voice'},
'em_santa': {'gender': 'M', 'language': 'es', 'description': 'Spanish male voice'},
# French - Female
'ff_siwis': {'gender': 'F', 'language': 'fr', 'description': 'French female voice'},
# Hindi - Female
'hf_alpha': {'gender': 'F', 'language': 'hi', 'description': 'Hindi female voice'},
'hf_beta': {'gender': 'F', 'language': 'hi', 'description': 'Hindi female voice'},
# Hindi - Male
'hm_omega': {'gender': 'M', 'language': 'hi', 'description': 'Hindi male voice'},
'hm_psi': {'gender': 'M', 'language': 'hi', 'description': 'Hindi male voice'},
# Italian - Female
'if_sara': {'gender': 'F', 'language': 'it', 'description': 'Italian female voice'},
# Italian - Male
'im_nicola': {'gender': 'M', 'language': 'it', 'description': 'Italian male voice'},
# Brazilian Portuguese - Female
'pf_dora': {'gender': 'F', 'language': 'pt', 'description': 'Portuguese female voice'},
# Brazilian Portuguese - Male
'pm_alex': {'gender': 'M', 'language': 'pt', 'description': 'Portuguese male voice'},
'pm_santa': {'gender': 'M', 'language': 'pt', 'description': 'Portuguese male voice'},
}
# Language mapping for voice filtering
VOICES_BY_LANGUAGE = {
'en-us': [v for v in ALL_VOICES if v.startswith('a')],
'en-gb': [v for v in ALL_VOICES if v.startswith('b')],
'ja': [v for v in ALL_VOICES if v.startswith('j')],
'zh': [v for v in ALL_VOICES if v.startswith('z')],
'es': [v for v in ALL_VOICES if v.startswith('e')],
'fr': [v for v in ALL_VOICES if v.startswith('f')],
'hi': [v for v in ALL_VOICES if v.startswith('h')],
'it': [v for v in ALL_VOICES if v.startswith('i')],
'pt': [v for v in ALL_VOICES if v.startswith('p')],
}
def get_voices_for_model(model: str) -> list:
"""
Get voices compatible with a specific model/language.
Args:
model: Model name (e.g., 'kokoro-en-us', 'kokoro-ja')
Returns:
List of compatible voice IDs
"""
# Extract language code from model name
if model == 'kokoro-en-us':
return VOICES_BY_LANGUAGE['en-us']
elif model == 'kokoro-en-gb':
return VOICES_BY_LANGUAGE['en-gb']
elif model == 'kokoro-ja':
return VOICES_BY_LANGUAGE['ja']
elif model == 'kokoro-zh':
return VOICES_BY_LANGUAGE['zh']
elif model == 'kokoro-es':
return VOICES_BY_LANGUAGE['es']
elif model == 'kokoro-fr':
return VOICES_BY_LANGUAGE['fr']
elif model == 'kokoro-hi':
return VOICES_BY_LANGUAGE['hi']
elif model == 'kokoro-it':
return VOICES_BY_LANGUAGE['it']
elif model == 'kokoro-pt':
return VOICES_BY_LANGUAGE['pt']
else:
# Return all voices if model not recognized
return ALL_VOICES
def get_voice_info(voice_id: str) -> dict:
"""
Get metadata for a specific voice.
Args:
voice_id: Voice identifier (e.g., 'af_bella')
Returns:
Dictionary with voice metadata
"""
return VOICE_METADATA.get(voice_id, {
'gender': 'Unknown',
'language': 'unknown',
'description': 'No description available'
})

View File

@ -6,7 +6,7 @@ Version: v0.1.1
Description:
Piper TTS engine adapter: real CLI invocation + output as WAV, OGG, or MP3.
Synthesizes WAV via Piper, converts to OGG/MP3 via ffmpeg-python if needed.
Uses dynamic model path: ./models/piper/[model]/model.onnx
Uses dynamic model path: settings.MODELS_DIR/piper/[model]/model.onnx
Author: Abby (ChatGPT)
Date: 2025-07-23
@ -28,14 +28,16 @@ logger = logging.getLogger(__name__)
class PiperEngine(TTSEngineBase):
def __init__(self):
self.piper_executable = shutil.which("piper")
# Use python -m piper instead of direct script execution
# This avoids shebang issues in multi-stage Docker builds
self.python_executable = shutil.which("python3.11") or shutil.which("python3") or shutil.which("python")
self.use_module_execution = True
self.ffmpeg_executable = shutil.which("ffmpeg")
self.PIPER_TIMEOUT_SECONDS = settings.PIPER_TIMEOUT_SECONDS
self.FFMPEG_TIMEOUT_SECONDS = settings.FFMPEG_TIMEOUT_SECONDS
def _load_config(self, model: str):
"""Load the model config JSON file to get speaker mappings."""
model_dir = f"/models/piper/{model}"
model_dir = os.path.join(settings.MODELS_DIR, "piper", model)
config_file = os.path.join(model_dir, f"{model}.onnx.json")
if os.path.isfile(config_file):
with open(config_file, 'r') as f:
@ -89,13 +91,13 @@ class PiperEngine(TTSEngineBase):
- Bug #7: Enhanced error context and logging
"""
# Validation
if not self.piper_executable:
raise RuntimeError("Piper executable not found. Please install it and ensure it's in your PATH.")
if not self.python_executable:
raise RuntimeError("Python executable not found. Cannot execute piper module.")
if not model:
raise ValueError("Model must be specified for Piper.")
# Validate model and config files (Bug #6)
model_dir = f"/models/piper/{model}"
model_dir = os.path.join(settings.MODELS_DIR, "piper", model)
model_file = os.path.join(model_dir, f"{model}.onnx")
config_file = os.path.join(model_dir, f"{model}.onnx.json")
@ -136,8 +138,8 @@ class PiperEngine(TTSEngineBase):
os.close(fd)
temp_files_to_cleanup.append(output_wav_path)
# Build command (Bug #1 - no --stdin flag, piper reads stdin by default)
cmd = [self.piper_executable, "--model", model_file, "--output-file", output_wav_path]
# Build command - use python -m piper to avoid shebang issues
cmd = [self.python_executable, "-m", "piper", "--model", model_file, "--output-file", output_wav_path]
if speaker:
speaker_id = self._get_speaker_id(speaker, model)
@ -279,7 +281,7 @@ class PiperEngine(TTSEngineBase):
logger.warning(f"Failed to cleanup temp file {temp_file}: {e}")
def list_models(self):
models_dir = "/models/piper/"
models_dir = os.path.join(settings.MODELS_DIR, "piper")
if not os.path.isdir(models_dir):
return []
return [name for name in os.listdir(models_dir)
@ -296,13 +298,13 @@ class PiperEngine(TTSEngineBase):
def healthcheck(self):
status = "ok"
if not self.piper_executable:
status = "missing_piper_executable"
if not self.python_executable:
status = "missing_python_executable"
return {"status": status, "engine": "piper"}
async def selftest(self):
if not self.piper_executable:
return {"selftest": False, "error": "Piper executable not found.", "engine": "piper"}
if not self.python_executable:
return {"selftest": False, "error": "Python executable not found.", "engine": "piper"}
try:
models = self.list_models()
if not models:

177
app/engines/xtts.py Normal file
View File

@ -0,0 +1,177 @@
"""
NovaAi – TTS-Engine-Hub
engines/xtts.py
Version: v0.1.0
Description:
Coqui XTTS v2 engine adapter.
Supports multilingual synthesis and voice cloning via reference audio.
"""
import os
import asyncio
import logging
import torch
from .engine_base import TTSEngineBase
from app.config import settings
logger = logging.getLogger(__name__)
class XTTSEngine(TTSEngineBase):
def __init__(self):
logger.debug("XTTSEngine __init__ started.")
self.device = "cpu"
if torch.cuda.is_available():
logger.debug("CUDA is available.")
if settings.XTTS_DEVICE == "cuda":
self.device = "cuda"
logger.debug(f"XTTS_DEVICE setting is 'cuda'. Using CUDA.")
else:
logger.debug(f"XTTS_DEVICE setting is '{settings.XTTS_DEVICE}'. Falling back to CPU despite CUDA availability.")
else:
logger.debug("CUDA is not available. Using CPU.")
self.model = None
self.tts = None
# Verify license acceptance
if not settings.XTTS_ACCEPT_LICENSE:
logger.warning("XTTS license not accepted. Engine will not load. Set XTTS_ACCEPT_LICENSE=true in .env")
return
try:
from TTS.api import TTS
logger.debug("Coqui TTS library imported successfully.")
except ImportError:
logger.error("Coqui TTS library not found. Install 'TTS' via pip.")
return
logger.info(f"Initializing XTTS v2 on {self.device}...")
try:
# Set environment variable to bypass TTS library's interactive license prompt
# This tells the TTS library that we agree to the terms
os.environ['COQUI_TOS_AGREED'] = '1'
# Initialize TTS with the model name.
# This will download the model if not present.
# We use the official model name.
logger.debug(f"Calling TTS('tts_models/multilingual/multi-dataset/xtts_v2').to({self.device})...")
self.tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(self.device)
logger.info("XTTS v2 model loaded successfully.")
except Exception as e:
logger.error(f"Failed to load XTTS model: {e}", exc_info=True) # exc_info=True to log traceback
self.tts = None
logger.debug("XTTSEngine __init__ finished.")
def list_models(self):
return ["xtts_v2"]
def list_voices(self, model: str = None):
"""
Returns a list of available reference audio files (speakers)
found in the VOICES_DIR.
"""
voices_dir = settings.VOICES_DIR
if not os.path.exists(voices_dir):
return ["default"]
# List .wav files in the voices directory
voices = [f for f in os.listdir(voices_dir) if f.lower().endswith(".wav")]
return sorted(voices) if voices else ["default"]
def healthcheck(self):
if not settings.XTTS_ACCEPT_LICENSE:
return {"status": "license_not_accepted", "detail": "Set XTTS_ACCEPT_LICENSE=true"}
if self.tts is None:
return {"status": "error", "detail": "Model not loaded"}
return {"status": "ok", "device": self.device}
async def synthesize(self, text: str, speaker: str = None, model: str = None, fmt: str = "wav"):
"""
Synthesize speech using XTTS v2.
Args:
text: Text to synthesize.
speaker: Filename of the reference audio in VOICES_DIR (e.g., "my_voice.wav").
model: Ignored (only xtts_v2 supported).
fmt: Output format (wav by default).
"""
if not self.tts:
raise RuntimeError("XTTS engine is not initialized or license not accepted.")
# Resolve speaker/reference audio
voices_dir = settings.VOICES_DIR
if not os.path.exists(voices_dir):
os.makedirs(voices_dir, exist_ok=True)
# precise path handling
speaker_wav = None
if speaker and speaker != "default":
potential_path = os.path.join(voices_dir, speaker)
if os.path.exists(potential_path):
speaker_wav = potential_path
else:
# Check if speaker has extension, if not try adding .wav
if not speaker.lower().endswith(".wav"):
potential_path_ext = os.path.join(voices_dir, f"{speaker}.wav")
if os.path.exists(potential_path_ext):
speaker_wav = potential_path_ext
# Fallback if no valid speaker provided - XTTS NEEDS a speaker reference.
# We'll use a default sample if provided, or fail.
# Ideally, we should ship a default reference.
if not speaker_wav:
# Try to find *any* wav file in the dir to use as default
available = self.list_voices()
if available and available[0] != "default":
speaker_wav = os.path.join(voices_dir, available[0])
logger.warning(f"No valid speaker '{speaker}' found. Using first available: {available[0]}")
else:
raise ValueError("XTTS requires a reference audio file (speaker). Please upload a .wav file to app/asset/voices/")
# Output file
import tempfile
fd, output_path = tempfile.mkstemp(suffix=".wav", prefix="xtts_")
os.close(fd)
# Run synthesis in thread pool to avoid blocking event loop
# XTTS API: tts.tts_to_file(text=..., speaker_wav=..., language=..., file_path=...)
# We need to detect language or default to English ("en")
# For now, we hardcode "en" or try to auto-detect if the library supports it,
# but tts_to_file usually requires language for multilingual models.
language = "en" # TODO: Add language parameter to API or auto-detect
logger.info(f"Synthesizing with XTTS. Speaker: {os.path.basename(speaker_wav)}, Lang: {language}")
try:
await asyncio.to_thread(
self.tts.tts_to_file,
text=text,
speaker_wav=speaker_wav,
language=language,
file_path=output_path
)
except Exception as e:
logger.error(f"XTTS synthesis failed: {e}")
if os.path.exists(output_path):
os.remove(output_path)
raise RuntimeError(f"XTTS synthesis failed: {str(e)}")
return output_path
async def selftest(self):
try:
# Check if we have at least one reference voice
voices = self.list_voices()
if not voices or voices == ["default"]:
return {"selftest": False, "error": "No reference voices found in asset/voices", "engine": "xtts"}
test_voice = voices[0]
output = await self.synthesize("XTTS selftest.", speaker=test_voice)
if os.path.exists(output) and os.path.getsize(output) > 0:
os.remove(output)
return {"selftest": True, "engine": "xtts"}
return {"selftest": False, "error": "Output file empty or missing", "engine": "xtts"}
except Exception as e:
return {"selftest": False, "error": str(e), "engine": "xtts"}

View File

@ -20,15 +20,28 @@ import os
import base64
import shutil
import uvicorn
import logging
from app.config import settings
from app.engines.piper import PiperEngine
from app.engines.styletts import StyleTTSEngine
from app.engines.chattts import ChatTTSEngine
from app.engines.f5_tts import F5TTSEngine
from app.engines.kokoro import KokoroEngine
from app.engines.xtts import XTTSEngine
from app.utils.text import chunk_text
from app.utils.audio import concat_audio
from app.utils.cache import build_cache_key
from app.routers import openai_compatible
# Configure logging based on settings
logging.basicConfig(level=settings.LOG_LEVEL, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# Explicitly configure uvicorn loggers
logging.getLogger("uvicorn.access").setLevel(settings.LOG_LEVEL)
logging.getLogger("uvicorn.error").setLevel(settings.LOG_LEVEL)
logging.getLogger("uvicorn.server").setLevel(settings.LOG_LEVEL)
# --- Master list of all possible engine classes. ---
ALL_ENGINES = {
@ -36,6 +49,8 @@ ALL_ENGINES = {
"styletts": StyleTTSEngine,
"chattts": ChatTTSEngine,
"f5-tts": F5TTSEngine,
"kokoro": KokoroEngine,
"xtts": XTTSEngine,
}
def create_app():
@ -50,14 +65,17 @@ def create_app():
app.ENGINE_REGISTRY = {}
for engine_name in settings.ACTIVE_ENGINES:
if engine_name in ALL_ENGINES:
print(f"Activating engine: {engine_name}")
logger.info(f"Activating engine: {engine_name}")
app.ENGINE_REGISTRY[engine_name] = ALL_ENGINES[engine_name]()
else:
print(f"Warning: Engine '{engine_name}' requested in config but not found in ALL_ENGINES.")
logger.warning(f"Engine '{engine_name}' requested in config but not found in ALL_ENGINES.")
# Ensure the audio asset/cache directory exists.
os.makedirs(settings.AUDIO_CACHE_DIR, exist_ok=True)
# Register Routers
app.include_router(openai_compatible.router)
class TTSRequest(BaseModel):
text: str
engine: str
@ -197,6 +215,10 @@ def create_app():
On startup, check for the existence of the models directory.
This helps prevent race conditions with volume mounts.
"""
if os.getenv("SKIP_MODEL_CHECK", "false").lower() == "true":
logger.info("Skipping model directory check (SKIP_MODEL_CHECK=true)")
return
model_path = "/models/piper"
max_retries = 10
retry_delay = 2 # seconds

0
app/routers/__init__.py Normal file
View File

View File

@ -0,0 +1,137 @@
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, Response
from pydantic import BaseModel, Field
from typing import Optional, Literal
import os
import logging
from app.config import settings
router = APIRouter()
logger = logging.getLogger(__name__)
class OpenAISpeechRequest(BaseModel):
model: str = Field(..., description="The ID of the model to use (e.g., 'kokoro', 'tts-1')")
input: str = Field(..., description="The text to generate audio for")
voice: str = Field(..., description="The voice to use")
response_format: Optional[Literal['mp3', 'opus', 'aac', 'flac', 'wav', 'pcm']] = Field('mp3', description="The format to return audio in")
speed: Optional[float] = Field(1.0, description="The speed of the generated audio (0.25 to 4.0)")
@router.post("/v1/audio/speech")
async def openai_speech_endpoint(req: OpenAISpeechRequest, request: Request):
"""
OpenAI-compatible speech endpoint.
Allows this API to be used as a drop-in replacement for OpenAI TTS.
"""
# 1. Resolve Engine and Model
# Strategy:
# - If 'model' matches an active engine name exactly (e.g., 'kokoro'), use it.
# - If 'model' is 'tts-1' or 'tts-1-hd', use the first available/active engine (or a specific default if we had one).
# - If 'model' contains a separator (e.g. 'kokoro:en-us'), split it.
engine_name = req.model.lower()
model_id = None
# Check for engine:model format
if ":" in engine_name:
engine_name, model_id = engine_name.split(":", 1)
elif "-" in engine_name and engine_name not in request.app.ENGINE_REGISTRY:
# Try splitting by hyphen if direct match fails (e.g. kokoro-en-us -> engine: kokoro?? No, ambiguous).
# Let's stick to checking availability.
pass
# Handle standard OpenAI model names -> Map to preferred local engine
if engine_name in ["tts-1", "tts-1-hd"]:
# Pick the first active engine as default, preferring 'kokoro' or 'xtts' if active
active_engines = list(request.app.ENGINE_REGISTRY.keys())
if not active_engines:
raise HTTPException(status_code=503, detail="No active TTS engines available.")
if "kokoro" in active_engines:
engine_name = "kokoro"
elif "xtts" in active_engines:
engine_name = "xtts"
else:
engine_name = active_engines[0]
# Check engine availability
engine = request.app.ENGINE_REGISTRY.get(engine_name)
if not engine:
raise HTTPException(status_code=404, detail=f"Model/Engine '{req.model}' not found. Available: {list(request.app.ENGINE_REGISTRY.keys())}")
# 2. Map 'voice' to 'speaker'
# Some engines are strict, others fuzzy. We pass it through.
speaker_id = req.voice
# 3. Map 'response_format' to 'fmt'
fmt = req.response_format
if fmt == "pcm":
# We don't natively support raw PCM in all engines yet, usually wav is closest or we need ffmpeg raw
# For now, let's treat pcm as wav or raise error.
# OpenAI PCM is usually 16-bit little-endian raw.
# Let's fallback to wav for now if engine doesn't support pcm explicitly.
fmt = "wav"
# 4. Synthesize
try:
# We rely on the engine's synthesize method.
# Note: speed is not currently supported by our BaseEngine interface.
# We are ignoring req.speed for now.
output_path = await engine.synthesize(
text=req.input,
speaker=speaker_id,
model=model_id, # Might be None, engine uses default
fmt=fmt
)
if not os.path.exists(output_path):
raise RuntimeError("Synthesis finished but output file is missing.")
except Exception as e:
logger.error(f"OpenAI API Synthesis failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
# 5. Return binary stream
# OpenAI returns the binary content with correct Content-Type.
media_type_map = {
"mp3": "audio/mpeg",
"opus": "audio/opus",
"aac": "audio/aac",
"flac": "audio/flac",
"wav": "audio/wav",
"pcm": "audio/pcm" # Not standard MIME, but commonly used
}
media_type = media_type_map.get(fmt, "application/octet-stream")
# We use FileResponse to stream the file efficiently
# We might want to add a background task to clean up the file after sending,
# but our Engine implementations often cache or handle temp files.
# The current 'synthesize' implementations in this project seem to return paths to
# temp files (Kokoro) or cached files (main.py logic).
# Since this endpoint bypasses main.py's caching logic, we might be leaking temp files
# if the engine creates unique temp files every time.
# KokoroEngine: cleans up internal temps but returns a final temp file. It expects caller to handle it?
# Inspecting Kokoro: "temp_files_to_cleanup.remove(output_other_path) -> return output_other_path".
# So Kokoro leaves the final file for the caller.
# We should delete the file after sending. FileResponse has a background task for this?
# No, we need to pass a background task to Starlette's Response.
from starlette.background import BackgroundTask
def cleanup_file(path: str):
try:
if os.path.exists(path):
os.remove(path)
logger.debug(f"Cleaned up OpenAI API temp file: {path}")
except Exception as e:
logger.warning(f"Failed to cleanup temp file {path}: {e}")
return FileResponse(
path=output_path,
media_type=media_type,
background=BackgroundTask(cleanup_file, output_path)
)

View File

@ -0,0 +1,20 @@
version: '3.8'
services:
app-kokoro-test:
image: ${REGISTRY}/${USERNAME}/${IMAGE_NAME}:dev-kokoro-v5
container_name: audio-engine-hub_kokoro_test
restart: unless-stopped
volumes:
# Mount local app directory for hot-reloading in dev
- ./app:/home/appuser/app
# Mount models directory to a top-level directory in the container
- ./app/models:/models
# Mount asset directory to persist generated audio files
- ./asset:/home/appuser/app/asset
# Use Dockerfile's CMD (gunicorn) instead of uvicorn for production
# command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
ports:
- "8001:8000"
env_file:
- .env.kokoro-test

View File

@ -0,0 +1,19 @@
version: '3.8'
services:
app-xtts-test:
image: audioenginehub-xtts-test:latest
build: .
container_name: audio-engine-hub_xtts_test
restart: unless-stopped
volumes:
# Mount local app directory for hot-reloading in dev
- ./app:/home/appuser/app
# Mount models directory to a top-level directory in the container
- ./app/models:/models
# Mount asset directory to persist generated audio files
- ./asset:/home/appuser/app/asset
ports:
- "8002:8000"
env_file:
- .env.xtts-test

View File

@ -12,7 +12,8 @@ services:
- ./app/models:/models
# Mount asset directory to persist generated audio files
- ./asset:/home/appuser/app/asset
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
# Use Dockerfile's CMD (gunicorn) instead of uvicorn for production
# command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
ports:
- "${APP_PORT:-8000}:8000"
env_file:

View File

@ -0,0 +1,84 @@
# Technische Beschreibung: Multi-Stage Docker Build für GPU-fähige PyTorch-Umgebungen
## Zielsetzung
Der Ansatz ermöglicht ein Docker-Image, das GPU-beschleunigte Frameworks (PyTorch + CUDA + Torchaudio) enthält, dabei jedoch kompakt bleibt, ohne Runtime-Downloads auskommt und reproduzierbar gebaut werden kann.
---
## 1. Grundprinzip
### Stage 1 – Build-/Dependency Stage
- CUDA-fähiges Base-Image (z. B. `nvidia/cuda:12.1.0-runtime-ubuntu22.04`)
- Installation schwerer Abhängigkeiten:
- `torch` (CUDA-Build)
- `torchaudio`
- Enthält alle Build-Tools und Systemdependencies
- Ergebnis: vollständiges GPU-fähiges `site-packages`
### Stage 2 – Runtime Stage
- Leichtes Slim-Python-Image (z. B. `python:3.11-slim`)
- Nur das fertige `site-packages` aus Stage 1 wird kopiert
- Keine Build-Kette, keine Compiler, keine großen Pakete
---
## 2. Vorteile gegenüber anderen Ansätzen
### Gegenüber Single-Stage Builds
| Problem | Multi-Stage Lösung |
|--------|---------------------|
| Finales Image wird 5–10 GB groß | Nur Runtime-Layer → deutlich kleiner |
| Komplexer Build im finalen Image | Build isoliert in Stage 1 |
| Sicherheitsrisiken | Minimale Angriffsfläche im Runtime-Image |
### Gegenüber Runtime-Installation (EntryPoint)
| Runtime-Install | Multi-Stage |
|------------------|-------------|
| Lange Startup-Zeit | Installation beim Build |
| Internetzugang nötig | Runtime benötigt kein Netzwerk |
| Nicht deterministisch | Reproduzierbare Builds |
---
## 3. Technischer Ablauf im Detail
### Stage 1: Builder
- CUDA-fähiges Image
- Installation aller Dependencies inkl. Torch + CUDA
- Resultierendes `site-packages` wird vorbereitet
### Stage 2: Runtime
- Schlankes Python-Image
- Kopieren der vorbereiteten Pakete aus Stage 1
- Hinzufügen der Anwendung
- Setzen des Entrypoints
**Host-Anforderungen:**
- NVIDIA Treiber
- NVIDIA Container Toolkit
- Start mit `--gpus all`
---
## 4. CI/CD Eignung
- Deterministische Builds
- Weniger Storage
- Schnelleres Deployment
- Keine externen Abhängigkeiten bei Runtime
- Ideal für Kubernetes, GitLab CI, GitHub Actions, ArgoCD
---
## 5. Risiken & Mitigation
| Risiko | Mitigation |
|--------|------------|
| CUDA-Version mismatch | Versionen pinnen & Kompatibilitätsmatrix nutzen |
| GPU nicht verfügbar | CPU-Fallback implementieren |
| Torch-Wheel entfernt | internes Wheel-Repository verwenden |
---
## 6. TL;DR
Multi-Stage Build =
**Torch/CUDA in eigener Build-Stage installieren → fertige Pakete in ein leichtes Runtime-Image kopieren → kleines, GPU-fähiges, sauberes Deployment.**

View File

@ -0,0 +1,27 @@
#!/bin/bash
# Patch misaki/espeak.py to work with newer phonemizer versions
# The newer phonemizer API doesn't have set_data_path() method
MISAKI_FILE="/opt/venv/lib/python3.11/site-packages/misaki/espeak.py"
if [ -f "$MISAKI_FILE" ]; then
echo "Patching misaki/espeak.py for phonemizer compatibility..."
# Comment out the problematic line
sed -i 's/^EspeakWrapper\.set_data_path(espeakng_loader\.get_data_path())/# EspeakWrapper.set_data_path(espeakng_loader.get_data_path()) # Patched: not needed with current phonemizer/' "$MISAKI_FILE"
echo "Patch applied successfully!"
else
echo "Warning: misaki/espeak.py not found at $MISAKI_FILE"
fi
# Download SpaCy model for Kokoro text processing
echo "Downloading SpaCy en_core_web_sm model for Kokoro..."
python3.11 -m spacy download en_core_web_sm
echo "SpaCy model downloaded successfully!"
# Create symlink for espeak-ng-data to fix hardcoded path issue
echo "Creating espeak-ng-data symlink..."
mkdir -p /home/runner/work/espeakng-loader/espeakng-loader/espeak-ng/_dynamic/share/
ln -sf /usr/lib/x86_64-linux-gnu/espeak-ng-data /home/runner/work/espeakng-loader/espeakng-loader/espeak-ng/_dynamic/share/espeak-ng-data
echo "Symlink created successfully!"

View File

@ -10,11 +10,29 @@ pydantic-settings
ffmpeg-python
piper-tts
# Kokoro TTS Engine
kokoro>=0.9.2
soundfile
phonemizer
scipy
munch
# Pin compatible espeakng-loader version for misaki (kokoro dependency)
espeakng-loader>=0.2.3,<0.2.5
# Coqui XTTS Engine
TTS
# Pin transformers to version compatible with TTS library
transformers<4.42.0
f5-tts
torch
# Pin torch to <2.6 to avoid weights_only loading issues with TTS library
torch<2.6
torchaudio
# Numba/Numpy compatibility for XTTS/Torch
numba<0.58
numpy<1.25
# Development & Testing
pytest-cov
pytest-asyncio

79
scripts/tts_client.py Executable file
View File

@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""
A simple CLI client to test the AudioEngineHub TTS server.
This script requires the 'requests' library.
Install it with: pip install requests
"""
import argparse
import requests
import os
from urllib.parse import urljoin
def main():
parser = argparse.ArgumentParser(description="CLI client for AudioEngineHub TTS server.")
parser.add_argument("--server-url", required=True, help="Base URL of the TTS server (e.g., http://localhost:8000).")
parser.add_argument("--text", required=True, help="Text to synthesize.")
parser.add_argument("--engine", required=True, help="TTS engine to use (e.g., piper).")
parser.add_argument("--model", help="Model to use (optional).")
parser.add_argument("--speaker", help="Speaker to use (optional).")
parser.add_argument("--output", required=True, help="Path to save the output audio file (e.g., output.ogg).")
args = parser.parse_args()
# --- 1. Make the TTS request ---
tts_url = urljoin(args.server_url, "/tts")
payload = {
"text": args.text,
"engine": args.engine,
"format": "ogg" # Or determine from output file extension
}
if args.model:
payload["model"] = args.model
if args.speaker:
payload["speaker"] = args.speaker
print(f"Requesting synthesis from {tts_url} with payload: {payload}")
# Add a short delay to mitigate potential connection race conditions
import time
time.sleep(1)
try:
response = requests.post(tts_url, json=payload)
response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx)
except requests.exceptions.RequestException as e:
print(f"ERROR: Could not connect to the server: {e}")
return
try:
data = response.json()
except requests.exceptions.JSONDecodeError:
print(f"ERROR: Failed to decode JSON response from server. Response text: {response.text}")
return
if "audio_url" not in data:
print(f"ERROR: Server response did not contain 'audio_url'. Response: {data}")
return
# --- 2. Download the audio file ---
audio_url = urljoin(args.server_url, data["audio_url"])
print(f"Downloading audio from {audio_url}...")
try:
audio_response = requests.get(audio_url, stream=True)
audio_response.raise_for_status()
except requests.exceptions.RequestException as e:
print(f"ERROR: Could not download the audio file: {e}")
return
# --- 3. Save the audio file ---
try:
with open(args.output, "wb") as f:
for chunk in audio_response.iter_content(chunk_size=8192):
f.write(chunk)
print(f"Successfully saved audio to '{args.output}'")
except IOError as e:
print(f"ERROR: Could not write to output file '{args.output}': {e}")
if __name__ == "__main__":
main()

View File

@ -136,3 +136,28 @@ To facilitate pushing and pulling Docker images from a remote registry (e.g., `g
* The `down` and `help` targets were updated accordingly.
This allows for flexible deployment, supporting both local development with on-demand building and production-like environments pulling from a registry.
### Debugging "Always the Same Text" Bug
The user reported that the TTS server was not synthesizing the provided text but was always returning audio for a fixed, incorrect text.
1. **Initial Reproduction Attempt (and "422 Unprocessable Entity" error):**
* Attempted to reproduce the bug by sending different texts via `scripts/tts_client.py`.
* Encountered a `422 Unprocessable Entity` error, which led to debugging model loading.
* **Resolution 1 (Model Loading):** Fixed by correcting the volume mount source in `docker-compose.yml` (`./app/models` to `/models`) and updating paths in `app/engines/piper.py` to absolute `/models/piper/`.
* **Resolution 2 (Startup Race Condition):** Added a startup event handler in `app/main.py` to wait for the models directory to be available, preventing a race condition.
* **Resolution 3 (Client Connection):** Mitigated `ConnectionResetError` from client by adding `time.sleep(1)` before `requests.post` call in `scripts/tts_client.py`, indicating a subtle client-server connection timing issue.
2. **Debugging the "Always the Same Text" Issue:**
* **Initial Test:** Generated audio for "This is a test with the vctk model." and "This is a completely different sentence to verify the bug."
* **Result:** `cmp` showed the files were different, contradicting the user's report that the text was always the same.
* **User Clarification:** User confirmed that *listening* to the files revealed the same speech output, indicating a deeper issue beyond simple file difference.
* **Investigation of `piper.py`:**
* The original method for passing text to the `piper` executable via `stdin` (`--stdin`) seemed correct.
* Manual testing `echo "text" | piper ...` inside the container proved `piper` executable works correctly with `stdin`.
* **Hypothesis:** The `asyncio.subprocess.communicate(input=...)` call was not reliably passing text to `piper`.
* **Attempted Fix 1 (Explicit stdin write):** Modified `piper.py` to manually write to `process.stdin`, `drain`, and `close`. This led to `ConnectionResetError` (server crash).
* **Attempted Fix 2 (Revert and `--input-file` strategy):** Reverted `piper.py` back to `process.communicate()` (after fixing `cmd` to use `--stdin`). Then, changed strategy to use a temporary file for input (`--input-file`) instead of `stdin`. This also led to `ConnectionResetError`.
* **Persistent Crash:** The server consistently crashed after each attempt to synthesize actual audio. Debugging was hampered by `uvicorn`'s reloader hiding tracebacks, even after disabling it. It became apparent that the crash was very low-level, possibly within the `piper` executable's interaction with the file system or system resources.
3. **Current Status:** The server still crashes when the `synthesize` method is called to generate real audio. The exact cause of this crash is still unknown, as no Python traceback is being produced in the server logs. Further debugging is required to stabilize the `piper` engine's subprocess execution.

180
tests/test_openai_api.py Normal file
View File

@ -0,0 +1,180 @@
import pytest
from fastapi.testclient import TestClient
from unittest.mock import AsyncMock, MagicMock
from app.routers.openai_compatible import OpenAISpeechRequest
import os
@pytest.fixture(autouse=True)
def mock_engine_registry(app_instance, tmp_path):
"""
Fixture to set up a mock engine and inject it into the app_instance's registry
for each test. This ensures a clean state and isolated mocks.
"""
mock_engine = MagicMock()
# Mock synthesis to return a dummy file path
dummy_file = tmp_path / "test_output.wav"
dummy_file.write_bytes(b"fake audio data")
mock_engine.synthesize = AsyncMock(return_value=str(dummy_file))
# Add common mock methods for engine interface
mock_engine.list_models.return_value = ["model1", "model2"]
mock_engine.list_voices.return_value = ["speaker1", "speaker2"]
mock_engine.healthcheck.return_value = {"status": "ok"}
app_instance.ENGINE_REGISTRY["mock-engine"] = mock_engine
app_instance.ENGINE_REGISTRY["kokoro"] = mock_engine # For tts-1 mapping test
return mock_engine
# --- Existing Tests (Modified to use fixture) ---
def test_openai_speech_endpoint_success(app_client, mock_engine_registry, tmp_path):
# Use the mock_engine_registry fixture to get the mock_engine
mock_engine = mock_engine_registry
payload = {
"model": "mock-engine",
"input": "Hello OpenAI",
"voice": "speaker1",
"response_format": "wav"
}
response = app_client.post("/v1/audio/speech", json=payload)
assert response.status_code == 200
assert response.headers["content-type"] == "audio/wav"
assert response.content == b"fake audio data"
mock_engine.synthesize.assert_called_once_with(
text="Hello OpenAI",
speaker="speaker1",
model=None, # Explicitly no model_id in this case
fmt="wav"
)
def test_openai_speech_endpoint_engine_not_found(app_client):
payload = {
"model": "non-existent-engine",
"input": "Test",
"voice": "default"
}
response = app_client.post("/v1/audio/speech", json=payload)
assert response.status_code == 404
assert "not found" in response.json()["detail"]
def test_openai_speech_endpoint_tts_1_mapping(app_client, mock_engine_registry):
mock_engine = mock_engine_registry # 'kokoro' engine is mapped to this mock
payload = {
"model": "tts-1",
"input": "Mapping test",
"voice": "speaker1",
"response_format": "mp3"
}
response = app_client.post("/v1/audio/speech", json=payload)
assert response.status_code == 200
assert response.headers["content-type"] == "audio/mpeg"
mock_engine.synthesize.assert_called_once()
args, kwargs = mock_engine.synthesize.call_args
assert kwargs["speaker"] == "speaker1"
assert kwargs["fmt"] == "mp3"
# --- New Test Cases ---
def test_openai_speech_endpoint_unsupported_format(app_client, mock_engine_registry):
mock_engine = mock_engine_registry
payload = {
"model": "mock-engine",
"input": "Test text.",
"voice": "speaker1",
"response_format": "unsupported_format" # This should be caught by pydantic's Literal
}
response = app_client.post("/v1/audio/speech", json=payload)
assert response.status_code == 422 # Unprocessable Entity due to Pydantic validation
assert "response_format" in response.json()["detail"][0]["loc"]
def test_openai_speech_endpoint_engine_synthesis_failure(app_client, mock_engine_registry):
mock_engine = mock_engine_registry
mock_engine.synthesize.side_effect = RuntimeError("Mock synthesis failed")
payload = {
"model": "mock-engine",
"input": "This text will fail.",
"voice": "speaker1"
}
response = app_client.post("/v1/audio/speech", json=payload)
assert response.status_code == 500
assert "Mock synthesis failed" in response.json()["detail"]
def test_openai_speech_endpoint_model_id_parsing(app_client, mock_engine_registry, tmp_path):
mock_engine = mock_engine_registry
# Ensure a different dummy file for this test's synthesis result
dummy_file = tmp_path / "model_id_output.wav"
dummy_file.write_bytes(b"model id audio data")
mock_engine.synthesize.return_value = str(dummy_file)
# For this test, we simulate an engine that supports a model_id
app_instance = app_client.app
app_instance.ENGINE_REGISTRY["xtts"] = mock_engine # Map 'xtts' to our mock
payload = {
"model": "xtts:multilingual", # Test parsing 'engine:model_id'
"input": "Testing specific model.",
"voice": "speaker1",
"response_format": "wav"
}
response = app_client.post("/v1/audio/speech", json=payload)
assert response.status_code == 200
assert response.headers["content-type"] == "audio/wav"
assert response.content == b"model id audio data"
mock_engine.synthesize.assert_called_once_with(
text="Testing specific model.",
speaker="speaker1",
model="multilingual", # Verify model_id was passed correctly
fmt="wav"
)
def test_openai_speech_endpoint_different_audio_formats(app_client, mock_engine_registry, tmp_path):
mock_engine = mock_engine_registry
# Test opus format
opus_dummy_file = tmp_path / "test_output.opus"
opus_dummy_file.write_bytes(b"opus data")
mock_engine.synthesize.return_value = str(opus_dummy_file)
payload_opus = {
"model": "mock-engine",
"input": "Hello opus.",
"voice": "speaker1",
"response_format": "opus"
}
response_opus = app_client.post("/v1/audio/speech", json=payload_opus)
assert response_opus.status_code == 200
assert response_opus.headers["content-type"] == "audio/opus"
assert response_opus.content == b"opus data"
mock_engine.synthesize.assert_called_with(text="Hello opus.", speaker="speaker1", model=None, fmt="opus")
# Reset mock and test flac format
mock_engine.synthesize.reset_mock()
flac_dummy_file = tmp_path / "test_output.flac"
flac_dummy_file.write_bytes(b"flac data")
mock_engine.synthesize.return_value = str(flac_dummy_file)
payload_flac = {
"model": "mock-engine",
"input": "Hello flac.",
"voice": "speaker1",
"response_format": "flac"
}
response_flac = app_client.post("/v1/audio/speech", json=payload_flac)
assert response_flac.status_code == 200
assert response_flac.headers["content-type"] == "audio/flac"
assert response_flac.content == b"flac data"
mock_engine.synthesize.assert_called_with(text="Hello flac.", speaker="speaker1", model=None, fmt="flac")