From b5f8960af12159d64f0b0bb687339351404df30c Mon Sep 17 00:00:00 2001 From: drjones Date: Thu, 1 Oct 2026 16:39:35 +0000 Subject: [PATCH] Kokoro neural voice via reverse tunnel on PVE; kokoro default engine, 3 voices --- app.py | 38 ++++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/app.py b/app.py index acb7f3e..f97d6d5 100644 --- a/app.py +++ b/app.py @@ -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") 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") +KOKORO_URL = os.environ.get("LYRA_KOKORO_URL", "http://10.30.20.85:8766") app = Flask(__name__) @@ -327,8 +328,10 @@ input[type=range]{width:150px}

Settings ✦ saved on this device

Voice engine -
-
Voice
+
+
Kokoro voice +
+
Browser voice
Speech rate 1.0
Pitch 1.0
Lyra text model
@@ -374,8 +377,8 @@ speechSynthesis.onvoiceschanged=loadVoices;loadVoices(); function speak(text,cb){ if(!$('voiceDot'))return;const dot=$('voiceDot');dot.classList.add('live'); const done=()=>{dot.classList.remove('live');if(cb)cb()}; - if($('setEngine').value==='piper'){ - fetch('/api/tts',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text})}) + if($('setEngine').value==='kokoro'){ + 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()}) .catch(()=>{browserSpeak(text,done)}); }else browserSpeak(text,done); @@ -508,7 +511,7 @@ function timerReset(){clearInterval(tInt);clearInterval(bInt);tInt=null;$('timer /* ---------- settings ---------- */ 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; $('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; @@ -516,10 +519,10 @@ function loadSettings(){ $('rateV').textContent=$('setRate').value;$('pitchV').textContent=$('setPitch').value;$('aeV').textContent=$('setAutoEvery').value; applyWarm();} 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.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();} function testVoice(){speak('Ready when you are. She looks incredible.')} $('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})`} $('setWarm').oninput=applyWarm; 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); """ @@ -542,18 +546,20 @@ def index(): @app.post("/api/tts") def api_tts(): - """Try Piper on the configured host (OpenAI-compat /v1/audio/speech); 404 -> client falls back.""" - text = request.json.get("text", "")[:500] - host = request.json.get("host", "") - base = host or OLLAMA_URL + """Kokoro neural TTS on light_reaper (via reverse tunnel on PVE). 404 -> client falls back to browser voice.""" + body = request.json or {} + text = (body.get("text") or "").strip()[:600] + voice = body.get("voice") or "af_heart" + if not text: + return jsonify(error="empty"), 400 try: - r = requests.post(f"{base.rsplit(':', 1)[0]}:8080/v1/audio/speech", - json={"input": text, "voice": "piper"}, timeout=20) - if r.status_code == 200: + r = requests.post(f"{KOKORO_URL}/tts", + json={"text": text, "voice": voice, "speed": 1.0}, timeout=90) + if r.status_code == 200 and r.content[:4] == b"RIFF": return app.response_class(r.content, mimetype="audio/wav") except Exception: pass - return jsonify(error="piper unavailable"), 404 + return jsonify(error="kokoro unavailable"), 404 if __name__ == "__main__":