feat: Overhaul application and add DX improvements

This commit introduces a wide range of improvements to the application, focusing on stability, developer experience (DX), and documentation.

Key changes include:

- **Fix Application Startup:** Resolved a critical bug where the FastAPI application instance was not correctly exposed, preventing Uvicorn from starting ().
- **Simplify Docker Compose:** Removed the integrated Traefik setup from the default  to support users with existing reverse proxies and simplify the local development environment.
- **Improve Makefile:**
    - Implemented a robust, automatic port-finding mechanism for Starting development environment on port 8001...
#1 [internal] load local bake definitions
#1 reading from stdin 534B done
#1 DONE 0.0s

#2 [internal] load build definition from Dockerfile
#2 transferring dockerfile: 1.22kB done
#2 WARN: FromAsCasing: 'as' and 'FROM' keywords' casing do not match (line 2)
#2 DONE 0.0s

#3 [internal] load metadata for docker.io/library/python:3.11
#3 DONE 0.7s

#4 [internal] load metadata for docker.io/library/python:3.11-slim
#4 DONE 0.7s

#5 [internal] load .dockerignore
#5 transferring context: 385B done
#5 DONE 0.0s

#6 [builder 1/4] FROM docker.io/library/python:3.11@sha256:bf2d36b8fb1b4a0b590b36736cdd8a6b5175b411bf135c42694ecd68ab8fed02
#6 DONE 0.0s

#7 [stage-1 1/6] FROM docker.io/library/python:3.11-slim@sha256:193fdd0bbcb3d2ae612bd6cc3548d2f7c78d65b549fcaa8af75624c47474444d
#7 DONE 0.0s

#8 [internal] load build context
#8 transferring context: 4.90kB done
#8 DONE 0.0s

#9 [builder 2/4] WORKDIR /opt/venv
#9 CACHED

#10 [stage-1 4/6] WORKDIR /home/appuser
#10 CACHED

#11 [stage-1 3/6] RUN useradd --create-home --shell /bin/bash appuser
#11 CACHED

#12 [stage-1 2/6] RUN apt-get update && apt-get install -y --no-install-recommends     ffmpeg     && rm -rf /var/lib/apt/lists/*
#12 CACHED

#13 [stage-1 5/6] COPY --from=builder /opt/venv /opt/venv
#13 CACHED

#14 [builder 4/4] RUN python -m venv . && . /opt/venv/bin/activate && pip install --no-cache-dir -r requirements.txt
#14 CACHED

#15 [builder 3/4] COPY requirements.txt .
#15 CACHED

#16 [stage-1 6/6] COPY app/ ./app
#16 CACHED

#17 exporting to image
#17 exporting layers done
#17 writing image sha256:6cac7caac7fda2808672ad2f3d117d46d38c1d93013867858543ec74917857b7 done
#17 naming to docker.io/library/audioenginehub-app done
#17 DONE 0.0s

#18 resolving provenance for metadata file
#18 DONE 0.0s and Using host port 8000 for single app container
8a1c69e868e7f13b4c8c9948e81921b48efd9536f326d200b9e912fb12ff66e3 to prevent port conflicts.
    - Added a  target (Running health check on running container...
App container is running on port 8001.
Waiting for app to initialize...
ERROR: Failed to decode JSON from health endpoint.) to run post-deployment sanity checks against the running container's  endpoint.
    - Recommended using Starting development environment on port 8002...
#1 [internal] load local bake definitions
#1 reading from stdin 534B done
#1 DONE 0.0s

#2 [internal] load build definition from Dockerfile
#2 transferring dockerfile: 1.22kB done
#2 WARN: FromAsCasing: 'as' and 'FROM' keywords' casing do not match (line 2)
#2 DONE 0.0s

#3 [internal] load metadata for docker.io/library/python:3.11-slim
#3 DONE 0.1s

#4 [internal] load metadata for docker.io/library/python:3.11
#4 DONE 0.2s

#5 [internal] load .dockerignore
#5 transferring context: 385B done
#5 DONE 0.0s

