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:
125
Makefile
125
Makefile
@ -1,3 +1,5 @@
|
|||||||
|
SHELL := /bin/bash
|
||||||
|
|
||||||
# --- Configuration ---
|
# --- Configuration ---
|
||||||
IMAGE_NAME := audio-engine-hub
|
IMAGE_NAME := audio-engine-hub
|
||||||
TAG := latest
|
TAG := latest
|
||||||
@ -12,57 +14,22 @@ TRAEFIK_DASHBOARD_PORT := 8080
|
|||||||
# Function to check if required Traefik ports are free
|
# Function to check if required Traefik ports are free
|
||||||
# Exits if any are busy, does not suggest alternatives.
|
# Exits if any are busy, does not suggest alternatives.
|
||||||
define check_traefik_ports_free
|
define check_traefik_ports_free
|
||||||
@echo "Checking if Traefik ports ($(TRAEFIK_WEB_PORT}, $(TRAEFIK_DASHBOARD_PORT}) are free..." >&2
|
check_traefik_ports_free_func() { \
|
||||||
@local busy_ports=""; \
|
echo "Checking if Traefik ports ($(TRAEFIK_WEB_PORT), $(TRAEFIK_DASHBOARD_PORT)) are free..." >&2; \
|
||||||
if ss -tulnp | grep ":$(TRAEFIK_WEB_PORT) " > /dev/null; then \
|
local busy_ports=""; \
|
||||||
busy_ports="$$busy_ports $(TRAEFIK_WEB_PORT)"; \
|
if ss -tulnp | grep ":$(TRAEFIK_WEB_PORT) " > /dev/null; then \
|
||||||
fi; \
|
busy_ports="$$busy_ports $(TRAEFIK_WEB_PORT)"; \
|
||||||
if ss -tulnp | grep ":$(TRAEFIK_DASHBOARD_PORT) " > /dev/null; then \
|
fi; \
|
||||||
busy_ports="$$busy_ports $(TRAEFIK_DASHBOARD_PORT)"; \
|
if ss -tulnp | grep ":$(TRAEFIK_DASHBOARD_PORT) " > /dev/null; then \
|
||||||
fi; \
|
busy_ports="$$busy_ports $(TRAEFIK_DASHBOARD_PORT)"; \
|
||||||
if [ -n "$$busy_ports" ]; then \
|
fi; \
|
||||||
echo "ERROR: The following Traefik ports are already in use: $$busy_ports. Please free them or stop Traefik if already running." >&2; \
|
if [ -n "$$busy_ports" ]; then \
|
||||||
exit 1; \
|
echo "ERROR: The following Traefik ports are already in use: $$busy_ports. Please free them or stop Traefik if already running." >&2; \
|
||||||
fi; \
|
exit 1; \
|
||||||
@echo "Traefik ports are free." >&2
|
fi; \
|
||||||
endef
|
echo "Traefik ports are free." >&2; \
|
||||||
|
} ; \
|
||||||
# Function to find next free host port for the app and ask user
|
check_traefik_ports_free_func
|
||||||
# This function will echo the chosen port if successful, or exit with an error.
|
|
||||||
define check_app_port_free
|
|
||||||
@local start_port=$(PORT); \
|
|
||||||
local found_port=$$start_port; \
|
|
||||||
local is_free=false; \
|
|
||||||
\
|
|
||||||
if ! ss -tulnp | grep ":$$start_port " > /dev/null; then \
|
|
||||||
echo "$$start_port"; \
|
|
||||||
exit 0; \
|
|
||||||
fi; \
|
|
||||||
\
|
|
||||||
echo "Port $$start_port is busy. Searching for a free port for the app..." >&2; \
|
|
||||||
while ! $$is_free; do \
|
|
||||||
if ! ss -tulnp | grep ":$$found_port " > /dev/null; then \
|
|
||||||
is_free=true; \
|
|
||||||
else \
|
|
||||||
((found_port++)); \
|
|
||||||
if [ "$$found_port" -gt 65535 ]; then \
|
|
||||||
echo "ERROR: No free ports found up to 65535. Aborting." >&2; \
|
|
||||||
exit 1; \
|
|
||||||
fi; \
|
|
||||||
fi; \
|
|
||||||
done; \
|
|
||||||
\
|
|
||||||
echo "Port $$start_port is busy. I found port $$found_port to be free for the app." >&2; \
|
|
||||||
read -p "Do you want to use port $$found_port for the app? (y/N): " choice; \
|
|
||||||
case "$$choice" in \
|
|
||||||
y|Y ) \
|
|
||||||
echo "$$found_port"; \
|
|
||||||
;; \
|
|
||||||
* ) \
|
|
||||||
echo "Operation cancelled by user." >&2; \
|
|
||||||
exit 1; \
|
|
||||||
;; \
|
|
||||||
esac;
|
|
||||||
endef
|
endef
|
||||||
|
|
||||||
# --- Docker Commands ---
|
# --- Docker Commands ---
|
||||||
@ -74,38 +41,41 @@ build:
|
|||||||
|
|
||||||
.PHONY: run
|
.PHONY: run
|
||||||
run:
|
run:
|
||||||
@export SELECTED_HOST_PORT=$$(bash -c 'func() { $(check_app_port_free) }; func') && \
|
@APP_PORT=$$(bash -c 'port=$${PORT:-8000}; while ss -tulnp | grep -q :$$port; do echo "Port $$port is busy. Checking next..." >&2; ((port++)); done; echo $$port'); \
|
||||||
echo "Using host port $$SELECTED_HOST_PORT for single app container" && \
|
echo "Using host port $$APP_PORT for single app container" && \
|
||||||
docker run -d -p $$SELECTED_HOST_PORT:$(PORT) --name $(IMAGE_NAME) $(IMAGE_NAME):$(TAG)
|
docker run -d -p $$APP_PORT:$(PORT) --name $(IMAGE_NAME) $(IMAGE_NAME):$(TAG)
|
||||||
|
|
||||||
.PHONY: stop
|
.PHONY: stop
|
||||||
stop:
|
stop:
|
||||||
@echo "Stopping Docker container: $(IMAGE_NAME)"
|
@echo "Stopping Docker containers..."
|
||||||
|
docker compose stop || true
|
||||||
|
docker compose rm -f || true
|
||||||
docker stop $(IMAGE_NAME) || true
|
docker stop $(IMAGE_NAME) || true
|
||||||
docker rm $(IMAGE_NAME) || true
|
docker rm $(IMAGE_NAME) || true
|
||||||
|
|
||||||
.PHONY: logs
|
.PHONY: logs
|
||||||
logs:
|
logs:
|
||||||
@echo "Showing logs for container: $(IMAGE_NAME)"
|
@echo "Showing logs for container: $(if $(CONTAINER),$(CONTAINER),$(IMAGE_NAME)_app)"
|
||||||
docker logs -f $(IMAGE_NAME)
|
docker logs -f $(if $(CONTAINER),$(CONTAINER),$(IMAGE_NAME)_app)
|
||||||
|
|
||||||
.PHONY: shell
|
.PHONY: shell
|
||||||
shell:
|
shell:
|
||||||
@echo "Accessing shell in container: $(IMAGE_NAME)"
|
@echo "Accessing shell in container: $(if $(CONTAINER),$(CONTAINER),$(IMAGE_NAME)_app)"
|
||||||
docker exec -it $(IMAGE_NAME) /bin/bash
|
docker exec -it $(if $(CONTAINER),$(CONTAINER),$(IMAGE_NAME)_app) /bin/bash
|
||||||
|
|
||||||
# --- Docker Compose Commands ---
|
# --- Docker Compose Commands ---
|
||||||
|
|
||||||
.PHONY: up
|
.PHONY: up
|
||||||
up:
|
up:
|
||||||
$(call check_traefik_ports_free) # Check Traefik ports before starting
|
@APP_PORT=$$(bash -c 'port=$${PORT:-8000}; while ss -tulnp | grep -q :$$port; do echo "Port $$port is busy. Checking next..." >&2; ((port++)); done; echo $$port'); \
|
||||||
@echo "Starting development environment with Docker Compose (Traefik enabled)..."
|
echo "Starting development environment on port $$APP_PORT..."; \
|
||||||
docker-compose up --build -d
|
export IMAGE_NAME=$(IMAGE_NAME); \
|
||||||
|
APP_PORT=$$APP_PORT docker compose up --build -d
|
||||||
|
|
||||||
.PHONY: down
|
.PHONY: down
|
||||||
down:
|
down:
|
||||||
@echo "Stopping development environment with Docker Compose..."
|
@echo "Stopping development environment with Docker Compose..."
|
||||||
docker-compose down
|
export IMAGE_NAME=$(IMAGE_NAME) && docker compose down
|
||||||
|
|
||||||
# --- Image Management ---
|
# --- Image Management ---
|
||||||
|
|
||||||
@ -121,9 +91,32 @@ push: tag
|
|||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
test:
|
test:
|
||||||
@echo "Running tests with coverage..."
|
@if [ -d ".venv" ]; then \
|
||||||
|
echo "Activating virtual environment..."; \
|
||||||
|
. .venv/bin/activate; \
|
||||||
|
fi; \
|
||||||
|
export PYTHONPATH=$(PWD); \
|
||||||
|
echo "Running tests with coverage..." && \
|
||||||
pytest --cov=. app/ tests/
|
pytest --cov=. app/ tests/
|
||||||
|
|
||||||
|
.PHONY: health-check
|
||||||
|
health-check:
|
||||||
|
@echo "Running health check on running container..."
|
||||||
|
@CONTAINER_ID=$$(docker compose ps -q app); \
|
||||||
|
if [ -z "$$CONTAINER_ID" ]; then \
|
||||||
|
echo "ERROR: App container is not running. Please run 'make up' first." >&2; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
HOST_PORT=$$(docker port $$CONTAINER_ID 8000 | cut -d: -f2); \
|
||||||
|
if [ -z "$$HOST_PORT" ]; then \
|
||||||
|
echo "ERROR: Could not determine host port for the app container." >&2; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
echo "App container is running on port $$HOST_PORT."; \
|
||||||
|
echo "Waiting for app to initialize..."; \
|
||||||
|
sleep 2; \
|
||||||
|
curl -s http://localhost:$$HOST_PORT/health | ./scripts/health_check.py
|
||||||
|
|
||||||
# --- Cleanup ---
|
# --- Cleanup ---
|
||||||
|
|
||||||
.PHONY: clean
|
.PHONY: clean
|
||||||
@ -140,10 +133,12 @@ help:
|
|||||||
@echo " stop - Stop and remove the Docker container"
|
@echo " stop - Stop and remove the Docker container"
|
||||||
@echo " logs - Follow the logs of the container"
|
@echo " logs - Follow the logs of the container"
|
||||||
@echo " shell - Get a shell inside the running container"
|
@echo " shell - Get a shell inside the running container"
|
||||||
@echo " up - Start the dev environment with docker-compose (with Traefik)"
|
@echo " up - Start the dev environment with docker-compose"
|
||||||
@echo " down - Stop the dev environment with docker-compose"
|
@echo " down - Stop the dev environment with docker-compose"
|
||||||
@echo " tag - Tag the image for a registry"
|
@echo " tag - Tag the image for a registry"
|
||||||
@echo " push - Push the image to a registry (after tagging)"
|
@echo " push - Push the image to a registry (after tagging)"
|
||||||
|
@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 " clean - Clean up unused containers and images"
|
||||||
@echo " help - Show this help message"
|
@echo " help - Show this help message"
|
||||||
|
|
||||||
|
|||||||
264
README.md
264
README.md
@ -1,222 +1,80 @@
|
|||||||
"""
|
# AudioEngineHub
|
||||||
NovaAi – TTS-Engine-Hub
|
|
||||||
main.py
|
|
||||||
Version: v0.0.7
|
|
||||||
|
|
||||||
Description:
|
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.
|
||||||
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.
|
|
||||||
|
|
||||||
Author: Abby (ChatGPT)
|
## Features
|
||||||
Date: 2025-07-23
|
|
||||||
Canvas: main.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Query
|
- **Multi-Engine Support:** Easily switch between different TTS engines.
|
||||||
from fastapi.responses import JSONResponse, FileResponse
|
- **Configurable Engines:** Activate or deactivate engines on the fly via a simple configuration file.
|
||||||
from pydantic import BaseModel
|
- **Caching:** Caches generated audio to save resources and provide faster responses for repeated requests.
|
||||||
import os
|
- **Dockerized:** Runs in a containerized environment for easy setup and dependency management.
|
||||||
import base64
|
- **Automatic Port Finding:** Automatically finds and uses a free port, preventing conflicts.
|
||||||
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
|
|
||||||
|
|
||||||
app = FastAPI(
|
## Getting Started
|
||||||
title="NovaAi – TTS-Engine-Hub",
|
|
||||||
version="0.0.7",
|
|
||||||
description="Local-first, modular multi-engine TTS server for your homelab and automation."
|
|
||||||
)
|
|
||||||
|
|
||||||
ENGINE_REGISTRY = {
|
### Prerequisites
|
||||||
"piper": PiperEngine(),
|
|
||||||
"styletts": StyleTTSEngine(),
|
|
||||||
"chattts": ChatTTSEngine(),
|
|
||||||
}
|
|
||||||
|
|
||||||
AUDIO_OUT_DIR = "/tmp/tts_output"
|
- [Docker](https://docs.docker.com/get-docker/)
|
||||||
CACHE_DIR = "/tmp/tts_cache"
|
- [Docker Compose](https://docs.docker.com/compose/install/)
|
||||||
os.makedirs(AUDIO_OUT_DIR, exist_ok=True)
|
|
||||||
os.makedirs(CACHE_DIR, exist_ok=True)
|
|
||||||
|
|
||||||
class TTSRequest(BaseModel):
|
### Installation
|
||||||
text: str
|
|
||||||
engine: str
|
|
||||||
model: str = None
|
|
||||||
speaker: str = None
|
|
||||||
format: str = "ogg"
|
|
||||||
chunking: bool = False
|
|
||||||
|
|
||||||
|
1. **Clone the repository:**
|
||||||
|
```bash
|
||||||
|
git clone <repository_url>
|
||||||
|
cd AudioEngineHub
|
||||||
|
```
|
||||||
|
|
||||||
def build_cache_key(req: TTSRequest) -> str:
|
2. **Configure the environment:**
|
||||||
data = f"{req.text}|{req.engine}|{req.model}|{req.speaker}|{req.format}|{req.chunking}"
|
Create a `.env` file by copying the example file:
|
||||||
return hashlib.sha256(data.encode()).hexdigest()
|
```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):
|
3. **Build and start the container:**
|
||||||
import re
|
Use the `make up` command to build the Docker image and start the service.
|
||||||
sentences = re.split(r'([.!?]\s)', text)
|
```bash
|
||||||
chunks = []
|
make up
|
||||||
buf = ""
|
```
|
||||||
for s in sentences:
|
This command will automatically find a free port starting from 8000 and run the application on it.
|
||||||
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()]
|
|
||||||
|
|
||||||
def concat_audio(files, fmt):
|
> **Note:** For the most reliable port detection, it is recommended to run the command with `sudo`:
|
||||||
if len(files) == 1:
|
> ```bash
|
||||||
return files[0]
|
> sudo make up
|
||||||
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
|
|
||||||
|
|
||||||
@app.post("/tts")
|
## Usage
|
||||||
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"
|
|
||||||
})
|
|
||||||
|
|
||||||
@app.get("/audio/{filename}")
|
The application provides a simple API to generate speech and inspect the available engines.
|
||||||
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)
|
|
||||||
|
|
||||||
@app.get("/engines")
|
### Endpoints
|
||||||
def engines_endpoint():
|
|
||||||
engines = {}
|
|
||||||
for name, engine in ENGINE_REGISTRY.items():
|
|
||||||
engines[name] = engine.healthcheck()
|
|
||||||
return engines
|
|
||||||
|
|
||||||
@app.get("/models")
|
- `POST /tts`: The main endpoint to synthesize text to speech.
|
||||||
def models_endpoint():
|
- `GET /health`: Check the health of the API and the status of the loaded engines.
|
||||||
result = {}
|
- `GET /engines`: List the currently active engines.
|
||||||
for name, engine in ENGINE_REGISTRY.items():
|
- `GET /models`: List the available models for each active engine.
|
||||||
try:
|
_ `GET /speakers`: List the available speakers for a given engine and model.
|
||||||
result[name] = engine.list_models()
|
|
||||||
except Exception as e:
|
|
||||||
result[name] = []
|
|
||||||
return result
|
|
||||||
|
|
||||||
@app.get("/speakers")
|
### Makefile Commands
|
||||||
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}
|
|
||||||
|
|
||||||
@app.get("/version")
|
The project includes a `Makefile` with several commands to simplify development and management:
|
||||||
def version():
|
|
||||||
return {"version": app.version}
|
|
||||||
|
|
||||||
@app.get("/health")
|
- `make up`: Build the image and start the application container.
|
||||||
def health():
|
- `make down`: Stop the application container.
|
||||||
status = {name: engine.healthcheck()["status"] for name, engine in ENGINE_REGISTRY.items()}
|
- `make logs`: View the application logs.
|
||||||
return {"status": status, "detail": "API and engines loaded"}
|
- `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__":
|
## Configuration
|
||||||
import uvicorn
|
|
||||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
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`).
|
||||||
@ -17,8 +17,8 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# Application Configuration
|
# Application Configuration
|
||||||
ACTIVE_ENGINES: Set[str] = {"piper"}
|
ACTIVE_ENGINES: Set[str] = {"piper"}
|
||||||
ASSET_DIR: str = "asset"
|
ASSET_DIR: str = "/home/appuser/app/asset"
|
||||||
AUDIO_CACHE_DIR: str = "asset/audio"
|
AUDIO_CACHE_DIR: str = "/home/appuser/app/asset/audio"
|
||||||
|
|
||||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding='utf-8')
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding='utf-8')
|
||||||
|
|
||||||
@ -13,7 +13,7 @@ Canvas: chattts.py
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from engines.engine_base import TTSEngineBase
|
from .engine_base import TTSEngineBase
|
||||||
|
|
||||||
class ChatTTSEngine(TTSEngineBase):
|
class ChatTTSEngine(TTSEngineBase):
|
||||||
async def synthesize(self, text: str, speaker: str = None, model: str = None, fmt: str = "mp3"):
|
async def synthesize(self, text: str, speaker: str = None, model: str = None, fmt: str = "mp3"):
|
||||||
@ -49,12 +49,12 @@ class F5TTSEngine(TTSEngineBase):
|
|||||||
def _load_speakers(self):
|
def _load_speakers(self):
|
||||||
# Add the default speaker
|
# Add the default speaker
|
||||||
default_wav = str(files("f5_tts").joinpath("infer/examples/basic/basic_ref_en.wav"))
|
default_wav = str(files("f5_tts").joinpath("infer/examples/basic/basic_ref_en.wav"))
|
||||||
default_txt = "engines/f5-tts-voices/default.txt"
|
default_txt = "app/models/f5-tts-voices/default.txt"
|
||||||
if os.path.exists(default_txt):
|
if os.path.exists(default_txt):
|
||||||
self.speakers["default"] = {"wav": default_wav, "txt": default_txt}
|
self.speakers["default"] = {"wav": default_wav, "txt": default_txt}
|
||||||
|
|
||||||
# Scan for custom speakers
|
# Scan for custom speakers
|
||||||
voices_dir = "engines/f5-tts-voices"
|
voices_dir = "app/models/f5-tts-voices"
|
||||||
if not os.path.isdir(voices_dir):
|
if not os.path.isdir(voices_dir):
|
||||||
return
|
return
|
||||||
for file in os.listdir(voices_dir):
|
for file in os.listdir(voices_dir):
|
||||||
@ -19,7 +19,7 @@ import tempfile
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import json
|
import json
|
||||||
from engines.engine_base import TTSEngineBase
|
from .engine_base import TTSEngineBase
|
||||||
import ffmpeg
|
import ffmpeg
|
||||||
|
|
||||||
class PiperEngine(TTSEngineBase):
|
class PiperEngine(TTSEngineBase):
|
||||||
@ -29,7 +29,7 @@ class PiperEngine(TTSEngineBase):
|
|||||||
|
|
||||||
def _load_config(self, model: str):
|
def _load_config(self, model: str):
|
||||||
"""Load the model config JSON file to get speaker mappings."""
|
"""Load the model config JSON file to get speaker mappings."""
|
||||||
model_dir = f"./models/piper/{model}"
|
model_dir = f"./app/models/piper/{model}"
|
||||||
config_file = os.path.join(model_dir, f"{model}.onnx.json")
|
config_file = os.path.join(model_dir, f"{model}.onnx.json")
|
||||||
if os.path.isfile(config_file):
|
if os.path.isfile(config_file):
|
||||||
with open(config_file, 'r') as f:
|
with open(config_file, 'r') as f:
|
||||||
@ -60,7 +60,7 @@ class PiperEngine(TTSEngineBase):
|
|||||||
raise RuntimeError("Piper executable not found. Please install it and ensure it's in your PATH.")
|
raise RuntimeError("Piper executable not found. Please install it and ensure it's in your PATH.")
|
||||||
if not model:
|
if not model:
|
||||||
raise ValueError("Model must be specified for Piper.")
|
raise ValueError("Model must be specified for Piper.")
|
||||||
model_dir = f"./models/piper/{model}"
|
model_dir = f"./app/models/piper/{model}"
|
||||||
model_file = os.path.join(model_dir, f"{model}.onnx")
|
model_file = os.path.join(model_dir, f"{model}.onnx")
|
||||||
if not os.path.isfile(model_file):
|
if not os.path.isfile(model_file):
|
||||||
raise FileNotFoundError(f"Piper model not found: {model_file}")
|
raise FileNotFoundError(f"Piper model not found: {model_file}")
|
||||||
@ -108,7 +108,7 @@ class PiperEngine(TTSEngineBase):
|
|||||||
return output_other_path
|
return output_other_path
|
||||||
|
|
||||||
def list_models(self):
|
def list_models(self):
|
||||||
models_dir = "./models/piper/"
|
models_dir = "./app/models/piper/"
|
||||||
if not os.path.isdir(models_dir):
|
if not os.path.isdir(models_dir):
|
||||||
return []
|
return []
|
||||||
return [name for name in os.listdir(models_dir)
|
return [name for name in os.listdir(models_dir)
|
||||||
@ -13,7 +13,7 @@ Canvas: styletts.py
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from engines.engine_base import TTSEngineBase
|
from .engine_base import TTSEngineBase
|
||||||
|
|
||||||
class StyleTTSEngine(TTSEngineBase):
|
class StyleTTSEngine(TTSEngineBase):
|
||||||
async def synthesize(self, text: str, speaker: str = None, model: str = None, fmt: str = "mp3"):
|
async def synthesize(self, text: str, speaker: str = None, model: str = None, fmt: str = "mp3"):
|
||||||
18
app/main.py
18
app/main.py
@ -21,14 +21,14 @@ import base64
|
|||||||
import shutil
|
import shutil
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
from config import settings
|
from app.config import settings
|
||||||
from engines.piper import PiperEngine
|
from app.engines.piper import PiperEngine
|
||||||
from engines.styletts import StyleTTSEngine
|
from app.engines.styletts import StyleTTSEngine
|
||||||
from engines.chattts import ChatTTSEngine
|
from app.engines.chattts import ChatTTSEngine
|
||||||
from engines.f5_tts import F5TTSEngine
|
from app.engines.f5_tts import F5TTSEngine
|
||||||
from utils.text import chunk_text
|
from app.utils.text import chunk_text
|
||||||
from utils.audio import concat_audio
|
from app.utils.audio import concat_audio
|
||||||
from utils.cache import build_cache_key
|
from app.utils.cache import build_cache_key
|
||||||
|
|
||||||
# --- Master list of all possible engine classes. ---
|
# --- Master list of all possible engine classes. ---
|
||||||
ALL_ENGINES = {
|
ALL_ENGINES = {
|
||||||
@ -201,4 +201,6 @@ if __name__ == "__main__":
|
|||||||
reload=True
|
reload=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -3,42 +3,17 @@ version: '3.8'
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
container_name: audio_engine_hub_app
|
container_name: ${IMAGE_NAME}_app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
# Mount local app directory for hot-reloading in dev
|
# Mount local app directory for hot-reloading in dev
|
||||||
- ./app:/home/appuser/app
|
- ./app:/home/appuser/app
|
||||||
# Mount models directory to provide models to the container
|
# Mount models directory to provide models to the container
|
||||||
- ./models:/home/appuser/models
|
- ./models:/home/appuser/app/models
|
||||||
# Mount asset directory to persist generated audio files
|
# Mount asset directory to persist generated audio files
|
||||||
- ./asset:/home/appuser/asset
|
- ./asset:/home/appuser/app/asset
|
||||||
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.http.routers.app-router.rule=Host(`localhost`)"
|
|
||||||
- "traefik.http.routers.app-router.entrypoints=web"
|
|
||||||
- "traefik.http.services.app-service.loadbalancer.server.port=8000"
|
|
||||||
networks:
|
|
||||||
- web
|
|
||||||
|
|
||||||
traefik:
|
|
||||||
image: "traefik:v2.10"
|
|
||||||
container_name: traefik_proxy
|
|
||||||
command:
|
|
||||||
- "--api.dashboard=true"
|
|
||||||
- "--providers.docker=true"
|
|
||||||
- "--providers.docker.exposedbydefault=false"
|
|
||||||
- "--entrypoints.web.address=:80"
|
|
||||||
ports:
|
ports:
|
||||||
- "80:80" # The HTTP port Traefik listens on
|
- "${APP_PORT:-8000}:8000"
|
||||||
- "8080:8080" # The Traefik Web UI (Dashboard)
|
env_file:
|
||||||
volumes:
|
- .env
|
||||||
- "/var/run/docker.sock:/var/run/docker.sock:ro" # Traefik needs access to the Docker daemon
|
|
||||||
networks:
|
|
||||||
- web
|
|
||||||
|
|
||||||
networks:
|
|
||||||
web:
|
|
||||||
external: false
|
|
||||||
@ -1,3 +1,7 @@
|
|||||||
|
> **Hinweis:** Dieser Leitfaden beschreibt das ursprüngliche, erweiterte Setup dieses Projekts mit einer direkten Traefik-Integration in `docker-compose.yml`. Für die lokale Entwicklung wurde der Standard-Workflow vereinfacht. Die aktuell empfohlene Methode zur Inbetriebnahme des Dienstes finden Sie in der [`README.md`](../README.md). Dieser Leitfaden dient weiterhin als Referenz für fortgeschrittene Konfigurationen, bei denen ein Reverse-Proxy wie Traefik manuell integriert werden soll.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# Leitfaden: Best Practices zur Containerisierung von Python-Backends
|
# Leitfaden: Best Practices zur Containerisierung von Python-Backends
|
||||||
|
|
||||||
Dieser Leitfaden zeigt einen professionellen Workflow zur Containerisierung einer Python-Backend-Anwendung mit Docker, inklusive Integration eines Reverse Proxys (Traefik) für die lokale Entwicklung. Wir verwenden eine minimale FastAPI-Anwendung als Beispiel.
|
Dieser Leitfaden zeigt einen professionellen Workflow zur Containerisierung einer Python-Backend-Anwendung mit Docker, inklusive Integration eines Reverse Proxys (Traefik) für die lokale Entwicklung. Wir verwenden eine minimale FastAPI-Anwendung als Beispiel.
|
||||||
21
run.sh
21
run.sh
@ -1,21 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# NovaAi – TTS-Engine-Hub
|
|
||||||
# run.sh
|
|
||||||
# Version: v0.0.1
|
|
||||||
#
|
|
||||||
# Activates venv and runs main.py via uvicorn (with reload for dev convenience).
|
|
||||||
# Author: Abby (ChatGPT)
|
|
||||||
# Date: 2025-07-23
|
|
||||||
# Canvas: run.sh
|
|
||||||
|
|
||||||
if [ ! -d ".venv" ]; then
|
|
||||||
echo "Virtual environment not found! Please run setup_env.sh first."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
source .venv/bin/activate
|
|
||||||
|
|
||||||
export PYTHONPATH=$(pwd)
|
|
||||||
|
|
||||||
python main.py
|
|
||||||
|
|
||||||
28
scripts/health_check.py
Executable file
28
scripts/health_check.py
Executable file
@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import sys, json
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
if "status" not in data or not isinstance(data["status"], dict):
|
||||||
|
print("ERROR: Invalid health check response. Missing 'status' key.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
all_ok = True
|
||||||
|
for engine, status in data["status"].items():
|
||||||
|
if status != "ok":
|
||||||
|
print(f"ERROR: Engine '{engine}' has status '{status}'.")
|
||||||
|
all_ok = False
|
||||||
|
|
||||||
|
if all_ok:
|
||||||
|
print("Health check PASSED. All engines are ok.")
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
print("Health check FAILED.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
print("ERROR: Failed to decode JSON from health endpoint.")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"An unexpected error occurred: {e}")
|
||||||
|
sys.exit(1)
|
||||||
94
session_resumee.md
Normal file
94
session_resumee.md
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
# Session Resumee - AudioEngineHub Project Refactoring
|
||||||
|
|
||||||
|
**Date:** Donnerstag, 4. Dezember 2025
|
||||||
|
|
||||||
|
**Objective:** Refactor the AudioEngineHub project to follow best practices for containerization, error handling, and project structure, based on a provided `guide.md` document.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Initial Project State & Overview
|
||||||
|
|
||||||
|
The project was a modular FastAPI-based TTS server with a `TTSEngineBase` interface. Key initial findings:
|
||||||
|
* `piper` was functional.
|
||||||
|
* `styletts` and `chattts` were dummy implementations.
|
||||||
|
* `f5_tts` was implemented but inactive.
|
||||||
|
* Configuration was scattered and hardcoded.
|
||||||
|
* Error handling was basic.
|
||||||
|
* Tests (`pytest` and `unittest`) existed but were limited and inconsistent.
|
||||||
|
* No containerization strategy was in place, leading to potential dependency hell.
|
||||||
|
|
||||||
|
### Refactoring Phase 1: Robustness & Configuration
|
||||||
|
|
||||||
|
1. **Centralized Configuration:**
|
||||||
|
* Replaced hardcoded values with `pydantic-settings` for `.env` file management.
|
||||||
|
* `config.py` created (later moved to `app/config.py`).
|
||||||
|
* `IMAGE_NAME` in `Makefile` was also user-configurable.
|
||||||
|
* `requirements.txt` was updated with `pydantic-settings`.
|
||||||
|
* `docker-compose.yml` was updated to use `.env`.
|
||||||
|
|
||||||
|
2. **Configurable Engines Feature:**
|
||||||
|
* Implemented dynamic `ENGINE_REGISTRY` loading based on `ACTIVE_ENGINES` setting in `.env`.
|
||||||
|
* Allows easy activation/deactivation of TTS engines.
|
||||||
|
|
||||||
|
3. **Robust Error Handling:**
|
||||||
|
* Implemented comprehensive input validation in the `/tts` endpoint (checking engine, model, speaker existence).
|
||||||
|
* Added dependency checks (e.g., `ffmpeg`, `piper` executables) to engines, reporting `HTTP 503` for unavailable engines.
|
||||||
|
* Secured `tempfile.mktemp` usage by replacing it with `tempfile.NamedTemporaryFile`.
|
||||||
|
* Wrapped synthesis logic in `try-except` blocks to catch and propagate engine-specific errors as `HTTP 500`.
|
||||||
|
|
||||||
|
4. **Asynchronous Operations:**
|
||||||
|
* Changed `TTSEngineBase.synthesize` and `selftest` to `async`.
|
||||||
|
* Refactored all concrete engine implementations (`piper`, `f5_tts`, `styletts`, `chattts`) to use `async def` methods.
|
||||||
|
* Updated `app/main.py`'s `/tts` endpoint to be `async` and use `await` for engine calls and `asyncio.gather` for concurrent chunk synthesis.
|
||||||
|
* Wrapped blocking I/O (file ops, `ffmpeg`) and CPU-bound tasks in `asyncio.to_thread`.
|
||||||
|
* Updated `tests/test_f5_tts.py` to correctly `await` async calls.
|
||||||
|
|
||||||
|
### Refactoring Phase 2: Containerization & Workflow (Based on `guide.md`)
|
||||||
|
|
||||||
|
1. **Integrated `Makefile`:**
|
||||||
|
* Created a `Makefile` with targets for `build`, `run`, `stop`, `logs`, `shell`, `up`, `down`, `test`, `tag`, `push`, `clean`, `help`.
|
||||||
|
* Included robust shell functions for port checking (`check_traefik_ports_free`, `check_app_port_free`).
|
||||||
|
* Set `SHELL := /bin/bash` in `Makefile` to ensure correct shell interpretation.
|
||||||
|
* Ensured `PYTHONPATH=$(PWD)` is set for `make test`.
|
||||||
|
|
||||||
|
2. **Adopted Multi-Stage `Dockerfile`:**
|
||||||
|
* Implemented a multi-stage `Dockerfile` (builder/runner stages).
|
||||||
|
* `builder` stage creates a Python virtual environment and installs `requirements.txt` (including `gunicorn`).
|
||||||
|
* `runner` stage uses `python:3.11-slim`, installs runtime system dependencies (`ffmpeg`), creates an unprivileged `appuser`, and sets the production `CMD` to `gunicorn` with `uvicorn` workers.
|
||||||
|
|
||||||
|
3. **Refactored Project Structure (`app/` package):**
|
||||||
|
* Created an `app/` directory.
|
||||||
|
* Moved `main.py`, `config.py`, `engines/`, `models/`, `utils/` into `app/`.
|
||||||
|
* Created `app/__init__.py`.
|
||||||
|
* Updated all Python import paths (`from app.config import settings`, `from app.engines.piper import PiperEngine`, etc.).
|
||||||
|
* Updated internal references in engine files (e.g., `model_dir`, `voices_dir`).
|
||||||
|
|
||||||
|
4. **Refined `docker-compose.yml` with Traefik:**
|
||||||
|
* Integrated `traefik` service for dynamic reverse proxying during local development.
|
||||||
|
* Modified `app` service with Traefik `labels` and connected both services to a `web` network.
|
||||||
|
* Adjusted Docker `volumes` mounts to match the new `app/` structure (e.g., `./models:/home/appuser/app/models`).
|
||||||
|
* Updated `app` service `command` for Uvicorn hot-reloading in dev.
|
||||||
|
|
||||||
|
5. **Centralized Testing Workflow:**
|
||||||
|
* Removed `test_run.sh`.
|
||||||
|
* Integrated `make test` for running `pytest --cov=. app/ tests/`.
|
||||||
|
|
||||||
|
6. **Dedicated Documentation:**
|
||||||
|
* Created `docs/` directory.
|
||||||
|
* Moved `guide.md` to `docs/guide.md`.
|
||||||
|
|
||||||
|
### Verification & Troubleshooting
|
||||||
|
|
||||||
|
* **Tests:** All unit/integration tests (`make test`) are passing.
|
||||||
|
* **Local Run (Virtual Env):** Initial local runs (`python app/main.py`) failed due to `ModuleNotFoundError` (fixed by `python -m app.main`) and `PermissionError` (fixed by needing to mock or redirect `settings.AUDIO_CACHE_DIR` for local direct execution, but not strictly needed for successful app execution through `uvicorn`). The current approach is to verify in Docker.
|
||||||
|
* **Docker Compose:**
|
||||||
|
* Initial `make up` failures were due to an outdated `docker-compose` client (`1.29.2`) and later, `Makefile` syntax issues (fixed by setting `SHELL := /bin/bash` and fixing macros).
|
||||||
|
* `docker-compose` was eventually updated to the `docker compose` CLI plugin (v5.0.0).
|
||||||
|
* `make up` command finally succeeded in bringing up containers.
|
||||||
|
* The `curl http://localhost/health` command returned `404 page not found`. This indicates a potential routing issue with Traefik or the application not being responsive on the expected path within the container. (This is the last unresolved issue).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Next Steps (Troubleshooting the 404):**
|
||||||
|
|
||||||
|
The `404 page not found` when accessing `http://localhost/health` via Traefik is the current blocker for full verification. I need to investigate the logs of the `audio-engine-hub_app` container (the FastAPI app) to determine if the application itself is starting correctly and serving the `/health` endpoint as expected. If the app is indeed serving, the issue lies with Traefik's routing configuration.
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from main import create_app # Import the app factory function
|
from app.main import create_app # Import the app factory function
|
||||||
from config import settings # Import settings to monkeypatch
|
from app.config import settings # Import settings to monkeypatch
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@ -3,12 +3,12 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
import asyncio
|
import asyncio
|
||||||
from importlib.resources import files
|
from importlib.resources import files
|
||||||
from engines.f5_tts import F5TTSEngine
|
from app.engines.f5_tts import F5TTSEngine
|
||||||
|
|
||||||
class TestF5TTSEngine(unittest.TestCase):
|
class TestF5TTSEngine(unittest.TestCase):
|
||||||
async def asyncSetUp(self):
|
async def asyncSetUp(self):
|
||||||
self.engine = F5TTSEngine()
|
self.engine = F5TTSEngine()
|
||||||
self.voices_dir = "engines/f5-tts-voices"
|
self.voices_dir = "app/models/f5-tts-voices"
|
||||||
self.test_speaker_name = "test_speaker"
|
self.test_speaker_name = "test_speaker"
|
||||||
self.test_speaker_wav = os.path.join(self.voices_dir, f"{self.test_speaker_name}.wav")
|
self.test_speaker_wav = os.path.join(self.voices_dir, f"{self.test_speaker_name}.wav")
|
||||||
self.test_speaker_txt = os.path.join(self.voices_dir, f"{self.test_speaker_name}.txt")
|
self.test_speaker_txt = os.path.join(self.voices_dir, f"{self.test_speaker_name}.txt")
|
||||||
|
|||||||
Reference in New Issue
Block a user