feat: add NeuTTS optional skill + local TTS provider backend

* feat(skills): add bundled neutts optional skill

Add NeuTTS optional skill with CLI scaffold, bootstrap helper, and
sample voice profile. Also fixes skills_hub.py to handle binary
assets (WAV files) during skill installation.

Changes:
- optional-skills/mlops/models/neutts/ — skill + CLI scaffold
- tools/skills_hub.py — binary asset support (read_bytes, write_bytes)
- tests/tools/test_skills_hub.py — regression tests for binary assets

* feat(tts): add NeuTTS as local TTS provider backend

Add NeuTTS as a fourth TTS provider option alongside Edge, ElevenLabs,
and OpenAI. NeuTTS runs fully on-device via neutts_cli — no API key
needed.

Provider behavior:
- Explicit: set tts.provider to 'neutts' in config.yaml
- Fallback: when Edge TTS is unavailable and neutts_cli is installed,
  automatically falls back to NeuTTS instead of failing
- check_tts_requirements() now includes NeuTTS in availability checks

NeuTTS outputs WAV natively. For Telegram voice bubbles, ffmpeg
converts to Opus (same pattern as Edge TTS).

Changes:
- tools/tts_tool.py — _generate_neutts(), _check_neutts_available(),
  provider dispatch, fallback logic, Opus conversion
- hermes_cli/config.py — tts.neutts config defaults

---------

Co-authored-by: unmodeled-tyler <unmodeled.tyler@proton.me>
This commit is contained in:
Teknium 2026-03-17 02:13:34 -07:00 committed by GitHub
parent 766f4aae2b
commit cb0deb5f9d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1359 additions and 24 deletions

View file

@ -25,7 +25,7 @@ from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional, Tuple, Union
from urllib.parse import urlparse, urlunparse
import httpx
@ -77,7 +77,7 @@ class SkillMeta:
class SkillBundle:
"""A downloaded skill ready for quarantine/scanning/installation."""
name: str
files: Dict[str, str] # relative_path -> text content
files: Dict[str, Union[str, bytes]] # relative_path -> file content
source: str
identifier: str
trust_level: str
@ -1940,13 +1940,18 @@ class OptionalSkillSource(SkillSource):
else:
skill_dir = resolved
files: Dict[str, str] = {}
files: Dict[str, Union[str, bytes]] = {}
for f in skill_dir.rglob("*"):
if f.is_file() and not f.name.startswith("."):
if (
f.is_file()
and not f.name.startswith(".")
and "__pycache__" not in f.parts
and f.suffix != ".pyc"
):
rel_path = str(f.relative_to(skill_dir))
try:
files[rel_path] = f.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
files[rel_path] = f.read_bytes()
except OSError:
continue
if not files:
@ -2257,7 +2262,10 @@ def quarantine_bundle(bundle: SkillBundle) -> Path:
for rel_path, file_content in bundle.files.items():
file_dest = dest / rel_path
file_dest.parent.mkdir(parents=True, exist_ok=True)
file_dest.write_text(file_content, encoding="utf-8")
if isinstance(file_content, bytes):
file_dest.write_bytes(file_content)
else:
file_dest.write_text(file_content, encoding="utf-8")
return dest