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
This commit is contained in:
1 parent
3db9bdb38b
commit
e5afeb06cc
15 files changed
+945
-8
No files matched your search
@@ -0,0 +1,5 @@
|
||||
from app.radio.manager import radio_manager
|
||||
from app.radio.dj import dj_host
|
||||
from app.radio.weather import get_current_weather
|
||||
|
||||
__all__ = ["radio_manager", "dj_host", "get_current_weather"]
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
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()
|
||||
@@ -0,0 +1,156 @@
|
||||
import os
|
||||
import shutil
|
||||
from typing import List, Dict, Any, Optional
|
||||
from app.config import settings
|
||||
from app.wave.rotator import wave_rotator
|
||||
from app.radio.dj import dj_host
|
||||
|
||||
class RadioManager:
|
||||
def __init__(self):
|
||||
self.radio_dir = os.path.join(settings.MUSIC_DIR, "AI Recommendations", "Радио")
|
||||
os.makedirs(self.radio_dir, exist_ok=True)
|
||||
|
||||
def get_existing_speech_files(self) -> List[Dict[str, Any]]:
|
||||
"""List currently generated DJ speech clips."""
|
||||
if not os.path.exists(self.radio_dir):
|
||||
return []
|
||||
files = []
|
||||
for f in sorted(os.listdir(self.radio_dir)):
|
||||
if f.lower().endswith(".mp3"):
|
||||
fpath = os.path.join(self.radio_dir, f)
|
||||
stat = os.stat(fpath)
|
||||
files.append({
|
||||
"filename": f,
|
||||
"path": fpath,
|
||||
"size_kb": round(stat.st_size / 1024, 1),
|
||||
"mtime": stat.st_mtime
|
||||
})
|
||||
return files
|
||||
|
||||
async def generate_radio_broadcast(
|
||||
self,
|
||||
voice: Optional[str] = None,
|
||||
max_segments: int = 5
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate AI DJ speech intros and build the 'AI Радио.m3u8' interleaved playlist."""
|
||||
wave_tracks = wave_rotator.get_current_buffer()
|
||||
if not wave_tracks:
|
||||
# If buffer is empty, refill it first
|
||||
print("[RadioManager] Wave buffer empty. Sourcing wave tracks first...")
|
||||
await wave_rotator.fill_buffer(mood="auto")
|
||||
wave_tracks = wave_rotator.get_current_buffer()
|
||||
|
||||
if not wave_tracks:
|
||||
return {"status": "error", "message": "Нет доступных треков в буфере Волны"}
|
||||
|
||||
# Clean old DJ voice tracks
|
||||
for old_file in os.listdir(self.radio_dir):
|
||||
if old_file.endswith(".mp3"):
|
||||
try:
|
||||
os.remove(os.path.join(self.radio_dir, old_file))
|
||||
except Exception as e:
|
||||
print(f"[RadioManager] Warning removing old radio file: {e}")
|
||||
|
||||
# Plan DJ speech checkpoints (e.g. before track 0, before track 3, before track 7, etc.)
|
||||
total_tracks = len(wave_tracks)
|
||||
checkpoints = [0]
|
||||
step = max(2, settings.RADIO_INTRO_INTERVAL)
|
||||
curr = step
|
||||
while curr < total_tracks and len(checkpoints) < max_segments:
|
||||
checkpoints.append(curr)
|
||||
curr += step
|
||||
|
||||
generated_segments = []
|
||||
speech_map: Dict[int, str] = {}
|
||||
|
||||
for idx, track_idx in enumerate(checkpoints):
|
||||
next_t = wave_tracks[track_idx]
|
||||
is_opening = (idx == 0)
|
||||
|
||||
# 1. Generate DJ speech text
|
||||
speech_text = await dj_host.generate_speech_text(
|
||||
next_track={"artist": next_t["artist"], "title": next_t["title"]},
|
||||
is_opening=is_opening
|
||||
)
|
||||
|
||||
# 2. Synthesize audio file
|
||||
segment_filename = f"{idx+1:02d}_dj_intro.mp3"
|
||||
segment_path = os.path.join(self.radio_dir, segment_filename)
|
||||
title = f"AI DJ: Приветствие и погода" if is_opening else f"AI DJ: Подводка к «{next_t['title']}»"
|
||||
|
||||
success = await dj_host.synthesize_speech(
|
||||
text=speech_text,
|
||||
output_file=segment_path,
|
||||
title=title,
|
||||
voice=voice
|
||||
)
|
||||
|
||||
if success:
|
||||
speech_map[track_idx] = segment_filename
|
||||
generated_segments.append({
|
||||
"segment": segment_filename,
|
||||
"text": speech_text,
|
||||
"before_track": f"{next_t['artist']} - {next_t['title']}"
|
||||
})
|
||||
print(f"[RadioManager] 🎙️ Created DJ clip {segment_filename}: \"{speech_text}\"")
|
||||
|
||||
# 3. Assemble interleaved M3U8 playlist
|
||||
playlist_lines = ["#EXTM3U\n"]
|
||||
for i, t in enumerate(wave_tracks):
|
||||
# Insert DJ voice if checkpoint
|
||||
if i in speech_map:
|
||||
dj_file = speech_map[i]
|
||||
playlist_lines.append(f"#EXTINF:-1,AI DJ - Эфирная подводка\n")
|
||||
playlist_lines.append(f"AI Recommendations/Радио/{dj_file}\n")
|
||||
|
||||
# Music track
|
||||
playlist_lines.append(f"#EXTINF:-1,{t['artist']} - {t['title']}\n")
|
||||
playlist_lines.append(f"AI Recommendations/Моя Волна/{t['filename']}\n")
|
||||
|
||||
# Write to /music/AI Радио.m3u8
|
||||
m3u_root_path = os.path.join(settings.MUSIC_DIR, "AI Радио.m3u8")
|
||||
with open(m3u_root_path, "w", encoding="utf-8") as f:
|
||||
f.writelines(playlist_lines)
|
||||
|
||||
# Write to /music/playlists/AI Радио.m3u8
|
||||
playlists_dir = os.path.join(settings.MUSIC_DIR, "playlists")
|
||||
os.makedirs(playlists_dir, exist_ok=True)
|
||||
m3u_playlists_path = os.path.join(playlists_dir, "AI Радио.m3u8")
|
||||
with open(m3u_playlists_path, "w", encoding="utf-8") as f:
|
||||
f.writelines(playlist_lines)
|
||||
|
||||
# Trigger background library scan in Navidrome
|
||||
try:
|
||||
from app.core.navidrome import trigger_navidrome_scan
|
||||
await trigger_navidrome_scan()
|
||||
except Exception as e:
|
||||
print(f"[RadioManager] Warning triggering Navidrome scan: {e}")
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"playlist_name": "AI Радио",
|
||||
"m3u_path": m3u_root_path,
|
||||
"total_music_tracks": len(wave_tracks),
|
||||
"dj_segments_count": len(generated_segments),
|
||||
"segments": generated_segments
|
||||
}
|
||||
|
||||
def get_radio_status(self) -> Dict[str, Any]:
|
||||
"""Get summary of current AI Radio state."""
|
||||
speech_files = self.get_existing_speech_files()
|
||||
m3u_path = os.path.join(settings.MUSIC_DIR, "AI Радио.m3u8")
|
||||
playlist_ready = os.path.exists(m3u_path)
|
||||
|
||||
return {
|
||||
"enabled": settings.RADIO_ENABLED,
|
||||
"playlist_ready": playlist_ready,
|
||||
"speech_clips_count": len(speech_files),
|
||||
"speech_clips": speech_files,
|
||||
"default_voice": settings.RADIO_VOICE,
|
||||
"available_voices": [
|
||||
{"id": "ru-RU-DmitryNeural", "name": "Дмитрий (Харизматичный DJ)", "gender": "Мужской"},
|
||||
{"id": "ru-RU-SvetlanaNeural", "name": "Светлана (Мягкий вечерний голос)", "gender": "Женский"}
|
||||
]
|
||||
}
|
||||
|
||||
radio_manager = RadioManager()
|
||||
@@ -0,0 +1,22 @@
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
from typing import Optional
|
||||
from app.config import settings
|
||||
|
||||
def get_current_weather() -> Optional[str]:
|
||||
"""Fetch current weather report from wttr.in in Russian."""
|
||||
city = urllib.parse.quote(settings.WEATHER_CITY) if settings.WEATHER_CITY else ""
|
||||
url = f"https://wttr.in/{city}?format=%C,+%t&lang=ru"
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={"User-Agent": "curl/7.88.1"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=3.5) as resp:
|
||||
text = resp.read().decode("utf-8").strip()
|
||||
# Basic validation
|
||||
if text and not text.startswith("<") and len(text) < 60:
|
||||
return text
|
||||
except Exception as e:
|
||||
print(f"[RadioWeather] Warning: unable to fetch weather: {e}")
|
||||
return None
|
||||
Reference in new issue
Block a user