From d3164e2e9604a4fcb3f6057c13f6b501703cefaa Mon Sep 17 00:00:00 2001 From: Labotics Robot Date: Fri, 19 Jun 2026 15:46:58 +0200 Subject: [PATCH] Step 1: frasario audio Piper (render + play) con struttura a componenti - config/: phrases.json (mappa phrase_id->testo) e config.json (voce, output dir) - src/tts/: render.py (render incrementale) e play.py (riproduzione, no rete) - clips/ e voices/ gitignorati (artefatti generati / modelli scaricati) Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 12 +++++++ README.md | 80 +++++++++++++++++++++++++++++++++++++++++ config/config.json | 5 +++ config/phrases.json | 6 ++++ requirements.txt | 1 + src/tts/__init__.py | 1 + src/tts/play.py | 83 ++++++++++++++++++++++++++++++++++++++++++ src/tts/render.py | 87 +++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 275 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config/config.json create mode 100644 config/phrases.json create mode 100644 requirements.txt create mode 100644 src/tts/__init__.py create mode 100644 src/tts/play.py create mode 100644 src/tts/render.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a7c8ed7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Generated audio artifacts — never committed +clips/ + +# Downloaded Piper voice models (large binaries; fetch with download_voices) +voices/ + +# Python +__pycache__/ +*.pyc + +# Editor (personal, machine-specific) +.vscode/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..f82dd96 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# TTS-Piper — Step 1: frasario audio + +Pre-renders four Italian phrases to WAV clips with [Piper](https://github.com/OHF-Voice/piper1-gpl) +and plays them back. At runtime nothing is synthesized and nothing is decided: +`play.py` just plays a WAV file that was rendered ahead of time. + +## Project layout + +``` +. +├── README.md project overview (this file) +├── requirements.txt Python dependencies +├── .gitignore excludes generated/downloaded artifacts +├── config/ data & configuration (edited by humans) +│ ├── config.json voice model path, voice config path, output directory +│ └── phrases.json map: phrase_id -> Italian text +├── src/ source code +│ └── tts/ the TTS component (one package per component) +│ ├── __init__.py +│ ├── render.py render phrases -> clips +│ └── play.py play a pre-rendered clip +├── voices/ downloaded Piper voice models (gitignored) +└── clips/ generated WAVs + .rendered.json (gitignored) +``` + +The structure is meant to grow: a new component (bus, dashboard, adapter…) gets +its own package under `src/`, not bolted onto `tts/`. Paths in `config.json` are +resolved relative to the project root (or absolute); nothing is hardcoded to an +absolute path, so the scripts run from any working directory. + +## Install + +Python 3 with pip. One dependency: + +```bash +python3 -m pip install -r requirements.txt +``` + +Download the Italian voice once (needs network; only at render time): + +```bash +python3 -m piper.download_voices it_IT-paola-medium --download-dir voices +``` + +This writes `voices/it_IT-paola-medium.onnx` and `voices/it_IT-paola-medium.onnx.json`. + +For playback you also need one audio player on the system; `play.py` looks for +`paplay`, `aplay`, or `ffplay` (in that order). On Ubuntu these come from +`pulseaudio-utils` / `alsa-utils` / `ffmpeg` and are usually already present. + +## Render the clips + +```bash +python3 src/tts/render.py +``` + +Produces `clips/routine_started.wav`, `clips/obstacle_detected.wav`, +`clips/object_lost.wav`, `clips/routine_finished.wav`. + +Re-runnable and incremental: edit a phrase in `config/phrases.json`, run +`render.py` again, and only that phrase's WAV is regenerated (the others are +reported as "up to date, skipped"). The `clips/.rendered.json` manifest records +the text each clip was rendered from. Delete a WAV (or the manifest) to force a +re-render. + +## Play the clips (no network) + +```bash +python3 src/tts/play.py routine_started # one clip by phrase_id +python3 src/tts/play.py --all # all clips, in phrases.json order +``` + +## Voice used + +- **Voice:** `it_IT-paola-medium` (Italian, female, "medium" quality, 22.05 kHz). +- **Source:** the Piper voices collection `rhasspy/piper-voices` on Hugging Face, + fetched by `python3 -m piper.download_voices`. + +To use a different voice: download it into `voices/` and update `voice_model` / +`voice_config` in `config/config.json`. diff --git a/config/config.json b/config/config.json new file mode 100644 index 0000000..f75d1bf --- /dev/null +++ b/config/config.json @@ -0,0 +1,5 @@ +{ + "voice_model": "voices/it_IT-paola-medium.onnx", + "voice_config": "voices/it_IT-paola-medium.onnx.json", + "output_dir": "clips" +} diff --git a/config/phrases.json b/config/phrases.json new file mode 100644 index 0000000..1806424 --- /dev/null +++ b/config/phrases.json @@ -0,0 +1,6 @@ +{ + "routine_started": "Inizio il compito.", + "obstacle_detected": "Mi fermo perché c'è un ostacolo.", + "object_lost": "Oggetto non rilevato nel campo visivo.", + "routine_finished": "Ho finito." +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cfd051e --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +piper-tts==1.4.2 diff --git a/src/tts/__init__.py b/src/tts/__init__.py new file mode 100644 index 0000000..5e29e21 --- /dev/null +++ b/src/tts/__init__.py @@ -0,0 +1 @@ +"""TTS component: render Italian phrases to WAV clips and play them back.""" diff --git a/src/tts/play.py b/src/tts/play.py new file mode 100644 index 0000000..1674444 --- /dev/null +++ b/src/tts/play.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Play a pre-rendered clip. No synthesis, no network — just plays a WAV file. + +Usage: + python3 src/tts/play.py play one clip + python3 src/tts/play.py --all play all clips (phrases.json order) + +Clips and phrase ids come from config/config.json and config/phrases.json, the +same data files render.py uses. +""" + +import json +import shutil +import subprocess +import sys +import time +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +CONFIG_DIR = PROJECT_ROOT / "config" + +# Pause between clips in --all mode (seconds). +PAUSE_BETWEEN = 0.6 + +# Audio players tried in order; first one found on the system is used. +PLAYERS = ["paplay", "aplay", "ffplay"] + + +def resolve(path_str): + p = Path(path_str) + return p if p.is_absolute() else (PROJECT_ROOT / p) + + +def load_json(path): + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + +def pick_player(): + for name in PLAYERS: + if shutil.which(name): + return name + sys.exit("No audio player found. Install one of: " + ", ".join(PLAYERS)) + + +def player_command(player, wav_path): + if player == "ffplay": + return ["ffplay", "-nodisp", "-autoexit", "-loglevel", "error", str(wav_path)] + # paplay and aplay both take the file path directly. + return [player, str(wav_path)] + + +def play_one(player, output_dir, phrase_id): + wav_path = output_dir / f"{phrase_id}.wav" + if not wav_path.exists(): + sys.exit(f"Clip not found: {wav_path}\nRun render.py first.") + print(f"Playing: {phrase_id} ({wav_path.name})") + subprocess.run(player_command(player, wav_path), check=True) + + +def main(): + if len(sys.argv) != 2: + sys.exit("Usage: python3 src/tts/play.py | --all") + + arg = sys.argv[1] + config = load_json(CONFIG_DIR / "config.json") + phrases = load_json(CONFIG_DIR / "phrases.json") + output_dir = resolve(config["output_dir"]) + player = pick_player() + + if arg == "--all": + for phrase_id in phrases: + play_one(player, output_dir, phrase_id) + time.sleep(PAUSE_BETWEEN) + elif arg in phrases: + play_one(player, output_dir, arg) + else: + known = ", ".join(phrases) + sys.exit(f"Unknown phrase_id: {arg}\nKnown ids: {known}") + + +if __name__ == "__main__": + main() diff --git a/src/tts/render.py b/src/tts/render.py new file mode 100644 index 0000000..8f19161 --- /dev/null +++ b/src/tts/render.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Pre-render every phrase in config/phrases.json to a WAV clip with Piper. + +Reads: + config/config.json -> voice model path, voice config path, output directory + config/phrases.json -> map of phrase_id -> Italian text + +Writes: + /.wav (one file per phrase) + +Re-runnable: change a phrase in config/phrases.json, run again, only that WAV +changes. A small sidecar manifest (/.rendered.json) records the +text each clip was rendered from; a phrase is re-rendered only when its text +changed or its WAV is missing, so editing one phrase touches only that one WAV. + +Paths in config.json are resolved relative to the project root (the folder two +levels above this file) unless they are already absolute, so there are no +hardcoded absolute paths. Run from anywhere: `python3 src/tts/render.py`. +""" + +import json +import sys +import wave +from pathlib import Path + +from piper import PiperVoice + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +CONFIG_DIR = PROJECT_ROOT / "config" + + +def resolve(path_str): + """Resolve a config path against the project root unless it is absolute.""" + p = Path(path_str) + return p if p.is_absolute() else (PROJECT_ROOT / p) + + +def load_json(path): + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + +def main(): + config = load_json(CONFIG_DIR / "config.json") + phrases = load_json(CONFIG_DIR / "phrases.json") + + model_path = resolve(config["voice_model"]) + config_path = resolve(config["voice_config"]) + output_dir = resolve(config["output_dir"]) + + if not model_path.exists(): + sys.exit( + f"Voice model not found: {model_path}\n" + "Download it first (see README.md), then re-run render.py." + ) + + output_dir.mkdir(parents=True, exist_ok=True) + + # What each existing clip was last rendered from, so we only redo changes. + manifest_path = output_dir / ".rendered.json" + rendered = load_json(manifest_path) if manifest_path.exists() else {} + + voice = None # loaded lazily, only if something actually needs rendering + written = 0 + for phrase_id, text in phrases.items(): + out_path = output_dir / f"{phrase_id}.wav" + if out_path.exists() and rendered.get(phrase_id) == text: + print(f" {phrase_id:18s} -- up to date, skipped") + continue + if voice is None: + print(f"Loading voice: {model_path.name}") + voice = PiperVoice.load(str(model_path), config_path=str(config_path)) + with wave.open(str(out_path), "wb") as wav_file: + voice.synthesize_wav(text, wav_file) + rendered[phrase_id] = text + written += 1 + size = out_path.stat().st_size + print(f" {phrase_id:18s} -> {out_path} ({size} bytes)") + + with open(manifest_path, "w", encoding="utf-8") as f: + json.dump(rendered, f, ensure_ascii=False, indent=2) + + print(f"Done: {written} clip(s) rendered, {len(phrases) - written} up to date.") + + +if __name__ == "__main__": + main()