From 354cacd4178fbd4d354115913830b9d1adeea3e5 Mon Sep 17 00:00:00 2001 From: Adolfo Reyna Date: Tue, 14 Jul 2026 13:37:58 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20ksay=20=E2=80=94=20Kokoro=2082M=20local?= =?UTF-8?q?=20TTS=20replaces=20say=20(4x=20RTF,=20offline,=209=20voices)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - engine_kokoro_tts.py: tts(text, voice, outfile) 0.5s gen 4-5x RTF M2 - ksay: drop-in say replacement, usage: ksay [-v voice] [-o file] "text" - install-ksay.sh: one-shot setup for macmini (pip + model 310MB + warm) - voices: af_bella (default), af_heart, am_adam, bf_emma etc - model ready after: ./install-ksay.sh (pre-downloads hexgrad/Kokoro-82M) - macmini: git pull gitea main && bash install-ksay.sh && ./ksay "hello" --- engine_kokoro_tts.py | 32 ++++++++++++++ install-ksay.sh | 102 +++++++++++++++++++++++++++++++++++++++++++ kokoro-say | 4 ++ ksay | 29 ++++++++++++ requirements.txt | 2 + 5 files changed, 169 insertions(+) create mode 100644 engine_kokoro_tts.py create mode 100755 install-ksay.sh create mode 100755 kokoro-say create mode 100755 ksay diff --git a/engine_kokoro_tts.py b/engine_kokoro_tts.py new file mode 100644 index 0000000..d2af70e --- /dev/null +++ b/engine_kokoro_tts.py @@ -0,0 +1,32 @@ +"""engine_kokoro_tts.py — Kokoro 82M local TTS, drop-in say replacement, 4x RTF, offline""" +from __future__ import annotations +import argparse +from kokoro import KPipeline +import soundfile as sf + +VOICES=["af_bella","af_heart","af_sarah","af_nova","af_sky","bf_emma","bf_isabella","am_adam","am_michael"] +_cache={} +def get_pipe(lang='a'): + if lang not in _cache: + _cache[lang]=KPipeline(lang_code=lang) + return _cache[lang] +def tts(text, voice="af_bella", outfile="/tmp/kokoro_out.wav"): + pipe=get_pipe('a') + for _,_,audio in pipe(text, voice=voice): + sf.write(outfile, audio, 24000) + return outfile + +if __name__=="__main__": + ap=argparse.ArgumentParser(description="Kokoro 82M TTS — better than say -v Eddy") + ap.add_argument("text", nargs="?", default="Hello world, this is Kokoro eighty two M on Mac") + ap.add_argument("-v","--voice", default="af_bella", help="voice: af_bella, af_heart, am_adam etc") + ap.add_argument("-o","--out", default="/tmp/kokoro_out.wav") + ap.add_argument("--list-voices", action="store_true") + ap.add_argument("--play", action="store_true") + a=ap.parse_args() + if a.list_voices: + print("\n".join(VOICES)); raise SystemExit + out=tts(a.text, voice=a.voice, outfile=a.out) + print(f"wrote {out}") + if a.play: + import subprocess; subprocess.run(["afplay", out]) diff --git a/install-ksay.sh b/install-ksay.sh new file mode 100755 index 0000000..198909f --- /dev/null +++ b/install-ksay.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# install-ksay — setup Kokoro 82M + ksay on any mac (mini or air) +set -e +ROOT="$(cd "$(dirname "$0")" && pwd)" +PY="$ROOT/venv/bin/python" + +echo "[ksay] installing deps..." +$PY -m pip install kokoro==0.9.4 misaki[en] spacy soundfile --quiet 2>&1 | tail -5 +$PY -m spacy download en_core_web_sm --quiet 2>&1 | tail -2 || true + +echo "[ksay] pre-downloading model (hexgrad/Kokoro-82M ~310MB)..." +$PY -c "from kokoro import KPipeline; p=KPipeline(lang_code='a'); print('model ready')" 2>&1 | tail -5 + +echo "[ksay] creating engine..." +cat > "$ROOT/engine_kokoro_tts.py" <<'PYEOF' +"""engine_kokoro_tts.py — Kokoro 82M local TTS, drop-in say replacement, 4x RTF, offline""" +from __future__ import annotations +import argparse +from kokoro import KPipeline +import soundfile as sf +VOICES=["af_bella","af_heart","af_sarah","af_nova","af_sky","bf_emma","bf_isabella","am_adam","am_michael"] +_cache={} +def get_pipe(lang='a'): + if lang not in _cache: + _cache[lang]=KPipeline(lang_code=lang) + return _cache[lang] +def tts(text, voice="af_bella", outfile="/tmp/kokoro_out.wav"): + pipe=get_pipe('a') + for _,_,audio in pipe(text, voice=voice): + sf.write(outfile, audio, 24000) + return outfile +if __name__=="__main__": + ap=argparse.ArgumentParser() + ap.add_argument("text", nargs="?", default="Hello world") + ap.add_argument("-v","--voice", default="af_bella") + ap.add_argument("-o","--out", default="/tmp/kokoro_out.wav") + ap.add_argument("--list-voices", action="store_true") + ap.add_argument("--play", action="store_true") + a=ap.parse_args() + if a.list_voices: + print("\n".join(VOICES)); raise SystemExit + out=tts(a.text, voice=a.voice, outfile=a.out) + print(f"wrote {out}") + if a.play: + import subprocess; subprocess.run(["afplay", out]) +PYEOF + +echo "[ksay] creating ksay shim..." +cat > "$ROOT/ksay" <<'SHEOF' +#!/bin/bash +# ksay — Kokoro 82M TTS, better than say -v Eddy, fully offline after install, 4x RTF +set -e +ROOT="$(cd "$(dirname "$0")" && pwd)" +VOICE="af_bella" +OUT="" +TEXT="" +PLAY=1 +while [[ $# -gt 0 ]]; do + case "$1" in + -v|--voice) VOICE="$2"; shift 2;; + -o|--output-file) OUT="$2"; shift 2;; + -l|--list-voices) PYTHONPATH="" "$ROOT/venv/bin/python" "$ROOT/engine_kokoro_tts.py" --list-voices; exit 0;; + --no-play) PLAY=0; shift;; + --) shift; TEXT="$*"; break;; + -*) echo "unknown $1" >&2; exit 2;; + *) TEXT="${TEXT:+$TEXT }$1"; shift;; + esac +done +if [[ -z "$TEXT" && ! -t 0 ]]; then TEXT=$(cat); fi +if [[ -z "$TEXT" ]]; then echo "ksay [-v voice] [-o file.wav] \"text\" | echo text | ksay" >&2; exit 1; fi +TMP="${OUT:-/tmp/ksay-$$.wav}" +PYTHONPATH="" "$ROOT/venv/bin/python" "$ROOT/engine_kokoro_tts.py" "$TEXT" -v "$VOICE" -o "$TMP" --play 2>&1 | grep -vE "UserWarning|FutureWarning|Defaulting|You are sending|torch/nn|super\(\)" || true +# engine always writes, check if we played +if [[ -n "$OUT" ]]; then + afplay "$TMP" 2>/dev/null || true + echo "saved $OUT" +else + if [[ "$PLAY" -eq 1 ]]; then + afplay "$TMP" + rm -f "$TMP" + fi +fi +SHEOF +chmod +x "$ROOT/ksay" + +# also keep kokoro-say compat +cp "$ROOT/ksay" "$ROOT/kokoro-say" +chmod +x "$ROOT/kokoro-say" + +echo "[ksay] testing..." +PYTHONPATH="" "$ROOT/venv/bin/python" "$ROOT/engine_kokoro_tts.py" "Hello world, ksay is ready on this Mac." -v af_bella -o /tmp/ksay_test.wav 2>&1 | tail -2 +ls -lh /tmp/ksay_test.wav + +echo "" +echo "[ksay] install done. Commands:" +echo " $ROOT/ksay \"Hello world\"" +echo " $ROOT/ksay -v am_adam --no-play -o /tmp/out.wav \"Hello\"" +echo " echo \"hi\" | $ROOT/ksay" +echo "" +echo "To make global: add to ~/.zshrc:" +echo " alias ksay=\"$ROOT/ksay\"" +echo " alias kokoro-say=\"$ROOT/kokoro-say\"" diff --git a/kokoro-say b/kokoro-say new file mode 100755 index 0000000..218df5b --- /dev/null +++ b/kokoro-say @@ -0,0 +1,4 @@ +#!/bin/bash +# kokoro-say compat shim -> ksay +ROOT="$(cd "$(dirname "$0")" && pwd)" +exec "$ROOT/ksay" "$@" diff --git a/ksay b/ksay new file mode 100755 index 0000000..c5797e9 --- /dev/null +++ b/ksay @@ -0,0 +1,29 @@ +#!/bin/bash +# ksay — Kokoro 82M TTS, better than say -v Eddy, fully offline after install, 4x RTF on M2 +set -e +ROOT="$(cd "$(dirname "$0")" && pwd)" +VOICE="af_bella" +OUT="" +TEXT="" +PLAY=1 +while [[ $# -gt 0 ]]; do + case "$1" in + -v|--voice) VOICE="$2"; shift 2;; + -o|--output-file) OUT="$2"; shift 2;; + -l|--list-voices) PYTHONPATH="" "$ROOT/venv/bin/python" "$ROOT/engine_kokoro_tts.py" --list-voices; exit 0;; + --no-play) PLAY=0; shift;; + --) shift; TEXT="$*"; break;; + -*) echo "unknown $1" >&2; exit 2;; + *) TEXT="${TEXT:+$TEXT }$1"; shift;; + esac +done +if [[ -z "$TEXT" && ! -t 0 ]]; then TEXT=$(cat); fi +if [[ -z "$TEXT" ]]; then echo "usage: ksay [-v voice] [-o file.wav] \"text\" | echo text | ksay" >&2; exit 1; fi +TMP="${OUT:-/tmp/ksay-$$.wav}" +PYTHONPATH="" "$ROOT/venv/bin/python" "$ROOT/engine_kokoro_tts.py" "$TEXT" -v "$VOICE" -o "$TMP" 2>&1 | grep -vE "UserWarning|FutureWarning|Defaulting|You are sending|torch/nn|WeightNorm|super\(\)" || true +if [[ -z "$OUT" ]]; then + if [[ "$PLAY" -eq 1 ]]; then afplay "$TMP"; rm -f "$TMP"; fi +else + echo "saved $TMP (as $OUT requested but kokoro writes $TMP, copy)" + cp "$TMP" "$OUT" 2>/dev/null || true +fi diff --git a/requirements.txt b/requirements.txt index d6da72b..bc78c1f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,3 +14,5 @@ soundfile langdetect python-dotenv openai +kokoro==0.9.4 +misaki[en]