Kokoro neural voice via reverse tunnel on PVE; kokoro default engine, 3 voices

This commit is contained in:
drjones
2026-10-01 16:39:35 +00:00
parent 6f67a5a361
commit b5f8960af1

38
app.py
View File

@@ -7,6 +7,7 @@ from flask import Flask, jsonify, request, render_template_string
OLLAMA_URL = os.environ.get("LYRA_OLLAMA_URL", "http://10.30.20.222:11434") OLLAMA_URL = os.environ.get("LYRA_OLLAMA_URL", "http://10.30.20.222:11434")
TEXT_MODEL = os.environ.get("LYRA_TEXT_MODEL", "ornith-1.5:9b-64k") TEXT_MODEL = os.environ.get("LYRA_TEXT_MODEL", "ornith-1.5:9b-64k")
VISION_MODEL = os.environ.get("LYRA_VISION_MODEL", "minicpm-v4.5:8b") VISION_MODEL = os.environ.get("LYRA_VISION_MODEL", "minicpm-v4.5:8b")
KOKORO_URL = os.environ.get("LYRA_KOKORO_URL", "http://10.30.20.85:8766")
app = Flask(__name__) app = Flask(__name__)
@@ -327,8 +328,10 @@ input[type=range]{width:150px}
<div class="card"> <div class="card">
<h2>Settings ✦ saved on this device</h2> <h2>Settings ✦ saved on this device</h2>
<div class="stg"><span>Voice engine</span><span class="c"> <div class="stg"><span>Voice engine</span><span class="c">
<select id="setEngine"><option value="browser">Browser TTS</option><option value="piper">Piper (.69, falls back)</option></select></span></div> <select id="setEngine"><option value="kokoro">Kokoro neural (sultry, self-hosted)</option><option value="browser">Browser TTS</option></select></span></div>
<div class="stg"><span>Voice</span><span class="c"><select id="setVoice" style="max-width:280px"></select></span></div> <div class="stg"><span>Kokoro voice</span><span class="c">
<select id="setKVoice"><option value="af_heart">Heart (warm, breathy)</option><option value="af_bella">Bella (bright, expressive)</option><option value="af_nicole">Nicole (soft, intimate)</option></select></span></div>
<div class="stg"><span>Browser voice</span><span class="c"><select id="setVoice" style="max-width:280px"></select></span></div>
<div class="stg"><span>Speech rate <b id="rateV">1.0</b></span><span class="c"><input type="range" id="setRate" min="0.5" max="2" step="0.05" value="1"></span></div> <div class="stg"><span>Speech rate <b id="rateV">1.0</b></span><span class="c"><input type="range" id="setRate" min="0.5" max="2" step="0.05" value="1"></span></div>
<div class="stg"><span>Pitch <b id="pitchV">1.0</b></span><span class="c"><input type="range" id="setPitch" min="0.5" max="1.6" step="0.05" value="1"></span></div> <div class="stg"><span>Pitch <b id="pitchV">1.0</b></span><span class="c"><input type="range" id="setPitch" min="0.5" max="1.6" step="0.05" value="1"></span></div>
<div class="stg"><span>Lyra text model</span><span class="c"><input id="setTModel" style="background:var(--panel2);color:var(--txt);border:1px solid #3a3466;border-radius:8px;padding:6px;width:200px"></span></div> <div class="stg"><span>Lyra text model</span><span class="c"><input id="setTModel" style="background:var(--panel2);color:var(--txt);border:1px solid #3a3466;border-radius:8px;padding:6px;width:200px"></span></div>
@@ -374,8 +377,8 @@ speechSynthesis.onvoiceschanged=loadVoices;loadVoices();
function speak(text,cb){ function speak(text,cb){
if(!$('voiceDot'))return;const dot=$('voiceDot');dot.classList.add('live'); if(!$('voiceDot'))return;const dot=$('voiceDot');dot.classList.add('live');
const done=()=>{dot.classList.remove('live');if(cb)cb()}; const done=()=>{dot.classList.remove('live');if(cb)cb()};
if($('setEngine').value==='piper'){ if($('setEngine').value==='kokoro'){
fetch('/api/tts',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text})}) fetch('/api/tts',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text,voice:$('setKVoice').value})})
.then(r=>r.ok?r.blob():Promise.reject()).then(b=>{const a=new Audio(URL.createObjectURL(b));a.onended=done;a.play()}) .then(r=>r.ok?r.blob():Promise.reject()).then(b=>{const a=new Audio(URL.createObjectURL(b));a.onended=done;a.play()})
.catch(()=>{browserSpeak(text,done)}); .catch(()=>{browserSpeak(text,done)});
}else browserSpeak(text,done); }else browserSpeak(text,done);
@@ -508,7 +511,7 @@ function timerReset(){clearInterval(tInt);clearInterval(bInt);tInt=null;$('timer
/* ---------- settings ---------- */ /* ---------- settings ---------- */
function loadSettings(){ function loadSettings(){
$('setEngine').value=S.engine||'browser';$('setVoice').value=S.voice||$('setVoice').value; $('setEngine').value=S.engine||'kokoro';$('setKVoice').value=S.kvoice||'af_heart';$('setVoice').value=S.voice||$('setVoice').value;
$('setRate').value=S.rate||1;$('setPitch').value=S.pitch||1; $('setRate').value=S.rate||1;$('setPitch').value=S.pitch||1;
$('setTModel').value=S.tmodel||'qwen3.5:4b';$('setVModel').value=S.vmodel||'minicpm-v4.5:8b'; $('setTModel').value=S.tmodel||'qwen3.5:4b';$('setVModel').value=S.vmodel||'minicpm-v4.5:8b';
$('setHost').value=S.host||'http://10.30.20.222:11434';$('setAutoEvery').value=S.autoEvery||45; $('setHost').value=S.host||'http://10.30.20.222:11434';$('setAutoEvery').value=S.autoEvery||45;
@@ -516,10 +519,10 @@ function loadSettings(){
$('rateV').textContent=$('setRate').value;$('pitchV').textContent=$('setPitch').value;$('aeV').textContent=$('setAutoEvery').value; $('rateV').textContent=$('setRate').value;$('pitchV').textContent=$('setPitch').value;$('aeV').textContent=$('setAutoEvery').value;
applyWarm();} applyWarm();}
function saveSettings(){ function saveSettings(){
S.engine=$('setEngine').value;S.voice=$('setVoice').value;S.rate=$('setRate').value;S.pitch=$('setPitch').value; S.engine=$('setEngine').value;S.kvoice=$('setKVoice').value;S.voice=$('setVoice').value;S.rate=$('setRate').value;S.pitch=$('setPitch').value;
S.tmodel=$('setTModel').value;S.vmodel=$('setVModel').value;S.host=$('setHost').value;S.autoEvery=+$('setAutoEvery').value; S.tmodel=$('setTModel').value;S.vmodel=$('setVModel').value;S.host=$('setHost').value;S.autoEvery=+$('setAutoEvery').value;
S.warm=$('setWarm').value;S.autoMode=$('autoMode').checked;S.autoSpeak=$('autoSpeak').checked; S.warm=$('setWarm').value;S.autoMode=$('autoMode').checked;S.autoSpeak=$('autoSpeak').checked;
saveS();applyWarm();$('engineTag').textContent=$('setEngine').value==='piper'?'piper → browser fallback':'browser voice'; saveS();applyWarm();applyEngineTag();
toast('Saved');autoDirector();} toast('Saved');autoDirector();}
function testVoice(){speak('Ready when you are. She looks incredible.')} function testVoice(){speak('Ready when you are. She looks incredible.')}
$('setRate').oninput=()=>$('rateV').textContent=$('setRate').value; $('setRate').oninput=()=>$('rateV').textContent=$('setRate').value;
@@ -528,7 +531,8 @@ $('setAutoEvery').oninput=()=>$('aeV').textContent=$('setAutoEvery').value;
function applyWarm(){const w=+$('setWarm').value;document.body.style.filter=`sepia(${w/400})`} function applyWarm(){const w=+$('setWarm').value;document.body.style.filter=`sepia(${w/400})`}
$('setWarm').oninput=applyWarm; $('setWarm').oninput=applyWarm;
loadSettings(); loadSettings();
$('engineTag').textContent=S.engine==='piper'?'piper → browser fallback':'browser voice'; function applyEngineTag(){$('engineTag').textContent=$('setEngine').value==='kokoro'?'kokoro · '+$('setKVoice').value:'browser voice'}
applyEngineTag();
setTimeout(autoDirector,800); setTimeout(autoDirector,800);
</script></body></html>""" </script></body></html>"""
@@ -542,18 +546,20 @@ def index():
@app.post("/api/tts") @app.post("/api/tts")
def api_tts(): def api_tts():
"""Try Piper on the configured host (OpenAI-compat /v1/audio/speech); 404 -> client falls back.""" """Kokoro neural TTS on light_reaper (via reverse tunnel on PVE). 404 -> client falls back to browser voice."""
text = request.json.get("text", "")[:500] body = request.json or {}
host = request.json.get("host", "") text = (body.get("text") or "").strip()[:600]
base = host or OLLAMA_URL voice = body.get("voice") or "af_heart"
if not text:
return jsonify(error="empty"), 400
try: try:
r = requests.post(f"{base.rsplit(':', 1)[0]}:8080/v1/audio/speech", r = requests.post(f"{KOKORO_URL}/tts",
json={"input": text, "voice": "piper"}, timeout=20) json={"text": text, "voice": voice, "speed": 1.0}, timeout=90)
if r.status_code == 200: if r.status_code == 200 and r.content[:4] == b"RIFF":
return app.response_class(r.content, mimetype="audio/wav") return app.response_class(r.content, mimetype="audio/wav")
except Exception: except Exception:
pass pass
return jsonify(error="piper unavailable"), 404 return jsonify(error="kokoro unavailable"), 404
if __name__ == "__main__": if __name__ == "__main__":