Files
navidrome-music-sync/app/radio/dj.py
T
Antigravity e5afeb06cc feat: Phase 3 AI Radio (DJ Host & interleaved playlist) and Like/Dislike/Delete actions
- Add Like button: promotes wave track to permanent library and refills buffer
- Add Dislike button: blacklists track in SQLite disliked_tracks, removes audio, refills buffer
- Add Delete button in history table for library file removal
- Add AI Radio module: wttr.in weather, Qwen 2.5 host monologues, speech synthesis, ID3 tags
- Generate interleaved 'AI Радио.m3u8' alongside clean 'Моя Волна.m3u8'
- Update Web UI with Radio DJ card, voice selection, and track actions
2026-10-12 16:34:46 +03:00

142 lines
6.7 KiB
Python

import os
import time
import httpx
import asyncio
import edge_tts
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
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"
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)
if 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_speech(
self,
text: str,
output_file: str,
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
# 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:
self._apply_id3_tags(output_file, title)
return True
except Exception as e:
print(f"[RadioDJ] edge-tts error ({e}), trying gTTS fallback...")
# 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()