From 4ed8e0c71a813404e8ac42a487c605412014e19a Mon Sep 17 00:00:00 2001 From: Antigravity Date: Mon, 12 Oct 2026 16:58:59 +0300 Subject: [PATCH] feat(radio): replace robot TTS with Piper Neural voices & add instant voice preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/config.py | 2 +- app/main.py | 15 ++++- app/radio/dj.py | 135 +++++++++++++++++++++++++++++++++++---- app/radio/manager.py | 7 +- app/static/app.js | 33 +++++++++- app/templates/index.html | 14 ++-- requirements.txt | 1 + 7 files changed, 183 insertions(+), 24 deletions(-) diff --git a/app/config.py b/app/config.py index f7010da..ae28eda 100644 --- a/app/config.py +++ b/app/config.py @@ -30,7 +30,7 @@ class Settings(BaseSettings): # AI Radio DJ Settings RADIO_ENABLED: bool = True - RADIO_VOICE: str = "ru-RU-DmitryNeural" + RADIO_VOICE: str = "ru_RU-dmitri-medium" WEATHER_CITY: str = "" RADIO_INTRO_INTERVAL: int = 2 diff --git a/app/main.py b/app/main.py index cbfb22b..c7dc875 100644 --- a/app/main.py +++ b/app/main.py @@ -22,7 +22,7 @@ from app.core.auth import ( ) from app.wave.rotator import wave_rotator 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") @@ -334,3 +334,16 @@ async def generate_radio_broadcast(req: Optional[RadioGenerateRequest] = None, u res = await radio_manager.generate_radio_broadcast(voice=voice) 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"} + ) + + diff --git a/app/radio/dj.py b/app/radio/dj.py index 73ac742..b9591f8 100644 --- a/app/radio/dj.py +++ b/app/radio/dj.py @@ -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: diff --git a/app/radio/manager.py b/app/radio/manager.py index 8d466bc..5abf919 100644 --- a/app/radio/manager.py +++ b/app/radio/manager.py @@ -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() diff --git a/app/static/app.js b/app/static/app.js index 3dafd8a..6a5e2e5 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -501,6 +501,7 @@ document.addEventListener("DOMContentLoaded", () => { // --- AI Radio Host Controller (Phase 3) --- const radioGenerateBtn = document.getElementById("radioGenerateBtn"); const radioVoiceSelect = document.getElementById("radioVoiceSelect"); + const radioVoicePreviewBtn = document.getElementById("radioVoicePreviewBtn"); const radioStatusLabel = document.getElementById("radioStatusLabel"); const radioClipsCountLabel = document.getElementById("radioClipsCountLabel"); const radioSegmentsContainer = document.getElementById("radioSegmentsContainer"); @@ -508,10 +509,29 @@ document.addEventListener("DOMContentLoaded", () => { 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) { radioGenerateBtn.addEventListener("click", async () => { radioGenerateBtn.disabled = true; - const voice = radioVoiceSelect ? radioVoiceSelect.value : "ru-RU-DmitryNeural"; + const voice = radioVoiceSelect ? radioVoiceSelect.value : "ru_RU-dmitri-medium"; if (radioStatusLabel) { radioStatusLabel.textContent = "🎙️ Ведущий пишет реплики и сводит эфир..."; radioStatusLabel.className = "badge badge-llm"; @@ -560,6 +580,17 @@ document.addEventListener("DOMContentLoaded", () => { if (radioClipsCountLabel) { 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 => ` + + `).join(""); + } + if (radioStatusLabel) { if (data.playlist_ready && data.speech_clips_count > 0) { radioStatusLabel.textContent = "🟢 Эфир в ротации"; diff --git a/app/templates/index.html b/app/templates/index.html index c80fc6f..67df8d2 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -147,7 +147,7 @@
-

🎙️ AI Радио AI DJ Эфир 🤖 Qwen 2.5 + Edge-TTS

+

🎙️ AI Радио AI DJ Эфир 🤖 Qwen 2.5 + Piper Neural

Персональный радиоэфир: AI-ведущий объявляет погоду, время суток и делает динамичные подводки перед песнями в отдельном плейлисте «AI Радио».

@@ -159,12 +159,18 @@
-
+
+
Эфир готов к запуску diff --git a/requirements.txt b/requirements.txt index bf2de7a..2d2d817 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,3 +13,4 @@ python-multipart>=0.0.12 spotifyscraper>=3.9.0 edge-tts>=6.1.12 gtts>=2.5.0 +piper-tts>=1.2.0