first commit
This commit is contained in:
222
README.md
Normal file
222
README.md
Normal 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)
|
||||
Reference in New Issue
Block a user