diff --git a/hosts/mars/hermes-agent.nix b/hosts/mars/hermes-agent.nix index 129b5de..70b6fa4 100644 --- a/hosts/mars/hermes-agent.nix +++ b/hosts/mars/hermes-agent.nix @@ -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" + ''; + }; } diff --git a/hosts/mars/kittentts/kitten-misaki-stub.py b/hosts/mars/kittentts/kitten-misaki-stub.py new file mode 100644 index 0000000..bdc4fa8 --- /dev/null +++ b/hosts/mars/kittentts/kitten-misaki-stub.py @@ -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 diff --git a/hosts/mars/kittentts/requirements.txt b/hosts/mars/kittentts/requirements.txt new file mode 100644 index 0000000..176e1d7 --- /dev/null +++ b/hosts/mars/kittentts/requirements.txt @@ -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