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