mars: add KittenTTS voice provider (CPU-only, offline mini model)
tts.provider: kittentts, voice Luna, mini-0.8 model (80M params) per darman. CPU-only onnxruntime inference — no GPU on mars, validated ~1x realtime on a weaker dev box. Upstream's misaki[en]+spacy declaration is deliberately not honored (pulls torch→CUDA, 5.6 GB); a .pth-registered stub satisfies the dead misaki import, failing loudly if it is ever actually used. HF model cache pre-seeded from hash-pinned store paths so HF_HUB_OFFLINE=1 runs with zero boot-time network. Provisioner shaped by the Mnemosyne review: uv venv --clear, root never executes python from the venv, ownership to the container uid before anything imports, stamp checked against venv+model files.
This commit is contained in:
@@ -77,6 +77,33 @@ let
|
|||||||
# Mnemosyne memory provider: third-party plugin, not in the image.
|
# Mnemosyne memory provider: third-party plugin, not in the image.
|
||||||
mnemosyneEnv = pkgs.callPackage ../../pkgs/mnemosyne-env.nix { };
|
mnemosyneEnv = pkgs.callPackage ../../pkgs/mnemosyne-env.nix { };
|
||||||
|
|
||||||
|
# KittenTTS voice provider inputs (CPU-only; model + wheel hash-pinned).
|
||||||
|
# Provisioning unit near the bottom of this file; background on why the
|
||||||
|
# deps deviate from upstream's declaration lives in
|
||||||
|
# ./kittentts/requirements.txt + ./kittentts/kitten-misaki-stub.py.
|
||||||
|
kittenttsWheel = pkgs.fetchurl {
|
||||||
|
url = "https://github.com/KittenML/KittenTTS/releases/download/0.8.1/kittentts-0.8.1-py3-none-any.whl";
|
||||||
|
sha256 = "sha256-SCpDbE8fMZIVNxA3bkWf82iVF+vNp8KwUeL9QYe0GFE=";
|
||||||
|
};
|
||||||
|
kittenttsReqs = pkgs.writeText "kittentts-requirements.txt" (
|
||||||
|
builtins.readFile ./kittentts/requirements.txt
|
||||||
|
);
|
||||||
|
kittenttsStub = pkgs.writeText "kitten-tts-stub.py" (
|
||||||
|
builtins.readFile ./kittentts/kitten-misaki-stub.py
|
||||||
|
);
|
||||||
|
kittenttsModelOnnx = pkgs.fetchurl {
|
||||||
|
url = "https://huggingface.co/KittenML/kitten-tts-mini-0.8/resolve/main/kitten_tts_mini_v0_8.onnx";
|
||||||
|
sha256 = "sha256-D1u65PxIAMmNvFRKh+z6eVEN4vuCItsw0S5b/pF335E=";
|
||||||
|
};
|
||||||
|
kittenttsModelVoices = pkgs.fetchurl {
|
||||||
|
url = "https://huggingface.co/KittenML/kitten-tts-mini-0.8/resolve/main/voices.npz";
|
||||||
|
sha256 = "sha256-QK0mOJUrd7ey8wEn4mCOFp/GndJWtTvYqqNAmjMZPEI=";
|
||||||
|
};
|
||||||
|
kittenttsModelConfig = pkgs.fetchurl {
|
||||||
|
url = "https://huggingface.co/KittenML/kitten-tts-mini-0.8/resolve/main/config.json";
|
||||||
|
sha256 = "sha256-axYLybGeJOyyHoS8FPin2iH99H7HLUJFC8XPUUthgEo=";
|
||||||
|
};
|
||||||
|
|
||||||
# Wire event names (X-GitHub-Event) each route accepts — NOT the
|
# Wire event names (X-GitHub-Event) each route accepts — NOT the
|
||||||
# subscription names the gitea hooks in services/dev/gitea.nix use. The two
|
# subscription names the gitea hooks in services/dev/gitea.nix use. The two
|
||||||
# namespaces collide; see the long comment on the route unit below.
|
# namespaces collide; see the long comment on the route unit below.
|
||||||
@@ -450,4 +477,90 @@ in
|
|||||||
chown -h ${hermesUid}:${hermesGid} "$pluginDir"
|
chown -h ${hermesUid}:${hermesGid} "$pluginDir"
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# ---- KittenTTS voice provider ------------------------------------------
|
||||||
|
# CPU-only onnxruntime TTS (no GPU on mars), mini model per darman. The
|
||||||
|
# upstream `misaki[en]` declaration is deliberately not honored — it pulls
|
||||||
|
# torch→CUDA (5.6 GB verified); the runtime phonemizes with espeak-ng only,
|
||||||
|
# so the dead `from misaki import en, espeak` import is satisfied by a
|
||||||
|
# .pth-registered stub (kitten-misaki-stub.py) that fails loudly if misaki
|
||||||
|
# is ever actually used.
|
||||||
|
#
|
||||||
|
# Provisioner invariants (shaped by the Mnemosyne-round review):
|
||||||
|
# - `uv venv --clear`: re-provision cannot wedge on an existing dir.
|
||||||
|
# - Root runs NO python from this venv: the unit itself does only fs
|
||||||
|
# writes; the venv is chowned to the container uid before Hermes ever
|
||||||
|
# imports from it. (Hermes executes provider code as uid 986.)
|
||||||
|
# - HF model cache is PRE-SEEDED from hash-pinned store paths, so
|
||||||
|
# HF_HUB_OFFLINE=1 gives zero boot-time network and no drift.
|
||||||
|
# - Idempotent via a stamp keyed on the requirements hash; checked
|
||||||
|
# against BOTH the venv python and model files being intact.
|
||||||
|
#
|
||||||
|
# Trust boundary: the venv lives inside hermesHome (HERMES_WRITE_SAFE_ROOT),
|
||||||
|
# so luna can technically alter her own TTS engine. Deliberate: it's her
|
||||||
|
# voice, not her jail — the webhook filter scripts remain the only
|
||||||
|
# write-protected-but-load-bearing items.
|
||||||
|
systemd.services.hermes-agent-kittentts-provision = {
|
||||||
|
description = "Provision KittenTTS voice provider (side venv + offline HF cache)";
|
||||||
|
before = [ "podman-hermes-agent.service" ];
|
||||||
|
wantedBy = [ "podman-hermes-agent.service" ];
|
||||||
|
wants = [ "network-online.target" ];
|
||||||
|
after = [ "network-online.target" ];
|
||||||
|
path = [ pkgs.uv pkgs.coreutils ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
TimeoutStartSec = 600;
|
||||||
|
};
|
||||||
|
script = ''
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
venv=${hermesHome}/kittentts-venv
|
||||||
|
hubDir=${hermesHome}/kittentts-hf/hub/models--KittenML--kitten-tts-mini-0.8
|
||||||
|
snap=$hubDir/snapshots/kitten
|
||||||
|
stampFile=${hermesHome}/kittentts-provision.stamp
|
||||||
|
reqHash=$(sha256sum ${kittenttsReqs} | cut -d' ' -f1)
|
||||||
|
|
||||||
|
# Idempotent early exit: stamp + venv + all three model files intact.
|
||||||
|
if [ -f "$stampFile" ] && [ "$(cat "$stampFile")" = "$reqHash" ] \
|
||||||
|
&& [ -x "$venv/bin/python" ] \
|
||||||
|
&& [ -f "$snap/kitten_tts_mini_v0_8.onnx" ] \
|
||||||
|
&& [ -f "$snap/voices.npz" ] \
|
||||||
|
&& [ -f "$venv/lib/python3.13/site-packages/kitten_tts_stub.py" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Venv (rebuilt rather than broken on --no-clear: uv exits 2 otherwise).
|
||||||
|
uv venv "$venv" --python ${pkgs.python313}/bin/python3 --clear --quiet
|
||||||
|
UV_CACHE_DIR=${hermesHome}/kittentts-uv-cache \
|
||||||
|
uv pip install --python "$venv/bin/python" --quiet \
|
||||||
|
--requirement ${kittenttsReqs}
|
||||||
|
# kittentts --no-deps: its overlay of spacy/misaki[en] is what drags in
|
||||||
|
# the CUDA tree; the requirements freeze already covers its real needs.
|
||||||
|
UV_CACHE_DIR=${hermesHome}/kittentts-uv-cache \
|
||||||
|
uv pip install --python "$venv/bin/python" --quiet --no-deps \
|
||||||
|
${kittenttsWheel}
|
||||||
|
|
||||||
|
# Dead-import shim: .pth auto-loads kitten_tts_stub at interpreter start
|
||||||
|
# so `from misaki import en, espeak` resolves without the real misaki.en.
|
||||||
|
siteDir=$("$venv/bin/python" -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])')
|
||||||
|
cp ${kittenttsStub} "$siteDir/kitten_tts_stub.py"
|
||||||
|
printf 'import kitten_tts_stub\n' > "$siteDir/zz-kitten-stub.pth"
|
||||||
|
|
||||||
|
# Seed the HF cache with the hashed model files (exact hub layout;
|
||||||
|
# hf_hub_download scans refs/snapshots on disk offline).
|
||||||
|
mkdir -p "$hubDir/refs" "$snap"
|
||||||
|
install -m 0444 ${kittenttsModelOnnx} "$snap/kitten_tts_mini_v0_8.onnx"
|
||||||
|
install -m 0444 ${kittenttsModelVoices} "$snap/voices.npz"
|
||||||
|
install -m 0444 ${kittenttsModelConfig} "$snap/config.json"
|
||||||
|
|
||||||
|
# Hand ownership to the container uid BEFORE any python runs in this
|
||||||
|
# tree (root never imports from it — that was Mnemosyne review #3).
|
||||||
|
chown -R ${hermesUid}:${hermesGid} "$venv" "$hubDir"
|
||||||
|
|
||||||
|
# Stamp LAST — a half-provisioned venv fails the integrity check and
|
||||||
|
# re-provisions on next boot rather than being trusted.
|
||||||
|
printf '%s' "$reqHash" > "$stampFile"
|
||||||
|
chown ${hermesUid}:${hermesGid} "$stampFile"
|
||||||
|
'';
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Runtime shim for KittenTTS on a CPU-only, disk-constrained host.
|
||||||
|
#
|
||||||
|
# kittentts/onnx_model.py opens with `from misaki import en, espeak` — a dead import:
|
||||||
|
# every code path that produces audio from text goes through phonemizer-fork's
|
||||||
|
# EspeakBackend (espeak-ng via espeakng-loader); misaki.en/espeak are never called.
|
||||||
|
# If misaki[en] were installed for real, those two imports would be a hook for the
|
||||||
|
# whole spacy → thinc → torch → CUDA chain (~5.6 GB verified). This stub registers
|
||||||
|
# `misaki.en` and `misaki.espeak` as import-time-only placeholders instead.
|
||||||
|
#
|
||||||
|
# Written into site-packages by the derivation (see pkgs/kittentts-env.nix) under
|
||||||
|
# `sitecustomize.py`-style auto-load — actually via a `kitten_tts_stub.py` + a `.pth`
|
||||||
|
# pointing at it, so any Python process in this env gets the stub registered before
|
||||||
|
# any kittentts import. If KittenTTS upstream ever starts USING misaki, this shim
|
||||||
|
# will fail loudly at import of the missing attributes (better than silent distortion),
|
||||||
|
# and the fix becomes "install the real misaki[en]" — a deliberate, reviewed change.
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
|
_misaki = sys.modules.get("misaki")
|
||||||
|
if _misaki is None:
|
||||||
|
# Avoid registering a fake parent before the real misaki loads — the base misaki
|
||||||
|
# (addict/regex only) is installed normally, so usually already here.
|
||||||
|
import misaki # noqa: F401 (raises if base misaki is missing — loud, not silent)
|
||||||
|
_misaki = sys.modules["misaki"]
|
||||||
|
|
||||||
|
if getattr(_misaki, "en", None) is None:
|
||||||
|
_stub = types.ModuleType("misaki.en")
|
||||||
|
sys.modules.setdefault("misaki.en", _stub)
|
||||||
|
_misaki.en = _stub
|
||||||
|
|
||||||
|
if getattr(_misaki, "espeak", None) is None:
|
||||||
|
_stub2 = types.ModuleType("misaki.espeak")
|
||||||
|
sys.modules.setdefault("misaki.espeak", _stub2)
|
||||||
|
_misaki.espeak = _stub2
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Pinned requirements for the KittenTTS side-venv on mars (validated freeze, CPU-only).
|
||||||
|
#
|
||||||
|
# DEPENDENCY DISCIPLINE (important — this is NOT the upstream dependency set):
|
||||||
|
# `kittentts` declares `misaki[en]` + spacy, which transitively pull torch → the whole
|
||||||
|
# CUDA runtime (~5.6 GB with nvidia-* wheels, verified in a clean install on this box).
|
||||||
|
# NONE of that is reachable at runtime: onnx_model.py imports `from misaki import en, espeak`
|
||||||
|
# at module load but generate() phonemizes exclusively through `phonemizer-fork` +
|
||||||
|
# `espeakng-loader` (espeak-ng via EspeakBackend); misaki.en/espeak glyphs are parsed but
|
||||||
|
# never called in the ONNX path. mars has no GPU and a modest disk budget, so:
|
||||||
|
#
|
||||||
|
# - install kittentts with --no-deps and enumerate its deps manually,
|
||||||
|
# - install base `misaki` (NOT misaki[en]) — only addict/regex,
|
||||||
|
# - the misaki.en import is satisfied by a stub registered in sitecustomize
|
||||||
|
# (kitten-misaki-stub.py, written into site-packages by the derivation).
|
||||||
|
#
|
||||||
|
# Freeze captured 2026-09-19 from a verified CPU-only venv (python 3.13, i5-4460,
|
||||||
|
# generation ~= 1x realtime with the mini model, HF_HUB_OFFLINE=1).
|
||||||
|
#
|
||||||
|
# kittentts itself comes from a GitHub release wheel (0.8.1), not PyPI (PyPI's 0.1.3 is
|
||||||
|
# the old v0.1-lineage); see pkgs/kittentts-env.nix.
|
||||||
|
addict==2.4.0
|
||||||
|
anyio==4.15.1
|
||||||
|
attrs==26.1.0
|
||||||
|
babel==2.18.0
|
||||||
|
certifi==2026.7.22
|
||||||
|
cffi==2.1.1
|
||||||
|
click==8.5.0
|
||||||
|
cloudpickle==3.1.2
|
||||||
|
csvw==4.1.0
|
||||||
|
dlinfo==2.0.0
|
||||||
|
docopt==0.6.2
|
||||||
|
espeakng-loader==0.2.4
|
||||||
|
filelock==4.0.1
|
||||||
|
flatbuffers==25.12.19
|
||||||
|
fsspec==2026.9.0
|
||||||
|
h11==0.16.0
|
||||||
|
hf-xet==1.6.0
|
||||||
|
httpcore==1.0.9
|
||||||
|
httpx==0.28.1
|
||||||
|
huggingface-hub==1.32.0
|
||||||
|
idna==3.20
|
||||||
|
isodate==0.7.2
|
||||||
|
joblib==1.6.0
|
||||||
|
jsonschema-specifications==2025.9.1
|
||||||
|
jsonschema==4.26.0
|
||||||
|
language-tags==1.3.1
|
||||||
|
misaki==0.9.4
|
||||||
|
num2words==0.5.14
|
||||||
|
numpy==2.5.3
|
||||||
|
onnxruntime==1.30.0
|
||||||
|
packaging==26.3
|
||||||
|
phonemizer-fork==3.3.2
|
||||||
|
protobuf==7.36.2
|
||||||
|
pycparser==3.0
|
||||||
|
pyparsing==3.3.2
|
||||||
|
python-dateutil==2.9.0.post0
|
||||||
|
pyyaml==6.0.3
|
||||||
|
rdflib==7.6.0
|
||||||
|
referencing==0.37.0
|
||||||
|
regex==2026.9.10
|
||||||
|
rfc3986==1.5.0
|
||||||
|
rpds-py==2026.6.3
|
||||||
|
segments==2.4.0
|
||||||
|
six==1.17.0
|
||||||
|
soundfile==0.14.0
|
||||||
|
termcolor==3.3.0
|
||||||
|
tqdm==4.70.1
|
||||||
|
typing-extensions==4.16.0
|
||||||
|
uritemplate==4.2.0
|
||||||
Reference in New Issue
Block a user