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:
1 parent
e5afeb06cc
commit
4ed8e0c71a
7 files changed
+183
-24
No files matched your search
+1
-1
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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 = "🟢 Эфир в ротации";
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in new issue
Block a user