Spaces:
Sleeping
Sleeping
| """ | |
| cantrell-kokoro-engine β StoryVoice Cloning Backend | |
| Docker Space, Python 3.12, FastAPI only | |
| KokoClone zero-shot voice cloning via Kanade voice conversion | |
| """ | |
| import os | |
| import sys | |
| from fastapi import FastAPI, HTTPException | |
| from fastapi.responses import Response | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from pydantic import BaseModel | |
| import uvicorn | |
| app = FastAPI() | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # ββ KokoClone voice cloning (lazy-loaded) ββββββββββββββββββββββββββββββββββββ | |
| BASE_DIR = os.path.dirname(os.path.abspath(__file__)) | |
| VOICES_DIR = os.path.join(BASE_DIR, "voices") | |
| sys.path.insert(0, BASE_DIR) | |
| _kokoclone = None | |
| def get_kokoclone(): | |
| global _kokoclone | |
| if _kokoclone is None: | |
| from core.cloner import KokoClone | |
| print("[StoryVoice] Loading KokoClone...") | |
| _kokoclone = KokoClone() | |
| print("[StoryVoice] KokoClone ready.") | |
| return _kokoclone | |
| def find_voice_audio(voice_id: str): | |
| """Find a reference MP3/WAV in the Space's voices/ folder.""" | |
| voice_id_clean = voice_id.replace(".mp3", "").replace(".wav", "").strip() | |
| for ext in (".mp3", ".wav"): | |
| p = os.path.join(VOICES_DIR, f"{voice_id_clean}{ext}") | |
| if os.path.exists(p): | |
| return p | |
| if os.path.isdir(VOICES_DIR): | |
| for f in os.listdir(VOICES_DIR): | |
| name, ext = os.path.splitext(f) | |
| if ext.lower() in (".mp3", ".wav") and name.lower() == voice_id_clean.lower(): | |
| return os.path.join(VOICES_DIR, f) | |
| return None | |
| # ββ Routes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def index(): | |
| html = """<!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8"> | |
| <title>Nyako StoryVoiceβ’</title> | |
| <style> | |
| *{box-sizing:border-box;} | |
| body{margin:0;background:#faf3e6;min-height:100vh;font-family:Georgia,serif;display:flex;align-items:center;justify-content:center;padding:20px;} | |
| .card{background:#fffdf8;border:1px solid #d9b968;border-radius:16px;padding:40px;width:100%;max-width:560px;box-shadow:0 4px 24px rgba(122,38,56,.08);} | |
| h1{color:#7a2638;font-size:20px;letter-spacing:.08em;text-transform:uppercase;margin:0 0 4px;text-align:center;} | |
| .sub{color:#9c8060;font-size:13px;font-style:italic;text-align:center;margin-bottom:16px;} | |
| label{display:block;color:#a8842f;font-size:11px;letter-spacing:.06em;text-transform:uppercase;margin-bottom:6px;} | |
| textarea{width:100%;background:#fffdf8;border:1px solid #e6d3a3;border-radius:8px;color:#3a2b1a;font-family:Georgia,serif;font-size:14px;padding:12px;resize:vertical;min-height:100px;outline:none;margin-bottom:16px;} | |
| select{width:100%;background:#fffdf8;border:1px solid #e6d3a3;border-radius:8px;color:#3a2b1a;font-size:14px;padding:10px 12px;outline:none;margin-bottom:16px;appearance:none;} | |
| button{width:100%;background:#7a2638;border:none;color:#fff;font-family:Georgia,serif;font-size:14px;letter-spacing:.06em;text-transform:uppercase;padding:14px;border-radius:8px;cursor:pointer;margin-bottom:16px;} | |
| button:disabled{opacity:.5;cursor:not-allowed;} | |
| audio{width:100%;margin-top:4px;} | |
| .status{text-align:center;color:#4caf50;font-size:12px;letter-spacing:.04em;} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="card"> | |
| <h1>Nyako StoryVoiceβ’</h1> | |
| <div class="sub">Voice Clone β Cantrell Creatives</div> | |
| <label>1. Text to Synthesize</label> | |
| <textarea id="clone-txt" rows="8">Welcome to Cantrell Creatives. This is where creativity lives β where your characters speak, your stories breathe, and your voice is finally heard.</textarea> | |
| <label>2. Your Voice</label> | |
| <select id="clone-voice"></select> | |
| <button id="clone-btn" onclick="generateClone()">π Generate Clone</button> | |
| <audio id="clone-player" controls style="display:none"></audio> | |
| <div class="status" id="clone-status"></div> | |
| </div> | |
| <script> | |
| fetch('/my-voices').then(r=>r.json()).then(function(data){ | |
| var sel = document.getElementById('clone-voice'); | |
| data.forEach(function(v){ | |
| var o = document.createElement('option'); | |
| o.value = v.voice_id; | |
| o.textContent = v.display_name; | |
| sel.appendChild(o); | |
| }); | |
| }); | |
| function generateClone(){ | |
| var btn = document.getElementById('clone-btn'); | |
| var status = document.getElementById('clone-status'); | |
| var player = document.getElementById('clone-player'); | |
| btn.disabled = true; | |
| btn.textContent = 'Cloning...'; | |
| status.textContent = ''; | |
| player.style.display = 'none'; | |
| var startTime = Date.now(); | |
| var timer = setInterval(function(){ | |
| status.style.color = '#c9a040'; | |
| status.textContent = 'β± ' + ((Date.now()-startTime)/1000).toFixed(1) + 's'; | |
| }, 100); | |
| fetch('/clone',{ | |
| method:'POST', | |
| headers:{'Content-Type':'application/json'}, | |
| body: JSON.stringify({ | |
| text: document.getElementById('clone-txt').value, | |
| voice_id: document.getElementById('clone-voice').value, | |
| lang: 'en' | |
| }) | |
| }) | |
| .then(function(r){ if(!r.ok) throw new Error('Clone failed'); return r.blob(); }) | |
| .then(function(blob){ | |
| clearInterval(timer); | |
| var genTime = ((Date.now()-startTime)/1000).toFixed(1); | |
| player.src = URL.createObjectURL(blob); | |
| player.style.display = 'block'; | |
| player.onloadedmetadata = function(){ | |
| status.style.color = '#4caf50'; | |
| status.textContent = 'β Ready β generated in ' + genTime + 's, clip length ' + player.duration.toFixed(1) + 's'; | |
| }; | |
| player.play(); | |
| }) | |
| .catch(function(e){ | |
| clearInterval(timer); | |
| status.style.color='#e74c3c'; | |
| status.textContent = 'Error: ' + e.message; | |
| }) | |
| .finally(function(){ | |
| btn.disabled = false; | |
| btn.textContent = 'Generate Clone'; | |
| }); | |
| } | |
| </script> | |
| </body> | |
| </html>""" | |
| return Response(content=html, media_type="text/html") | |
| def health(): | |
| return {"status": "ok", "engine": "kokoclone"} | |
| def my_voices(): | |
| """Returns list of custom voice MP3s available for cloning.""" | |
| found = [] | |
| if os.path.isdir(VOICES_DIR): | |
| for f in sorted(os.listdir(VOICES_DIR)): | |
| name, ext = os.path.splitext(f) | |
| if ext.lower() in (".mp3", ".wav"): | |
| found.append({"voice_id": name, "display_name": name, "file": f}) | |
| return found | |
| # ββ KokoClone endpoints βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class CloneRequest(BaseModel): | |
| text: str | |
| voice_id: str # name of your reference MP3 in the voices/ folder | |
| speed: float = 1.0 | |
| lang: str = "en" | |
| def clone(req: CloneRequest): | |
| """Generate speech cloned to match a reference voice MP3.""" | |
| if not req.text.strip(): | |
| raise HTTPException(status_code=400, detail="text is required") | |
| ref_path = find_voice_audio(req.voice_id) | |
| if not ref_path: | |
| raise HTTPException(status_code=404, detail=f"Voice reference not found: {req.voice_id}") | |
| try: | |
| import tempfile | |
| cloner = get_kokoclone() | |
| with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: | |
| out_path = tmp.name | |
| cloner.generate( | |
| text=req.text.strip(), | |
| lang=req.lang, | |
| reference_audio=ref_path, | |
| output_path=out_path | |
| ) | |
| with open(out_path, "rb") as f: | |
| audio_bytes = f.read() | |
| os.remove(out_path) | |
| return Response(content=audio_bytes, media_type="audio/wav") | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| class ConvertRequest(BaseModel): | |
| voice_id: str # target reference voice MP3 | |
| source_audio_b64: str # base64-encoded source WAV/MP3 | |
| def convert(req: ConvertRequest): | |
| """Re-voice existing audio to match a reference voice MP3.""" | |
| import base64, tempfile | |
| ref_path = find_voice_audio(req.voice_id) | |
| if not ref_path: | |
| raise HTTPException(status_code=404, detail=f"Voice reference not found: {req.voice_id}") | |
| try: | |
| cloner = get_kokoclone() | |
| src_bytes = base64.b64decode(req.source_audio_b64) | |
| with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as src_tmp: | |
| src_tmp.write(src_bytes) | |
| src_path = src_tmp.name | |
| with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as out_tmp: | |
| out_path = out_tmp.name | |
| cloner.convert( | |
| source_audio=src_path, | |
| reference_audio=ref_path, | |
| output_path=out_path | |
| ) | |
| with open(out_path, "rb") as f: | |
| audio_bytes = f.read() | |
| os.remove(src_path) | |
| os.remove(out_path) | |
| return Response(content=audio_bytes, media_type="audio/wav") | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| if __name__ == "__main__": | |
| uvicorn.run(app, host="0.0.0.0", port=7860) |