#6 [builder 1/4] FROM docker.io/library/python:3.11@sha256:bf2d36b8fb1b4a0b590b36736cdd8a6b5175b411bf135c42694ecd68ab8fed02
#6 DONE 0.0s

#7 [stage-1 1/6] FROM docker.io/library/python:3.11-slim@sha256:193fdd0bbcb3d2ae612bd6cc3548d2f7c78d65b549fcaa8af75624c47474444d
#7 DONE 0.0s

#8 [internal] load build context
#8 transferring context: 1.09GB 5.1s
#8 transferring context: 1.66GB 7.9s done
#8 DONE 8.0s

#9 [builder 3/4] COPY requirements.txt .
#9 CACHED

#10 [builder 4/4] RUN python -m venv . && . /opt/venv/bin/activate && pip install --no-cache-dir -r requirements.txt
#10 CACHED

#11 [stage-1 4/6] WORKDIR /home/appuser
#11 CACHED

#12 [stage-1 3/6] RUN useradd --create-home --shell /bin/bash appuser
#12 CACHED

#13 [builder 2/4] WORKDIR /opt/venv
#13 CACHED

#14 [stage-1 2/6] RUN apt-get update && apt-get install -y --no-install-recommends     ffmpeg     && rm -rf /var/lib/apt/lists/*
#14 CACHED

#15 [stage-1 5/6] COPY --from=builder /opt/venv /opt/venv
#15 CACHED

#16 [stage-1 6/6] COPY app/ ./app
#16 CACHED

#17 exporting to image
#17 exporting layers done
#17 writing image sha256:6cac7caac7fda2808672ad2f3d117d46d38c1d93013867858543ec74917857b7 done
#17 naming to docker.io/library/audioenginehub-app done
#17 DONE 0.0s

#18 resolving provenance for metadata file
#18 DONE 0.0s for reliable port detection.
- **Update Documentation:**
    - Replaced the outdated  (which contained old source code) with a comprehensive guide covering setup, usage, and  commands.
    - Added a note to  to clarify that it describes an older, more advanced setup, pointing readers to the new  for the current recommended workflow.

These changes address the service startup failures and significantly improve the project's usability and maintainability.
This commit is contained in:
2025-12-04 17:33:44 +01:00
parent 21cdc65ade
commit 528a185d3b
32 changed files with 277 additions and 342 deletions

264
README.md
View File

