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:
commit
d3164e2e96
8 files changed
+275
No files matched your search
+12
@@ -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/
|
||||||
@@ -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`.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"voice_model": "voices/it_IT-paola-medium.onnx",
|
||||||
|
"voice_config": "voices/it_IT-paola-medium.onnx.json",
|
||||||
|
"output_dir": "clips"
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
piper-tts==1.4.2
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""TTS component: render Italian phrases to WAV clips and play them back."""
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user