first commit

This commit is contained in:
2025-12-04 11:58:36 +01:00
commit 21cdc65ade
38 changed files with 8176 additions and 0 deletions

34
.dockerignore Normal file
View File

@ -0,0 +1,34 @@
# Git / version control
.git
.gitignore
.dockerignore
# Virtual environment
.venv
venv/
.env/
# IDE / Editor folders
.idea/
.vscode/
# Python cache
__pycache__/
*.pyc
*.pyo
# Build artifacts
build/
dist/
*.egg-info
# Test artifacts
.pytest_cache/
.coverage
# Docker files
Dockerfile
docker-compose.yml
# Local environment settings
.env

7
.env.example Normal file
View File

@ -0,0 +1,7 @@
# Comma-separated list of engines to activate.
# Available options (potentially): piper, styletts, f5_tts, chattts
ACTIVE_ENGINES='["piper", "styletts"]'
# Server configuration
HOST=0.0.0.0
PORT=8000

52
.gitignore vendored Normal file
View File

@ -0,0 +1,52 @@
# .gitignore für Python-Projekt
# Version: v1.1
# Erstellt: 2025-07-25
# Bytecode & Caches
__pycache__/
*.py[cod]
*$py.class
# Virtuelle Umgebungen
venv/
.env/
.virtualenv/
.venv/
# IDE/Editor
.vscode/
.idea/
# Test- & Coverage-Dateien
htmlcov/
.coverage
.coverage.*
.pytest_cache/
nosetests.xml
coverage.xml
# Build & Distribution
build/
dist/
*.egg-info/
.eggs/
pip-wheel-metadata/
# Logs & Temp
*.log
*.tmp
*.bak
*.swp
.DS_Store
# Umgebungsvariablen
.env
# Sprachmodelle (TTS)
tts_models/
voice_models/
*.pt
*.onnx
*.bin
*.safetensors
asset/

42
Dockerfile Normal file
View File

@ -0,0 +1,42 @@
# Stage 1: Builder
FROM python:3.11 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
# Stage 2: Runner (The final image)
FROM python:3.11-slim
# Install system dependencies needed at runtime
# ffmpeg is required for audio conversion
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
&& 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 --from=builder /opt/venv /opt/venv
# 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"]

150
Makefile Normal file
View File

@ -0,0 +1,150 @@
# --- Configuration ---
IMAGE_NAME := audio-engine-hub
TAG := latest
# Default internal application port (exposed to Traefik)
PORT := 8000
# Traefik's external entrypoints
TRAEFIK_WEB_PORT := 80
TRAEFIK_DASHBOARD_PORT := 8080
# --- Port Checking Functions ---
# Function to check if required Traefik ports are free
# Exits if any are busy, does not suggest alternatives.
define check_traefik_ports_free
@echo "Checking if Traefik ports ($(TRAEFIK_WEB_PORT}, $(TRAEFIK_DASHBOARD_PORT}) are free..." >&2
@local busy_ports=""; \
if ss -tulnp | grep ":$(TRAEFIK_WEB_PORT) " > /dev/null; then \
busy_ports="$$busy_ports $(TRAEFIK_WEB_PORT)"; \
fi; \
if ss -tulnp | grep ":$(TRAEFIK_DASHBOARD_PORT) " > /dev/null; then \
busy_ports="$$busy_ports $(TRAEFIK_DASHBOARD_PORT)"; \
fi; \
if [ -n "$$busy_ports" ]; then \
echo "ERROR: The following Traefik ports are already in use: $$busy_ports. Please free them or stop Traefik if already running." >&2; \
exit 1; \
fi; \
@echo "Traefik ports are free." >&2
endef
# Function to find next free host port for the app and ask user
# 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
# --- Docker Commands ---
.PHONY: build
build:
@echo "Building Docker image: $(IMAGE_NAME):$(TAG)"
docker build -t $(IMAGE_NAME):$(TAG) .
.PHONY: run
run:
@export SELECTED_HOST_PORT=$$(bash -c 'func() { $(check_app_port_free) }; func') && \
echo "Using host port $$SELECTED_HOST_PORT for single app container" && \
docker run -d -p $$SELECTED_HOST_PORT:$(PORT) --name $(IMAGE_NAME) $(IMAGE_NAME):$(TAG)
.PHONY: stop
stop:
@echo "Stopping Docker container: $(IMAGE_NAME)"
docker stop $(IMAGE_NAME) || true
docker rm $(IMAGE_NAME) || true
.PHONY: logs
logs:
@echo "Showing logs for container: $(IMAGE_NAME)"
docker logs -f $(IMAGE_NAME)
.PHONY: shell
shell:
@echo "Accessing shell in container: $(IMAGE_NAME)"
docker exec -it $(IMAGE_NAME) /bin/bash
# --- Docker Compose Commands ---
.PHONY: up
up:
$(call check_traefik_ports_free) # Check Traefik ports before starting
@echo "Starting development environment with Docker Compose (Traefik enabled)..."
docker-compose up --build -d
.PHONY: down
down:
@echo "Stopping development environment with Docker Compose..."
docker-compose down
# --- Image Management ---
.PHONY: tag
tag:
@echo "Tagging image $(IMAGE_NAME):$(TAG) as $(REGISTRY)/$(IMAGE_NAME):$(TAG)"
docker tag $(IMAGE_NAME):$(TAG) $(REGISTRY)/$(IMAGE_NAME):$(TAG)
.PHONY: push
push: tag
@echo "Pushing image $(REGISTRY)/$(IMAGE_NAME):$(TAG) to registry..."
docker push $(REGISTRY)/$(IMAGE_NAME):$(TAG)
.PHONY: test
test:
@echo "Running tests with coverage..."
pytest --cov=. app/ tests/
# --- Cleanup ---
.PHONY: clean
clean:
@echo "Cleaning up stopped containers and dangling images..."
docker container prune -f
docker image prune -f
.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 with docker-compose (with Traefik)"
@echo " down - Stop the dev environment with docker-compose"
@echo " tag - Tag the image for a registry"
@echo " push - Push the image to a registry (after tagging)"
@echo " clean - Clean up unused containers and images"
@echo " help - Show this help message"
.DEFAULT_GOAL := help

222
README.md Normal file
View File

@ -0,0 +1,222 @@
"""
NovaAi – TTS-Engine-Hub
main.py
Version: v0.0.7
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.
Author: Abby (ChatGPT)
Date: 2025-07-23
Canvas: main.py
"""
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
app = FastAPI(
title="NovaAi – TTS-Engine-Hub",
version="0.0.7",
description="Local-first, modular multi-engine TTS server for your homelab and automation."
)
ENGINE_REGISTRY = {
"piper": PiperEngine(),
"styletts": StyleTTSEngine(),
"chattts": ChatTTSEngine(),
}
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)
class TTSRequest(BaseModel):
text: str
engine: str
model: str = None
speaker: str = None
format: str = "ogg"
chunking: bool = False
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()
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()]
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
@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"
})
@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)
@app.get("/engines")
def engines_endpoint():
engines = {}
for name, engine in ENGINE_REGISTRY.items():
engines[name] = engine.healthcheck()
return engines
@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
@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}
@app.get("/version")
def version():
return {"version": app.version}
@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"}
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

0
app/__init__.py Normal file
View File

204
app/main.py Normal file
View File

@ -0,0 +1,204 @@
"""
NovaAi – TTS-Engine-Hub
main.py
Version: v0.1.0
Description:
Refactored main.py: uses utils modules for chunking, concat, and cache key generation.
All endpoints, features, and logic as before, but cleaner and more modular.
Author: Abby (ChatGPT)
Date: 2025-07-23
Canvas: main.py
"""
import asyncio
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import JSONResponse, FileResponse
from pydantic import BaseModel
import os
import base64
import shutil
import uvicorn
from config import settings
from engines.piper import PiperEngine
from engines.styletts import StyleTTSEngine
from engines.chattts import ChatTTSEngine
from engines.f5_tts import F5TTSEngine
from utils.text import chunk_text
from utils.audio import concat_audio
from utils.cache import build_cache_key
# --- Master list of all possible engine classes. ---
ALL_ENGINES = {
"piper": PiperEngine,
"styletts": StyleTTSEngine,
"chattts": ChatTTSEngine,
"f5-tts": F5TTSEngine,
}
def create_app():
app = FastAPI(
title="NovaAi – TTS-Engine-Hub",
version="0.3.0",
description="Local-first, modular multi-engine TTS server for your homelab and automation."
)
# Dynamically build the registry of active engines based on settings.
# This registry is local to the app instance created by this function.
app.ENGINE_REGISTRY = {}
for engine_name in settings.ACTIVE_ENGINES:
if engine_name in ALL_ENGINES:
print(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.")
# Ensure the audio asset/cache directory exists.
os.makedirs(settings.AUDIO_CACHE_DIR, exist_ok=True)
class TTSRequest(BaseModel):
text: str
engine: str
model: str = None
speaker: str = None
format: str = "ogg"
chunking: bool = False
@app.post("/tts")
async def tts_endpoint(req: TTSRequest, as_base64: bool = Query(False, alias="as")):
# --- 1. Check for engine and handle health ---
engine = app.ENGINE_REGISTRY.get(req.engine.lower())
if not engine:
raise HTTPException(status_code=404, detail=f"Engine '{req.engine}' not found.")
health = engine.healthcheck()
if health.get("status") != "ok":
raise HTTPException(status_code=503, detail=f"Engine '{req.engine}' is not available. Status: {health.get('status')}")
# --- 2. Input validation ---
available_models = engine.list_models()
if req.model and available_models and req.model not in available_models:
raise HTTPException(status_code=400, detail=f"Model '{req.model}' not found for engine '{req.engine}'. Available models: {available_models}")
available_voices = engine.list_voices(req.model)
if req.speaker and available_voices and req.speaker not in available_voices:
raise HTTPException(status_code=400, detail=f"Speaker '{req.speaker}' not found for model '{req.model}'. Available speakers: {available_voices}")
# --- 3. Check cache ---
cache_key = build_cache_key(req)
ext = f'.{req.format.lower()}'
output_filename = f"tts_{cache_key}{ext}"
output_filepath = os.path.join(settings.AUDIO_CACHE_DIR, output_filename)
is_cached = await asyncio.to_thread(os.path.isfile, output_filepath)
if is_cached:
if as_base64:
audio_bytes = await asyncio.to_thread(lambda: open(output_filepath, "rb").read())
audio_b64 = base64.b64encode(audio_bytes).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/{output_filename}", "cached": True, "chunking": req.chunking,
"message": "Audio served from cache. Download from audio_url"
})
# --- 4. Synthesize audio ---
try:
if req.chunking and len(req.text) > 250:
chunks = chunk_text(req.text, maxlen=250)
synthesis_tasks = [engine.synthesize(c, speaker=req.speaker, model=req.model, fmt=req.format) for c in chunks]
chunk_files = await asyncio.gather(*synthesis_tasks)
synthesized_path = await asyncio.to_thread(concat_audio, chunk_files, req.format.lower())
else:
synthesized_path = await engine.synthesize(req.text, speaker=req.speaker, model=req.model, fmt=req.format)
except (RuntimeError, ValueError, FileNotFoundError) as e:
raise HTTPException(status_code=500, detail=f"Error during synthesis: {e}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"An unexpected error occurred: {e}")
# --- 5. Cache and return result ---
await asyncio.to_thread(shutil.copy, synthesized_path, output_filepath)
# If the synth created a temp file in a different directory, clean it up
if settings.AUDIO_CACHE_DIR not in os.path.abspath(synthesized_path):
await asyncio.to_thread(os.remove, synthesized_path)
if as_base64:
audio_bytes = await asyncio.to_thread(lambda: open(output_filepath, "rb").read())
audio_b64 = base64.b64encode(audio_bytes).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/{output_filename}", "cached": False, "chunking": req.chunking,
"message": "Synthesized new audio. Download from audio_url"
})
@app.get("/audio/{filename}")
async def audio_file(filename: str): # Made async
fpath = os.path.join(settings.AUDIO_CACHE_DIR, filename)
if not await asyncio.to_thread(os.path.isfile, fpath) or not await asyncio.to_thread(lambda: fpath.startswith(os.path.abspath(settings.AUDIO_CACHE_DIR))):
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")
def engines_endpoint():
engines = {}
for name, engine in app.ENGINE_REGISTRY.items():
engines[name] = engine.healthcheck()
return engines
@app.get("/models")
def models_endpoint():
result = {}
for name, engine in app.ENGINE_REGISTRY.items():
try:
result[name] = engine.list_models()
except Exception as e:
result[name] = {"error": str(e)}
return result
@app.get("/speakers")
def speakers_endpoint(engine: str, model: str = None):
e = app.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")
def version():
return {"version": app.version}
@app.get("/health")
def health():
status = {name: engine.healthcheck()["status"] for name, engine in app.ENGINE_REGISTRY.items()}
return {"status": status, "detail": "API and engines loaded"}
return app
# If main.py is executed directly, create the app and run uvicorn
if __name__ == "__main__":
app_instance = create_app()
uvicorn.run(
app_instance, # Pass the app instance
host=settings.HOST,
port=settings.PORT,
reload=True
)

25
config.py Normal file
View File

@ -0,0 +1,25 @@
"""
NovaAi – TTS-Engine-Hub
config.py
Version: v0.0.1
Description:
Centralized configuration management using pydantic-settings.
Loads settings from a .env file and environment variables.
"""
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import List, Set
class Settings(BaseSettings):
# Server Configuration
HOST: str = "0.0.0.0"
PORT: int = 8000
# Application Configuration
ACTIVE_ENGINES: Set[str] = {"piper"}
ASSET_DIR: str = "asset"
AUDIO_CACHE_DIR: str = "asset/audio"
model_config = SettingsConfigDict(env_file=".env", env_file_encoding='utf-8')
settings = Settings()

44
docker-compose.yml Normal file
View File

