First Version

This commit is contained in:
2026-07-10 15:02:09 +01:00
commit 38494a85a9
562 changed files with 196313 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "plugin-tts-kokoro"
version = "0.1.0"
edition = "2024"
[dependencies]
core-api = { path = "../core-api" }
anyhow = "1"
async-trait = "0.1"
serde_json = "1"
tokio = { version = "1", features = ["full"] }
tracing = "0.1"
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "charset", "http2", "system-proxy", "json"] }
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""Kokoro-ONNX TTS inference server.
Started by the plugin-tts-kokoro Rust plugin. Prints "PORT:<n>" to stdout
once the HTTP server is bound so the plugin knows which port to connect to.
Model files (kokoro-v1.0.onnx + voices-v1.0.bin) are downloaded from GitHub
releases on first run and cached in --model-dir.
Endpoints
---------
POST /synthesize
Body: {"text": "...", "voice": "if_sara", "lang": "it", "speed": 1.0}
Returns: audio/wav bytes
GET /health
Returns: {"status": "ok"}
"""
import argparse
import io
import os
import socket
import sys
import threading
import numpy as np
import soundfile as sf
import urllib.request
from fastapi import FastAPI, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel
from kokoro_onnx import Kokoro
import uvicorn
# ── Model URLs ────────────────────────────────────────────────────────────────
MODEL_BASE_URL = "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0"
MODEL_ONNX_FILE = "kokoro-v1.0.onnx"
MODEL_VOICES_FILE = "voices-v1.0.bin"
VALID_VOICES = {
"af_heart", "af_bella", "af_nicole", "af_sarah", "af_sky",
"am_adam", "am_michael",
"bf_emma", "bf_isabella", "bm_george", "bm_lewis",
"if_sara", "im_nicola",
"jf_alpha", "jf_gongitsune", "jm_kumo",
"zf_xiaobei", "zm_yunxi",
}
# ── Globals set at startup ────────────────────────────────────────────────────
kokoro_model: Kokoro | None = None
default_voice = "if_sara"
default_lang = "it"
default_speed = 1.0
# ── Model loading ─────────────────────────────────────────────────────────────
def download_if_missing(model_dir: str, filename: str) -> str:
path = os.path.join(model_dir, filename)
if not os.path.exists(path):
url = f"{MODEL_BASE_URL}/{filename}"
print(f"[kokoro] downloading {filename} from {url}", flush=True)
urllib.request.urlretrieve(url, path)
print(f"[kokoro] downloaded {filename}", flush=True)
return path
def load_model(model_dir: str) -> None:
global kokoro_model
os.makedirs(model_dir, exist_ok=True)
onnx_path = download_if_missing(model_dir, MODEL_ONNX_FILE)
voices_path = download_if_missing(model_dir, MODEL_VOICES_FILE)
print(f"[kokoro] loading model from {model_dir}", flush=True)
kokoro_model = Kokoro(onnx_path, voices_path)
print("[kokoro] model ready", flush=True)
# ── FastAPI server ────────────────────────────────────────────────────────────
app = FastAPI()
class SynthesizeRequest(BaseModel):
text: str
voice: str | None = None
lang: str | None = None
speed: float | None = None
@app.get("/health")
def health():
return {"status": "ok"}
@app.post("/synthesize")
def synthesize(req: SynthesizeRequest):
if kokoro_model is None:
raise HTTPException(status_code=503, detail="model not loaded")
voice = req.voice if req.voice in VALID_VOICES else default_voice
lang = req.lang or default_lang
speed = req.speed or default_speed
try:
samples, sample_rate = kokoro_model.create(req.text, voice=voice, speed=speed, lang=lang)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
buf = io.BytesIO()
sf.write(buf, samples, sample_rate, format="WAV")
return Response(content=buf.getvalue(), media_type="audio/wav")
# ── Entry point ───────────────────────────────────────────────────────────────
def find_free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--model-dir", default="models/kokoro")
parser.add_argument("--default-voice", default="if_sara")
parser.add_argument("--default-lang", default="it")
parser.add_argument("--default-speed", type=float, default=1.0)
args = parser.parse_args()
global default_voice, default_lang, default_speed
default_voice = args.default_voice
default_lang = args.default_lang
default_speed = args.default_speed
load_model(args.model_dir)
port = find_free_port()
# Signal to the Rust parent that we are ready.
print(f"PORT:{port}", flush=True)
uvicorn.run(app, host="127.0.0.1", port=port, log_level="warning")
if __name__ == "__main__":
main()
+318
View File
@@ -0,0 +1,318 @@
//! Kokoro ONNX TTS plugin.
//!
//! On start, writes the embedded `kokoro_server.py` to `models/kokoro/`,
//! spawns it as a subprocess, reads the bound port from its stdout, then
//! registers itself as a [`TextToSpeech`] provider with the TTS manager.
//!
//! The subprocess downloads the Kokoro ONNX model files from GitHub releases
//! on first run (~310 MB + 27 MB) and caches them in `models/kokoro/`.
//! No API token required.
//!
//! # Config (stored in `plugins` SQLite table)
//!
//! ```json
//! {
//! "voice": "if_sara",
//! "lang": "it",
//! "speed": 1.0
//! }
//! ```
//!
//! | Field | Values | Default |
//! |---------|-----------------------------------------|------------|
//! | `voice` | `"if_sara"` \| `"im_nicola"` \| … | `"if_sara"` |
//! | `lang` | `"it"` \| `"en-us"` \| `"en-gb"` \| … | `"it"` |
//! | `speed` | `0.5` `2.0` | `1.0` |
use std::sync::{Arc, atomic::{AtomicBool, Ordering}};
use anyhow::{Context, Result, anyhow};
use async_trait::async_trait;
use serde_json::{Value, json};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command};
use tokio::sync::Mutex;
use tracing::{info, warn};
const KOKORO_SERVER_PY: &str = include_str!("kokoro_server.py");
use core_api::plugin::{Plugin, PluginContext};
use core_api::tts::TextToSpeech;
const PLUGIN_ID: &str = "kokoro_tts";
const MODEL_DIR: &str = "models/kokoro";
const PROVIDER_ID: &str = "kokoro_tts";
const SERVER_PY_NAME: &str = "kokoro_server.py";
// ── Config ────────────────────────────────────────────────────────────────────
#[derive(Clone, PartialEq, Debug)]
struct KokoroConfig {
voice: String,
lang: String,
speed: f64,
}
impl KokoroConfig {
fn from_value(v: &Value) -> Self {
Self {
voice: v["voice"].as_str().unwrap_or("if_sara").to_string(),
lang: v["lang"].as_str().unwrap_or("it").to_string(),
speed: v["speed"].as_f64().unwrap_or(1.0),
}
}
}
// ── KokoroSynthesiser ─────────────────────────────────────────────────────────
struct KokoroSynthesiser {
port: u16,
config: KokoroConfig,
http: reqwest::Client,
}
impl KokoroSynthesiser {
fn new(port: u16, config: KokoroConfig) -> Self {
Self { port, config, http: reqwest::Client::new() }
}
}
#[async_trait]
impl TextToSpeech for KokoroSynthesiser {
fn id(&self) -> &str { PROVIDER_ID }
fn name(&self) -> &str { "Kokoro TTS" }
fn description(&self) -> Option<&str> {
Some("Local Kokoro ONNX TTS — lightweight, fast, multilingual. \
Runs on CPU, no GPU required. ~310 MB model, auto-downloaded on first use.")
}
fn instructions(&self) -> Option<&str> {
Some("\
Kokoro TTS produces natural spoken audio. \
Write as you would speak: short sentences, no markdown, no bullet points, no symbols. \
For Italian, use standard Italian orthography — accented letters are fine. \
Do not include URLs, file paths, or code snippets; describe them in words instead.")
}
async fn synthesize(&self, text: &str, instructions: Option<&str>) -> Result<Vec<u8>> {
// instructions may carry a voice override as the first word (same convention as Orpheus).
let voice = instructions
.and_then(|s| s.split_whitespace().next())
.map(str::to_owned);
let url = format!("http://127.0.0.1:{}/synthesize", self.port);
let resp = self.http
.post(&url)
.json(&json!({
"text": text,
"voice": voice.as_deref().unwrap_or(&self.config.voice),
"lang": self.config.lang,
"speed": self.config.speed,
}))
.send()
.await
.map_err(|e| anyhow!("kokoro_tts: request failed: {e}"))?;
let status = resp.status();
if !status.is_success() {
let msg = resp.text().await.unwrap_or_default();
anyhow::bail!("kokoro_tts: server error {status}: {msg}");
}
Ok(resp.bytes().await.map(|b| b.to_vec())
.map_err(|e| anyhow!("kokoro_tts: failed to read bytes: {e}"))?)
}
}
// ── Plugin inner state ────────────────────────────────────────────────────────
struct Inner {
child: Child,
port: u16,
config: KokoroConfig,
script_path: std::path::PathBuf,
}
// ── KokoroTtsPlugin ───────────────────────────────────────────────────────────
pub struct KokoroTtsPlugin {
running: AtomicBool,
inner: Mutex<Option<Inner>>,
}
impl KokoroTtsPlugin {
pub fn new() -> Self {
Self {
running: AtomicBool::new(false),
inner: Mutex::new(None),
}
}
async fn do_start(&self, config: &KokoroConfig, ctx: &PluginContext) -> Result<()> {
std::fs::create_dir_all(MODEL_DIR)
.context("kokoro_tts: failed to create model dir")?;
let script_path = std::path::Path::new(MODEL_DIR).join(SERVER_PY_NAME);
std::fs::write(&script_path, KOKORO_SERVER_PY)
.context("kokoro_tts: failed to write embedded server script")?;
let mut child = Command::new("python3")
.args([
script_path.to_str().unwrap(),
"--model-dir", MODEL_DIR,
"--default-voice", &config.voice,
"--default-lang", &config.lang,
"--default-speed", &config.speed.to_string(),
])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.context("kokoro_tts: failed to spawn python3")?;
// Log stderr in background so Python errors appear in Skald's log file.
if let Some(stderr) = child.stderr.take() {
tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
tracing::warn!("kokoro_tts(py/err): {line}");
}
});
}
let stdout = child.stdout.take()
.ok_or_else(|| anyhow!("kokoro_tts: no stdout from subprocess"))?;
let mut lines = BufReader::new(stdout).lines();
let port = loop {
match lines.next_line().await? {
None => anyhow::bail!("kokoro_tts: subprocess exited before printing port"),
Some(line) => {
if let Some(p) = line.strip_prefix("PORT:") {
break p.trim().parse::<u16>()
.context("kokoro_tts: invalid port from subprocess")?;
}
info!("kokoro_tts(py): {line}");
}
}
};
info!(port, "kokoro_tts: python server ready");
let synthesiser = Arc::new(KokoroSynthesiser::new(port, config.clone()));
ctx.tts_registry.register(Arc::clone(&synthesiser) as _).await;
self.running.store(true, Ordering::Relaxed);
*self.inner.lock().await = Some(Inner {
child,
port,
config: config.clone(),
script_path,
});
Ok(())
}
async fn do_stop(&self, ctx: &PluginContext) {
ctx.tts_registry.unregister(PROVIDER_ID).await;
if let Some(mut inner) = self.inner.lock().await.take() {
let _ = inner.child.kill().await;
let _ = std::fs::remove_file(&inner.script_path);
}
self.running.store(false, Ordering::Relaxed);
info!("kokoro_tts: stopped");
}
}
#[async_trait]
impl Plugin for KokoroTtsPlugin {
fn id(&self) -> &str { PLUGIN_ID }
fn name(&self) -> &str { "Kokoro TTS" }
fn description(&self) -> &str {
"Local text-to-speech using Kokoro ONNX. Lightweight (~310 MB), fast on CPU, \
multilingual (Italian, English, Japanese, Chinese, Spanish, French…). \
No API token required — model is downloaded automatically on first use."
}
fn is_running(&self) -> bool { self.running.load(Ordering::Relaxed) }
fn config_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"voice": {
"type": "string",
"enum": [
"if_sara", "im_nicola",
"af_heart", "af_bella", "af_nicole", "af_sarah", "af_sky",
"am_adam", "am_michael",
"bf_emma", "bf_isabella", "bm_george", "bm_lewis"
],
"default": "if_sara",
"description": "Voice ID. Prefix: a=American, b=British, i=Italian, j=Japanese, z=Chinese; f=female, m=male."
},
"lang": {
"type": "string",
"enum": ["it", "en-us", "en-gb", "ja", "zh", "es", "fr", "hi", "pt-br", "ko"],
"default": "it",
"description": "Language code for phonemisation."
},
"speed": {
"type": "number",
"minimum": 0.5,
"maximum": 2.0,
"default": 1.0,
"description": "Speech speed multiplier."
}
}
})
}
async fn reload(&self, enabled: bool, config: Value, ctx: PluginContext) -> Result<()> {
let new_cfg = KokoroConfig::from_value(&config);
let is_running = self.is_running();
let config_changed = self.inner.lock().await
.as_ref()
.map(|i| i.config != new_cfg)
.unwrap_or(false);
match (enabled, is_running) {
(true, false) => self.do_start(&new_cfg, &ctx).await?,
(false, true) => self.do_stop(&ctx).await,
(true, true) if config_changed => {
info!("kokoro_tts: config changed — restarting");
self.do_stop(&ctx).await;
self.do_start(&new_cfg, &ctx).await?;
}
_ => {}
}
Ok(())
}
async fn start(&self, ctx: PluginContext) -> Result<()> {
let _ = ctx;
Ok(())
}
async fn stop(&self) -> Result<()> {
warn!("kokoro_tts: stop() called without ctx — cannot unregister from TtsManager");
if let Some(mut inner) = self.inner.lock().await.take() {
let _ = inner.child.kill().await;
let _ = inner.child.wait().await;
}
self.running.store(false, Ordering::Relaxed);
Ok(())
}
fn runtime_status(&self) -> Option<Value> {
let inner = self.inner.try_lock().ok()?;
let inner = inner.as_ref()?;
Some(json!({
"port": inner.port,
"voice": inner.config.voice,
"lang": inner.config.lang,
"speed": inner.config.speed,
}))
}
fn as_any(&self) -> &dyn std::any::Any { self }
fn as_arc_any(self: Arc<Self>) -> Arc<dyn std::any::Any + Send + Sync> { self }
}