""" 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)