@ -1,222 +1,80 @@
"""
NovaAi – TTS-Engine-Hub
main.py
Version: v0.0.7
# AudioEngineHub
Description:
Adds /speakers endpoint to list speakers for a given engine/model.
Returns list of available speakers from engine.list_voices(model).
All previous endpoints and logic included.
AudioEngineHub is a local-first, modular, multi-engine Text-to-Speech (TTS) server designed for homelabs and automation. It provides a single, unified API to interact with various TTS engines like Piper and StyleTTS.
Author: Abby (ChatGPT)
Date: 2025-07-23
Canvas: main.py
"""
## Features
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import JSONResponse, FileResponse
from pydantic import BaseModel
import os
import base64
from engines.piper import PiperEngine
from engines.styletts import StyleTTSEngine
from engines.chattts import ChatTTSEngine
import shutil
import uuid
import hashlib
import tempfile
import ffmpeg
- **Multi-Engine Support:** Easily switch between different TTS engines.
- **Configurable Engines:** Activate or deactivate engines on the fly via a simple configuration file.
- **Caching:** Caches generated audio to save resources and provide faster responses for repeated requests.
- **Dockerized:** Runs in a containerized environment for easy setup and dependency management.
- **Automatic Port Finding:** Automatically finds and uses a free port, preventing conflicts.
app = FastAPI(
title="NovaAi – TTS-Engine-Hub",
version="0.0.7",
description="Local-first, modular multi-engine TTS server for your homelab and automation."
)
## Getting Started
ENGINE_REGISTRY = {
"piper": PiperEngine(),
"styletts": StyleTTSEngine(),
"chattts": ChatTTSEngine(),
}
### Prerequisites
AUDIO_OUT_DIR = "/tmp/tts_output"
CACHE_DIR = "/tmp/tts_cache"
os.makedirs(AUDIO_OUT_DIR, exist_ok=True)
os.makedirs(CACHE_DIR, exist_ok=True)
- [Docker](https://docs.docker.com/get-docker/)
- [Docker Compose](https://docs.docker.com/compose/install/)
class TTSRequest(BaseModel):
text: str
engine: str
model: str = None
speaker: str = None
format: str = "ogg"
chunking: bool = False
### Installation
1. **Clone the repository:**
```bash
git clone <repository_url>
cd AudioEngineHub
```
def build_cache_key(req: TTSRequest) -> str:
data = f"{req.text}|{req.engine}|{req.model}|{req.speaker}|{req.format}|{req.chunking}"
return hashlib.sha256(data.encode()).hexdigest()
2. **Configure the environment:**
Create a `.env` file by copying the example file:
```bash
cp .env.example .env
```
Open the `.env` file and configure the `ACTIVE_ENGINES` list to include the engines you want to use. For example:
```
ACTIVE_ENGINES='["piper", "styletts"]'
```
def chunk_text(text, maxlen=250):
import re
sentences = re.split(r'([.!?]\s)', text)
chunks = []
buf = ""
for s in sentences:
if len(buf) + len(s) > maxlen:
if buf:
chunks.append(buf.strip())
buf = ""
buf += s
if buf.strip():
chunks.append(buf.strip())
return [c for c in chunks if c.strip()]
3. **Build and start the container:**
Use the `make up` command to build the Docker image and start the service.
```bash
make up
```
This command will automatically find a free port starting from 8000 and run the application on it.
def concat_audio(files, fmt):
if len(files) == 1:
return files[0]
output_file = tempfile.mktemp(suffix=f'.{fmt}', prefix="chunked_", dir="/tmp")
if fmt == "wav":
import wave
data = []
params = None
for f in files:
with wave.open(f, 'rb') as wf:
if params is None:
params = wf.getparams()
data.append(wf.readframes(wf.getnframes()))
with wave.open(output_file, 'wb') as wf:
wf.setparams(params)
for d in data:
wf.writeframes(d)
else:
with tempfile.NamedTemporaryFile("w", delete=False) as tf:
for f in files:
tf.write(f"file '{f}'\n")
tf.flush()
(
ffmpeg
.input(tf.name, format='concat', safe=0)
.output(output_file, acodec='copy')
.run(overwrite_output=True, quiet=True)
)
os.unlink(tf.name)
return output_file
> **Note:** For the most reliable port detection, it is recommended to run the command with `sudo`:
> ```bash
> sudo make up
> ```
@app.post("/tts")
def tts_endpoint(req: TTSRequest, as_base64: bool = Query(False, alias="as")):
cache_key = build_cache_key(req)
ext = f'.{req.format.lower()}'
cached_file = os.path.join(CACHE_DIR, f"tts_{cache_key}{ext}")
if os.path.isfile(cached_file):
fname = f"tts_{cache_key}{ext}"
dest = os.path.join(AUDIO_OUT_DIR, fname)
shutil.copy(cached_file, dest)
if as_base64:
with open(cached_file, "rb") as f:
audio_b64 = base64.b64encode(f.read()).decode("utf-8")
return JSONResponse({
"engine": req.engine,
"model": req.model,
"speaker": req.speaker,
"format": req.format,
"audio_base64": audio_b64,
"chunking": req.chunking,
"message": "Audio from cache, base64 included"
})
return JSONResponse({
"engine": req.engine,
"model": req.model,
"speaker": req.speaker,
"format": req.format,
"audio_url": f"/audio/{fname}",
"cached": True,
"chunking": req.chunking,
"message": "Audio served from cache. Download from audio_url"
})
engine = ENGINE_REGISTRY.get(req.engine.lower())
if not engine:
raise HTTPException(status_code=404, detail=f"Engine '{req.engine}' not found.")
if req.chunking and len(req.text) > 250:
chunks = chunk_text(req.text, maxlen=250)
chunk_files = [engine.synthesize(c, speaker=req.speaker, model=req.model, fmt=req.format) for c in chunks]
audio_path = concat_audio(chunk_files, req.format.lower())
else:
audio_path = engine.synthesize(req.text, speaker=req.speaker, model=req.model, fmt=req.format)
shutil.copy(audio_path, cached_file)
fname = f"tts_{cache_key}{ext}"
dest = os.path.join(AUDIO_OUT_DIR, fname)
shutil.copy(audio_path, dest)
if as_base64:
with open(cached_file, "rb") as f:
audio_b64 = base64.b64encode(f.read()).decode("utf-8")
return JSONResponse({
"engine": req.engine,
"model": req.model,
"speaker": req.speaker,
"format": req.format,
"audio_base64": audio_b64,
"chunking": req.chunking,
"message": "Audio from synth, base64 included"
})
return JSONResponse({
"engine": req.engine,
"model": req.model,
"speaker": req.speaker,
"format": req.format,
"audio_url": f"/audio/{fname}",
"cached": False,
"chunking": req.chunking,
"message": "Synthesized new audio. Download from audio_url"
})
## Usage
@app.get("/audio/{filename}")
def audio_file(filename: str):
fpath = os.path.join(AUDIO_OUT_DIR, filename)
if not os.path.isfile(fpath):
raise HTTPException(status_code=404, detail="Audio file not found")
media_type = "audio/wav" if filename.endswith(".wav") else (
"audio/ogg" if filename.endswith(".ogg") else "audio/mpeg"
)
return FileResponse(fpath, media_type=media_type, filename=filename)
The application provides a simple API to generate speech and inspect the available engines.
@app.get("/engines")
def engines_endpoint():
engines = {}
for name, engine in ENGINE_REGISTRY.items():
engines[name] = engine.healthcheck()
return engines
### Endpoints
@app.get("/models")
def models_endpoint():
result = {}
for name, engine in ENGINE_REGISTRY.items():
try:
result[name] = engine.list_models()
except Exception as e:
result[name] = []
return result
- `POST /tts`: The main endpoint to synthesize text to speech.
- `GET /health`: Check the health of the API and the status of the loaded engines.
- `GET /engines`: List the currently active engines.
- `GET /models`: List the available models for each active engine.
_ `GET /speakers`: List the available speakers for a given engine and model.
@app.get("/speakers")
def speakers_endpoint(engine: str, model: str = None):
e = ENGINE_REGISTRY.get(engine.lower())
if not e:
raise HTTPException(status_code=404, detail=f"Engine '{engine}' not found.")
try:
speakers = e.list_voices(model)
except Exception as err:
speakers = []
return {"engine": engine, "model": model, "speakers": speakers}
### Makefile Commands
@app.get("/version")
def version():
return {"version": app.version}
The project includes a `Makefile` with several commands to simplify development and management:
@app.get("/health")
def health():
status = {name: engine.healthcheck()["status"] for name, engine in ENGINE_REGISTRY.items()}
return {"status": status, "detail": "API and engines loaded"}
- `make up`: Build the image and start the application container.
- `make down`: Stop the application container.
- `make logs`: View the application logs.
- `make health-check`: Run a sanity check to ensure the deployed container is healthy and all engines are "ok".
- `make test`: Run the `pytest` test suite.
- `make help`: Display a list of all available commands.
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
## Configuration
The application is configured through the `.env` file in the root of the project.
- `ACTIVE_ENGINES`: A comma-separated list of strings specifying which TTS engines to activate. Available engines are defined in `app/main.py`.
- `HOST`: The host address for the server (defaults to `0.0.0.0`).
- `PORT`: The internal port for the server (defaults to `8000`).
- `IMAGE_NAME`: The name of the Docker image to build (defaults to `audioenginehub`).