refactor: split music streaming into Hope Music
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:
snowspeeder authored and Shane Maynard committed 2026-09-12 06:35:15 -04:00
1 parent d4ea4fc984
commit 436b3fc8b1
23 files changed
+356 -2518

No files matched your search

+144 -31
View File
@@ -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.