Music tool

Full voice/chat control of Music Assistant (MA) through Home Assistant. One inner tool, music, with an action enum. It reuses the same ha_url/ha_token the other HA tools use and calls HA's REST API:

  • music_assistant.*play_media, search, get_library, get_queue, transfer_queue
  • media_player.* — transport, volume, shuffle, join/unjoin
  • button.press — favorites

MA's own docs note there is no native HA voice support for initiating playback (HA intents only cover next/prev/pause/volume) and point to an LLM recognizer as the way forward. Muninn is that LLM, so it drives MA directly and sidesteps HA's intent layer.

Source: src/tools/music.rs.

Two backends

There are two independent ways Muninn can reach MA; they coexist:

  1. The native music tool (this doc) — HA REST → MA. The primary, role-gated (admin/adult/child), human-shaped surface: play modes, room targeting, DJ, taste split. This is what a "play some music on the bar" request hits. It needs only ha_url/ha_token (+ music_assistant_config_entry_id for search/genre/favorites).
  2. An optional MA MCP server — a [[tools.mcp_servers]] entry (name = "music", kind = "http", url = "http://<ma-host>:8095/mcp/v1", Bearer token) pointing at Music Assistant's own FastMCP endpoint. This exposes MA's ~70 raw tools (playback_*, library_*, players_*, volume_*, config_*, debug_*, …) directly to the sub-agent. It's admin-gated (all MCP tools are) and unconfigured by default. Useful for low-level / debugging access; the native tool is preferred for everyday control. Requires MA's MCP Server plugin to be enabled on the server and a long-lived MA token. ⚠️ Its url/token are instance-specific — repoint both if the MA server moves.

What you can say

Talk to Muninn naturally — chat or voice. The sub-agent maps the request onto a music call.

Playing

Say Result
"play Royals" / "play music by Maluma" / "play the album X" / "play my workout playlist" named content, searched across the whole library
"play some music" favorites first, then random fill, shuffled, varies per room, minus disliked tracks
"surprise me" / "play stuff I don't usually play" / "some deep cuts" random library excluding favorites (discover mode)
"play music like this" / "more like Royals" / "Daft Punk radio" endless similar-tracks station (radio mode)
"play some jazz" / "90s music" / "something chill" / "workout music" a matching playlist (genre mode)
"play it on the bar" / "in the kitchen" / "in here" targets that room (voice knows the originating room)
"add X to the queue" / "play X next" enqueue instead of replace

Transport & volume

Say Result
"pause / resume / stop / next / skip / previous" acts on one player
"pause everything" / "stop the music everywhere" every playing speaker at once
"turn it up / down" / "set volume to 40%" / "mute / unmute" volume
"shuffle on / off" shuffle toggle
"what's playing?" / "what's the volume?" status (track + volume + mute)

Taste

Say Result
"I like this" / "add to favorites" favorites the current song in MA
"I don't like this one" skips it now and remembers to avoid it (Muninn memory)

Multi-room

Say Result
"move it to the kitchen" transfer the queue to another speaker
"join the bar to the gym" synced playback — bar plays the gym's audio in sync
"ungroup the bar" remove a speaker from its group

Play modes

The play action picks a strategy from the optional mode parameter (favorites | discover | radio | genre), or infers it from phrasing when mode is absent.

Mode Trigger Mechanism
favorites (default) bare "play", or an all-generic query ("some music", "something", "tunes") get_library favorite=true (order_by=random) + random fill if <25; minus dislikes; shuffle_set(true)
discover mode=discover, or "surprise me / something I don't usually play / deep cuts / haven't heard / explore" random get_library pool minus the favorites URI set (and dislikes); falls back to plain random if the library is all-favorites
radio mode=radio, or "like this / more like X / similar to / sounds like / X radio / X station" play_media radio_mode=true, seeded from query (fluff stripped by radio_seed) or, if none, the current track
genre mode=genre, or a decade ("90s", "2000s"), or the word "genre" search media_type=[playlist] → play the top playlist; fallback if no playlist matches: a radio station seeded on the term
(specific) any other named query play_media media_id=<query> — MA resolves the song/artist/album/playlist

Why genre uses playlists: this library is YouTube-Music-backed with no genre or year metadata on tracks (get_library with search="jazz" returns nothing; there is no genre library). Genres live in curated playlists ("90s Hits Playlist", "Best of Classic Rock", "Phonk Workout"), so a playlist search is the reliable way in. Open-vocabulary genres can't be rule-matched, so genre detection is mostly LLM-driven via mode=genre; only decades and the literal word "genre" are auto-detected.


DJ mode (radio host)

Optional — [tools.dj] enabled, off by default; needs MA + a configured TtsRouter ([tools.tts]). Source: src/dj.rs.

