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 @@