110 lines
3.7 KiB
Python
110 lines
3.7 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
TTS-Modul: Liest JSON-Input und gibt Audio aus (Thorsten Emotional Voice).
|
||
|
||
JSON-Input Format:
|
||
{
|
||
"text": "Der zu sprechende Text",
|
||
"emotion": "neutral", // optional: amused|angry|disgusted|drunk|neutral|sleepy|surprised|whisper
|
||
"speaker_id": 4, // optional: 0-7 (überschreibt emotion)
|
||
"output_file": "output.wav" // optional: Ausgabedatei
|
||
}
|
||
|
||
Aufruf:
|
||
python tts_module.py # liest data/tts_input.json
|
||
python tts_module.py input.json # liest angegebene JSON-Datei
|
||
echo '{"text":"Hallo"}' | python tts_module.py # liest von stdin
|
||
"""
|
||
|
||
import sys
|
||
import json
|
||
import wave
|
||
import os
|
||
from pathlib import Path
|
||
|
||
# Pfade relativ zu diesem Script
|
||
SCRIPT_DIR = Path(__file__).parent
|
||
DEFAULT_INPUT = SCRIPT_DIR / "data" / "tts_input.json"
|
||
DEFAULT_OUTPUT = SCRIPT_DIR / "data" / "output.wav"
|
||
|
||
MODEL_PATH = SCRIPT_DIR / "de_DE-thorsten_emotional-medium.onnx"
|
||
CONFIG_PATH = SCRIPT_DIR / "de_DE-thorsten_emotional-medium.onnx.json"
|
||
|
||
# Emotion-Name → Speaker-ID
|
||
EMOTION_MAP = {
|
||
"amused": 0,
|
||
"angry": 1,
|
||
"disgusted": 2,
|
||
"drunk": 3,
|
||
"neutral": 4,
|
||
"sleepy": 5,
|
||
"surprised": 6,
|
||
"whisper": 7,
|
||
}
|
||
|
||
def load_input(source: str | None = None) -> dict:
|
||
"""Lädt JSON-Input aus Datei, stdin oder Standardpfad."""
|
||
if source:
|
||
with open(source, "r", encoding="utf-8") as f:
|
||
return json.load(f)
|
||
# Prüfe ob stdin Daten hat (z.B. Pipe)
|
||
if not sys.stdin.isatty():
|
||
return json.load(sys.stdin)
|
||
# Standard: data/tts_input.json
|
||
with open(DEFAULT_INPUT, "r", encoding="utf-8") as f:
|
||
return json.load(f)
|
||
|
||
def synthesize(data: dict) -> str:
|
||
"""Synthetisiert Text und gibt den Pfad zur Ausgabedatei zurück."""
|
||
from piper import PiperVoice
|
||
from piper.voice import SynthesisConfig
|
||
|
||
text = data.get("text", "")
|
||
if not text:
|
||
raise ValueError("Kein 'text' im JSON angegeben.")
|
||
|
||
# Speaker-ID bestimmen: direkt oder über Emotion-Name
|
||
if "speaker_id" in data:
|
||
speaker_id = int(data["speaker_id"])
|
||
else:
|
||
emotion = data.get("emotion", "neutral").lower()
|
||
speaker_id = EMOTION_MAP.get(emotion, EMOTION_MAP["neutral"])
|
||
|
||
output_file = str(data.get("output_file", DEFAULT_OUTPUT))
|
||
os.makedirs(os.path.dirname(output_file) or ".", exist_ok=True)
|
||
|
||
print(f"[TTS] Lade Modell...", flush=True)
|
||
voice = PiperVoice.load(str(MODEL_PATH), str(CONFIG_PATH))
|
||
syn_config = SynthesisConfig(speaker_id=speaker_id)
|
||
|
||
emotion_name = next((k for k, v in EMOTION_MAP.items() if v == speaker_id), str(speaker_id))
|
||
print(f"[TTS] Synthesisiere: '{text[:60]}...' | Emotion: {emotion_name} (ID {speaker_id})", flush=True)
|
||
|
||
with wave.open(output_file, "wb") as wav_file:
|
||
wav_file.setnchannels(1)
|
||
wav_file.setsampwidth(2)
|
||
wav_file.setframerate(voice.config.sample_rate)
|
||
for chunk in voice.synthesize(text, syn_config):
|
||
wav_file.writeframes(chunk.audio_int16_bytes)
|
||
|
||
print(f"[TTS] Audio gespeichert: {output_file}", flush=True)
|
||
return output_file
|
||
|
||
def main():
|
||
source = sys.argv[1] if len(sys.argv) > 1 else None
|
||
try:
|
||
data = load_input(source)
|
||
output_path = synthesize(data)
|
||
# Optional: Audio automatisch abspielen
|
||
if data.get("autoplay", False):
|
||
os.system(f"aplay '{output_path}' 2>/dev/null || paplay '{output_path}' 2>/dev/null")
|
||
except FileNotFoundError as e:
|
||
print(f"[TTS] Fehler: Datei nicht gefunden – {e}", file=sys.stderr)
|
||
sys.exit(1)
|
||
except Exception as e:
|
||
print(f"[TTS] Fehler: {e}", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
if __name__ == "__main__":
|
||
main()
|