feat(radio): replace robot TTS with Piper Neural voices & add instant voice preview

- Integrate local Piper ONNX neural TTS engine (Dmitri, Irina, Denis, Ruslan)
- Add GET /api/radio/preview-voice endpoint with cached pre-rendered samples
- Add '▶ Прослушать голос' button in Web UI for 1-click preview before generating
- Add automated filter to strip LLM refusals and fall back to clean radio phrasing
This commit is contained in:
Antigravity committed 2026-10-12 16:58:59 +03:00
1 parent e5afeb06cc
commit 4ed8e0c71a
7 files changed
+183 -24

No files matched your search

+123 -12
View File
@@ -2,7 +2,7 @@ import os
import time
import httpx
import asyncio
import edge_tts
import tempfile
from datetime import datetime
from typing import List, Dict, Any, Optional
from mutagen.id3 import ID3, TIT2, TPE1, TALB, ID3NoHeaderError
@@ -10,11 +10,71 @@ from mutagen.id3 import ID3, TIT2, TPE1, TALB, ID3NoHeaderError
from app.config import settings
from app.radio.weather import get_current_weather
VOICE_CATALOG: Dict[str, Dict[str, Any]] = {
"ru_RU-dmitri-medium": {
"id": "ru_RU-dmitri-medium",
"name": "Дмитрий (Энергичный DJ)",
"gender": "Мужской",
"desc": "Бодрый и харизматичный голос ведущего радиоэфира",
"engine": "piper",
"model_file": "ru_RU-dmitri-medium.onnx"
},
"ru_RU-irina-medium": {
"id": "ru_RU-irina-medium",
"name": "Ирина (Естественный женский)",
"gender": "Женский",
"desc": "Мягкий, естественный и дружелюбный женский тембр",
"engine": "piper",
"model_file": "ru_RU-irina-medium.onnx"
},
"ru_RU-denis-medium": {
"id": "ru_RU-denis-medium",
"name": "Денис (Глубокий баритон)",
"gender": "Мужской",
"desc": "Плотный, уверенный радио-голос с глубоким низом",
"engine": "piper",
"model_file": "ru_RU-denis-medium.onnx"
},
"ru_RU-ruslan-medium": {
"id": "ru_RU-ruslan-medium",
"name": "Руслан (Спокойный ведущий)",
"gender": "Мужской",
"desc": "Размеренный, интеллигентный и чилловый голос",
"engine": "piper",
"model_file": "ru_RU-ruslan-medium.onnx"
},
"gtts_ru": {
"id": "gtts_ru",
"name": "Google (Стандартный робот)",
"gender": "Женский",
"desc": "Классический синтез Google Translate",
"engine": "gtts",
"model_file": None
}
}
class RadioDJ:
def __init__(self):
self.ollama_url = f"{settings.OLLAMA_URL}/api/generate"
self.model = settings.OLLAMA_MODEL
self.default_voice = settings.RADIO_VOICE or "ru-RU-DmitryNeural"
self.default_voice = settings.RADIO_VOICE or "ru_RU-dmitri-medium"
self.voices_dir = os.path.join(settings.DATA_DIR, "voices")
self.previews_dir = os.path.join(self.voices_dir, "previews")
os.makedirs(self.voices_dir, exist_ok=True)
os.makedirs(self.previews_dir, exist_ok=True)
def get_available_voices(self) -> List[Dict[str, Any]]:
"""Return list of available voice profiles."""
return list(VOICE_CATALOG.values())
def get_voice_preview_path(self, voice_id: str) -> Optional[str]:
"""Return absolute path to pre-generated preview mp3 for the given voice."""
if voice_id not in VOICE_CATALOG:
voice_id = self.default_voice
path = os.path.join(self.previews_dir, f"{voice_id}.mp3")
if os.path.exists(path) and os.path.getsize(path) > 1000:
return path
return None
def get_time_of_day_info(self) -> Dict[str, str]:
"""Determine time of day greeting."""
@@ -76,7 +136,10 @@ class RadioDJ:
lines = [l.strip() for l in text.split("\n") if l.strip()]
if lines:
cleaned = " ".join(lines)
if len(cleaned) > 10:
refusal_words = ["извините", "не могу", "авторских прав", "я языковая модель", "как ии", "служб безопасности", "отказаться", "i cannot", "i am unable"]
if any(w in cleaned.lower() for w in refusal_words):
print(f"[RadioDJ] Filtered out LLM refusal: {cleaned}")
elif len(cleaned) > 10:
return cleaned
except Exception as e:
print(f"[RadioDJ] Ollama generation fallback due to: {e}")
@@ -105,6 +168,55 @@ class RadioDJ:
except Exception as tag_err:
print(f"[RadioDJ] Warning tagging audio: {tag_err}")
async def _synthesize_piper(self, text: str, model_filename: str, output_file: str) -> bool:
"""Synthesize audio using local neural Piper TTS model and convert to MP3 via ffmpeg."""
model_path = os.path.join(self.voices_dir, model_filename)
if not os.path.exists(model_path):
print(f"[RadioDJ] Piper model {model_filename} not found at {model_path}")
return False
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp_wav:
wav_path = tmp_wav.name
try:
cmd = [
"piper",
"--model", model_path,
"--output_file", wav_path
]
proc = await asyncio.create_subprocess_exec(
*cmd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await proc.communicate(input=text.encode("utf-8"))
if proc.returncode != 0:
print(f"[RadioDJ] Piper error: {stderr.decode('utf-8', errors='ignore')}")
return False
# Convert WAV to MP3 using ffmpeg
ffmpeg_cmd = [
"ffmpeg", "-y",
"-i", wav_path,
"-codec:a", "libmp3lame",
"-b:a", "192k",
output_file
]
ff_proc = await asyncio.create_subprocess_exec(
*ffmpeg_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
await ff_proc.communicate()
return os.path.exists(output_file) and os.path.getsize(output_file) > 500
except Exception as e:
print(f"[RadioDJ] Piper synthesis exception: {e}")
return False
finally:
if os.path.exists(wav_path):
os.remove(wav_path)
async def synthesize_speech(
self,
text: str,
@@ -112,18 +224,17 @@ class RadioDJ:
title: str = "Ведущий в эфире",
voice: Optional[str] = None
) -> bool:
"""Synthesize speech using edge-tts or rock-solid gTTS fallback and write ID3 tags for Navidrome."""
selected_voice = voice or self.default_voice
"""Synthesize speech using high-fidelity Piper TTS or fallback to gTTS, and write ID3 tags for Navidrome."""
selected_voice_id = voice or self.default_voice
voice_info = VOICE_CATALOG.get(selected_voice_id, VOICE_CATALOG.get(self.default_voice))
# 1. Attempt edge-tts
try:
tts = edge_tts.Communicate(text, selected_voice)
await tts.save(output_file)
if os.path.exists(output_file) and os.path.getsize(output_file) > 500:
# 1. Attempt Piper TTS if configured
if voice_info and voice_info["engine"] == "piper" and voice_info["model_file"]:
success = await self._synthesize_piper(text, voice_info["model_file"], output_file)
if success:
self._apply_id3_tags(output_file, title)
return True
except Exception as e:
print(f"[RadioDJ] edge-tts error ({e}), trying gTTS fallback...")
print(f"[RadioDJ] Piper synthesis failed for {selected_voice_id}, falling back to gTTS...")
# 2. Resilient fallback to gTTS
try:
+2 -5
View File
@@ -146,11 +146,8 @@ class RadioManager:
"playlist_ready": playlist_ready,
"speech_clips_count": len(speech_files),
"speech_clips": speech_files,
"default_voice": settings.RADIO_VOICE,
"available_voices": [
{"id": "ru-RU-DmitryNeural", "name": "Дмитрий (Харизматичный DJ)", "gender": "Мужской"},
{"id": "ru-RU-SvetlanaNeural", "name": "Светлана (Мягкий вечерний голос)", "gender": "Женский"}
]
"default_voice": settings.RADIO_VOICE or "ru_RU-dmitri-medium",
"available_voices": dj_host.get_available_voices()
}
radio_manager = RadioManager()