Files
hermes-agent/optional-skills/productivity/wake-word-training/scripts/generate_positives.py
T
Brooklyn Nicholson 576c090d2f feat(skills): wake-word-training — teach Hermes to train custom wake words
Adds an optional skill so Hermes can build a custom openWakeWord model for a
phrase other than the bundled "hey hermes" (defaults to "hey <profile>"). The
skill drives openWakeWord's own trainer via the terminal tool — no heavy
training stack ships in core, and the existing resolver already loads the
resulting .onnx by path, so there are zero changes to the wake runtime.

- SKILL.md: end-to-end procedure (synthetic positives, augmentation/negatives,
  train, export into the profile's ~/.hermes/wakewords/, wire config, restart).
- scripts/generate_positives.py: OpenAI-TTS positives (default when
  OPENAI_API_KEY is set) with import-lazy synthesis; pure planning helpers.
- scripts/make_training_config.py: emits the openWakeWord training YAML.
- references/platforms.md: Colab fast path + macOS caveats + dataset sources.
- tests: frontmatter contract + pure helper logic, stdlib-only, no network.
- docs: point the wake-word "different phrase" section at the skill.
2026-07-24 10:11:38 -05:00

137 lines
4.9 KiB
Python

#!/usr/bin/env python3
"""Generate synthetic positive samples of a wake phrase for openWakeWord.
OpenAI TTS (``gpt-4o-mini-tts``) speaks the phrase across many voices with small
text/pacing variations, resampled to the 16 kHz mono WAV openWakeWord trains on.
This is the higher-quality alternative to openWakeWord's built-in Piper
generator; use it when ``OPENAI_API_KEY`` is set.
Heavy deps (openai, soundfile, numpy, scipy) are imported lazily inside the
synthesis path so the pure planning helpers stay import-light and unit-testable.
"""
from __future__ import annotations
import argparse
import re
# OpenAI's standard TTS voice set — cycling these gives speaker diversity, which
# is what drives custom wake-word robustness (openWakeWord's own models lean on
# exactly this kind of multi-voice synthetic data).
OPENAI_VOICES = (
"alloy", "ash", "ballad", "coral", "echo",
"fable", "onyx", "nova", "sage", "shimmer",
)
TARGET_SAMPLE_RATE = 16_000
def slugify(phrase: str) -> str:
"""`"Hey Morgane!"` → `"hey_morgane"` — the model filename stem."""
s = re.sub(r"[^a-z0-9]+", "_", phrase.strip().lower())
return s.strip("_") or "wake_word"
def build_variations(phrase: str) -> list[str]:
"""Small textual variations so the TTS doesn't render one frozen prosody."""
core = phrase.strip().rstrip(".!?,")
# Punctuation nudges cadence/intonation; duplicates are dropped by dict order.
seen = {core: None, f"{core}.": None, f"{core}!": None, f"{core}...": None}
return list(seen)
def voice_list(engine: str = "openai") -> list[str]:
if engine == "openai":
return list(OPENAI_VOICES)
raise ValueError(f"unknown engine: {engine!r}")
def plan_clips(phrase: str, count: int, voices: list[str]) -> list[tuple[str, str, str]]:
"""Deterministic (voice, text, filename) plan for *count* clips.
Voices and text variations are cycled independently so the set stays evenly
balanced across speakers regardless of *count*. Filenames are zero-padded and
unique, ready to drop into openWakeWord's positive-samples directory.
"""
if count < 1:
return []
if not voices:
raise ValueError("need at least one voice")
variations = build_variations(phrase)
stem = slugify(phrase)
width = max(4, len(str(count - 1)))
plan = []
for i in range(count):
voice = voices[i % len(voices)]
text = variations[i % len(variations)]
plan.append((voice, text, f"{stem}_{i:0{width}d}.wav"))
return plan
def _resample_to_16k_mono(pcm, src_rate: int):
"""int16 mono PCM at *src_rate* → int16 mono PCM at 16 kHz (lazy scipy)."""
import numpy as np
from scipy.signal import resample
if src_rate == TARGET_SAMPLE_RATE:
return pcm
n = round(len(pcm) * TARGET_SAMPLE_RATE / src_rate)
out = resample(pcm.astype("float32"), n)
return np.clip(out, -32768, 32767).astype("int16")
def _synthesize(plan, out_dir, api_key): # pragma: no cover - network/heavy deps
"""Call OpenAI TTS for each planned clip and write a 16 kHz mono WAV."""
import io
import wave
from pathlib import Path
import numpy as np
import soundfile as sf
from openai import OpenAI
client = OpenAI(api_key=api_key)
out = Path(out_dir)
out.mkdir(parents=True, exist_ok=True)
for voice, text, name in plan:
resp = client.audio.speech.create(
model="gpt-4o-mini-tts", voice=voice, input=text, response_format="wav"
)
data, rate = sf.read(io.BytesIO(resp.read()), dtype="int16")
if getattr(data, "ndim", 1) == 2: # stereo → mono
data = data.mean(axis=1).astype("int16")
data = _resample_to_16k_mono(np.asarray(data), rate)
with wave.open(str(out / name), "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(TARGET_SAMPLE_RATE)
w.writeframes(np.asarray(data).tobytes())
return len(plan)
def main(argv=None) -> int:
import os
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--phrase", required=True, help='e.g. "hey morgane"')
ap.add_argument("--out-dir", required=True, help="positive-samples directory")
ap.add_argument("--count", type=int, default=500, help="clips to generate")
ap.add_argument("--engine", default="openai", choices=["openai"])
args = ap.parse_args(argv)
api_key = (os.getenv("OPENAI_API_KEY") or "").strip()
if not api_key:
ap.error(
"OPENAI_API_KEY not set. Set it, or use openWakeWord's offline Piper "
"generator instead (see the skill's references/platforms.md)."
)
plan = plan_clips(args.phrase, args.count, voice_list(args.engine))
written = _synthesize(plan, args.out_dir, api_key)
print(f"wrote {written} positive samples to {args.out_dir}")
return 0
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())