@ -0,0 +1,44 @@
version: '3.8'
services:
app:
build: .
container_name: audio_engine_hub_app
restart: unless-stopped
volumes:
# Mount local app directory for hot-reloading in dev
- ./app:/home/appuser/app
# Mount models directory to provide models to the container
- ./models:/home/appuser/models
# Mount asset directory to persist generated audio files
- ./asset:/home/appuser/asset
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:
- "80:80" # The HTTP port Traefik listens on
- "8080:8080" # The Traefik Web UI (Dashboard)
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro" # Traefik needs access to the Docker daemon
networks:
- web
networks:
web:
external: false

48
engines/chattts.py Normal file
View File

@ -0,0 +1,48 @@
"""
NovaAi – TTS-Engine-Hub
engines/chattts.py
Version: v0.0.1
Description:
ChatTTS engine adapter.
Implements TTSEngineBase interface for ChatTTS integration (dummy implementation).
Author: Abby (ChatGPT)
Date: 2025-07-23
Canvas: chattts.py
"""
import asyncio
from engines.engine_base import TTSEngineBase
class ChatTTSEngine(TTSEngineBase):
async def synthesize(self, text: str, speaker: str = None, model: str = None, fmt: str = "mp3"):
# Dummy implementation: returns an empty string as it doesn't produce a file.
print("Warning: ChatTTSEngine.synthesize is a dummy and does not produce audio.")
return ""
def list_models(self):
# Dummy implementation
return ["chattts-v1", "chattts-v2"]
def list_voices(self, model: str = None):
# Dummy implementation
return ["default", "custom1", "custom2"]
def healthcheck(self):
# Dummy implementation
return {"status": "ok", "engine": "chattts"}
async def selftest(self):
# Dummy implementation
return {"selftest": True, "engine": "chattts"}
if __name__ == "__main__":
async def main():
engine = ChatTTSEngine()
print("Selftest:", await engine.selftest())
print("Models:", engine.list_models())
print("Voices:", engine.list_voices())
print("Healthcheck:", engine.healthcheck())
asyncio.run(main())

45
engines/engine_base.py Normal file
View File

@ -0,0 +1,45 @@
"""
NovaAi – TTS-Engine-Hub
engine_base.py
Version: v0.0.1
Description:
Abstract base class for all TTS engine modules.
Defines the required interface for engine adapters.
Author: Abby (ChatGPT)
Date: 2025-07-23
Canvas: engine_base.py
"""
from abc import ABC, abstractmethod
import asyncio
class TTSEngineBase(ABC):
@abstractmethod
async def synthesize(self, text: str, speaker: str = None, model: str = None, fmt: str = "ogg"):
"""
Asynchronously generate speech audio from text input.
Returns path to audio file.
"""
raise NotImplementedError
@abstractmethod
def list_models(self):
"""Return a list of available models."""
raise NotImplementedError
@abstractmethod
def list_voices(self, model: str = None):
"""Return a list of available voices for a model."""
raise NotImplementedError
@abstractmethod
def healthcheck(self):
"""Return health/status info for this engine."""
raise NotImplementedError
@abstractmethod
async def selftest(self):
"""Asynchronously run internal self-test (basic functionality check)."""
raise NotImplementedError

View File

@ -0,0 +1 @@
Some call me nature, others call me mother nature.

143
engines/f5_tts.py Normal file
View File

@ -0,0 +1,143 @@
"""
NovaAi – TTS-Engine-Hub
f5_tts.py
Version: v0.0.2
Description:
F5-TTS engine module.
Implements the TTSEngineBase for F5-TTS text-to-speech synthesis.
Now with robust speaker handling.
Author: Your Name (or leave as generated)
Date: 2025-12-03
"""
import os
import tempfile
import torch
import torchaudio
import numpy as np
import soundfile as sf
import asyncio
from .engine_base import TTSEngineBase
from importlib.resources import files
try:
from f5_tts.api import F5TTS
except ImportError:
print("Warning: F5TTS could not be imported. F5-TTS engine will not be available.")
F5TTS = None
class F5TTSEngine(TTSEngineBase):
def __init__(self):
# Initialize F5-TTS specific resources, models, etc.
print("F5-TTS Engine Initializing...")
self.speakers = {}
self.model = None
if F5TTS:
try:
self.model = F5TTS(model="F5TTS_v1_Base")
print("F5-TTS Engine Initialized.")
self._load_speakers()
except Exception as e:
print(f"Error initializing F5-TTS Engine: {e}")
self.model = None
else:
print("F5-TTS Engine not initialized because F5TTS is not available.")
def _load_speakers(self):
# Add the default speaker
default_wav = str(files("f5_tts").joinpath("infer/examples/basic/basic_ref_en.wav"))
default_txt = "engines/f5-tts-voices/default.txt"
if os.path.exists(default_txt):
self.speakers["default"] = {"wav": default_wav, "txt": default_txt}
# Scan for custom speakers
voices_dir = "engines/f5-tts-voices"
if not os.path.isdir(voices_dir):
return
for file in os.listdir(voices_dir):
if file.endswith(".wav"):
speaker_name = file.rsplit('.', 1)[0]
wav_path = os.path.join(voices_dir, file)
txt_path = os.path.join(voices_dir, f"{speaker_name}.txt")
if os.path.exists(txt_path):
self.speakers[speaker_name] = {"wav": wav_path, "txt": txt_path}
print(f"Found custom speaker: {speaker_name}")
def _blocking_synthesize(self, text: str, speaker: str, fmt: str):
"""The actual blocking synthesis logic."""
speaker_data = self.speakers[speaker]
ref_file = speaker_data["wav"]
with open(speaker_data["txt"], 'r') as f:
ref_text = f.read()
print(f"F5-TTS: Synthesizing '{text}' with reference voice from '{ref_file}'.")
wav, sr, spec = self.model.infer(
ref_file=ref_file,
ref_text=ref_text,
gen_text=text,
)
with tempfile.NamedTemporaryFile(delete=False, suffix=f".{fmt}") as temp_file:
if fmt == "wav":
torchaudio.save(temp_file.name, torch.from_numpy(wav).unsqueeze(0), sr, format="wav")
else:
# Convert to float32 for soundfile
wav_float = wav.astype(np.float32) / np.iinfo(wav.dtype).max
sf.write(temp_file.name, wav_float, sr)
return temp_file.name
async def synthesize(self, text: str, speaker: str = None, model: str = None, fmt: str = "ogg"):
"""
Asynchronously generate speech audio from text input using F5-TTS.
"""
if not self.model:
raise RuntimeError("F5-TTS Engine not initialized.")
speaker_to_use = speaker if speaker in self.speakers else "default"
if speaker and speaker not in self.speakers:
print(f"Warning: Speaker '{speaker}' not found. Falling back to default speaker.")
if speaker_to_use not in self.speakers:
raise RuntimeError("No default speaker found for F5-TTS. Please add a 'default.wav' and 'default.txt' to the 'engines/f5-tts-voices' directory.")
try:
# Run the blocking synthesis in a separate thread
return await asyncio.to_thread(self._blocking_synthesize, text, speaker_to_use, fmt)
except Exception as e:
raise RuntimeError(f"F5-TTS synthesis failed: {e}")
def list_models(self):
"""Return a list of available F5-TTS models."""
if not self.model:
return []
return ["F5TTS_v1_Base"]
def list_voices(self, model: str = None):
"""Return a list of available F5-TTS voices for a model."""
return list(self.speakers.keys())
def healthcheck(self):
"""Return health/status info for F5-TTS engine."""
if self.model:
return {"status": "ok", "message": "F5-TTS engine is ready"}
else:
return {"status": "error", "message": "F5-TTS engine failed to initialize"}
async def selftest(self):
"""Run internal self-test for F5-TTS."""
if not self.model:
return {"status": "failed", "message": "F5-TTS Engine not initialized."}
try:
# Await the async synthesize method
audio_file = await self.synthesize("this is a test.")
selftest_passed = os.path.exists(audio_file) and os.path.getsize(audio_file) > 0
if selftest_passed:
os.remove(audio_file)
return {"status": "passed" if selftest_passed else "failed", "message": "F5-TTS self-test successful"}
except Exception as e:
return {"status": "failed", "message": f"F5-TTS self-test failed: {e}"}

163
engines/piper.py Normal file
View File

