|
|
|
@@ -77,6 +77,44 @@ let
|
|
|
|
|
# Mnemosyne memory provider: third-party plugin, not in the image.
|
|
|
|
|
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=";
|
|
|
|
|
};
|
|
|
|
|
# uv parses the version out of the filename; the store hash prefix breaks it.
|
|
|
|
|
kittenttsWheelFile = "${pkgs.linkFarm "kittentts-wheel" {
|
|
|
|
|
"kittentts-0.8.1-py3-none-any.whl" = kittenttsWheel;
|
|
|
|
|
}}/kittentts-0.8.1-py3-none-any.whl";
|
|
|
|
|
kittenttsRuntimeLibs = "${pkgs.stdenv.cc.cc.lib}/lib ${pkgs.zlib}/lib";
|
|
|
|
|
kittenttsReqs = pkgs.writeText "kittentts-requirements.txt" (
|
|
|
|
|
builtins.readFile ./kittentts/requirements.txt
|
|
|
|
|
);
|
|
|
|
|
# STT add-on: faster-whisper (CPU) for Telegram voice-note transcription,
|
|
|
|
|
# same side-venv, appended by the provisioning unit. Keeps one venv + one
|
|
|
|
|
# stamp for the whole local-audio stack. See kittentts/stt-requirements.txt.
|
|
|
|
|
kittenttsSttReqs = pkgs.writeText "kittentts-stt-requirements.txt" (
|
|
|
|
|
builtins.readFile ./kittentts/stt-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/c02725660cea441db4c383af69f1f26f5cd00947/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/c02725660cea441db4c383af69f1f26f5cd00947/voices.npz";
|
|
|
|
|
sha256 = "sha256-QK0mOJUrd7ey8wEn4mCOFp/GndJWtTvYqqNAmjMZPEI=";
|
|
|
|
|
};
|
|
|
|
|
kittenttsModelConfig = pkgs.fetchurl {
|
|
|
|
|
url = "https://huggingface.co/KittenML/kitten-tts-mini-0.8/resolve/c02725660cea441db4c383af69f1f26f5cd00947/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.
|
|
|
|
@@ -274,6 +312,18 @@ in
|
|
|
|
|
# Authentik rejects. Safe to trust any peer: 9119 is already scoped to
|
|
|
|
|
# loopback + tailscale0 only.
|
|
|
|
|
FORWARDED_ALLOW_IPS = "*";
|
|
|
|
|
|
|
|
|
|
# KittenTTS: point huggingface_hub at the pre-seeded offline cache and
|
|
|
|
|
# forbid network — no model drift, no boot-time fetch (review #4).
|
|
|
|
|
HF_HOME = "/opt/data/kittentts-hf";
|
|
|
|
|
HF_HUB_OFFLINE = "1";
|
|
|
|
|
|
|
|
|
|
# STT (local whisper): expose the side-venv's site-packages to Hermes's
|
|
|
|
|
# own interpreter so faster-whisper imports in-process. PYTHONPATH dirs
|
|
|
|
|
# don't run .pth auto-imports, but faster-whisper has no dead imports —
|
|
|
|
|
# it imports cleanly from a plain path injection (unlike kittentts,
|
|
|
|
|
# which is driven through the command wrapper for exactly that reason).
|
|
|
|
|
PYTHONPATH = "/opt/data/kittentts-venv/lib/python3.13/site-packages";
|
|
|
|
|
};
|
|
|
|
|
environmentFiles = [ config.sops.templates."hermes-agent.env".path ];
|
|
|
|
|
cmd = [ "gateway" "run" ];
|
|
|
|
@@ -450,4 +500,209 @@ 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 + KittenTTS review rounds):
|
|
|
|
|
# - Build in a ROOT-OWNED staging dir (/var/lib/hermes-kittentts, 0755 so
|
|
|
|
|
# the uid-986 delivery step can read it; nothing in it is secret):
|
|
|
|
|
# stamp, staging venv, uv cache and staging model copies all live there.
|
|
|
|
|
# Root never reads or executes anything the container can write — the
|
|
|
|
|
# container cannot symlink-takeover the stamp (review #1) or plant a
|
|
|
|
|
# wheel in the uv cache (review #2); only FINISHED artifacts are copied
|
|
|
|
|
# into hermesHome, and the stamp compares the delivered copy against
|
|
|
|
|
# staging byte-for-byte.
|
|
|
|
|
# - Root runs python ONLY from the staging venv (never from the delivered
|
|
|
|
|
# uid-986-owned tree in hermesHome) — after a root-side offline import
|
|
|
|
|
# check; a failing build aborts before anything is delivered.
|
|
|
|
|
# - HF model cache delivered to hermesHome from staging (refs/main ->
|
|
|
|
|
# snapshots/<real commit sha>), and the container env pins
|
|
|
|
|
# HF_HOME=/opt/data/kittentts-hf + HF_HUB_OFFLINE=1: zero boot-time
|
|
|
|
|
# network, no drift.
|
|
|
|
|
# - Idempotency stamp keyed on the FULL input set (requirements + wheel +
|
|
|
|
|
# stub + model files), not just requirements (review #5); a deleted
|
|
|
|
|
# delivered file falls out of the fast path and re-provisions cheaply.
|
|
|
|
|
# - Order after prepare-dirs (review #6) — mirrors the mnemosyne unit.
|
|
|
|
|
#
|
|
|
|
|
# Trust boundary: the DELIVERED venv lives inside hermesHome
|
|
|
|
|
# (HERMES_WRITE_SAFE_ROOT), so luna can alter her own TTS engine — and a
|
|
|
|
|
# deleted/modified copy just triggers a fresh delivery from the root-owned
|
|
|
|
|
# staging area on next boot (self-heals instead of wedging). 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 prepare-dirs (review #6): on a fresh state dir this unit must not
|
|
|
|
|
# create hermesHome root-owned before prepare-dirs sets the ownership
|
|
|
|
|
# layout — same ordering contract the mnemosyne unit has. PLUS
|
|
|
|
|
# network-online ordering (review2 #3): this unit CAN download at boot
|
|
|
|
|
# (unlike mnemosyne's store-path build), so uv must not run before the
|
|
|
|
|
# network is actually up.
|
|
|
|
|
after = [
|
|
|
|
|
"network-online.target"
|
|
|
|
|
"hermes-agent-prepare-dirs.service"
|
|
|
|
|
];
|
|
|
|
|
requires = [ "hermes-agent-prepare-dirs.service" ];
|
|
|
|
|
# diffutils: `cmp` in the fast path; missing, it silently re-delivers every boot.
|
|
|
|
|
path = [ pkgs.uv pkgs.coreutils pkgs.diffutils pkgs.util-linux pkgs.auto-patchelf pkgs.patchelf ];
|
|
|
|
|
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
|
|
|
|
|
# Real upstream commit SHA as snapshot dir: hf_hub_download resolves
|
|
|
|
|
# refs/main -> snapshots/<sha>; "kitten" (review #3) is never found
|
|
|
|
|
# offline and silently triggers a re-download.
|
|
|
|
|
modelSha=c02725660cea441db4c383af69f1f26f5cd00947
|
|
|
|
|
snap=$hubDir/snapshots/$modelSha
|
|
|
|
|
# REVIEW #1/#2: nothing root touches lives in hermesHome. Stamp, staging
|
|
|
|
|
# venv and uv cache live root-owned under /var/lib/hermes-kittentts (a
|
|
|
|
|
# path the container can not pathwrite or symlinks into its own tree);
|
|
|
|
|
# the FINISHED staging venv and model files are the only things copied
|
|
|
|
|
# into hermesHome, and only after being validated. Root doesn't follow
|
|
|
|
|
# any uid-986-writable path while running as root.
|
|
|
|
|
stageDir=/var/lib/hermes-kittentts
|
|
|
|
|
# Root-side staging of the DELIVERED TREE (venv + full HF hub layout
|
|
|
|
|
# including refs/main) — everything root writes lives here.
|
|
|
|
|
# review2 #4: root never writes into hermesHome; delivery happens as
|
|
|
|
|
# the container uid via setpriv, copying from these root-owned sources.
|
|
|
|
|
stageVenv=$stageDir/venv
|
|
|
|
|
stageHub=$stageDir/kittentts-hf
|
|
|
|
|
stageSnap=$stageDir/hf-model
|
|
|
|
|
# Input key: requirements + wheel + stub + model files + resolved script.
|
|
|
|
|
# Review #5 — a miss on the old requirements-only stamp let a changed
|
|
|
|
|
# wheel/stub/model URL keep running the stale install forever.
|
|
|
|
|
inputHash=$({ cat ${kittenttsReqs} ${kittenttsSttReqs} ${kittenttsWheel} ${kittenttsStub} \
|
|
|
|
|
${kittenttsModelOnnx} ${kittenttsModelVoices} ${kittenttsModelConfig}
|
|
|
|
|
echo ${kittenttsRuntimeLibs}; } | sha256sum | cut -d' ' -f1)
|
|
|
|
|
stampFile=$stageDir/provision.stamp
|
|
|
|
|
|
|
|
|
|
# Idempotent early exit: stamp matches the full input hash, staging venv
|
|
|
|
|
# validated, delivered copy intact check runs below.
|
|
|
|
|
if [ -f "$stampFile" ] && [ "$(cat "$stampFile")" = "$inputHash" ] \
|
|
|
|
|
&& [ -x "$stageVenv/bin/python" ] \
|
|
|
|
|
&& [ -f "$stageSnap/config.json" ]; then
|
|
|
|
|
# Delivered artifacts in hermesHome must ALSO match the staging copy:
|
|
|
|
|
# luna can rewrite her copy, that's fine — but then the missing file
|
|
|
|
|
# forces a re-provision (cheap copy, not a rebuild) so deletions
|
|
|
|
|
# cannot wedge the gateway without a voice.
|
|
|
|
|
if [ -x "$venv/bin/python" ] \
|
|
|
|
|
&& cmp -s "$stageSnap/config.json" "$snap/config.json" 2>/dev/null \
|
|
|
|
|
&& cmp -s "$stageSnap/kitten_tts_mini_v0_8.onnx" "$snap/kitten_tts_mini_v0_8.onnx" 2>/dev/null \
|
|
|
|
|
&& cmp -s "$stageSnap/voices.npz" "$snap/voices.npz" 2>/dev/null; then
|
|
|
|
|
exit 0
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
# ---- staging venv + staged hub tree: root-owned path, uv cache
|
|
|
|
|
# included. Root runs python from HERE (container can write nothing in
|
|
|
|
|
# this tree); the FINISHED result is copied into hermesHome AS THE
|
|
|
|
|
# CONTAINER USER via setpriv (review2 #4) — root never writes into
|
|
|
|
|
# hermesHome, so no uid-986-controlled path is ever followed while
|
|
|
|
|
# running as root; symlink-takeover of stamps/refs/install targets is
|
|
|
|
|
# structurally impossible. ----
|
|
|
|
|
mkdir -p "$stageDir" "$stageSnap" "$stageVenv" "$stageHub"
|
|
|
|
|
# 0755: the setpriv'd uid-986 delivery below must be able to read it.
|
|
|
|
|
chmod 0755 "$stageDir"
|
|
|
|
|
install -m 0444 ${kittenttsModelConfig} "$stageSnap/config.json"
|
|
|
|
|
install -m 0444 ${kittenttsModelOnnx} "$stageSnap/kitten_tts_mini_v0_8.onnx"
|
|
|
|
|
install -m 0444 ${kittenttsModelVoices} "$stageSnap/voices.npz"
|
|
|
|
|
|
|
|
|
|
# Skip the venv REBUILD when staging is still valid (review2 minor:
|
|
|
|
|
# damaged delivery should be a copy, not a rebuild) — but only for the
|
|
|
|
|
# CURRENT inputs, else a bump delivers the old venv under a new stamp.
|
|
|
|
|
if [ "$(cat "$stampFile" 2>/dev/null)" != "$inputHash" ] \
|
|
|
|
|
|| ! [ -x "$stageVenv/bin/python" ] \
|
|
|
|
|
|| ! [ -f "$stageVenv/lib/python3.13/site-packages/kitten_tts_stub.py" ]; then
|
|
|
|
|
rm -rf "$stageVenv"
|
|
|
|
|
UV_CACHE_DIR=$stageDir/uv-cache \
|
|
|
|
|
uv venv "$stageVenv" --python ${pkgs.python313}/bin/python3 --quiet
|
|
|
|
|
UV_CACHE_DIR=$stageDir/uv-cache \
|
|
|
|
|
uv pip install --python "$stageVenv/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=$stageDir/uv-cache \
|
|
|
|
|
uv pip install --python "$stageVenv/bin/python" --quiet --no-deps \
|
|
|
|
|
${kittenttsWheelFile}
|
|
|
|
|
|
|
|
|
|
# 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=$("$stageVenv/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"
|
|
|
|
|
|
|
|
|
|
# PyPI manylinux .so's (numpy, onnxruntime) need libstdc++, which the
|
|
|
|
|
# Nix loader never finds, on the host or in the container.
|
|
|
|
|
auto-patchelf --paths "$stageVenv" --libs ${kittenttsRuntimeLibs}
|
|
|
|
|
|
|
|
|
|
# STT add-on: faster-whisper into the SAME venv (shares the
|
|
|
|
|
# numpy/onnxruntime pins; see kittentts/stt-requirements.txt).
|
|
|
|
|
UV_CACHE_DIR=$stageDir/uv-cache \
|
|
|
|
|
uv pip install --python "$stageVenv/bin/python" --quiet \
|
|
|
|
|
--requirement ${kittenttsSttReqs}
|
|
|
|
|
# av's bundled ffmpeg libs also need the stdc++/zlib link (already
|
|
|
|
|
# covered by the auto-patchelf pass above — rerun it so the newly
|
|
|
|
|
# installed ctranslate2/av binaries get RPATHs too).
|
|
|
|
|
auto-patchelf --paths "$stageVenv" --libs ${kittenttsRuntimeLibs}
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
# Stage the full delivered hub tree (exact hf_hub_download layout:
|
|
|
|
|
# refs/main -> snapshots/<sha>; review1 #3) under root-owned staging.
|
|
|
|
|
stageModelDir=$stageHub/hub/models--KittenML--kitten-tts-mini-0.8
|
|
|
|
|
mkdir -p "$stageModelDir/refs" "$stageModelDir/snapshots/$modelSha"
|
|
|
|
|
printf '%s' "$modelSha" > "$stageModelDir/refs/main"
|
|
|
|
|
install -m 0444 ${kittenttsModelOnnx} "$stageModelDir/snapshots/$modelSha/kitten_tts_mini_v0_8.onnx"
|
|
|
|
|
install -m 0444 ${kittenttsModelVoices} "$stageModelDir/snapshots/$modelSha/voices.npz"
|
|
|
|
|
install -m 0444 ${kittenttsModelConfig} "$stageModelDir/snapshots/$modelSha/config.json"
|
|
|
|
|
# No blobs/ indirection: kittentts reads paths RETURNED by
|
|
|
|
|
# hf_hub_download, which serves the resolved snapshot file directly
|
|
|
|
|
# (prefer-dir layout works offline for fully-materialized files).
|
|
|
|
|
|
|
|
|
|
# Root-side sanity: the staged interpreter must construct the model
|
|
|
|
|
# END-TO-END offline (review2 minor — import alone doesn't exercise
|
|
|
|
|
# hf_hub_download; a broken cache layout must fail HERE, not in the
|
|
|
|
|
# gateway). Points HF_HOME at the staged hub tree itself.
|
|
|
|
|
HF_HOME=$stageHub \
|
|
|
|
|
HF_HUB_OFFLINE=1 \
|
|
|
|
|
PHONEMIZER_ESPEAK_LIBRARY="$("$stageVenv/bin/python" -c 'import espeakng_loader,pathlib;print(pathlib.Path(espeakng_loader.get_library_path()))')" \
|
|
|
|
|
PHONEMIZER_ESPEAK_DATA_PATH="$("$stageVenv/bin/python" -c 'import espeakng_loader,pathlib;print(pathlib.Path(espeakng_loader.get_data_path()))')" \
|
|
|
|
|
"$stageVenv/bin/python" -c 'from kittentts import KittenTTS; KittenTTS("KittenML/kitten-tts-mini-0.8"); print("kittentts offline build ok")' >/dev/null
|
|
|
|
|
|
|
|
|
|
# ---- deliver AS THE CONTAINER USER (review2 #4): root never writes
|
|
|
|
|
# into hermesHome, so no symlink race and no `chown` step. setpriv
|
|
|
|
|
# drops to uid 986, rm -rf's the old delivered copies and copies the
|
|
|
|
|
# fresh staging tree in. cp-as-986 also fixes review2 #2: rm+cp in one
|
|
|
|
|
# step, no mv -Tf rename-replace on a non-empty directory.
|
|
|
|
|
setpriv --reuid=${hermesUid} --regid=${hermesGid} --clear-groups \
|
|
|
|
|
${pkgs.runtimeShell} -c '
|
|
|
|
|
set -eu
|
|
|
|
|
rm -rf "$1" "$2"
|
|
|
|
|
cp -a "$3" "$1"
|
|
|
|
|
cp -a "$4" "$2"
|
|
|
|
|
' _ \
|
|
|
|
|
"${hermesHome}/kittentts-venv" \
|
|
|
|
|
"${hermesHome}/kittentts-hf" \
|
|
|
|
|
"$stageVenv" \
|
|
|
|
|
"$stageHub"
|
|
|
|
|
|
|
|
|
|
# Stamp LAST, root-owned outside hermesHome — luna can delete it (which
|
|
|
|
|
# forces a cheap re-delivery on next boot), not tamper via symlink.
|
|
|
|
|
printf '%s' "$inputHash" > "$stampFile"
|
|
|
|
|
'';
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|