- 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
253 lines
12 KiB
Python
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()
|