Files
navidrome-music-sync/app/radio/dj.py
T
Antigravity 4ed8e0c71a 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
2026-10-12 16:58:59 +03:00

253 lines
12 KiB
Python

import os
import time
import httpx
import asyncio
import tempfile
from datetime import datetime
from typing import List, Dict, Any, Optional
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-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."""
hour = (datetime.utcnow().hour + 3) % 24 # Europe/Moscow UTC+3 approximation
if 5 <= hour < 12:
return {"greeting": "Доброе утро", "period": "утренний эфир"}
elif 12 <= hour < 18:
return {"greeting": "Добрый день", "period": "дневной эфир"}
elif 18 <= hour < 23:
return {"greeting": "Добрый вечер", "period": "вечерний эфир"}
else:
return {"greeting": "Доброй ночи", "period": "ночной эфир"}
async def generate_speech_text(
self,
next_track: Optional[Dict[str, str]] = None,
is_opening: bool = False
) -> str:
"""Generate charismatic short radio host remark via Ollama qwen2.5:1.5b."""
tod = self.get_time_of_day_info()
weather = get_current_weather()
weather_part = f"Погода: {weather}." if weather else ""
track_part = f"Следующий трек: «{next_track['title']}» исполнителя {next_track['artist']}." if next_track else ""
if is_opening:
system_instruction = (
f"Ты харизматичный ведущий радиостанции «AI Радио». "
f"Сейчас {tod['period']}, {tod['greeting']}. {weather_part} "
f"Сделай открывающее приветствие радиослушателям (ровно 2 коротких предложения, до 25 слов) "
f"и объяви первый трек: {track_part}. "
f"Никаких приветствий от лица нейросети, никаких кавычек, ремарок в скобках или мета-текста. "
f"Только чистая живая речь радиоведущего на русском языке."
)
else:
system_instruction = (
f"Ты классный ведущий радиостанции «AI Радио». "
f"Сейчас в эфире {tod['period']}. "
f"Сделай лаконичную подводку ведущего (1-2 предложения, до 20 слов) перед следующим треком: {track_part}. "
f"Без кавычек, без смайлов, без скобок, только чистый текст ведущего на русском."
)
# Call Ollama with keep_alive: 0
try:
async with httpx.AsyncClient(timeout=25.0) as client:
resp = await client.post(
self.ollama_url,
json={
"model": self.model,
"prompt": system_instruction,
"stream": False,
"keep_alive": 0
}
)
if resp.status_code == 200:
text = resp.json().get("response", "").strip()
# Clean up quotes, brackets, preamble
text = text.replace('"', '').replace('«', '').replace('»', '').replace('*', '')
lines = [l.strip() for l in text.split("\n") if l.strip()]
if lines:
cleaned = " ".join(lines)
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}")
# Deterministic fallback if Ollama is unavailable
if is_opening:
w_str = f" За окном {weather}." if weather else ""
t_str = f" Открываем эфир треком {next_track['artist']} — {next_track['title']}!" if next_track else " Начинаем наш музыкальный эфир!"
return f"{tod['greeting']} в эфире AI Радио!{w_str}{t_str} Поехали!"
else:
if next_track:
return f"Продолжаем поток на AI Радио. В эфире звучит {next_track['artist']} — {next_track['title']}."
return "Вы слушаете AI Радио — только лучшая музыка для отличного настроения."
def _apply_id3_tags(self, output_file: str, title: str):
"""Write ID3 tags so Navidrome categorizes host clips as AI Radio tracks."""
try:
try:
tags = ID3(output_file)
except ID3NoHeaderError:
tags = ID3()
tags.add(TPE1(encoding=3, text=["AI Радио (DJ)"]))
tags.add(TALB(encoding=3, text=["AI Радио Эфир"]))
tags.add(TIT2(encoding=3, text=[title]))
tags.save(output_file)
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,
output_file: str,
title: str = "Ведущий в эфире",
voice: Optional[str] = None
) -> bool:
"""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 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
print(f"[RadioDJ] Piper synthesis failed for {selected_voice_id}, falling back to gTTS...")
# 2. Resilient fallback to gTTS
try:
from gtts import gTTS
tts = gTTS(text=text, lang="ru")
tts.save(output_file)
if os.path.exists(output_file) and os.path.getsize(output_file) > 500:
self._apply_id3_tags(output_file, title)
return True
except Exception as e:
print(f"[RadioDJ] gTTS synthesis error: {e}")
return False
dj_host = RadioDJ()