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

+1 -1
View File
@@ -30,7 +30,7 @@ class Settings(BaseSettings):
# AI Radio DJ Settings # AI Radio DJ Settings
RADIO_ENABLED: bool = True RADIO_ENABLED: bool = True
RADIO_VOICE: str = "ru-RU-DmitryNeural" RADIO_VOICE: str = "ru_RU-dmitri-medium"
WEATHER_CITY: str = "" WEATHER_CITY: str = ""
RADIO_INTRO_INTERVAL: int = 2 RADIO_INTRO_INTERVAL: int = 2
+14 -1
View File
@@ -22,7 +22,7 @@ from app.core.auth import (
) )
from app.wave.rotator import wave_rotator from app.wave.rotator import wave_rotator
from app.wave.playlist import ensure_smart_playlist from app.wave.playlist import ensure_smart_playlist
from app.radio import radio_manager from app.radio import radio_manager, dj_host
app = FastAPI(title="Navidrome Music Sync", version="1.0.0") app = FastAPI(title="Navidrome Music Sync", version="1.0.0")
@@ -334,3 +334,16 @@ async def generate_radio_broadcast(req: Optional[RadioGenerateRequest] = None, u
res = await radio_manager.generate_radio_broadcast(voice=voice) res = await radio_manager.generate_radio_broadcast(voice=voice)
return res return res
@app.get("/api/radio/preview-voice")
def preview_voice(voice: str = Query(...)):
"""Stream voice sample preview."""
preview_path = dj_host.get_voice_preview_path(voice)
if not preview_path or not os.path.exists(preview_path):
raise HTTPException(status_code=404, detail="Превью голоса не найдено")
return FileResponse(
preview_path,
media_type="audio/mpeg",
headers={"Content-Disposition": f"inline; filename=preview_{voice}.mp3"}
)
+123 -12
View File
@@ -2,7 +2,7 @@ import os
import time import time
import httpx import httpx
import asyncio import asyncio
import edge_tts import tempfile
from datetime import datetime from datetime import datetime
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from mutagen.id3 import ID3, TIT2, TPE1, TALB, ID3NoHeaderError 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.config import settings
from app.radio.weather import get_current_weather 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: class RadioDJ:
def __init__(self): def __init__(self):
self.ollama_url = f"{settings.OLLAMA_URL}/api/generate" self.ollama_url = f"{settings.OLLAMA_URL}/api/generate"
self.model = settings.OLLAMA_MODEL 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]: def get_time_of_day_info(self) -> Dict[str, str]:
"""Determine time of day greeting.""" """Determine time of day greeting."""
@@ -76,7 +136,10 @@ class RadioDJ:
lines = [l.strip() for l in text.split("\n") if l.strip()] lines = [l.strip() for l in text.split("\n") if l.strip()]
if lines: if lines:
cleaned = " ".join(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 return cleaned
except Exception as e: except Exception as e:
print(f"[RadioDJ] Ollama generation fallback due to: {e}") print(f"[RadioDJ] Ollama generation fallback due to: {e}")
@@ -105,6 +168,55 @@ class RadioDJ:
except Exception as tag_err: except Exception as tag_err:
print(f"[RadioDJ] Warning tagging audio: {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( async def synthesize_speech(
self, self,
text: str, text: str,
@@ -112,18 +224,17 @@ class RadioDJ:
title: str = "Ведущий в эфире", title: str = "Ведущий в эфире",
voice: Optional[str] = None voice: Optional[str] = None
) -> bool: ) -> bool:
"""Synthesize speech using edge-tts or rock-solid gTTS fallback and write ID3 tags for Navidrome.""" """Synthesize speech using high-fidelity Piper TTS or fallback to gTTS, and write ID3 tags for Navidrome."""
selected_voice = voice or self.default_voice 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 # 1. Attempt Piper TTS if configured
try: if voice_info and voice_info["engine"] == "piper" and voice_info["model_file"]:
tts = edge_tts.Communicate(text, selected_voice) success = await self._synthesize_piper(text, voice_info["model_file"], output_file)
await tts.save(output_file) if success:
if os.path.exists(output_file) and os.path.getsize(output_file) > 500:
self._apply_id3_tags(output_file, title) self._apply_id3_tags(output_file, title)
return True return True
except Exception as e: print(f"[RadioDJ] Piper synthesis failed for {selected_voice_id}, falling back to gTTS...")
print(f"[RadioDJ] edge-tts error ({e}), trying gTTS fallback...")
# 2. Resilient fallback to gTTS # 2. Resilient fallback to gTTS
try: try:
+2 -5
View File
@@ -146,11 +146,8 @@ class RadioManager:
"playlist_ready": playlist_ready, "playlist_ready": playlist_ready,
"speech_clips_count": len(speech_files), "speech_clips_count": len(speech_files),
"speech_clips": speech_files, "speech_clips": speech_files,
"default_voice": settings.RADIO_VOICE, "default_voice": settings.RADIO_VOICE or "ru_RU-dmitri-medium",
"available_voices": [ "available_voices": dj_host.get_available_voices()
{"id": "ru-RU-DmitryNeural", "name": "Дмитрий (Харизматичный DJ)", "gender": "Мужской"},
{"id": "ru-RU-SvetlanaNeural", "name": "Светлана (Мягкий вечерний голос)", "gender": "Женский"}
]
} }
radio_manager = RadioManager() radio_manager = RadioManager()
+32 -1
View File
@@ -501,6 +501,7 @@ document.addEventListener("DOMContentLoaded", () => {
// --- AI Radio Host Controller (Phase 3) --- // --- AI Radio Host Controller (Phase 3) ---
const radioGenerateBtn = document.getElementById("radioGenerateBtn"); const radioGenerateBtn = document.getElementById("radioGenerateBtn");
const radioVoiceSelect = document.getElementById("radioVoiceSelect"); const radioVoiceSelect = document.getElementById("radioVoiceSelect");
const radioVoicePreviewBtn = document.getElementById("radioVoicePreviewBtn");
const radioStatusLabel = document.getElementById("radioStatusLabel"); const radioStatusLabel = document.getElementById("radioStatusLabel");
const radioClipsCountLabel = document.getElementById("radioClipsCountLabel"); const radioClipsCountLabel = document.getElementById("radioClipsCountLabel");
const radioSegmentsContainer = document.getElementById("radioSegmentsContainer"); const radioSegmentsContainer = document.getElementById("radioSegmentsContainer");
@@ -508,10 +509,29 @@ document.addEventListener("DOMContentLoaded", () => {
fetchRadioStatus(); fetchRadioStatus();
// Voice sample preview playback
if (radioVoicePreviewBtn) {
radioVoicePreviewBtn.addEventListener("click", () => {
const selectedVoice = radioVoiceSelect ? radioVoiceSelect.value : "ru_RU-dmitri-medium";
const audioUrl = `api/radio/preview-voice?voice=${encodeURIComponent(selectedVoice)}`;
const selectedOption = (radioVoiceSelect && radioVoiceSelect.options[radioVoiceSelect.selectedIndex])
? radioVoiceSelect.options[radioVoiceSelect.selectedIndex].text
: "Голос ведущего";
playPreviewTrack(
audioUrl,
`Сэмпл: ${selectedOption}`,
"AI Радио (Предпрослушивание)",
"",
"",
audioUrl
);
});
}
if (radioGenerateBtn) { if (radioGenerateBtn) {
radioGenerateBtn.addEventListener("click", async () => { radioGenerateBtn.addEventListener("click", async () => {
radioGenerateBtn.disabled = true; radioGenerateBtn.disabled = true;
const voice = radioVoiceSelect ? radioVoiceSelect.value : "ru-RU-DmitryNeural"; const voice = radioVoiceSelect ? radioVoiceSelect.value : "ru_RU-dmitri-medium";
if (radioStatusLabel) { if (radioStatusLabel) {
radioStatusLabel.textContent = "🎙️ Ведущий пишет реплики и сводит эфир..."; radioStatusLabel.textContent = "🎙️ Ведущий пишет реплики и сводит эфир...";
radioStatusLabel.className = "badge badge-llm"; radioStatusLabel.className = "badge badge-llm";
@@ -560,6 +580,17 @@ document.addEventListener("DOMContentLoaded", () => {
if (radioClipsCountLabel) { if (radioClipsCountLabel) {
radioClipsCountLabel.textContent = `${data.speech_clips_count || 0} записей`; radioClipsCountLabel.textContent = `${data.speech_clips_count || 0} записей`;
} }
// Populate voice selector dynamically
if (radioVoiceSelect && data.available_voices && data.available_voices.length > 0) {
const currentVal = radioVoiceSelect.value || data.default_voice;
radioVoiceSelect.innerHTML = data.available_voices.map(v => `
<option value="${escapeHtml(v.id)}" ${v.id === currentVal ? "selected" : ""}>
🎙️ ${escapeHtml(v.name)}
</option>
`).join("");
}
if (radioStatusLabel) { if (radioStatusLabel) {
if (data.playlist_ready && data.speech_clips_count > 0) { if (data.playlist_ready && data.speech_clips_count > 0) {
radioStatusLabel.textContent = "🟢 Эфир в ротации"; radioStatusLabel.textContent = "🟢 Эфир в ротации";
+10 -4
View File
@@ -147,7 +147,7 @@
<section class="card radio-card"> <section class="card radio-card">
<div class="radio-header"> <div class="radio-header">
<div class="radio-header-title"> <div class="radio-header-title">
<h2>🎙️ AI Радио <span class="badge badge-radio">AI DJ Эфир</span> <span class="badge badge-llm">🤖 Qwen 2.5 + Edge-TTS</span></h2> <h2>🎙️ AI Радио <span class="badge badge-radio">AI DJ Эфир</span> <span class="badge badge-llm">🤖 Qwen 2.5 + Piper Neural</span></h2>
<p class="radio-subtitle"> <p class="radio-subtitle">
Персональный радиоэфир: AI-ведущий объявляет погоду, время суток и делает динамичные подводки перед песнями в отдельном плейлисте «AI Радио». Персональный радиоэфир: AI-ведущий объявляет погоду, время суток и делает динамичные подводки перед песнями в отдельном плейлисте «AI Радио».
</p> </p>
@@ -159,12 +159,18 @@
<!-- Radio Host Voice & Context Row --> <!-- Radio Host Voice & Context Row -->
<div class="radio-settings-row"> <div class="radio-settings-row">
<div class="radio-voice-group"> <div class="radio-voice-group" style="flex-wrap: wrap;">
<label for="radioVoiceSelect" class="radio-voice-label">Голос ведущего:</label> <label for="radioVoiceSelect" class="radio-voice-label">Голос ведущего:</label>
<select id="radioVoiceSelect" class="radio-voice-select"> <select id="radioVoiceSelect" class="radio-voice-select">
<option value="ru-RU-DmitryNeural" selected>🎙️ Дмитрий (Харизматичный)</option> <option value="ru_RU-dmitri-medium" selected>🎙️ Дмитрий (Энергичный DJ)</option>
<option value="ru-RU-SvetlanaNeural">🎙️ Светлана (Мягкий)</option> <option value="ru_RU-irina-medium">🎙️ Ирина (Естественный женский)</option>
<option value="ru_RU-denis-medium">🎙️ Денис (Глубокий баритон)</option>
<option value="ru_RU-ruslan-medium">🎙️ Руслан (Спокойный ведущий)</option>
<option value="gtts_ru">🎙️ Google (Стандартный робот)</option>
</select> </select>
<button id="radioVoicePreviewBtn" type="button" class="btn-sm btn-play-preview" title="Послушать сэмпл выбранного голоса">
▶ Прослушать голос
</button>
</div> </div>
<div class="radio-status-badge-wrap"> <div class="radio-status-badge-wrap">
<span id="radioStatusLabel" class="badge badge-context">Эфир готов к запуску</span> <span id="radioStatusLabel" class="badge badge-context">Эфир готов к запуску</span>
+1
View File
@@ -13,3 +13,4 @@ python-multipart>=0.0.12
spotifyscraper>=3.9.0 spotifyscraper>=3.9.0
edge-tts>=6.1.12 edge-tts>=6.1.12
gtts>=2.5.0 gtts>=2.5.0
piper-tts>=1.2.0