plo / app.py
Arabi32's picture
Rename run.sh to app.py
4dc73c7 verified
Raw
History Blame Contribute Delete
18.7 kB
# โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—
# โ•‘ Pro Voice Studio (XTTS + NLP + Resemble Enhance Integration) โ•‘
# โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
import os, sys, time, json, uuid, shutil
import uvicorn
from fastapi import FastAPI, Form, File, UploadFile, HTTPException
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
from fastapi.middleware.cors import CORSMiddleware
import torch
import torchaudio
# === Model 1 Imports (NLP) ===
import pyarabic.araby as araby
# === Model 2 Imports (TTS) ===
from TTS.api import TTS
# === Model 3 Imports (Enhancement/Super Resolution) ===
from resemble_enhance.enhancer.inference import enhance
# โ”€โ”€ Env โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
os.environ["COQUI_TOS_AGREED"] = "1"
os.environ.setdefault("HF_HOME", "/data/huggingface")
VOICE_LIB = "/data/voice_library"
OUTPUT_DIR = "/data/outputs"
HISTORY_FILE = "/data/history.json"
for d in [VOICE_LIB, OUTPUT_DIR]:
os.makedirs(d, exist_ok=True)
# โ”€โ”€ Hardware โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"[*] Pipeline running on {device.upper()} โ€ฆ")
# โ”€โ”€ Load Core TTS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
print("[*] Loading Model 2: XTTS v2 โ€ฆ")
xtts_engine = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(device)
print("[โœ“] XTTS Model ready.")
# โ”€โ”€ Text Normalization Wrapper (Model 1) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def normalize_arabic_text(text: str) -> str:
"""Pre-process text to guide the TTS model better."""
text = araby.strip_tashkeel(text) # Optional: Can be modified based on need
text = araby.normalize_ligature(text)
# Add subtle punctuation spacing to give the acoustic model breathing room
text = text.replace("ุŒ", "ุŒ ").replace(".", ". ")
return " ".join(text.split())
# โ”€โ”€ Audio Enhancer Wrapper (Model 3) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def enhance_audio_quality(input_path: str, output_path: str):
"""Pass XTTS output through Resemble Enhance for ElevenLabs-like quality."""
print("[*] Running Model 3: Resemble Enhance (Super Resolution)...")
dwav, sr = torchaudio.load(input_path)
# Apply inference enhancement (NFE=32 is a good balance of speed/quality)
hwav, new_sr = enhance(dwav=dwav, sr=sr, device=device, nfe=32)
# Save the polished audio
torchaudio.save(output_path, hwav, new_sr)
print("[โœ“] Enhancement complete.")
# โ”€โ”€ History helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def load_history():
if os.path.exists(HISTORY_FILE):
try: return json.load(open(HISTORY_FILE))
except: pass
return []
def save_history(h):
json.dump(h, open(HISTORY_FILE, "w"), ensure_ascii=False, indent=2)
# โ”€โ”€ FastAPI app โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
app = FastAPI(title="Pro Voice Studio")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
LANGUAGES = {
"ar": "ุงู„ุนุฑุจูŠุฉ", "en": "English", "es": "Espaรฑol", "fr": "Franรงais",
"de": "Deutsch", "it": "Italiano", "pt": "Portuguรชs","ru": "ะ ัƒััะบะธะน",
"zh-cn": "ไธญๆ–‡", "ja": "ๆ—ฅๆœฌ่ชž"
}
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# HTML Frontend
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
HTML = r"""<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pro Voice Studio | Multi-Model</title>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=IBM+Plex+Sans+Arabic:wght@300;400;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<style>
:root { --bg: #0b0c0f; --surface: #13151a; --border: #1f2330; --amber: #f5a623; --amber-dim:#a06a10; --green: #3ddc84; --red: #ff5252; --text: #e8eaf0; --muted: #6b7280; --mono: 'IBM Plex Mono', monospace; --sans: 'IBM Plex Sans Arabic', sans-serif; }
* { box-sizing: border-box; } body { margin: 0; background: var(--bg); color: var(--text); font-family: var(--sans); min-height: 100vh; }
::-webkit-scrollbar { width: 5px; } ::-webkit-scrollbar-track { background: var(--surface); } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; } .amber { color: var(--amber); } .tag { font-family: var(--mono); font-size: 10px; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); }
input[type=range] { -webkit-appearance: none; width: 100%; height: 3px; background: var(--border); border-radius: 2px; outline: none; } input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; background: var(--amber); border-radius: 50%; cursor: pointer; transition: transform .15s; }
select, textarea { background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 6px 10px; font-family: var(--sans); outline: none; width: 100%;} textarea { resize: vertical; padding: 12px; }
.file-drop { border: 2px dashed var(--border); border-radius: 8px; padding: 16px; text-align: center; cursor: pointer; position: relative; } .file-drop:hover { border-color: var(--amber); background: rgba(245,166,35,.05); } .file-drop input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
.btn-primary { background: var(--amber); color: #000; font-weight: 700; border: none; border-radius: 8px; padding: 14px 24px; cursor: pointer; font-family: var(--sans); font-size: 15px; width: 100%; transition: opacity .2s; } .btn-primary:disabled { opacity: .4; cursor: not-allowed; }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--muted); border-radius: 6px; padding: 6px 12px; cursor: pointer; font-size: 12px; } .btn-ghost:hover { color: var(--text); border-color: var(--muted); }
.badge { display: inline-flex; align-items: center; gap: 4px; background: rgba(245,166,35,.12); border: 1px solid rgba(245,166,35,.3); color: var(--amber); border-radius: 20px; padding: 2px 10px; font-size: 11px; font-family: var(--mono); } .badge.green { background: rgba(61,220,132,.1); border-color: rgba(61,220,132,.3); color: var(--green); } .badge.purple { background: rgba(168, 85, 247, .1); border-color: rgba(168, 85, 247, .3); color: #a855f7; }
.waveform { display: flex; align-items: center; gap: 3px; height: 28px; } .waveform span { flex: 1; background: var(--amber); border-radius: 2px; animation: wave 1s ease-in-out infinite; opacity: .7; } @keyframes wave { 0%,100%{height:4px} 50%{height:24px} } .waveform span:nth-child(2){animation-delay:.1s} .waveform span:nth-child(3){animation-delay:.2s} .waveform span:nth-child(4){animation-delay:.3s}
.tab { cursor:pointer; padding:8px 16px; border-radius:6px; font-size:13px; color:var(--muted); transition:all .2s; } .tab.active { background:rgba(245,166,35,.15); color:var(--amber); }
audio { width:100%; accent-color:var(--amber); }
.param-row { display:grid; grid-template-columns:140px 1fr 48px; align-items:center; gap:12px; } .param-label { font-size:12px; color:var(--muted); font-family:var(--mono); } .param-val { font-size:13px; color:var(--amber); font-family:var(--mono); text-align:right; }
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect } = React;
const device = "DEVICE_PLACEHOLDER";
const fmt = v => parseFloat(v).toFixed(2);
const apiPost = (url, body) => fetch(url, { method:"POST", body }).then(r => { if (!r.ok) return r.json().then(e => { throw new Error(e.detail || "Server error"); }); return r.json(); });
function Slider({ label, min, max, step, value, onChange }) {
return ( <div className="param-row"><span className="param-label">{label}</span><input type="range" min={min} max={max} step={step} value={value} onChange={e => onChange(parseFloat(e.target.value))} /><span className="param-val">{fmt(value)}</span></div> );
}
function FileZone({ label, file, onFile }) {
return ( <div><div className="tag mb-1">{label}</div><div className="file-drop"> <input type="file" accept="audio/*" onChange={e=>onFile(e.target.files[0])} /> {file ? <span style={{color:"var(--green)",fontSize:12}}>โœ“ {file.name}</span> : <span style={{color:"var(--muted)",fontSize:12}}>ุงุณุญุจ ู…ู„ูุงู‹ ุฃูˆ ุงู†ู‚ุฑ</span>} </div></div> );
}
function WaveAnim() { return <div className="waveform">{[1,2,3,4,5,6,7].map(i=><span key={i}/>)}</div>; }
function App() {
const [tab, setTab] = useState("generate");
const [text, setText] = useState(""); const [lang, setLang] = useState("ar"); const [file1, setFile1] = useState(null);
const [temperature, setTemp] = useState(0.75); const [speed, setSpeed] = useState(1.0);
const [useEnhancer, setUseEnhancer] = useState(false);
const [status, setStatus] = useState("idle"); const [statusMsg, setStatusMsg] = useState(""); const [audioUrl, setAudioUrl] = useState(null);
const [history, setHistory] = useState([]); const [voices, setVoices] = useState([]); const [selVoice, setSelVoice] = useState(null);
useEffect(() => { fetch("/history").then(r=>r.json()).then(setHistory).catch(()=>{}); fetch("/voices").then(r=>r.json()).then(setVoices).catch(()=>{}); }, []);
const generate = async () => {
if (!text.trim()) return setStatusMsg("ุฃุฏุฎู„ ุงู„ู†ุต.");
if (!file1 && !selVoice) return setStatusMsg("ูŠุฌุจ ุชุญุฏูŠุฏ ุตูˆุช.");
setStatus("running"); setStatusMsg(useEnhancer ? "ุฌุงุฑูŠ ุงู„ุชูˆู„ูŠุฏ... (ุงู„ุชูƒุงู…ู„ ู…ูุนู„ุŒ ู‚ุฏ ูŠุณุชุบุฑู‚ ูˆู‚ุชุงู‹ ุทูˆูŠู„ุงู‹)" : ""); setAudioUrl(null);
const fd = new FormData();
fd.append("text", text); fd.append("language", lang); fd.append("temperature", temperature); fd.append("speed", speed);
fd.append("enhance_audio", useEnhancer);
if (file1) fd.append("files", file1); if (selVoice) fd.append("voice_name", selVoice);
try { const data = await apiPost("/generate", fd); setAudioUrl(`/audio/${data.filename}`); setStatus("done"); }
catch(e) { setStatus("error"); setStatusMsg(e.message); }
};
const isRTL = ["ar"].includes(lang);
return (
<div style={{maxWidth:720,margin:"0 auto",padding:"24px 16px"}}>
<div style={{marginBottom:28,textAlign:"center"}}>
<div className="tag" style={{marginBottom:6}}>Multi-Model Pipeline</div>
<h1 style={{margin:0,fontSize:26,fontWeight:700,letterSpacing:"-.02em"}}><span className="amber">Pro Voice</span> Studio</h1>
<div style={{marginTop:8,display:"flex",justifyContent:"center",gap:6}}>
<span className={`badge ${device==="cuda"?"green":"red"}`}>โ— {device.toUpperCase()}</span>
<span className="badge purple">ElevenLabs-Like Pipeline</span>
</div>
</div>
{tab==="generate" && (
<div style={{display:"flex",flexDirection:"column",gap:14}}>
<div className="card" style={{padding:16}}><div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:10}}><div className="tag">ุงู„ู†ุต (ูŠุชู… ู…ุนุงู„ุฌุชู‡ ุจู€ NLP)</div><select value={lang} onChange={e=>setLang(e.target.value)} style={{width:'auto'}}>{Object.entries(LANGUAGES_JSON).map(([k,v])=><option key={k} value={k}>{v}</option>)}</select></div><textarea dir={isRTL?"rtl":"ltr"} rows={4} value={text} onChange={e=>setText(e.target.value)} /></div>
<div className="card" style={{padding:16}}><div className="tag" style={{marginBottom:10}}>ุจุตู…ุฉ ุงู„ุตูˆุช</div><FileZone label="ุนูŠู†ุฉ" file={file1} onFile={setFile1} />
{voices.length>0 && <div style={{marginTop:12,display:"flex",gap:8}}>{voices.map(v=>( <div key={v} style={{padding:"6px 14px",borderRadius:20,cursor:"pointer",fontSize:12,border:"1px solid",borderColor:selVoice===v?"var(--amber)":"var(--border)",color:selVoice===v?"var(--amber)":"var(--muted)"}} onClick={()=>setSelVoice(selVoice===v?null:v)}>{v}</div> ))}</div>}
</div>
<div className="card" style={{padding:16, border:"1px solid rgba(168, 85, 247, 0.4)", background:"rgba(168, 85, 247, 0.05)"}}>
<label style={{display:"flex", alignItems:"center", gap:10, cursor:"pointer"}}>
<input type="checkbox" checked={useEnhancer} onChange={e=>setUseEnhancer(e.target.checked)} style={{width:18, height:18, accentColor:"#a855f7"}}/>
<div>
<div style={{fontWeight:600, color:"#a855f7"}}>ุชูุนูŠู„ ุฌูˆุฏุฉ ุงู„ุงุณุชูˆุฏูŠูˆ (Super-Resolution)</div>
<div style={{fontSize:11, color:"var(--muted)", marginTop:4}}>ูŠุณุชุฎุฏู… ู†ู…ูˆุฐุฌ Resemble Enhance ู„ุชู†ู‚ูŠุฉ ุงู„ุตูˆุช ูˆุฑูุน ุฏู‚ุชู‡ ู„ู€ 44.1kHz (ูŠุชุทู„ุจ GPU).</div>
</div>
</label>
</div>
{statusMsg && <div style={{padding:"10px 14px",borderRadius:8,fontSize:13,background:"rgba(255,82,82,.08)",color:"var(--red)"}}>{statusMsg}</div>}
<button className="btn-primary" onClick={generate} disabled={status==="running"}>{status==="running" ? <span style={{display:"flex",justifyContent:"center",gap:10}}><WaveAnim/> ุฌุงุฑูŠ ุงู„ู…ุนุงู„ุฌุฉ ุงู„ู…ุนู…ุงุฑูŠุฉโ€ฆ</span> : "โšก ุชูˆู„ูŠุฏ ุงู„ุตูˆุช"}</button>
{audioUrl && (
<div className="card" style={{padding:16}}>
<audio src={audioUrl} controls autoPlay />
<div style={{marginTop:12}}><a href={audioUrl} download><button className="btn-ghost" style={{width:'100%'}}>โฌ‡ ุชุญู…ูŠู„ ุงู„ู…ู„ู ุงู„ู†ู‡ุงุฆูŠ</button></a></div>
</div>
)}
</div>
)}
</div>
);
}
const LANGUAGES_JSON = {"ar": "ุงู„ุนุฑุจูŠุฉ", "en": "English"};
ReactDOM.createRoot(document.getElementById("root")).render(<App/>);
</script>
</body>
</html>
"""
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# Routes
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
@app.get("/", response_class=HTMLResponse)
async def ui():
return HTML.replace("DEVICE_PLACEHOLDER", device)
@app.post("/generate")
async def generate(
text: str = Form(...),
language: str = Form("ar"),
temperature: float = Form(0.75),
speed: float = Form(1.0),
enhance_audio: str = Form("false"),
voice_name: str = Form(None),
files: list[UploadFile] = File(default=[]),
):
if not text.strip(): raise HTTPException(400, "ุงู„ู†ุต ูุงุฑุบ.")
# 1. NLP Processing (Model 1)
processed_text = normalize_arabic_text(text) if language == "ar" else text
# Prepare voice references
ref_paths, tmp_files = [], []
for f in files:
path = f"/tmp/ref_{uuid.uuid4().hex}_{f.filename}"
with open(path, "wb") as buf: shutil.copyfileobj(f.file, buf)
ref_paths.append(path)
tmp_files.append(path)
if voice_name:
lib_dir = os.path.join(VOICE_LIB, voice_name)
if os.path.isdir(lib_dir):
ref_paths += [os.path.join(lib_dir, fn) for fn in os.listdir(lib_dir) if fn.endswith((".wav", ".mp3"))]
if not ref_paths: raise HTTPException(400, "ูŠุฌุจ ุชุญุฏูŠุฏ ุนูŠู†ุฉ ุตูˆุชูŠุฉ.")
# 2. TTS Generation (Model 2)
base_out_name = f"gen_base_{uuid.uuid4().hex[:8]}.wav"
base_out_path = os.path.join(OUTPUT_DIR, base_out_name)
final_out_name = base_out_name
try:
xtts_engine.tts_to_file(
text=processed_text, speaker_wav=ref_paths, language=language,
file_path=base_out_path, temperature=float(temperature), speed=float(speed)
)
# 3. Super Resolution & Enhancement (Model 3)
if enhance_audio.lower() == "true":
enhanced_name = f"gen_pro_{uuid.uuid4().hex[:8]}.wav"
enhanced_path = os.path.join(OUTPUT_DIR, enhanced_name)
enhance_audio_quality(base_out_path, enhanced_path)
final_out_name = enhanced_name
finally:
for p in tmp_files:
try: os.remove(p)
except: pass
hist = load_history()
hist.append({"filename": final_out_name, "text": processed_text[:100], "ts": int(time.time())})
save_history(hist)
return {"filename": final_out_name}
@app.get("/audio/{filename}")
def get_audio(filename: str):
path = os.path.join(OUTPUT_DIR, filename)
if not os.path.exists(path): raise HTTPException(404, "Not found")
return FileResponse(path, media_type="audio/wav")
@app.get("/history")
def get_history(): return JSONResponse(load_history())
@app.get("/voices")
def list_voices(): return [d for d in os.listdir(VOICE_LIB) if os.path.isdir(os.path.join(VOICE_LIB, d))]
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=7860, log_level="info")