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) <noreply@anthropic.com>
This commit is contained in:
Labotics RobotandClaude Opus 4.8 committed 2026-06-19 15:46:58 +02:00
commit d3164e2e96
8 files changed
+275

No files matched your search

+12
View File
@@ -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/
+80
View File
@@ -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`.
+5
View File
@@ -0,0 +1,5 @@
{
"voice_model": "voices/it_IT-paola-medium.onnx",
"voice_config": "voices/it_IT-paola-medium.onnx.json",
"output_dir": "clips"
}
+6
View File
@@ -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."
}
+1
View File
@@ -0,0 +1 @@
piper-tts==1.4.2
+1
View File
@@ -0,0 +1 @@
"""TTS component: render Italian phrases to WAV clips and play them back."""
+83
View File
@@ -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 <phrase_id> 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 <phrase_id> | --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()
+87
View File
@@ -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:
<output_dir>/<phrase_id>.wav (one file per phrase)
Re-runnable: change a phrase in config/phrases.json, run again, only that WAV
changes. A small sidecar manifest (<output_dir>/.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()