First Version
This commit is contained in:
@@ -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()
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user