@ -0,0 +1,163 @@
"""
NovaAi – TTS-Engine-Hub
engines/piper.py
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
Author: Abby (ChatGPT)
Date: 2025-07-23
Canvas: piper.py
"""
import asyncio
import subprocess
import tempfile
import os
import shutil
import json
from engines.engine_base import TTSEngineBase
import ffmpeg
class PiperEngine(TTSEngineBase):
def __init__(self):
self.piper_executable = shutil.which("piper")
self.ffmpeg_executable = shutil.which("ffmpeg")
def _load_config(self, model: str):
"""Load the model config JSON file to get speaker mappings."""
model_dir = f"./models/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:
return json.load(f)
return {}
def _get_speaker_id(self, speaker: str, model: str):
"""Convert speaker name to speaker ID using the model's config."""
if not speaker or speaker == "default":
return None
if speaker.isdigit():
return speaker
config = self._load_config(model)
speaker_id_map = config.get('speaker_id_map', {})
return str(speaker_id_map.get(speaker))
def _run_ffmpeg_blocking(self, input_path, output_path):
"""Wrapper for the blocking ffmpeg call."""
(
ffmpeg
.input(input_path)
.output(output_path)
.run(overwrite_output=True, quiet=True)
)
async def synthesize(self, text: str, speaker: str = None, model: str = None, fmt: str = "ogg"):
if not self.piper_executable:
raise RuntimeError("Piper executable not found. Please install it and ensure it's in your PATH.")
if not model:
raise ValueError("Model must be specified for Piper.")
model_dir = f"./models/piper/{model}"
model_file = os.path.join(model_dir, f"{model}.onnx")
if not os.path.isfile(model_file):
raise FileNotFoundError(f"Piper model not found: {model_file}")
with tempfile.NamedTemporaryFile(suffix=".wav", prefix="piper_", delete=False) as wav_file:
output_wav_path = wav_file.name
cmd = [self.piper_executable, "--model", model_file, "--output_file", output_wav_path, "--stdin_text"]
if speaker:
speaker_id = self._get_speaker_id(speaker, model)
if speaker_id:
cmd += ["--speaker", speaker_id]
process = await asyncio.create_subprocess_exec(
*cmd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate(input=text.encode('utf-8'))
if process.returncode != 0:
os.remove(output_wav_path)
raise RuntimeError(f"Piper synth failed: {stderr.decode()}")
fmt = (fmt or "ogg").lower()
if fmt == "wav":
return output_wav_path
if not self.ffmpeg_executable:
os.remove(output_wav_path)
raise RuntimeError("ffmpeg not found, cannot convert audio format.")
with tempfile.NamedTemporaryFile(suffix=f'.{fmt}', prefix="piper_conv_", delete=False) as converted_file:
output_other_path = converted_file.name
try:
await asyncio.to_thread(self._run_ffmpeg_blocking, output_wav_path, output_other_path)
except Exception as e:
raise RuntimeError(f"ffmpeg conversion failed: {e}")
finally:
os.remove(output_wav_path)
return output_other_path
def list_models(self):
models_dir = "./models/piper/"
if not os.path.isdir(models_dir):
return []
return [name for name in os.listdir(models_dir)
if os.path.isdir(os.path.join(models_dir, name))]
def list_voices(self, model: str = None):
if not model:
return ["default"]
config = self._load_config(model)
speaker_id_map = config.get('speaker_id_map', {})
if speaker_id_map:
return ["default"] + sorted(speaker_id_map.keys())
return ["default"]
def healthcheck(self):
status = "ok"
if not self.piper_executable:
status = "missing_piper_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"}
try:
models = self.list_models()
if not models:
return {"selftest": False, "error": "No Piper models found.", "engine": "piper"}
test_text = "This is a selftest."
first_model = models[0]
voices = self.list_voices(first_model)
test_voice = voices[0] if voices else None
audio_file = await self.synthesize(test_text, speaker=test_voice, model=first_model, 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": models, "engine": "piper"}
except Exception as e:
return {"selftest": False, "error": str(e), "engine": "piper"}
if __name__ == "__main__":
async def main():
engine = PiperEngine()
print("Selftest:", await engine.selftest())
print("Models:", engine.list_models())
print("Voices:", engine.list_voices(engine.list_models()[0]))
print("Healthcheck:", engine.healthcheck())
asyncio.run(main())

48
engines/styletts.py Normal file
View File

@ -0,0 +1,48 @@
"""
NovaAi – TTS-Engine-Hub
engines/styletts.py
Version: v0.0.1
Description:
StyleTTS engine adapter.
Implements TTSEngineBase interface for StyleTTS integration (dummy implementation).
Author: Abby (ChatGPT)
Date: 2025-07-23
Canvas: styletts.py
"""
import asyncio
from engines.engine_base import TTSEngineBase
class StyleTTSEngine(TTSEngineBase):
async def synthesize(self, text: str, speaker: str = None, model: str = None, fmt: str = "mp3"):
# Dummy implementation: returns an empty string as it doesn't produce a file.
print("Warning: StyleTTSEngine.synthesize is a dummy and does not produce audio.")
return ""
def list_models(self):
# Dummy implementation
return ["styletts_v2_de", "styletts_v2_en"]
def list_voices(self, model: str = None):
# Dummy implementation
return ["neutral", "emotional", "female"]
def healthcheck(self):
# Dummy implementation
return {"status": "ok", "engine": "styletts"}
async def selftest(self):
# Dummy implementation
return {"selftest": True, "engine": "styletts"}
if __name__ == "__main__":
async def main():
engine = StyleTTSEngine()
print("Selftest:", await engine.selftest())
print("Models:", engine.list_models())
print("Voices:", engine.list_voices())
print("Healthcheck:", engine.healthcheck())
asyncio.run(main())

296
guide.md Normal file
View File

@ -0,0 +1,296 @@
# 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.
Die vorgestellten Methoden umfassen:
- Optimierte, sichere Docker-Images durch Multi-Stage-Builds.
- Einen einfachen Workflow durch die Verwendung eines `Makefile`.
- Trennung von Entwicklungs- und Produktionsumgebungen.
- Korrekte Verwaltung von Code und Images mit Git und einer Container Registry.
- Integration von Traefik für dynamisches Routing in der lokalen Entwicklung.
## 1. Projektstruktur
Wir haben die folgende Struktur erstellt:
```
/
├── .dockerignore # Listet Dateien auf, die Docker ignorieren soll.
├── .gitignore # Listet Dateien auf, die Git ignorieren soll.
├── Dockerfile # Die Blaupause für unser produktives Docker-Image.
├── Makefile # Vereinfacht die Ausführung von Docker-Befehlen.
├── app/
│ ├── __init__.py # Macht 'app' zu einem Python-Paket.
│ └── main.py # Unser FastAPI-Anwendungscode.
├── docker-compose.yml # Definiert die lokale Entwicklungsumgebung mit Traefik.
├── guide.md # Dieser Leitfaden.
└── requirements.txt # Liste der Python-Abhängigkeiten.
```
## 2. Die Komponenten im Detail
### `requirements.txt`
Hier definieren wir die Python-Bibliotheken, die unser Projekt benötigt.
```
fastapi
uvicorn[standard]
gunicorn
```
- `fastapi`: Das Web-Framework.
- `uvicorn`: Ein schneller ASGI-Server, ideal für die Entwicklung.
- `gunicorn`: Ein robuster WSGI-Produktionsserver, der Uvicorn-Worker zur Ausführung unserer ASGI-Anwendung nutzt.
### `.gitignore` & `.dockerignore`
- **`.gitignore`**: Verhindert, dass sensible Daten (`.env`), temporäre Dateien (`__pycache__`) oder lokale Konfigurationen (`.vscode/`) in Git eingecheckt werden.
- **`.dockerignore`**: Verhindert, dass unnötige oder sensible Dateien in das Docker-Image kopiert werden. Dies hält das Image klein und sicher. Wir schließen z.B. das `.git`-Verzeichnis, das `Dockerfile` selbst und lokale Umgebungsdateien aus.
### `app/main.py`
Eine minimale FastAPI-Anwendung mit zwei Endpunkten.
```python
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def read_root():
return {"message": "Hello World"}
@app.get("/health")
def health_check():
return {"status": "ok"}
```
### `Dockerfile` (Multi-Stage-Build)
Dies ist das Herzstück unserer Containerisierung. Ein Multi-Stage-Build trennt die Build-Umgebung von der Laufzeitumgebung.
**Stufe 1: `builder`**
```dockerfile
FROM python:3.11 as builder
WORKDIR /opt/venv
RUN python -m venv .
COPY requirements.txt .
RUN . /opt/venv/bin/activate && pip install --no-cache-dir -r requirements.txt
```
- Wir starten mit einem vollständigen Python-Image.
- Erstellen ein virtuelles Environment (`venv`) und installieren die Abhängigkeiten hinein.
- Das Ergebnis ist ein Ordner `/opt/venv` mit einer sauberen Python-Umgebung.
**Stufe 2: `runner` (Das finale Image)**
```dockerfile
FROM python:3.11-slim
RUN useradd --create-home --shell /bin/bash appuser
WORKDIR /home/appuser/app
COPY --from=builder /opt/venv /opt/venv
COPY app/ .
ENV PATH="/opt/venv/bin:$PATH"
EXPOSE 8000
USER appuser
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", "-w", "2", "-b", "0.0.0.0:8000", "main:app"]
```
- Wir starten mit einem minimalen `-slim`-Image.
- **Sicherheit**: Wir erstellen einen unprivilegierten Benutzer `appuser`. Die Anwendung wird als dieser Benutzer ausgeführt.
- Wir kopieren das `venv` aus der `builder`-Stufe und unseren App-Code.
- **Produktions-Server**: Wir starten die App mit `gunicorn` und 2 `uvicorn`-Workern. Dies ist ein stabiles Setup für die Produktion.
### `docker-compose.yml` (Für die lokale Entwicklung mit Traefik)
Diese Datei definiert unsere lokale Entwicklungsumgebung, die nun auch Traefik als Reverse Proxy enthält.
**Was ist Traefik?**
Traefik ist ein moderner Edge Router und Reverse Proxy, der dynamisch Dienste auf der Grundlage ihrer Konfiguration (z.B. Docker-Labels) entdeckt. Er leitet Anfragen an die richtigen Container weiter, ohne dass man manuelle Konfigurationen in einer separaten Datei vornehmen muss.
```yaml
version: '3.8'
services:
app:
build: .
volumes:
- ./app:/home/appuser/app
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
labels:
- "traefik.enable=true"
# Definiert einen Router für den App-Dienst
- "traefik.http.routers.app-router.rule=Host(`localhost`)"
- "traefik.http.routers.app-router.entrypoints=web"
# Definiert den internen Port des Dienstes (im Container)
- "traefik.http.services.app-service.loadbalancer.server.port=8000"
networks:
- web
traefik:
image: "traefik:v2.10"
command:
- "--api.dashboard=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false" # Nur Dienste mit traefik.enable=true exposen
- "--entrypoints.web.address=:80"
ports:
- "80:80" # Der HTTP-Port, über den Traefik lauscht
- "8080:8080" # Das Web UI (Dashboard) von Traefik
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro" # Traefik braucht Zugriff auf den Docker-Daemon
networks:
- web
networks:
web:
external: false
```
- **`app` Service**: Die direkte Port-Exponierung wurde entfernt. Stattdessen nutzt Traefik die `labels`, um das Routing zu konfigurieren.
- `traefik.enable=true`: Aktiviert die Erkennung durch Traefik.
- `traefik.http.routers.app-router.rule=Host('localhost')`: Sagt Traefik, dass Anfragen an die Host `localhost` an diesen Dienst geleitet werden sollen.
- `traefik.http.routers.app-router.entrypoints=web`: Verknüpft diesen Router mit dem `web`-Entrypoint von Traefik (Port 80).
- `traefik.http.services.app-service.loadbalancer.server.port=8000`: Informiert Traefik über den internen Port des FastAPI-Dienstes.
- **`traefik` Service**: Dies ist der Traefik-Container selbst.
- Er konfiguriert das Dashboard (`--api.dashboard=true`) und aktiviert den Docker-Provider (`--providers.docker=true`), der Container anhand ihrer Labels entdeckt.
- Er lauscht auf Port 80 (HTTP) und 8080 (Dashboard).
- `/var/run/docker.sock`: Ermöglicht Traefik die Kommunikation mit dem Docker-Daemon, um Container und deren Labels zu erkennen.
- **`networks: web`**: Erstellt ein gemeinsames Netzwerk, in dem Traefik und die `app` kommunizieren können.
### `Makefile` (Der Kommando-Runner)
Das `Makefile` gibt uns einfache Befehle für komplexe Aktionen. Es wurde angepasst, um die Traefik-Ports zu berücksichtigen.
```makefile
# --- Configuration ---
# Default internal application port (exposed to Traefik)
PORT := 8000
# Traefik's external entrypoints
TRAEFIK_WEB_PORT := 80
TRAEFIK_DASHBOARD_PORT := 8080
# --- Port Checking Functions ---
# Function to check if required Traefik ports are free
# Exits if any are busy, does not suggest alternatives.
define check_traefik_ports_free
@echo "Checking if Traefik ports ($(TRAEFIK_WEB_PORT}, $(TRAEFIK_DASHBOARD_PORT}) are free..." >&2
@local busy_ports=""; \
if ss -tulnp | grep ":$(TRAEFIK_WEB_PORT) " > /dev/null; then \
busy_ports="$$busy_ports $(TRAEFIK_WEB_PORT)"; \
fi; \
if ss -tulnp | grep ":$(TRAEFIK_DASHBOARD_PORT) " > /dev/null; then \
busy_ports="$$busy_ports $(TRAEFIK_DASHBOARD_PORT)"; \
fi; \
if [ -n "$$busy_ports" ]; then \
echo "ERROR: The following Traefik ports are already in use: $$busy_ports. Please free them or stop Traefik if already running." >&2; \
exit 1; \
fi; \
@echo "Traefik ports are free." >&2
endef
# Function to find next free host port for the app and ask user
# 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
# --- Docker Commands ---
.PHONY: build
build:
@echo "Building Docker image: $(IMAGE_NAME):$(TAG)"
docker build -t $(IMAGE_NAME):$(TAG) .
.PHONY: run
run:
@export SELECTED_HOST_PORT=$$(bash -c 'func() { $(check_app_port_free) }; func') && \
echo "Using host port $$SELECTED_HOST_PORT for single app container" && \
docker run -d -p $$SELECTED_HOST_PORT:$(PORT) --name $(IMAGE_NAME) $(IMAGE_NAME):$(TAG)
.PHONY: stop
stop:
@echo "Stopping Docker container: $(IMAGE_NAME)"
docker stop $(IMAGE_NAME) || true
docker rm $(IMAGE_NAME) || true
.PHONY: logs
logs:
@echo "Showing logs for container: $(IMAGE_NAME)"
docker logs -f $(IMAGE_NAME)
.PHONY: shell
shell:
@echo "Accessing shell in container: $(IMAGE_NAME)"
docker exec -it $(IMAGE_NAME) /bin/bash
# --- Docker Compose Commands ---
.PHONY: up
up:
$(call check_traefik_ports_free) # Check Traefik ports before starting
@echo "Starting development environment with Docker Compose (Traefik enabled)..."
docker-compose up --build -d
.PHONY: down
down:
@echo "Stopping development environment with Docker Compose..."
docker-compose down
# --- Image Management ---
.PHONY: tag
tag:
@echo "Tagging image $(IMAGE_NAME):$(TAG) as $(REGISTRY)/$(IMAGE_NAME):$(TAG)"
docker tag $(IMAGE_NAME):$(TAG) $(REGISTRY)/$(IMAGE_NAME):$(TAG)
.PHONY: push
push: tag
@echo "Pushing image $(REGISTRY)/$(IMAGE_NAME):$(TAG) to registry..."
docker push $(REGISTRY)/$(IMAGE_NAME):$(TAG)
# --- Cleanup ---
.PHONY: clean
clean:
@echo "Cleaning up stopped containers and dangling images..."
docker container prune -f
docker image prune -f
.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 with docker-compose (with Traefik)"
@echo " down - Stop the dev environment with docker-compose"
@echo " tag - Tag the image for a registry"
@echo " push - Push the image to a registry (after tagging)"
@echo " clean - Clean up unused containers and images"
@echo " help - Show this help message"
.DEFAULT_GOAL := help

View File

@ -0,0 +1,493 @@
{
"audio": {
"sample_rate": 22050,
"quality": "high"
},
"espeak": {
"voice": "de"
},
"inference": {
"noise_scale": 0.667,
"length_scale": 1,
"noise_w": 0.8
},
"phoneme_type": "espeak",
"phoneme_map": {},
"phoneme_id_map": {
"_": [
0
],
"^": [
1
],
"$": [
2
],
" ": [
3
],
"!": [
4
],
"'": [
5
],
"(": [
6
],
")": [
7
],
",": [
8
],
"-": [
9
],
".": [
10
],
":": [
11
],
";": [
12
],
"?": [
13
],
"a": [
14
],
"b": [
15
],
"c": [
16
],
"d": [
17
],
"e": [
18
],
"f": [
19
],
"h": [
20
],
"i": [
21
],
"j": [
22
],
"k": [
23
],
"l": [
24
],
"m": [
25
],
"n": [
26
],
"o": [
27
],
"p": [
28
],
"q": [
29
],
"r": [
30
],
"s": [
31
],
"t": [
32
],
"u": [
33
],
"v": [
34
],
"w": [
35
],
"x": [
36
],
"y": [
37
],
"z": [
38
],
"æ": [
39
],
"ç": [
40
],
"ð": [
41
],
"ø": [
42
],
"ħ": [
43
],
"ŋ": [
44
],
"œ": [
45
],
"ǀ": [
46
],
"ǁ": [
47
],
"ǂ": [
48
],
"ǃ": [
49
],
"ɐ": [
50
],
"ɑ": [
51
],
"ɒ": [
52
],
"ɓ": [
53
],
"ɔ": [
54
],
"ɕ": [
55
],
"ɖ": [
56
],
"ɗ": [
57
],
"ɘ": [
58
],
"ə": [
59
],
"ɚ": [
60
],
"ɛ": [
61
],
"ɜ": [
62
],
"ɞ": [
63
],
"ɟ": [
64
],
"ɠ": [
65
],
"ɡ": [
66
],
"ɢ": [
67
],
"ɣ": [
68
],
"ɤ": [
69
],
"ɥ": [
70
],
"ɦ": [
71
],
"ɧ": [
72
],
"ɨ": [
73
],
"ɪ": [
74
],
"ɫ": [
75
],
"ɬ": [
76
],
"ɭ": [
77
],
"ɮ": [
78
],
"ɯ": [
79
],
"ɰ": [
80
],
"ɱ": [
81
],
"ɲ": [
82
],
"ɳ": [
83
],
"ɴ": [
84
],
"ɵ": [
85
],
"ɶ": [
86
],
"ɸ": [
87
],
"ɹ": [
88
],
"ɺ": [
89
],
"ɻ": [
90
],
"ɽ": [
91
],
"ɾ": [
92
],
"ʀ": [
93
],
"ʁ": [
94
],
"ʂ": [
95
],
"ʃ": [
96
],
"ʄ": [
97
],
"ʈ": [
98
],
"ʉ": [
99
],
"ʊ": [
100
],
"ʋ": [
101
],
"ʌ": [
102
],
"ʍ": [
103
],
"ʎ": [
104
],
"ʏ": [
105
],
"ʐ": [
106
],
"ʑ": [
107
],
"ʒ": [
108
],
"ʔ": [
109
],
"ʕ": [
110
],
"ʘ": [
111
],
"ʙ": [
112
],
"ʛ": [
113
],
"ʜ": [
114
],
"ʝ": [
115
],
"ʟ": [
116
],
"ʡ": [
117
],
"ʢ": [
118
],
"ʲ": [
119
],
"ˈ": [
120
],
"ˌ": [
121
],
"ː": [
122
],
"ˑ": [
123
],
"˞": [
124
],
"β": [
125
],
"θ": [
126
],
"χ": [
127
],
"ᵻ": [
128
],
"ⱱ": [
129
],
"0": [
130
],
"1": [
131
],
"2": [
132
],
"3": [
133
],
"4": [
134
],
"5": [
135
],
"6": [
136
],
"7": [
137
],
"8": [
138
],
"9": [
139
],
"̧": [
140
],
"̃": [
141
],
"̪": [
142
],
"̯": [
143
],
"̩": [
144
],
"ʰ": [
145
],
"ˤ": [
146
],
"ε": [
147
],
"↓": [
148
],
"#": [
149
],
"\"": [
150
],
"↑": [
151
],
"̺": [
152
],
"̻": [
153
]
},
"num_symbols": 256,
"num_speakers": 1,
"speaker_id_map": {},
"piper_version": "1.0.0",
"language": {
"code": "de_DE",
"family": "de",
"region": "DE",
"name_native": "Deutsch",
"name_english": "German",
"country_english": "Germany"
},
"dataset": "thorsten"
}

View File

@ -0,0 +1,502 @@
{
"piper_version": "1.1.0",
"audio": {
"sample_rate": 22050,
"quality": "medium"
},
"espeak": {
"voice": "de"
},
"inference": {
"noise_scale": 0.667,
"length_scale": 1,
"noise_w": 0.8
},
"phoneme_type": "espeak",
"phoneme_map": {},
"phoneme_id_map": {
"_": [
0
],
"^": [
1
],
"$": [
2
],
" ": [
3
],
"!": [
4
],
"'": [
5
],
"(": [
6
],
")": [
7
],
",": [
8
],
"-": [
9
],
".": [
10
],
":": [
11
],
";": [
12
],
"?": [
13
],
"a": [
14
],
"b": [
15
],
"c": [
16
],
"d": [
17
],
"e": [
18
],
"f": [
19
],
"h": [
20
],
"i": [
21
],
"j": [
22
],
"k": [
23
],
"l": [
24
],
"m": [
25
],
"n": [
26
],
"o": [
27
],
"p": [
28
],
"q": [
29
],
"r": [
30
],
"s": [
31
],
"t": [
32
],
"u": [
33
],
"v": [
34
],
"w": [
35
],
"x": [
36
],
"y": [
37
],
"z": [
38
],
"æ": [
39
],
"ç": [
40
],
"ð": [
41
],
"ø": [
42
],
"ħ": [
43
],
"ŋ": [
44
],
"œ": [
45
],
"ǀ": [
46
],
"ǁ": [
47
],
"ǂ": [
48
],
"ǃ": [
49
],
"ɐ": [
50
],
"ɑ": [
51
],
"ɒ": [
52
],
"ɓ": [
53
],
"ɔ": [
54
],
"ɕ": [
55
],
"ɖ": [
56
],
"ɗ": [
57
],
"ɘ": [
58
],
"ə": [
59
],
"ɚ": [
60
],
"ɛ": [
61
],
"ɜ": [
62
],
"ɞ": [
63
],
"ɟ": [
64
],
"ɠ": [
65
],
"ɡ": [
66
],
"ɢ": [
67
],
"ɣ": [
68
],
"ɤ": [
69
],
"ɥ": [
70
],
"ɦ": [
71
],
"ɧ": [
72
],
"ɨ": [
73
],
"ɪ": [
74
],
"ɫ": [
75
],
"ɬ": [
76
],
"ɭ": [
77
],
"ɮ": [
78
],
"ɯ": [
79
],
"ɰ": [
80
],
"ɱ": [
81
],
"ɲ": [
82
],
"ɳ": [
83
],
"ɴ": [
84
],
"ɵ": [
85
],
"ɶ": [
86
],
"ɸ": [
87
],
"ɹ": [
88
],
"ɺ": [
89
],
"ɻ": [
90
],
"ɽ": [
91
],
"ɾ": [
92
],
"ʀ": [
93
],
"ʁ": [
94
],
"ʂ": [
95
],
"ʃ": [
96
],
"ʄ": [
97
],
"ʈ": [
98
],
"ʉ": [
99
],
"ʊ": [
100
],
"ʋ": [
101
],
"ʌ": [
102
],
"ʍ": [
103
],
"ʎ": [
104
],
"ʏ": [
105
],
"ʐ": [
106
],
"ʑ": [
107
],
"ʒ": [
108
],
"ʔ": [
109
],
"ʕ": [
110
],
"ʘ": [
111
],
"ʙ": [
112
],
"ʛ": [
113
],
"ʜ": [
114
],
"ʝ": [
115
],
"ʟ": [
116
],
"ʡ": [
117
],
"ʢ": [
118
],
"ʲ": [
119
],
"ˈ": [
120
],
"ˌ": [
121
],
"ː": [
122
],
"ˑ": [
123
],
"˞": [
124
],
"β": [
125
],
"θ": [
126
],
"χ": [
127
],
"ᵻ": [
128
],
"ⱱ": [
129
],
"0": [
130
],
"1": [
131
],
"2": [
132
],
"3": [
133
],
"4": [
134
],
"5": [
135
],
"6": [
136
],
"7": [
137
],
"8": [
138
],
"9": [
139
],
"̧": [
140
],
"̃": [
141
],
"̪": [
142
],
"̯": [
143
],
"̩": [
144
],
"ʰ": [
145
],
"ˤ": [
146
],
"ε": [
147
],
"↓": [
148
],
"#": [
149
],
"\"": [
150
],
"↑": [
151
],
"̺": [
152
],
"̻": [
153
]
},
"num_symbols": 256,
"num_speakers": 8,
"speaker_id_map": {
"amused": 0,
"angry": 1,
"disgusted": 2,
"drunk": 3,
"neutral": 4,
"sleepy": 5,
"surprised": 6,
"whisper": 7
},
"language": {
"code": "de_DE",
"family": "de",
"region": "DE",
"name_native": "Deutsch",
"name_english": "German",
"country_english": "Germany"
},
"dataset": "thorsten_emotional"
}

View File

@ -0,0 +1,502 @@
{
"dataset": "cori",
"audio": {
"sample_rate": 22050,
"quality": "high"
},
"espeak": {
"voice": "en"
},
"language": {
"code": "en_GB",
"family": "en",
"region": "GB",
"name_native": "English",
"name_english": "English",
"country_english": "Great Britain"
},
"inference": {
"noise_scale": 0.667,
"length_scale": 1,
"noise_w": 0.8
},
"phoneme_type": "espeak",
"phoneme_map": {},
"phoneme_id_map": {
" ": [
3
],
"!": [
4
],
"\"": [
150
],
"#": [
149
],
"$": [
2
],
"'": [
5
],
"(": [
6
],
")": [
7
],
",": [
8
],
"-": [
9
],
".": [
10
],
"0": [
130
],
"1": [
131
],
"2": [
132
],
"3": [
133
],
"4": [
134
],
"5": [
135
],
"6": [
136
],
"7": [
137
],
"8": [
138
],
"9": [
139
],
":": [
11
],
";": [
12
],
"?": [
13
],
"X": [
156
],
"^": [
1
],
"_": [
0
],
"a": [
14
],
"b": [
15
],
"c": [
16
],
"d": [
17
],
"e": [
18
],
"f": [
19
],
"g": [
154
],
"h": [
20
],
"i": [
21
],
"j": [
22
],
"k": [
23
],
"l": [
24
],
"m": [
25
],
"n": [
26
],
"o": [
27
],
"p": [
28
],
"q": [
29
],
"r": [
30
],
"s": [
31
],
"t": [
32
],
"u": [
33
],
"v": [
34
],
"w": [
35
],
"x": [
36
],
"y": [
37
],
"z": [
38
],
"æ": [
39
],
"ç": [
40
],
"ð": [
41
],
"ø": [
42
],
"ħ": [
43
],
"ŋ": [
44
],
"œ": [
45
],
"ǀ": [
46
],
"ǁ": [
47
],
"ǂ": [
48
],
"ǃ": [
49
],
"ɐ": [
50
],
"ɑ": [
51
],
"ɒ": [
52
],
"ɓ": [
53
],
"ɔ": [
54
],
"ɕ": [
55
],
"ɖ": [
56
],
"ɗ": [
57
],
"ɘ": [
58
],
"ə": [
59
],
"ɚ": [
60
],
"ɛ": [
61
],
"ɜ": [
62
],
"ɞ": [
63
],
"ɟ": [
64
],
"ɠ": [
65
],
"ɡ": [
66
],
"ɢ": [
67
],
"ɣ": [
68
],
"ɤ": [
69
],
"ɥ": [
70
],
"ɦ": [
71
],
"ɧ": [
72
],
"ɨ": [
73
],
"ɪ": [
74
],
"ɫ": [
75
],
"ɬ": [
76
],
"ɭ": [
77
],
"ɮ": [
78
],
"ɯ": [
79
],
"ɰ": [
80
],
"ɱ": [
81
],
"ɲ": [
82
],
"ɳ": [
83
],
"ɴ": [
84
],
"ɵ": [
85
],
"ɶ": [
86
],
"ɸ": [
87
],
"ɹ": [
88
],
"ɺ": [
89
],
"ɻ": [
90
],
"ɽ": [
91
],
"ɾ": [
92
],
"ʀ": [
93
],
"ʁ": [
94
],
"ʂ": [
95
],
"ʃ": [
96
],
"ʄ": [
97
],
"ʈ": [
98
],
"ʉ": [
99
],
"ʊ": [
100
],
"ʋ": [
101
],
"ʌ": [
102
],
"ʍ": [
103
],
"ʎ": [
104
],
"ʏ": [
105
],
"ʐ": [
106
],
"ʑ": [
107
],
"ʒ": [
108
],
"ʔ": [
109
],
"ʕ": [
110
],
"ʘ": [
111
],
"ʙ": [
112
],
"ʛ": [
113
],
"ʜ": [
114
],
"ʝ": [
115
],
"ʟ": [
116
],
"ʡ": [
117
],
"ʢ": [
118
],
"ʦ": [
155
],
"ʰ": [
145
],
"ʲ": [
119
],
"ˈ": [
120
],
"ˌ": [
121
],
"ː": [
122
],
"ˑ": [
123
],
"˞": [
124
],
"ˤ": [
146
],
"̃": [
141
],
"̧": [
140
],
"̩": [
144
],
"̪": [
142
],
"̯": [
143
],
"̺": [
152
],
"̻": [
153
],
"β": [
125
],
"ε": [
147
],
"θ": [
126
],
"χ": [
127
],
"ᵻ": [
128
],
"↑": [
151
],
"↓": [
148
],
"ⱱ": [
129
]
},
"num_symbols": 256,
"num_speakers": 1,
"speaker_id_map": {},
"piper_version": "1.0.0"
}

View File

@ -0,0 +1,603 @@
{
"audio": {
"sample_rate": 22050,
"quality": "medium"
},
"espeak": {
"voice": "en-gb-x-rp"
},
"inference": {
"noise_scale": 0.333,
"length_scale": 1.4,
"noise_w": 0.333
},
"phoneme_type": "espeak",
"phoneme_map": {},
"phoneme_id_map": {
"_": [
0
],
"^": [
1
],
"$": [
2
],
" ": [
3
],
"!": [
4
],
"'": [
5
],
"(": [
6
],
")": [
7
],
",": [
8
],
"-": [
9
],
".": [
10
],
":": [
11
],
";": [
12
],
"?": [
13
],
"a": [
14
],
"b": [
15
],
"c": [
16
],
"d": [
17
],
"e": [
18
],
"f": [
19
],
"h": [
20
],
"i": [
21
],
"j": [
22
],
"k": [
23
],
"l": [
24
],
"m": [
25
],
"n": [
26
],
"o": [
27
],
"p": [
28
],
"q": [
29
],
"r": [
30
],
"s": [
31
],
"t": [
32
],
"u": [
33
],
"v": [
34
],
"w": [
35
],
"x": [
36
],
"y": [
37
],
"z": [
38
],
"æ": [
39
],
"ç": [
40
],
"ð": [
41
],
"ø": [
42
],
"ħ": [
43
],
"ŋ": [
44
],
"œ": [
45
],
"ǀ": [
46
],
"ǁ": [
47
],
"ǂ": [
48
],
"ǃ": [
49
],
"ɐ": [
50
],
"ɑ": [
51
],
"ɒ": [
52
],
"ɓ": [
53
],
"ɔ": [
54
],
"ɕ": [
55
],
"ɖ": [
56
],
"ɗ": [
57
],
"ɘ": [
58
],
"ə": [
59
],
"ɚ": [
60
],
"ɛ": [
61
],
"ɜ": [
62
],
"ɞ": [
63
],
"ɟ": [
64
],
"ɠ": [
65
],
"ɡ": [
66
],
"ɢ": [
67
],
"ɣ": [
68
],
"ɤ": [
69
],
"ɥ": [
70
],
"ɦ": [
71
],
"ɧ": [
72
],
"ɨ": [
73
],
"ɪ": [
74
],
"ɫ": [
75
],
"ɬ": [
76
],
"ɭ": [
77
],
"ɮ": [
78
],
"ɯ": [
79
],
"ɰ": [
80
],
"ɱ": [
81
],
"ɲ": [
82
],
"ɳ": [
83
],
"ɴ": [
84
],
"ɵ": [
85
],
"ɶ": [
86
],
"ɸ": [
87
],
"ɹ": [
88
],
"ɺ": [
89
],
"ɻ": [
90
],
"ɽ": [
91
],
"ɾ": [
92
],
"ʀ": [
93
],
"ʁ": [
94
],
"ʂ": [
95
],
"ʃ": [
96
],
"ʄ": [
97
],
"ʈ": [
98
],
"ʉ": [
99
],
"ʊ": [
100
],
"ʋ": [
101
],
"ʌ": [
102
],
"ʍ": [
103
],
"ʎ": [
104
],
"ʏ": [
105
],
"ʐ": [
106
],
"ʑ": [
107
],
"ʒ": [
108
],
"ʔ": [
109
],
"ʕ": [
110
],
"ʘ": [
111
],
"ʙ": [
112
],
"ʛ": [
113
],
"ʜ": [
114
],
"ʝ": [
115
],
"ʟ": [
116
],
"ʡ": [
117
],
"ʢ": [
118
],
"ʲ": [
119
],
"ˈ": [
120
],
"ˌ": [
121
],
"ː": [
122
],
"ˑ": [
123
],
"˞": [
124
],
"β": [
125
],
"θ": [
126
],
"χ": [
127
],
"ᵻ": [
128
],
"ⱱ": [
129
],
"0": [
130
],
"1": [
131
],
"2": [
132
],
"3": [
133
],
"4": [
134
],
"5": [
135
],
"6": [
136
],
"7": [
137
],
"8": [
138
],
"9": [
139
],
"̧": [
140
],
"̃": [
141
],
"̪": [
142
],
"̯": [
143
],
"̩": [
144
],
"ʰ": [
145
],
"ˤ": [
146
],
"ε": [
147
],
"↓": [
148
],
"#": [
149
],
"\"": [
150
],
"↑": [
151
],
"̺": [
152
],
"̻": [
153
]
},
"num_symbols": 256,
"num_speakers": 109,
"speaker_id_map": {
"p239": 0,
"p236": 1,
"p264": 2,
"p250": 3,
"p259": 4,
"p247": 5,
"p261": 6,
"p263": 7,
"p283": 8,
"p286": 9,
"p274": 10,
"p276": 11,
"p270": 12,
"p281": 13,
"p277": 14,
"p231": 15,
"p271": 16,
"p238": 17,
"p257": 18,
"p273": 19,
"p284": 20,
"p329": 21,
"p361": 22,
"p287": 23,
"p360": 24,
"p374": 25,
"p376": 26,
"p310": 27,
"p304": 28,
"p334": 29,
"p340": 30,
"p323": 31,
"p347": 32,
"p330": 33,
"p308": 34,
"p314": 35,
"p317": 36,
"p339": 37,
"p311": 38,
"p294": 39,
"p305": 40,
"p266": 41,
"p335": 42,
"p318": 43,
"p351": 44,
"p333": 45,
"p313": 46,
"p316": 47,
"p244": 48,
"p307": 49,
"p363": 50,
"p336": 51,
"p297": 52,
"p312": 53,
"p267": 54,
"p275": 55,
"p295": 56,
"p258": 57,
"p288": 58,
"p301": 59,
"p232": 60,
"p292": 61,
"p272": 62,
"p280": 63,
"p278": 64,
"p341": 65,
"p268": 66,
"p298": 67,
"p299": 68,
"p279": 69,
"p285": 70,
"p326": 71,
"p300": 72,
"s5": 73,
"p230": 74,
"p345": 75,
"p254": 76,
"p269": 77,
"p293": 78,
"p252": 79,
"p262": 80,
"p243": 81,
"p227": 82,
"p343": 83,
"p255": 84,
"p229": 85,
"p240": 86,
"p248": 87,
"p253": 88,
"p233": 89,
"p228": 90,
"p282": 91,
"p251": 92,
"p246": 93,
"p234": 94,
"p226": 95,
"p260": 96,
"p245": 97,
"p241": 98,
"p303": 99,
"p265": 100,
"p306": 101,
"p237": 102,
"p249": 103,
"p256": 104,
"p302": 105,
"p364": 106,
"p225": 107,
"p362": 108
},
"piper_version": "1.0.0",
"language": {
"code": "en_GB",
"family": "en",
"region": "GB",
"name_native": "English",
"name_english": "English",
"country_english": "Great Britain"
},
"dataset": "vctk"
}

View File

@ -0,0 +1,508 @@
{
"dataset": "hfc_female",
"audio": {
"sample_rate": 22050,
"quality": "medium"
},
"espeak": {
"voice": "en-us"
},
"language": {
"code": "en_US",
"family": "en",
"region": "US",
"name_native": "English",
"name_english": "English",
"country_english": "United States"
},
"inference": {
"noise_scale": 0.667,
"length_scale": 1,
"noise_w": 0.8
},
"phoneme_type": "espeak",
"phoneme_map": {},
"phoneme_id_map": {
" ": [
3
],
"!": [
4
],
"\"": [
150
],
"#": [
149
],
"$": [
2
],
"'": [
5
],
"(": [
6
],
")": [
7
],
",": [
8
],
"-": [
9
],
".": [
10
],
"0": [
130
],
"1": [
131
],
"2": [
132
],
"3": [
133
],
"4": [
134
],
"5": [
135
],
"6": [
136
],
"7": [
137
],
"8": [
138
],
"9": [
139
],
":": [
11
],
";": [
12
],
"?": [
13
],
"X": [
156
],
"^": [
1
],
"_": [
0
],
"a": [
14
],
"b": [
15
],
"c": [
16
],
"d": [
17
],
"e": [
18
],
"f": [
19
],
"g": [
154
],
"h": [
20
],
"i": [
21
],
"j": [
22
],
"k": [
23
],
"l": [
24
],
"m": [
25
],
"n": [
26
],
"o": [
27
],
"p": [
28
],
"q": [
29
],
"r": [
30
],
"s": [
31
],
"t": [
32
],
"u": [
33
],
"v": [
34
],
"w": [
35
],
"x": [
36
],
"y": [
37
],
"z": [
38
],
"æ": [
39
],
"ç": [
40
],
"ð": [
41
],
"ø": [
42
],
"ħ": [
43
],
"ŋ": [
44
],
"œ": [
45
],
"ǀ": [
46
],
"ǁ": [
47
],
"ǂ": [
48
],
"ǃ": [
49
],
"ɐ": [
50
],
"ɑ": [
51
],
"ɒ": [
52
],
"ɓ": [
53
],
"ɔ": [
54
],
"ɕ": [
55
],
"ɖ": [
56
],
"ɗ": [
57
],
"ɘ": [
58
],
"ə": [
59
],
"ɚ": [
60
],
"ɛ": [
61
],
"ɜ": [
62
],
"ɞ": [
63
],
"ɟ": [
64
],
"ɠ": [
65
],
"ɡ": [
66
],
"ɢ": [
67
],
"ɣ": [
68
],
"ɤ": [
69
],
"ɥ": [
70
],
"ɦ": [
71
],
"ɧ": [
72
],
"ɨ": [
73
],
"ɪ": [
74
],
"ɫ": [
75
],
"ɬ": [
76
],
"ɭ": [
77
],
"ɮ": [
78
],
"ɯ": [
79
],
"ɰ": [
80
],
"ɱ": [
81
],
"ɲ": [
82
],
"ɳ": [
83
],
"ɴ": [
84
],
"ɵ": [
85
],
"ɶ": [
86
],
"ɸ": [
87
],
"ɹ": [
88
],
"ɺ": [
89
],
"ɻ": [
90
],
"ɽ": [
91
],
"ɾ": [
92
],
"ʀ": [
93
],
"ʁ": [
94
],
"ʂ": [
95
],
"ʃ": [
96
],
"ʄ": [
97
],
"ʈ": [
98
],
"ʉ": [
99
],
"ʊ": [
100
],
"ʋ": [
101
],
"ʌ": [
102
],
"ʍ": [
103
],
"ʎ": [
104
],
"ʏ": [
105
],
"ʐ": [
106
],
"ʑ": [
107
],
"ʒ": [
108
],
"ʔ": [
109
],
"ʕ": [
110
],
"ʘ": [
111
],
"ʙ": [
112
],
"ʛ": [
113
],
"ʜ": [
114
],
"ʝ": [
115
],
"ʟ": [
116
],
"ʡ": [
117
],
"ʢ": [
118
],
"ʦ": [
155
],
"ʰ": [
145
],
"ʲ": [
119
],
"ˈ": [
120
],
"ˌ": [
121
],
"ː": [
122
],
"ˑ": [
123
],
"˞": [
124
],
"ˤ": [
146
],
"̃": [
141
],
"̊": [
158
],
"̝": [
157
],
"̧": [
140
],
"̩": [
144
],
"̪": [
142
],
"̯": [
143
],
"̺": [
152
],
"̻": [
153
],
"β": [
125
],
"ε": [
147
],
"θ": [
126
],
"χ": [
127
],
"ᵻ": [
128
],
"↑": [
151
],
"↓": [
148
],
"ⱱ": [
129
]
},
"num_symbols": 256,
"num_speakers": 1,
"speaker_id_map": {},
"piper_version": "1.0.0"
}

View File

@ -0,0 +1,502 @@
{
"dataset": "kristin",
"audio": {
"sample_rate": 22050,
"quality": "medium"
},
"espeak": {
"voice": "en"
},
"language": {
"code": "en_US",
"family": "en",
"region": "US",
"name_native": "English",
"name_english": "English",
"country_english": "United States"
},
"inference": {
"noise_scale": 0.667,
"length_scale": 1,
"noise_w": 0.8
},
"phoneme_type": "espeak",
"phoneme_map": {},
"phoneme_id_map": {
" ": [
3
],
"!": [
4
],
"\"": [
150
],
"#": [
149
],
"$": [
2
],
"'": [
5
],
"(": [
6
],
")": [
7
],
",": [
8
],
"-": [
9
],
".": [
10
],
"0": [
130
],
"1": [
131
],
"2": [
132
],
"3": [
133
],
"4": [
134
],
"5": [
135
],
"6": [
136
],
"7": [
137
],
"8": [
138
],
"9": [
139
],
":": [
11
],
";": [
12
],
"?": [
13
],
"X": [
156
],
"^": [
1
],
"_": [
0
],
"a": [
14
],
"b": [
15
],
"c": [
16
],
"d": [
17
],
"e": [
18
],
"f": [
19
],
"g": [
154
],
"h": [
20
],
"i": [
21
],
"j": [
22
],
"k": [
23
],
"l": [
24
],
"m": [
25
],
"n": [
26
],
"o": [
27
],
"p": [
28
],
"q": [
29
],
"r": [
30
],
"s": [
31
],
"t": [
32
],
"u": [
33
],
"v": [
34
],
"w": [
35
],
"x": [
36
],
"y": [
37
],
"z": [
38
],
"æ": [
39
],
"ç": [
40
],
"ð": [
41
],
"ø": [
42
],
"ħ": [
43
],
"ŋ": [
44
],
"œ": [
45
],
"ǀ": [
46
],
"ǁ": [
47
],
"ǂ": [
48
],
"ǃ": [
49
],
"ɐ": [
50
],
"ɑ": [
51
],
"ɒ": [
52
],
"ɓ": [
53
],
"ɔ": [
54
],
"ɕ": [
55
],
"ɖ": [
56
],
"ɗ": [
57
],
"ɘ": [
58
],
"ə": [
59
],
"ɚ": [
60
],
"ɛ": [
61
],
"ɜ": [
62
],
"ɞ": [
63
],
"ɟ": [
64
],
"ɠ": [
65
],
"ɡ": [
66
],
"ɢ": [
67
],
"ɣ": [
68
],
"ɤ": [
69
],
"ɥ": [
70
],
"ɦ": [
71
],
"ɧ": [
72
],
"ɨ": [
73
],
"ɪ": [
74
],
"ɫ": [
75
],
"ɬ": [
76
],
"ɭ": [
77
],
"ɮ": [
78
],
"ɯ": [
79
],
"ɰ": [
80
],
"ɱ": [
81
],
"ɲ": [
82
],
"ɳ": [
83
],
"ɴ": [
84
],
"ɵ": [
85
],
"ɶ": [
86
],
"ɸ": [
87
],
"ɹ": [
88
],
"ɺ": [
89
],
"ɻ": [
90
],
"ɽ": [
91
],
"ɾ": [
92
],
"ʀ": [
93
],
"ʁ": [
94
],
"ʂ": [
95
],
"ʃ": [
96
],
"ʄ": [
97
],
"ʈ": [
98
],
"ʉ": [
99
],
"ʊ": [
100
],
"ʋ": [
101
],
"ʌ": [
102
],
"ʍ": [
103
],
"ʎ": [
104
],
"ʏ": [
105
],
"ʐ": [
106
],
"ʑ": [
107
],
"ʒ": [
108
],
"ʔ": [
109
],
"ʕ": [
110
],
"ʘ": [
111
],
"ʙ": [
112
],
"ʛ": [
113
],
"ʜ": [
114
],
"ʝ": [
115
],
"ʟ": [
116
],
"ʡ": [
117
],
"ʢ": [
118
],
"ʦ": [
155
],
"ʰ": [
145
],
"ʲ": [
119
],
"ˈ": [
120
],
"ˌ": [
121
],
"ː": [
122
],
"ˑ": [
123
],
"˞": [
124
],
"ˤ": [
146
],
"̃": [
141
],
"̧": [
140
],
"̩": [
144
],
"̪": [
142
],
"̯": [
143
],
"̺": [
152
],
"̻": [
153
],
"β": [
125
],
"ε": [
147
],
"θ": [
126
],
"χ": [
127
],
"ᵻ": [
128
],
"↑": [
151
],
"↓": [
148
],
"ⱱ": [
129
]
},
"num_symbols": 256,
"num_speakers": 1,
"speaker_id_map": {},
"piper_version": "1.0.0"
}

View File

@ -0,0 +1,493 @@
{
"audio": {
"sample_rate": 22050,
"quality": "high"
},
"espeak": {
"voice": "de"
},
"inference": {
"noise_scale": 0.667,
"length_scale": 1,
"noise_w": 0.8
},
"phoneme_type": "espeak",
"phoneme_map": {},
"phoneme_id_map": {
"_": [
0
],
"^": [
1
],
"$": [
2
],
" ": [
3
],
"!": [
4
],
"'": [
5
],
"(": [
6
],
")": [
7
],
",": [
8
],
"-": [
9
],
".": [
10
],
":": [
11
],
";": [
12
],
"?": [
13
],
"a": [
14
],
"b": [
15
],
"c": [
16
],
"d": [
17
],
"e": [
18
],
"f": [
19
],
"h": [
20
],
"i": [
21
],
"j": [
22
],
"k": [
23
],
"l": [
24
],
"m": [
25
],
"n": [
26
],
"o": [
27
],
"p": [
28
],
"q": [
29
],
"r": [
30
],
"s": [
31
],
"t": [
32
],
"u": [
33
],
"v": [
34
],
"w": [
35
],
"x": [
36
],
"y": [
37
],
"z": [
38
],
"æ": [
39
],
"ç": [
40
],
"ð": [
41
],
"ø": [
42
],
"ħ": [
43
],
"ŋ": [
44
],
"œ": [
45
],
"ǀ": [
46
],
"ǁ": [
47
],
"ǂ": [
48
],
"ǃ": [
49
],
"ɐ": [
50
],
"ɑ": [
51
],
"ɒ": [
52
],
"ɓ": [
53
],
"ɔ": [
54
],
"ɕ": [
55
],
"ɖ": [
56
],
"ɗ": [
57
],
"ɘ": [
58
],
"ə": [
59
],
"ɚ": [
60
],
"ɛ": [
61
],
"ɜ": [
62
],
"ɞ": [
63
],
"ɟ": [
64
],
"ɠ": [
65
],
"ɡ": [
66
],
"ɢ": [
67
],
"ɣ": [
68
],
"ɤ": [
69
],
"ɥ": [
70
],
"ɦ": [
71
],
"ɧ": [
72
],
"ɨ": [
73
],
"ɪ": [
74
],
"ɫ": [
75
],
"ɬ": [
76
],
"ɭ": [
77
],
"ɮ": [
78
],
"ɯ": [
79
],
"ɰ": [
80
],
"ɱ": [
81
],
"ɲ": [
82
],
"ɳ": [
83
],
"ɴ": [
84
],
"ɵ": [
85
],
"ɶ": [
86
],
"ɸ": [
87
],
"ɹ": [
88
],
"ɺ": [
89
],
"ɻ": [
90
],
"ɽ": [
91
],
"ɾ": [
92
],
"ʀ": [
93
],
"ʁ": [
94
],
"ʂ": [
95
],
"ʃ": [
96
],
"ʄ": [
97
],
"ʈ": [
98
],
"ʉ": [
99
],
"ʊ": [
100
],
"ʋ": [
101
],
"ʌ": [
102
],
"ʍ": [
103
],
"ʎ": [
104
],
"ʏ": [
105
],
"ʐ": [
106
],
"ʑ": [
107
],
"ʒ": [
108
],
"ʔ": [
109
],
"ʕ": [
110
],
"ʘ": [
111
],
"ʙ": [
112
],
"ʛ": [
113
],
"ʜ": [
114
],
"ʝ": [
115
],
"ʟ": [
116
],
"ʡ": [
117
],
"ʢ": [
118
],
"ʲ": [
119
],
"ˈ": [
120
],
"ˌ": [
121
],
"ː": [
122
],
"ˑ": [
123
],
"˞": [
124
],
"β": [
125
],
"θ": [
126
],
"χ": [
127
],
"ᵻ": [
128
],
"ⱱ": [
129
],
"0": [
130
],
"1": [
131
],
"2": [
132
],
"3": [
133
],
"4": [
134
],
"5": [
135
],
"6": [
136
],
"7": [
137
],
"8": [
138
],
"9": [
139
],
"̧": [
140
],
"̃": [
141
],
"̪": [
142
],
"̯": [
143
],
"̩": [
144
],
"ʰ": [
145
],
"ˤ": [
146
],
"ε": [
147
],
"↓": [
148
],
"#": [
149
],
"\"": [
150
],
"↑": [
151
],
"̺": [
152
],
"̻": [
153
]
},
"num_symbols": 256,
"num_speakers": 1,
"speaker_id_map": {},
"piper_version": "1.0.0",
"language": {
"code": "de_DE",
"family": "de",
"region": "DE",
"name_native": "Deutsch",
"name_english": "German",
"country_english": "Germany"
},
"dataset": "thorsten"
}

View File

@ -0,0 +1,502 @@
{
"piper_version": "1.1.0",
"audio": {
"sample_rate": 22050,
"quality": "medium"
},
"espeak": {
"voice": "de"
},
"inference": {
"noise_scale": 0.667,
"length_scale": 1,
"noise_w": 0.8
},
"phoneme_type": "espeak",
"phoneme_map": {},
"phoneme_id_map": {
"_": [
0
],
"^": [
1
],
"$": [
2
],
" ": [
3
],
"!": [
4
],
"'": [
5
],
"(": [
6
],
")": [
7
],
",": [
8
],
"-": [
9
],
".": [
10
],
":": [
11
],
";": [
12
],
"?": [
13
],
"a": [
14
],
"b": [
15
],
"c": [
16
],
"d": [
17
],
"e": [
18
],
"f": [
19
],
"h": [
20
],
"i": [
21
],
"j": [
22
],
"k": [
23
],
"l": [
24
],
"m": [
25
],
"n": [
26
],
"o": [
27
],
"p": [
28
],
"q": [
29
],
"r": [
30
],
"s": [
31
],
"t": [
32
],
"u": [
33
],
"v": [
34
],
"w": [
35
],
"x": [
36
],
"y": [
37
],
"z": [
38
],
"æ": [
39
],
"ç": [
40
],
"ð": [
41
],
"ø": [
42
],
"ħ": [
43
],
"ŋ": [
44
],
"œ": [
45
],
"ǀ": [
46
],
"ǁ": [
47
],
"ǂ": [
48
],
"ǃ": [
49
],
"ɐ": [
50
],
"ɑ": [
51
],
"ɒ": [
52
],
"ɓ": [
53
],
"ɔ": [
54
],
"ɕ": [
55
],
"ɖ": [
56
],
"ɗ": [
57
],
"ɘ": [
58
],
"ə": [
59
],
"ɚ": [
60
],
"ɛ": [
61
],
"ɜ": [
62
],
"ɞ": [
63
],
"ɟ": [
64
],
"ɠ": [
65
],
"ɡ": [
66
],
"ɢ": [
67
],
"ɣ": [
68
],
"ɤ": [
69
],
"ɥ": [
70
],
"ɦ": [
71
],
"ɧ": [
72
],
"ɨ": [
73
],
"ɪ": [
74
],
"ɫ": [
75
],
"ɬ": [
76
],
"ɭ": [
77
],
"ɮ": [
78
],
"ɯ": [
79
],
"ɰ": [
80
],
"ɱ": [
81
],
"ɲ": [
82
],
"ɳ": [
83
],
"ɴ": [
84
],
"ɵ": [
85
],
"ɶ": [
86
],
"ɸ": [
87
],
"ɹ": [
88
],
"ɺ": [
89
],
"ɻ": [
90
],
"ɽ": [
91
],
"ɾ": [
92
],
"ʀ": [
93
],
"ʁ": [
94
],
"ʂ": [
95
],
"ʃ": [
96
],
"ʄ": [
97
],
"ʈ": [
98
],
"ʉ": [
99
],
"ʊ": [
100
],
"ʋ": [
101
],
"ʌ": [
102
],
"ʍ": [
103
],
"ʎ": [
104
],
"ʏ": [
105
],
"ʐ": [
106
],
"ʑ": [
107
],
"ʒ": [
108
],
"ʔ": [
109
],
"ʕ": [
110
],
"ʘ": [
111
],
"ʙ": [
112
],
"ʛ": [
113
],
"ʜ": [
114
],
"ʝ": [
115
],
"ʟ": [
116
],
"ʡ": [
117
],
"ʢ": [
118
],
"ʲ": [
119
],
"ˈ": [
120
],
"ˌ": [
121
],
"ː": [
122
],
"ˑ": [
123
],
"˞": [
124
],
"β": [
125
],
"θ": [
126
],
"χ": [
127
],
"ᵻ": [
128
],
"ⱱ": [
129
],
"0": [
130
],
"1": [
131
],
"2": [
132
],
"3": [
133
],
"4": [
134
],
"5": [
135
],
"6": [
136
],
"7": [
137
],
"8": [
138
],
"9": [
139
],
"̧": [
140
],
"̃": [
141
],
"̪": [
142
],
"̯": [
143
],
"̩": [
144
],
"ʰ": [
145
],
"ˤ": [
146
],
"ε": [
147
],
"↓": [
148
],
"#": [
149
],
"\"": [
150
],
"↑": [
151
],
"̺": [
152
],
"̻": [
153
]
},
"num_symbols": 256,
"num_speakers": 8,
"speaker_id_map": {
"amused": 0,
"angry": 1,
"disgusted": 2,
"drunk": 3,
"neutral": 4,
"sleepy": 5,
"surprised": 6,
"whisper": 7
},
"language": {
"code": "de_DE",
"family": "de",
"region": "DE",
"name_native": "Deutsch",
"name_english": "German",
"country_english": "Germany"
},
"dataset": "thorsten_emotional"
}

View File

@ -0,0 +1,502 @@
{
"dataset": "cori",
"audio": {
"sample_rate": 22050,
"quality": "high"
},
"espeak": {
"voice": "en"
},
"language": {
"code": "en_GB",
"family": "en",
"region": "GB",
"name_native": "English",
"name_english": "English",
"country_english": "Great Britain"
},
"inference": {
"noise_scale": 0.667,
"length_scale": 1,
"noise_w": 0.8
},
"phoneme_type": "espeak",
"phoneme_map": {},
"phoneme_id_map": {
" ": [
3
],
"!": [
4
],
"\"": [
150
],
"#": [
149
],
"$": [
2
],
"'": [
5
],
"(": [
6
],
")": [
7
],
",": [
8
],
"-": [
9
],
".": [
10
],
"0": [
130
],
"1": [
131
],
"2": [
132
],
"3": [
133
],
"4": [
134
],
"5": [
135
],
"6": [
136
],
"7": [
137
],
"8": [
138
],
"9": [
139
],
":": [
11
],
";": [
12
],
"?": [
13
],
"X": [
156
],
"^": [
1
],
"_": [
0
],
"a": [
14
],
"b": [
15
],
"c": [
16
],
"d": [
17
],
"e": [
18
],
"f": [
19
],
"g": [
154
],
"h": [
20
],
"i": [
21
],
"j": [
22
],
"k": [
23
],
"l": [
24
],
"m": [
25
],
"n": [
26
],
"o": [
27
],
"p": [
28
],
"q": [
29
],
"r": [
30
],
"s": [
31
],
"t": [
32
],
"u": [
33
],
"v": [
34
],
"w": [
35
],
"x": [
36
],
"y": [
37
],
"z": [
38
],
"æ": [
39
],
"ç": [
40
],
"ð": [
41
],
"ø": [
42
],
"ħ": [
43
],
"ŋ": [
44
],
"œ": [
45
],
"ǀ": [
46
],
"ǁ": [
47
],
"ǂ": [
48
],
"ǃ": [
49
],
"ɐ": [
50
],
"ɑ": [
51
],
"ɒ": [
52
],
"ɓ": [
53
],
"ɔ": [
54
],
"ɕ": [
55
],
"ɖ": [
56
],
"ɗ": [
57
],
"ɘ": [
58
],
"ə": [
59
],
"ɚ": [
60
],
"ɛ": [
61
],
"ɜ": [
62
],
"ɞ": [
63
],
"ɟ": [
64
],
"ɠ": [
65
],
"ɡ": [
66
],
"ɢ": [
67
],
"ɣ": [
68
],
"ɤ": [
69
],
"ɥ": [
70
],
"ɦ": [
71
],
"ɧ": [
72
],
"ɨ": [
73
],
"ɪ": [
74
],
"ɫ": [
75
],
"ɬ": [
76
],
"ɭ": [
77
],
"ɮ": [
78
],
"ɯ": [
79
],
"ɰ": [
80
],
"ɱ": [
81
],
"ɲ": [
82
],
"ɳ": [
83
],
"ɴ": [
84
],
"ɵ": [
85
],
"ɶ": [
86
],
"ɸ": [
87
],
"ɹ": [
88
],
"ɺ": [
89
],
"ɻ": [
90
],
"ɽ": [
91
],
"ɾ": [
92
],
"ʀ": [
93
],
"ʁ": [
94
],
"ʂ": [
95
],
"ʃ": [
96
],
"ʄ": [
97
],
"ʈ": [
98
],
"ʉ": [
99
],
"ʊ": [
100
],
"ʋ": [
101
],
"ʌ": [
102
],
"ʍ": [
103
],
"ʎ": [
104
],
"ʏ": [
105
],
"ʐ": [
106
],
"ʑ": [
107
],
"ʒ": [
108
],
"ʔ": [
109
],
"ʕ": [
110
],
"ʘ": [
111
],
"ʙ": [
112
],
"ʛ": [
113
],
"ʜ": [
114
],
"ʝ": [
115
],
"ʟ": [
116
],
"ʡ": [
117
],
"ʢ": [
118
],
"ʦ": [
155
],
"ʰ": [
145
],
"ʲ": [
119
],
"ˈ": [
120
],
"ˌ": [
121
],
"ː": [
122
],
"ˑ": [
123
],
"˞": [
124
],
"ˤ": [
146
],
"̃": [
141
],
"̧": [
140
],
"̩": [
144
],
"̪": [
142
],
"̯": [
143
],
"̺": [
152
],
"̻": [
153
],
"β": [
125
],
"ε": [
147
],
"θ": [
126
],
"χ": [
127
],
"ᵻ": [
128
],
"↑": [
151
],
"↓": [
148
],
"ⱱ": [
129
]
},
"num_symbols": 256,
"num_speakers": 1,
"speaker_id_map": {},
"piper_version": "1.0.0"
}

View File

@ -0,0 +1,603 @@
{
"audio": {
"sample_rate": 22050,
"quality": "medium"
},
"espeak": {
"voice": "en-gb-x-rp"
},
"inference": {
"noise_scale": 0.333,
"length_scale": 1.4,
"noise_w": 0.333
},
"phoneme_type": "espeak",
"phoneme_map": {},
"phoneme_id_map": {
"_": [
0
],
"^": [
1
],
"$": [
2
],
" ": [
3
],
"!": [
4
],
"'": [
5
],
"(": [
6
],
")": [
7
],
",": [
8
],
"-": [
9
],
".": [
10
],
":": [
11
],
";": [
12
],
"?": [
13
],
"a": [
14
],
"b": [
15
],
"c": [
16
],
"d": [
17
],
"e": [
18
],
"f": [
19
],
"h": [
20
],
"i": [
21
],
"j": [
22
],
"k": [
23
],
"l": [
24
],
"m": [
25
],
"n": [
26
],
"o": [
27
],
"p": [
28
],
"q": [
29
],
"r": [
30
],
"s": [
31
],
"t": [
32
],
"u": [
33
],
"v": [
34
],
"w": [
35
],
"x": [
36
],
"y": [
37
],
"z": [
38
],
"æ": [
39
],
"ç": [
40
],
"ð": [
41
],
"ø": [
42
],
"ħ": [
43
],
"ŋ": [
44
],
"œ": [
45
],
"ǀ": [
46
],
"ǁ": [
47
],
"ǂ": [
48
],
"ǃ": [
49
],
"ɐ": [
50
],
"ɑ": [
51
],
"ɒ": [
52
],
"ɓ": [
53
],
"ɔ": [
54
],
"ɕ": [
55
],
"ɖ": [
56
],
"ɗ": [
57
],
"ɘ": [
58
],
"ə": [
59
],
"ɚ": [
60
],
"ɛ": [
61
],
"ɜ": [
62
],
"ɞ": [
63
],
"ɟ": [
64
],
"ɠ": [
65
],
"ɡ": [
66
],
"ɢ": [
67
],
"ɣ": [
68
],
"ɤ": [
69
],
"ɥ": [
70
],
"ɦ": [
71
],
"ɧ": [
72
],
"ɨ": [
73
],
"ɪ": [
74
],
"ɫ": [
75
],
"ɬ": [
76
],
"ɭ": [
77
],
"ɮ": [
78
],
"ɯ": [
79
],
"ɰ": [
80
],
"ɱ": [
81
],
"ɲ": [
82
],
"ɳ": [
83
],
"ɴ": [
84
],
"ɵ": [
85
],
"ɶ": [
86
],
"ɸ": [
87
],
"ɹ": [
88
],
"ɺ": [
89
],
"ɻ": [
90
],
"ɽ": [
91
],
"ɾ": [
92
],
"ʀ": [
93
],
"ʁ": [
94
],
"ʂ": [
95
],
"ʃ": [
96
],
"ʄ": [
97
],
"ʈ": [
98
],
"ʉ": [
99
],
"ʊ": [
100
],
"ʋ": [
101
],
"ʌ": [
102
],
"ʍ": [
103
],
"ʎ": [
104
],
"ʏ": [
105
],
"ʐ": [
106
],
"ʑ": [
107
],
"ʒ": [
108
],
"ʔ": [
109
],
"ʕ": [
110
],
"ʘ": [
111
],
"ʙ": [
112
],
"ʛ": [
113
],
"ʜ": [
114
],
"ʝ": [
115
],
"ʟ": [
116
],
"ʡ": [
117
],
"ʢ": [
118
],
"ʲ": [
119
],
"ˈ": [
120
],
"ˌ": [
121
],
"ː": [
122
],
"ˑ": [
123
],
"˞": [
124
],
"β": [
125
],
"θ": [
126
],
"χ": [
127
],
"ᵻ": [
128
],
"ⱱ": [
129
],
"0": [
130
],
"1": [
131
],
"2": [
132
],
"3": [
133
],
"4": [
134
],
"5": [
135
],
"6": [
136
],
"7": [
137
],
"8": [
138
],
"9": [
139
],
"̧": [
140
],
"̃": [
141
],
"̪": [
142
],
"̯": [
143
],
"̩": [
144
],
"ʰ": [
145
],
"ˤ": [
146
],
"ε": [
147
],
"↓": [
148
],
"#": [
149
],
"\"": [
150
],
"↑": [
151
],
"̺": [
152
],
"̻": [
153
]
},
"num_symbols": 256,
"num_speakers": 109,
"speaker_id_map": {
"p239": 0,
"p236": 1,
"p264": 2,
"p250": 3,
"p259": 4,
"p247": 5,
"p261": 6,
"p263": 7,
"p283": 8,
"p286": 9,
"p274": 10,
"p276": 11,
"p270": 12,
"p281": 13,
"p277": 14,
"p231": 15,
"p271": 16,
"p238": 17,
"p257": 18,
"p273": 19,
"p284": 20,
"p329": 21,
"p361": 22,
"p287": 23,
"p360": 24,
"p374": 25,
"p376": 26,
"p310": 27,
"p304": 28,
"p334": 29,
"p340": 30,
"p323": 31,
"p347": 32,
"p330": 33,
"p308": 34,
"p314": 35,
"p317": 36,
"p339": 37,
"p311": 38,
"p294": 39,
"p305": 40,
"p266": 41,
"p335": 42,
"p318": 43,
"p351": 44,
"p333": 45,
"p313": 46,
"p316": 47,
"p244": 48,
"p307": 49,
"p363": 50,
"p336": 51,
"p297": 52,
"p312": 53,
"p267": 54,
"p275": 55,
"p295": 56,
"p258": 57,
"p288": 58,
"p301": 59,
"p232": 60,
"p292": 61,
"p272": 62,
"p280": 63,
"p278": 64,
"p341": 65,
"p268": 66,
"p298": 67,
"p299": 68,
"p279": 69,
"p285": 70,
"p326": 71,
"p300": 72,
"s5": 73,
"p230": 74,
"p345": 75,
"p254": 76,
"p269": 77,
"p293": 78,
"p252": 79,
"p262": 80,
"p243": 81,
"p227": 82,
"p343": 83,
"p255": 84,
"p229": 85,
"p240": 86,
"p248": 87,
"p253": 88,
"p233": 89,
"p228": 90,
"p282": 91,
"p251": 92,
"p246": 93,
"p234": 94,
"p226": 95,
"p260": 96,
"p245": 97,
"p241": 98,
"p303": 99,
"p265": 100,
"p306": 101,
"p237": 102,
"p249": 103,
"p256": 104,
"p302": 105,
"p364": 106,
"p225": 107,
"p362": 108
},
"piper_version": "1.0.0",
"language": {
"code": "en_GB",
"family": "en",
"region": "GB",
"name_native": "English",
"name_english": "English",
"country_english": "Great Britain"
},
"dataset": "vctk"
}

View File

@ -0,0 +1,508 @@
{
"dataset": "hfc_female",
"audio": {
"sample_rate": 22050,
"quality": "medium"
},
"espeak": {
"voice": "en-us"
},
"language": {
"code": "en_US",
"family": "en",
"region": "US",
"name_native": "English",
"name_english": "English",
"country_english": "United States"
},
"inference": {
"noise_scale": 0.667,
"length_scale": 1,
"noise_w": 0.8
},
"phoneme_type": "espeak",
"phoneme_map": {},
"phoneme_id_map": {
" ": [
3
],
"!": [
4
],
"\"": [
150
],
"#": [
149
],
"$": [
2
],
"'": [
5
],
"(": [
6
],
")": [
7
],
",": [
8
],
"-": [
9
],
".": [
10
],
"0": [
130
],
"1": [
131
],
"2": [
132
],
"3": [
133
],
"4": [
134
],
"5": [
135
],
"6": [
136
],
"7": [
137
],
"8": [
138
],
"9": [
139
],
":": [
11
],
";": [
12
],
"?": [
13
],
"X": [
156
],
"^": [
1
],
"_": [
0
],
"a": [
14
],
"b": [
15
],
"c": [
16
],
"d": [
17
],
"e": [
18
],
"f": [
19
],
"g": [
154
],
"h": [
20
],
"i": [
21
],
"j": [
22
],
"k": [
23
],
"l": [
24
],
"m": [
25
],
"n": [
26
],
"o": [
27
],
"p": [
28
],
"q": [
29
],
"r": [
30
],
"s": [
31
],
"t": [
32
],
"u": [
33
],
"v": [
34
],
"w": [
35
],
"x": [
36
],
"y": [
37
],
"z": [
38
],
"æ": [
39
],
"ç": [
40
],
"ð": [
41
],
"ø": [
42
],
"ħ": [
43
],
"ŋ": [
44
],
"œ": [
45
],
"ǀ": [
46
],
"ǁ": [
47
],
"ǂ": [
48
],
"ǃ": [
49
],
"ɐ": [
50
],
"ɑ": [
51
],
"ɒ": [
52
],
"ɓ": [
53
],
"ɔ": [
54
],
"ɕ": [
55
],
"ɖ": [
56
],
"ɗ": [
57
],
"ɘ": [
58
],
"ə": [
59
],
"ɚ": [
60
],
"ɛ": [
61
],
"ɜ": [
62
],
"ɞ": [
63
],
"ɟ": [
64
],
"ɠ": [
65
],
"ɡ": [
66
],
"ɢ": [
67
],
"ɣ": [
68
],
"ɤ": [
69
],
"ɥ": [
70
],
"ɦ": [
71
],
"ɧ": [
72
],
"ɨ": [
73
],
"ɪ": [
74
],
"ɫ": [
75
],
"ɬ": [
76
],
"ɭ": [
77
],
"ɮ": [
78
],
"ɯ": [
79
],
"ɰ": [
80
],
"ɱ": [
81
],
"ɲ": [
82
],
"ɳ": [
83
],
"ɴ": [
84
],
"ɵ": [
85
],
"ɶ": [
86
],
"ɸ": [
87
],
"ɹ": [
88
],
"ɺ": [
89
],
"ɻ": [
90
],
"ɽ": [
91
],
"ɾ": [
92
],
"ʀ": [
93
],
"ʁ": [
94
],
"ʂ": [
95
],
"ʃ": [
96
],
"ʄ": [
97
],
"ʈ": [
98
],
"ʉ": [
99
],
"ʊ": [
100
],
"ʋ": [
101
],
"ʌ": [
102
],
"ʍ": [
103
],
"ʎ": [
104
],
"ʏ": [
105
],
"ʐ": [
106
],
"ʑ": [
107
],
"ʒ": [
108
],
"ʔ": [
109
],
"ʕ": [
110
],
"ʘ": [
111
],
"ʙ": [
112
],
"ʛ": [
113
],
"ʜ": [
114
],
"ʝ": [
115
],
"ʟ": [
116
],
"ʡ": [
117
],
"ʢ": [
118
],
"ʦ": [
155
],
"ʰ": [
145
],
"ʲ": [
119
],
"ˈ": [
120
],
"ˌ": [
121
],
"ː": [
122
],
"ˑ": [
123
],
"˞": [
124
],
"ˤ": [
146
],
"̃": [
141
],
"̊": [
158
],
"̝": [
157
],
"̧": [
140
],
"̩": [
144
],
"̪": [
142
],
"̯": [
143
],
"̺": [
152
],
"̻": [
153
],
"β": [
125
],
"ε": [
147
],
"θ": [
126
],
"χ": [
127
],
"ᵻ": [
128
],
"↑": [
151
],
"↓": [
148
],
"ⱱ": [
129
]
},
"num_symbols": 256,
"num_speakers": 1,
"speaker_id_map": {},
"piper_version": "1.0.0"
}

View File

@ -0,0 +1,502 @@
{
"dataset": "kristin",
"audio": {
"sample_rate": 22050,
"quality": "medium"
},
"espeak": {
"voice": "en"
},
"language": {
"code": "en_US",
"family": "en",
"region": "US",
"name_native": "English",
"name_english": "English",
"country_english": "United States"
},
"inference": {
"noise_scale": 0.667,
"length_scale": 1,
"noise_w": 0.8
},
"phoneme_type": "espeak",
"phoneme_map": {},
"phoneme_id_map": {
" ": [
3
],
"!": [
4
],
"\"": [
150
],
"#": [
149
],
"$": [
2
],
"'": [
5
],
"(": [
6
],
")": [
7
],
",": [
8
],
"-": [
9
],
".": [
10
],
"0": [
130
],
"1": [
131
],
"2": [
132
],
"3": [
133
],
"4": [
134
],
"5": [
135
],
"6": [
136
],
"7": [
137
],
"8": [
138
],
"9": [
139
],
":": [
11
],
";": [
12
],
"?": [
13
],
"X": [
156
],
"^": [
1
],
"_": [
0
],
"a": [
14
],
"b": [
15
],
"c": [
16
],
"d": [
17
],
"e": [
18
],
"f": [
19
],
"g": [
154
],
"h": [
20
],
"i": [
21
],
"j": [
22
],
"k": [
23
],
"l": [
24
],
"m": [
25
],
"n": [
26
],
"o": [
27
],
"p": [
28
],
"q": [
29
],
"r": [
30
],
"s": [
31
],
"t": [
32
],
"u": [
33
],
"v": [
34
],
"w": [
35
],
"x": [
36
],
"y": [
37
],
"z": [
38
],
"æ": [
39
],
"ç": [
40
],
"ð": [
41
],
"ø": [
42
],
"ħ": [
43
],
"ŋ": [
44
],
"œ": [
45
],
"ǀ": [
46
],
"ǁ": [
47
],
"ǂ": [
48
],
"ǃ": [
49
],
"ɐ": [
50
],
"ɑ": [
51
],
"ɒ": [
52
],
"ɓ": [
53
],
"ɔ": [
54
],
"ɕ": [
55
],
"ɖ": [
56
],
"ɗ": [
57
],
"ɘ": [
58
],
"ə": [
59
],
"ɚ": [
60
],
"ɛ": [
61
],
"ɜ": [
62
],
"ɞ": [
63
],
"ɟ": [
64
],
"ɠ": [
65
],
"ɡ": [
66
],
"ɢ": [
67
],
"ɣ": [
68
],
"ɤ": [
69
],
"ɥ": [
70
],
"ɦ": [
71
],
"ɧ": [
72
],
"ɨ": [
73
],
"ɪ": [
74
],
"ɫ": [
75
],
"ɬ": [
76
],
"ɭ": [
77
],
"ɮ": [
78
],
"ɯ": [
79
],
"ɰ": [
80
],
"ɱ": [
81
],
"ɲ": [
82
],
"ɳ": [
83
],
"ɴ": [
84
],
"ɵ": [
85
],
"ɶ": [
86
],
"ɸ": [
87
],
"ɹ": [
88
],
"ɺ": [
89
],
"ɻ": [
90
],
"ɽ": [
91
],
"ɾ": [
92
],
"ʀ": [
93
],
"ʁ": [
94
],
"ʂ": [
95
],
"ʃ": [
96
],
"ʄ": [
97
],
"ʈ": [
98
],
"ʉ": [
99
],
"ʊ": [
100
],
"ʋ": [
101
],
"ʌ": [
102
],
"ʍ": [
103
],
"ʎ": [
104
],
"ʏ": [
105
],
"ʐ": [
106
],
"ʑ": [
107
],
"ʒ": [
108
],
"ʔ": [
109
],
"ʕ": [
110
],
"ʘ": [
111
],
"ʙ": [
112
],
"ʛ": [
113
],
"ʜ": [
114
],
"ʝ": [
115
],
"ʟ": [
116
],
"ʡ": [
117
],
"ʢ": [
118
],
"ʦ": [
155
],
"ʰ": [
145
],
"ʲ": [
119
],
"ˈ": [
120
],
"ˌ": [
121
],
"ː": [
122
],
"ˑ": [
123
],
"˞": [
124
],
"ˤ": [
146
],
"̃": [
141
],
"̧": [
140
],
"̩": [
144
],
"̪": [
142
],
"̯": [
143
],
"̺": [
152
],
"̻": [
153
],
"β": [
125
],
"ε": [
147
],
"θ": [
126
],
"χ": [
127
],
"ᵻ": [
128
],
"↑": [
151
],
"↓": [
148
],
"ⱱ": [
129
]
},
"num_symbols": 256,
"num_speakers": 1,
"speaker_id_map": {},
"piper_version": "1.0.0"
}

22
requirements.txt Normal file
View File

@ -0,0 +1,22 @@
# NovaAi – TTS-Engine-Hub
# requirements.txt
# Version: v0.0.1
fastapi
uvicorn
gunicorn
pydantic
pydantic-settings
ffmpeg-python
piper-tts
f5-tts
torch
torchaudio
# Development & Testing
pytest-cov
pytest-asyncio

21
run.sh Executable file
View File

@ -0,0 +1,21 @@
#!/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

34
setup_env.sh Normal file
View File

@ -0,0 +1,34 @@
#!/usr/bin/env bash
# NovaAi – TTS-Engine-Hub
# setup_env.sh
# Version: v0.0.1
#
# Deletes old venv (if exists), creates new one, and installs dependencies from requirements.txt.
# Author: Abby (ChatGPT)
# Date: 2025-07-23
# Canvas: setup_env.sh
set -e
if [ -d ".venv" ]; then
echo "Removing existing venv..."
rm -rf .venv
fi
echo "Creating new virtual environment..."
python3 -m venv .venv
echo "Activating virtual environment..."
source .venv/bin/activate
if [ ! -f requirements.txt ]; then
echo "requirements.txt not found!"
exit 1
fi
echo "Installing requirements..."
pip install --upgrade pip
pip install -r requirements.txt
echo "Setup complete!"

34
tests/conftest.py Normal file
View File

@ -0,0 +1,34 @@
import pytest
from fastapi.testclient import TestClient
from main import create_app # Import the app factory function
from config import settings # Import settings to monkeypatch
import os
@pytest.fixture
def tmp_audio_dir(tmp_path):
"""Provides a temporary directory for audio caching for each test."""
audio_dir = tmp_path / "test_audio_cache"
audio_dir.mkdir()
return audio_dir
@pytest.fixture
def app_instance(tmp_audio_dir, monkeypatch):
"""
Provides a fresh FastAPI application instance for each test,
with its AUDIO_CACHE_DIR redirected to a temporary location.
"""
monkeypatch.setattr(settings, "AUDIO_CACHE_DIR", str(tmp_audio_dir))
app = create_app()
return app
@pytest.fixture
def app_client(app_instance):
"""Provides a TestClient instance for the FastAPI application."""
with TestClient(app_instance) as client:
yield client
@pytest.fixture
def piper_engine(app_instance):
"""Provides the PiperEngine instance from the registry of the fresh app instance."""
# Access the engine registry from the app_instance
return app_instance.ENGINE_REGISTRY.get("piper")

123
tests/test_api.py Normal file
View File

@ -0,0 +1,123 @@
from fastapi.testclient import TestClient
import pytest
# from main import app, ENGINE_REGISTRY # No longer needed, using fixtures
import shutil
import asyncio
TEST_TEXT = "Dies ist ein NovaAi Test."
# Ensure the engine being tested is in the default .env config
ENGINE = "piper"
MODEL = "de_DE-thorsten-high"
FORMAT = "ogg"
# === Happy Path Tests ===
@pytest.mark.asyncio
async def test_tts_link_success(app_client):
"""Tests successful synthesis returning a URL."""
resp = app_client.post("/tts", json={
"text": TEST_TEXT,
"engine": ENGINE,
"model": MODEL,
"format": FORMAT
})
assert resp.status_code == 200
data = resp.json()
assert "audio_url" in data
# Also test the audio download endpoint
audio_url = data['audio_url']
audio_resp = app_client.get(audio_url)
assert audio_resp.status_code == 200
assert len(audio_resp.content) > 1000
@pytest.mark.asyncio
async def test_tts_base64_success(app_client):
"""Tests successful synthesis returning base64 data."""
resp = app_client.post("/tts?as=true", json={
"text": TEST_TEXT,
"engine": ENGINE,
"model": MODEL,
"format": FORMAT
})
assert resp.status_code == 200
data = resp.json()
assert "audio_base64" in data
assert len(data["audio_base64"]) > 1000
@pytest.mark.asyncio
async def test_health_and_engines_endpoints(app_client):
"""Tests the /health and /engines endpoints for correct responses."""
resp_engines = app_client.get("/engines")
assert resp_engines.status_code == 200
engines = resp_engines.json()
assert ENGINE in engines
resp_health = app_client.get("/health")
assert resp_health.status_code == 200
health = resp_health.json()
assert health["status"][ENGINE] == "ok"
# === Failure Path Tests ===
@pytest.mark.asyncio
async def test_tts_fails_with_invalid_engine(app_client):
"""Tests that a request with a non-existent engine fails with HTTP 404."""
response = app_client.post("/tts", json={
"text": TEST_TEXT,
"engine": "non_existent_engine",
"model": MODEL,
})
assert response.status_code == 404
assert "Engine 'non_existent_engine' not found" in response.json()["detail"]
@pytest.mark.asyncio
async def test_tts_fails_with_invalid_model(app_client):
"""Tests that a request with a non-existent model fails with HTTP 400."""
response = app_client.post("/tts", json={
"text": TEST_TEXT,
"engine": ENGINE,
"model": "non_existent_model",
})
assert response.status_code == 400
assert "Model 'non_existent_model' not found" in response.json()["detail"]
@pytest.mark.asyncio
async def test_tts_fails_with_invalid_speaker(app_client, piper_engine):
"""Tests that a request with a non-existent speaker fails with HTTP 400."""
# This model has speakers, so requesting a non-existent one should fail.
# We need to pick a model that is known to have multiple speakers
# The default piper engine models should have this.
model_with_speakers = "en_US-kristin-medium" # Example model with speakers
# First, check if the model is actually available to test against
if model_with_speakers not in piper_engine.list_models():
pytest.skip(f"Model '{model_with_speakers}' not available for '{ENGINE}' to test against.")
response = app_client.post("/tts", json={
"text": "This is a test.",
"engine": ENGINE,
"model": model_with_speakers,
"speaker": "non_existent_speaker"
})
assert response.status_code == 400
assert "Speaker 'non_existent_speaker' not found" in response.json()["detail"]
@pytest.mark.asyncio
async def test_tts_fails_with_unavailable_engine(app_client, piper_engine, monkeypatch):
"""Tests that a request fails with HTTP 503 if an engine is unhealthy."""
# Simulate the piper executable being not found by monkeypatching the specific instance
monkeypatch.setattr(piper_engine, "piper_executable", None)
response = app_client.post("/tts", json={
"text": TEST_TEXT,
"engine": ENGINE,
"model": MODEL,
})
assert response.status_code == 503
assert "is not available" in response.json()["detail"]

70
tests/test_f5_tts.py Normal file
View File

@ -0,0 +1,70 @@
import unittest
import os
import shutil
import asyncio
from importlib.resources import files
from engines.f5_tts import F5TTSEngine
class TestF5TTSEngine(unittest.TestCase):
async def asyncSetUp(self):
self.engine = F5TTSEngine()
self.voices_dir = "engines/f5-tts-voices"
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_txt = os.path.join(self.voices_dir, f"{self.test_speaker_name}.txt")
# Create a dummy speaker for testing
if not await asyncio.to_thread(os.path.exists, self.test_speaker_wav):
default_wav_path = str(files("f5_tts").joinpath("infer/examples/basic/basic_ref_en.wav"))
await asyncio.to_thread(shutil.copy, default_wav_path, self.test_speaker_wav)
if not await asyncio.to_thread(os.path.exists, self.test_speaker_txt):
await asyncio.to_thread(lambda: open(self.test_speaker_txt, "w").write("Some call me nature, others call me mother nature."))
# Reload speakers to include the new test speaker
self.engine._load_speakers()
async def asyncTearDown(self):
# Clean up the dummy speaker files
if await asyncio.to_thread(os.path.exists, self.test_speaker_wav):
await asyncio.to_thread(os.remove, self.test_speaker_wav)
if await asyncio.to_thread(os.path.exists, self.test_speaker_txt):
await asyncio.to_thread(os.remove, self.test_speaker_txt)
async def test_synthesize_default_wav(self):
if not self.engine.model:
self.skipTest("F5-TTS model not initialized.")
text = "Hello, this is a test with the default voice."
audio_file = await self.engine.synthesize(text, fmt="wav")
self.assertTrue(await asyncio.to_thread(os.path.exists, audio_file))
self.assertTrue(await asyncio.to_thread(os.path.getsize, audio_file) > 0)
await asyncio.to_thread(os.remove, audio_file)
async def test_synthesize_custom_speaker_wav(self):
if not self.engine.model:
self.skipTest("F5-TTS model not initialized.")
text = "Hello, this is a test with a custom voice."
audio_file = await self.engine.synthesize(text, speaker=self.test_speaker_name, fmt="wav")
self.assertTrue(await asyncio.to_thread(os.path.exists, audio_file))
self.assertTrue(await asyncio.to_thread(os.path.getsize, audio_file) > 0)
await asyncio.to_thread(os.remove, audio_file)
async def test_synthesize_ogg(self):
if not self.engine.model:
self.skipTest("F5-TTS model not initialized.")
text = "Hello, this is a test in ogg format."
audio_file = await self.engine.synthesize(text, fmt="ogg")
self.assertTrue(await asyncio.to_thread(os.path.exists, audio_file))
self.assertTrue(await asyncio.to_thread(os.path.getsize, audio_file) > 0)
await asyncio.to_thread(os.remove, audio_file)
if __name__ == '__main__':
unittest.main()

76
utils/audio.py Normal file
View File

@ -0,0 +1,76 @@
"""
NovaAi – TTS-Engine-Hub
utils/audio.py
Version: v0.0.1
Description:
Audio processing utilities: concat, merging chunks, etc.
Author: Abby (ChatGPT)
Date: 2025-07-23
Canvas: utils/audio.py
"""
import tempfile
import os
import ffmpeg
import shutil
def check_ffmpeg():
"""Check if ffmpeg is installed and available in the system's PATH."""
return shutil.which("ffmpeg") is not None
def concat_audio(files, fmt):
"""
Concatenate a list of audio files into a single file of the given format.
Supports: wav, ogg, mp3
"""
if len(files) == 1:
# If there's only one file, just return it, no cleanup needed here.
return files[0]
# Securely create a temporary file for the output
with tempfile.NamedTemporaryFile(suffix=f'.{fmt}', prefix="chunked_", delete=False) as temp_output_file:
output_file_path = temp_output_file.name
try:
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_path, 'wb') as wf:
wf.setparams(params)
for d in data:
wf.writeframes(d)
else:
if not check_ffmpeg():
raise RuntimeError("ffmpeg not found. Please install ffmpeg and ensure it is in your PATH.")
with tempfile.NamedTemporaryFile("w", delete=False) as tf:
list_file_path = tf.name
for f in files:
tf.write(f"file '{os.path.abspath(f)}'\\n")
tf.flush()
try:
(
ffmpeg
.input(list_file_path, format='concat', safe=0)
.output(output_file_path, acodec='copy')
.run(overwrite_output=True, quiet=True)
)
finally:
os.unlink(list_file_path)
finally:
# Clean up the input chunk files
for f in files:
if os.path.exists(f):
os.remove(f)
return output_file_path

22
utils/cache.py Normal file
View File

@ -0,0 +1,22 @@
"""
NovaAi – TTS-Engine-Hub
utils/cache.py
Version: v0.0.1
Description:
Caching utilities for TTS requests (cache key generation, etc).
Author: Abby (ChatGPT)
Date: 2025-07-23
Canvas: utils/cache.py
"""
import hashlib
# Für die API: Wichtig! req muss mindestens die Felder .text, .engine, .model, .speaker, .format, .chunking haben.
def build_cache_key(req) -> str:
"""
Build a cache key from all relevant TTS request parameters.
"""
data = f"{req.text}|{req.engine}|{req.model}|{req.speaker}|{req.format}|{getattr(req, 'chunking', False)}"
return hashlib.sha256(data.encode()).hexdigest()

30
utils/text.py Normal file
View File

@ -0,0 +1,30 @@
"""
NovaAi – TTS-Engine-Hub
utils/text.py
Version: v0.0.1
Description:
Text processing utilities: chunking, splitting, etc.
Author: Abby (ChatGPT)
Date: 2025-07-23
Canvas: utils/text.py
"""
def chunk_text(text, maxlen=250):
"""
Split text into chunks of roughly maxlen (split at sentence boundaries if possible).
"""
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()]