refactor: split music streaming into Hope Music
Rebuild & Deploy / rebuild (push) Successful in 2m7s
Rebuild & Deploy / rebuild (push) Successful in 2m7s
Move the media server, downloader, library volume, and browser player to the standalone hope-media-music product. Keep Hope's music tools as a thin HTTP client of the LAN service on port 8094. Use a stable voice-room player scope instead of ephemeral LiveKit room names. The agent now owns the conversation handshake: fresh reported playback mutes its input, joining pauses music, and the last participant leaving resumes only music it paused. Peer failures remain non-fatal. Also move the voice deploy workflow onto the active Unraid Gitea runner and remove yt-dlp/ffmpeg/media health and volume dependencies from the image.
This commit is contained in:
1 parent
d4ea4fc984
commit
436b3fc8b1
23 files changed
+356
-2518
No files matched your search
+144
-31
@@ -11,6 +11,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import textwrap
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from dotenv import load_dotenv
|
||||
@@ -58,6 +59,18 @@ WEB_MCP_ENABLED = os.environ.get("WEB_MCP_ENABLED", "true").lower() in ("1", "tr
|
||||
FIRECRAWL_BASE = os.environ.get("FIRECRAWL_BASE", "http://192.168.86.2:3002")
|
||||
SKILLS_DIR = os.environ.get("SKILLS_DIR", "/skills")
|
||||
VOICE_USERS_FILE = os.environ.get("VOICE_USERS_FILE", "/etc/hope/users.json")
|
||||
# Hope Music is a separate LAN service. The agent is its control surface; the
|
||||
# standalone Hope Music page is the browser audio player.
|
||||
MUSIC_BASE_URL = os.environ.get(
|
||||
"MUSIC_BASE_URL", "http://192.168.86.2:8094"
|
||||
).rstrip("/")
|
||||
# This is deliberately stable rather than the short-lived LiveKit room name.
|
||||
# The Hope Music page defaults to the same scope, so playback survives across
|
||||
# voice sessions and both products address one virtual player.
|
||||
MUSIC_ROOM = os.environ.get("MUSIC_ROOM", "voice-room")
|
||||
# The player reports every few seconds while audio is active. A stale playing
|
||||
# report must never leave Hope's microphone disabled after the page closes.
|
||||
MUSIC_REPORT_TTL = float(os.environ.get("MUSIC_REPORT_TTL", "30"))
|
||||
|
||||
|
||||
def load_user_profile(user_id: str) -> dict:
|
||||
@@ -208,15 +221,15 @@ SYSTEM_PROMPT = textwrap.dedent("""\
|
||||
stalled); task_status can also be called to check up on a task. Use
|
||||
list_tasks to see every task you have dispatched.
|
||||
|
||||
# Music (media player in the web UI)
|
||||
You can play music on the user's web player. For any music request, call
|
||||
search_music first, then:
|
||||
# Music (standalone Hope Music player)
|
||||
You can control the user's separate Hope Music web player. For any music
|
||||
request, call search_music first, then:
|
||||
- One clearly-right result → play_song with its video_id. Announce it in
|
||||
one short sentence, then stop talking — playback pauses the
|
||||
conversation until the user comes back to you.
|
||||
- Ambiguous results → offer_song_choices with the top 2-3 options, name
|
||||
them briefly, and let the user pick by voice or in the player panel.
|
||||
Never start playback on a guess.
|
||||
them briefly, and let the user pick by voice or in Hope Music. Never
|
||||
start playback on a guess.
|
||||
Prefer what is already saved: search_music puts downloaded library
|
||||
matches first (in_library=true) — always pick one of those over a fresh
|
||||
YouTube result, and list_library shows the whole saved collection which
|
||||
@@ -693,9 +706,9 @@ def build_mcp_toolsets(user_profile: dict, task_dir: str) -> list[mcp.MCPToolset
|
||||
)
|
||||
logger.info("Dispatch MCP toolset enabled (%s)", task_dir)
|
||||
|
||||
# Media tools talk to the local media server; the room name scopes player
|
||||
# state so concurrent sessions don't fight over one virtual player.
|
||||
media_server_url = os.environ.get("MEDIA_SERVER_URL", "http://127.0.0.1:8093")
|
||||
# Media tools call the separate hope-media-music service over HTTP. A
|
||||
# stable scope lets the standalone player and every voice session address
|
||||
# the same virtual player.
|
||||
media_mcp_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "media_mcp.py")
|
||||
toolsets.append(
|
||||
mcp.MCPToolset(
|
||||
@@ -705,14 +718,14 @@ def build_mcp_toolsets(user_profile: dict, task_dir: str) -> list[mcp.MCPToolset
|
||||
args=[media_mcp_script],
|
||||
env={
|
||||
**os.environ,
|
||||
"MEDIA_SERVER_URL": media_server_url,
|
||||
"MEDIA_ROOM": os.path.basename(task_dir.rstrip("/")) or "default",
|
||||
"MUSIC_BASE_URL": MUSIC_BASE_URL,
|
||||
"MUSIC_ROOM": MUSIC_ROOM,
|
||||
},
|
||||
client_session_timeout_seconds=60,
|
||||
),
|
||||
)
|
||||
)
|
||||
logger.info("Media MCP toolset enabled (%s)", media_server_url)
|
||||
logger.info("Media MCP enabled (%s, room %s)", MUSIC_BASE_URL, MUSIC_ROOM)
|
||||
|
||||
extra = os.environ.get("EXTRA_MCP_SERVERS", "")
|
||||
if extra:
|
||||
@@ -828,13 +841,12 @@ async def handle_job(ctx: JobContext) -> None:
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("failed to publish model list: %s", e)
|
||||
|
||||
# Listen for conversation controls (voice selection and voice/text mode).
|
||||
# Defined here so it captures this job's session via closure.
|
||||
# Music and conversation are mutually exclusive by design: while a track
|
||||
# plays the agent's ears are off; re-engaging the conversation pauses the
|
||||
# music, and ending the conversation lets the user resume it.
|
||||
# Listen for conversation controls (voice selection, voice/text mode, and
|
||||
# model switching). Music state comes from the separate service, not from
|
||||
# the voice UI's data channel.
|
||||
session_mode = {"value": "voice"} # UI voice/text switch
|
||||
media_playback = {"value": "stopped"} # UI-reported player state
|
||||
media_playback = {"value": "stopped"} # fresh service-reported state
|
||||
music_paused_by_agent = {"value": False}
|
||||
|
||||
def _apply_listening() -> None:
|
||||
listening = (
|
||||
@@ -843,9 +855,108 @@ async def handle_job(ctx: JobContext) -> None:
|
||||
)
|
||||
session.input.set_audio_enabled(listening)
|
||||
|
||||
async def _music_request(
|
||||
method: str, path: str, *, warn: bool = True, **kwargs
|
||||
) -> dict | None:
|
||||
import httpx
|
||||
|
||||
url = f"{MUSIC_BASE_URL}/media/api{path}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as http:
|
||||
response = await http.request(method, url, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# Hope Music is a peer service: it being unavailable must never
|
||||
# take the conversation down with it.
|
||||
if warn:
|
||||
logger.warning("Music service %s %s failed: %s", method, path, exc)
|
||||
return None
|
||||
|
||||
async def _music_snapshot(*, warn: bool = True) -> dict | None:
|
||||
return await _music_request(
|
||||
"GET", "/state", warn=warn, params={"room": MUSIC_ROOM}
|
||||
)
|
||||
|
||||
def _music_is_playing(snapshot: dict | None) -> bool:
|
||||
"""Return true only for fresh, browser-reported playback."""
|
||||
reported = (snapshot or {}).get("reported") or {}
|
||||
if reported.get("state") != "playing":
|
||||
return False
|
||||
try:
|
||||
age = time.time() - float(reported.get("ts") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return 0 <= age < MUSIC_REPORT_TTL
|
||||
|
||||
async def _pause_music_for_conversation() -> None:
|
||||
"""Pause actual playback when the user engages Hope."""
|
||||
if music_paused_by_agent["value"]:
|
||||
return
|
||||
if not _music_is_playing(await _music_snapshot()):
|
||||
return
|
||||
result = await _music_request(
|
||||
"POST", "/control",
|
||||
json={"room": MUSIC_ROOM, "action": "pause"},
|
||||
)
|
||||
if result is not None:
|
||||
music_paused_by_agent["value"] = True
|
||||
logger.info("Paused Hope Music for the conversation (%s)", MUSIC_ROOM)
|
||||
|
||||
async def _resume_music_after_conversation() -> None:
|
||||
if not music_paused_by_agent["value"]:
|
||||
return
|
||||
result = await _music_request(
|
||||
"POST", "/control",
|
||||
json={"room": MUSIC_ROOM, "action": "resume"},
|
||||
)
|
||||
if result is not None:
|
||||
music_paused_by_agent["value"] = False
|
||||
logger.info("Resumed Hope Music after the conversation (%s)", MUSIC_ROOM)
|
||||
|
||||
async def _poll_music_state() -> None:
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(2)
|
||||
# Jobs deliberately survive page reloads. Do not keep making
|
||||
# network requests after the room has no human participant.
|
||||
if not ctx.room.remote_participants:
|
||||
continue
|
||||
snapshot = await _music_snapshot(warn=False)
|
||||
reported = (snapshot or {}).get("reported") or {}
|
||||
state = reported.get("state") or "stopped"
|
||||
if state == "playing" and not _music_is_playing(snapshot):
|
||||
state = "stopped"
|
||||
if state not in ("playing", "paused", "stopped"):
|
||||
state = "stopped"
|
||||
if state != media_playback["value"]:
|
||||
logger.info("Hope Music playback -> %s", state)
|
||||
media_playback["value"] = state
|
||||
_apply_listening()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("Hope Music state poller failed")
|
||||
|
||||
def _on_participant_joined(*_args) -> None:
|
||||
"""A returning participant starts a new conversation handshake."""
|
||||
asyncio.create_task(_pause_music_for_conversation())
|
||||
|
||||
def _on_participant_left(*_args) -> None:
|
||||
"""When the last human leaves, hand playback back to Hope Music."""
|
||||
|
||||
async def _deferred() -> None:
|
||||
# The event can fire before the participant is removed from the
|
||||
# room object; re-check the roster after it settles.
|
||||
await asyncio.sleep(0.5)
|
||||
if not ctx.room.remote_participants:
|
||||
await _resume_music_after_conversation()
|
||||
|
||||
asyncio.create_task(_deferred())
|
||||
|
||||
def _on_room_data(packet) -> None:
|
||||
topic = getattr(packet, "topic", None)
|
||||
if topic and topic not in ("voice-control", "conversation-control", "media-control"):
|
||||
if topic and topic not in ("voice-control", "conversation-control"):
|
||||
return
|
||||
try:
|
||||
msg = json.loads(packet.data.decode("utf-8"))
|
||||
@@ -865,17 +976,6 @@ async def handle_job(ctx: JobContext) -> None:
|
||||
session.output.set_audio_enabled(voice_enabled)
|
||||
_apply_listening()
|
||||
return
|
||||
|
||||
if msg.get("type") == "media_state":
|
||||
state = msg.get("state")
|
||||
if state not in ("playing", "paused", "stopped"):
|
||||
return
|
||||
logger.info("Media playback -> %s (agent listening: %s)",
|
||||
state, session_mode["value"] == "voice" and state != "playing")
|
||||
media_playback["value"] = state
|
||||
_apply_listening()
|
||||
return
|
||||
|
||||
if msg.get("type") == "set_voice":
|
||||
voice = msg.get("voice", "")
|
||||
if not voice:
|
||||
@@ -913,6 +1013,8 @@ async def handle_job(ctx: JobContext) -> None:
|
||||
logger.warning("Failed to update model: %s", e)
|
||||
|
||||
ctx.room.on("data_received", _on_room_data)
|
||||
ctx.room.on("participant_connected", _on_participant_joined)
|
||||
ctx.room.on("participant_disconnected", _on_participant_left)
|
||||
|
||||
# Publish user/agent transcripts to the room so the web UI can render them.
|
||||
async def publish_transcript(role: str, text: str) -> None:
|
||||
@@ -933,9 +1035,9 @@ async def handle_job(ctx: JobContext) -> None:
|
||||
role = getattr(msg, "role", None)
|
||||
text = getattr(msg, "text_content", None)
|
||||
if role == "user":
|
||||
asyncio.create_task(publish_transcript("user", text))
|
||||
asyncio.create_task(publish_transcript("user", text or ""))
|
||||
elif role == "assistant":
|
||||
asyncio.create_task(publish_transcript("agent", text))
|
||||
asyncio.create_task(publish_transcript("agent", text or ""))
|
||||
|
||||
@session.on("user_input_transcribed")
|
||||
def _on_user_transcribed(ev) -> None:
|
||||
@@ -1017,6 +1119,17 @@ async def handle_job(ctx: JobContext) -> None:
|
||||
)
|
||||
|
||||
await ctx.connect()
|
||||
await _pause_music_for_conversation()
|
||||
music_poll_task = asyncio.create_task(
|
||||
_poll_music_state(), name=f"music-poller-{ctx.room.name}"
|
||||
)
|
||||
|
||||
async def _stop_music_poller(*_args) -> None:
|
||||
music_poll_task.cancel()
|
||||
await asyncio.gather(music_poll_task, return_exceptions=True)
|
||||
await _resume_music_after_conversation()
|
||||
|
||||
ctx.add_shutdown_callback(_stop_music_poller)
|
||||
# The participant can appear in the browser slightly before AgentSession is
|
||||
# ready to accept lk.chat streams. Explicitly signal readiness so the first
|
||||
# typed message is never raced against session startup.
|
||||
|
||||
Reference in new issue
Block a user