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:
2026-09-19 12:09:03 +00:00
parent 32dd2abdce
commit 76ebc8c822
3 changed files with 216 additions and 0 deletions
+113
View File
@@ -81,6 +81,33 @@ let
# plugin symlink Docker needs at $HERMES_HOME/plugins/mnemosyne.
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
# subscription names the gitea hooks in services/dev/gitea.nix use. The two
# namespaces collide; see the long comment on the route unit below.
@@ -494,4 +521,90 @@ in
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"
'';
};
}