Files
audio-engine-hub/README.md
2025-12-04 11:58:36 +01:00

223 lines
7.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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