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()