Kokoro neural voice via reverse tunnel on PVE; kokoro default engine, 3 voices
This commit is contained in:
38
app.py
38
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")
|
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__":
|
||||||
|
|||||||
Reference in New Issue
Block a user