Flips a per-player radio-host mode on via the dj action (aliases radio_host / radio host; the LLM's common {"action":"play","mode":"dj"} is normalized to canonical action:"dj", and bot.rs also has a deterministic toggle).

Say Result
"be a DJ for the basement" / "DJ mode on" turns radio-host mode on for that player
"stop being a DJ" / "DJ off" turns it off for that player

How it behaves:

  • A background worker polls each active player (poll_secs, default 3s) and **5s into every new track** (announce_after_secs) speaks a short on-air intro over the music — ducked via the TtsRouter announce path, so the song dips under the voice and recovers (no mute/jump).
  • The announcer persona is chosen by the song's detected language; the patter vibe rotates each song (round-robin) so it never repeats. Personas + vibes live in an editable markdown file ([tools.dj] md_file, default dj.md), re-read per announcement — retune without a rebuild (missing/unparseable → baked-in defaults).
  • Optional web grounding (web_grounding, default on) runs a quick SearXNG search so the patter cites real artist/song facts instead of hallucinating (time-budgeted → fact-free fallback; inert without [tools] searxng_url).
  • The active-player set is persisted (JSON) so DJ mode survives a restart; a forgotten session auto-expires after idle_timeout_secs (default 30 min) of non-playback.

Config [tools.dj]: enabled, md_file, poll_secs, announce_after_secs, idle_timeout_secs, web_grounding.


Behavioural details

  • Resume vs restart — a non-specific "play" on a paused player resumes where it left off instead of building a fresh queue. A named song always starts fresh.
  • Per-room variety — favorites/discover queues are pulled order_by=random, so each room (and each request) gets a different opener instead of always starting on the same track.
  • Grouped pause doesn't cascade — pausing/stopping one speaker that's in a sync group first detaches it (detach_if_grouped unjoins, then polls ~1.6 s until it has actually left) so only that speaker stops. Without this, MA pauses the whole synced session.
  • "radio"/"station" are whole-word matches — so "play Radiohead" or "Radio Ga Ga" still play the actual artist/song, not a station.
  • Slow-device backgrounding — an idle cast/ESPHome target can take ~7 s to cold-start a stream vs ~0.2 s for a warm MA player. play_media is raced against a 1.5 s deadline: if it finishes fast the real result is surfaced; if it's still connecting the POST is left running and Muninn answers optimistically so the turn doesn't hang. Trade-off: a genuine failure on a slow device won't be reported in-turn.

Taste split

Likes and dislikes live in two different systems by design:

  • Likes → MA favorites. The current song's favorite button (button.<player>_favorite_current_song, matched by friendly-name) is pressed. Favorites are a persistent MA store and a play source (get_library favorite=true).
  • Dislikes → Muninn memory. MA has no dislike concept, so a dislike skips the track now and records a personal-scoped preference memory (category=preference). Future favorites/discover queues drop tracks whose title matches a dislike memory (best-effort title containment; a structured taste table is a follow-up).

Player resolution

player accepts a room word, a player name, or a raw media_player.* id. Resolution order:

  1. Explicit entity id (media_player.*)
  2. Config room map[tools.music_rooms] curated per-room default (exact, then longest contained key), separator-insensitive
  3. HA area — one /api/template call using integration_entities('music_assistant') + area_name(p), matching the word to an area (longest wins), preferring a speaker over a TV/display
  4. Device registry — for voice, the originating ctx.room → its media_player
  5. Fuzzy name match against friendly names / entity ids (norm() strips spaces/hyphens/ underscores so "pixel 3xl" matches "pixel-3xl")

If nothing resolves, the reply lists the available players and room words so the LLM can pick in one step. music_default_player is the final fallback; for "pause/stop everything" no single target is resolved.


Configuration

In [tools] (set in muninn.toml.local, since deploy.sh ships only the binary):

[tools]
ha_url = "http://<ha-host>:8123"
ha_token = "<long-lived-access-token>"
music_assistant_config_entry_id = "<MA-integration-entry-id>"    # enables search / library / genre — instance-specific, see note
music_default_player = "media_player.zeus_desk"                  # optional final fallback

# Curated per-room DEFAULT player. Checked before HA-area resolution, so these
# pin "the one I mean" for multi-player rooms. Rooms not listed resolve via HA areas.
[tools.music_rooms]
bar              = "media_player.pixel_3xl"
kitchen          = "media_player.nesthubmax46b3_2"
gym              = "media_player.pixel_6a"
# ...

Finding the config entry id. Easiest is HA's template engine — render {{ config_entry_id('media_player.<any MA player>') }} (e.g. the bar player) via Developer Tools → Template, or over REST:

curl -s -X POST -H "Authorization: Bearer $HA_TOKEN" -H "Content-Type: application/json" \
  -d '{"template":"{{ config_entry_id(\"media_player.pixel_3xl\") }}"}' \
  "$HA_URL/api/template"

(Also available via the HA WebSocket config_entries/get or the MA integration page.)

⚠️ This id changes if you remove and re-add the MA integration — e.g. when repointing HA from an MA add-on to a standalone MA server on another host. After any such move, re-render the id and update this config, or search/genre/favorites silently break (playback to a player still works, because that goes through the media_player.* entities, which survive the re-add). The room map ([tools.music_rooms]) entity ids survive too — HA dedupes by unique_id.


Permissions

music is allowed for admin / adult / child (playback is low-risk); denied for guest / unknown. It is not in HA_RESTRICTED_TOOLS — a pure role gate, so kids don't need to be in ha_allowed_users. See the role matrix in CLAUDE.


Follow-ups (not built)

  • Structured taste table (hard like/dislike weighting) vs prose-memory dislikes
  • Quiet-hours / per-room volume cap
  • Robust favorite-button mapping via entity-registry device_id (vs friendly-name match)
  • Person/time-aware default seed ("play some music" differs by who/when)
  • Presence-driven auto-follow (transfer the queue as you move between rooms)