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", or a query made only of known genre/mood/activity words ("jazz", "classic rock", "something chill" — GENRE_VOCAB + looks_like_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. The confirmation reads back the track that actually started (confirm_new_track, ≤ ~0.7 s) and names that, flagging it when it shares no word with the query; if it can't be read back in-turn (cold device), it says so instead of echoing the query as if matched

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 unusual genres still rely on the LLM setting mode=genre; decades, the literal word "genre", and a closed vocabulary of common bare genre/mood words (GENRE_VOCAB) are auto-detected. The vocabulary rule exists because the LLM routinely omits mode: until 2026-10-17 a bare "jazz" fell to the specific play and MA resolved it to Will Smith's "Gettin' Jiggy Wit It" — with a confirmation that echoed "jazz" back, so nobody could tell. The vocabulary rule yields to an explicit other mode.


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, a phone, or a raw media_player.* id. Resolution order:

  1. Explicit entity id (media_player.*)
  2. A player named outright — its friendly name or entity id (±the media_player. prefix), compared separator-insensitively. ⚠️ Before the room map on purpose: both the map and the area heuristic match by containment, so "Gym PC Assistant" (contains the room key gym) was silently redirected to the gym's default player — the treadmill phone one wall away, reported as success.
  3. A phone — "my phone" / "mi teléfono" is the phone of whoever is speaking, resolved through [[users]] phone_player; "Matthew's phone" resolves by name. ⚠️ This cannot live in [tools.music_rooms]: that table is flat, so one "my phone" key would answer with one person's phone for the whole household. A NAMED miss falls through (so the legacy oz phone room key still works); "my phone" with no phone_player says so instead of guessing.
  4. Config room map — [tools.music_rooms] curated per-room default (exact, then longest contained key), separator-insensitive
  5. 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
  6. Device registry — for voice, the originating ctx.room → its media_player
  7. 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.

Is the target actually there? (ensure_reachable)

⚠️ HA answers music_assistant.play_media for a player that is not there with HTTP 200 and an empty changed-states array (measured 2026-09-11), and play_media_nonblocking reads any 2xx as success. So every acting action (everything but status, which should be able to report an off player) first checks the resolved target is a live MA player.

A phone is the case that makes this bite: its player leaves Music Assistant entirely while the speaker is off, and the Kai app's speaker switches itself off after 30 idle minutes. If the target is a phone with a phone_switch, the tool turns that switch on and polls for up to PHONE_WAKE_SECS (15 s) for MA to see it, then plays. Otherwise it says the phone is off — it never claims to be playing at a silent speaker.


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"
# ...

# Players that are only a SOURCE feeding an external amp: volume/up/down/mute —
# and the level reported by `status` — go to the amp instead of the player.
[tools.music_volume_entities]
"media_player.movies_room_2" = "media_player.tx_rz30"

A phone belongs to a PERSON, not to a room word — it moves with them, and "my phone" means a different phone for each speaker:

[[users]]
user_id      = "oz"
display_name = "Oz"
phone_player = "media_player.oz_phone"          # its Music Assistant player
phone_switch = "switch.kai_phone_speaker"       # turns that phone's speaker on

phone_switch is optional but nearly always wanted: without it, a phone whose speaker is off can only be reached after its owner turns the speaker on by hand.

Why music_volume_entities exists. A player like the movies room is a source, not an amplifier: Sendspin on the Google TV Streamer → HDMI → Onkyo TX-RZ30 → speakers. That puts two gain stages in series, and the MA player's own volume normally sits pinned at 100%, so "turn it up" has nowhere to go — the amp, sitting at whatever level it powered on with, is the real ceiling. Mapping the player to the amp here makes the amp the single volume authority. Music Assistant cannot do this itself: for a Sendspin player, volume_control only offers follow_protocol or none (no HA-entity option, unlike power_control).

Keys are matched case-insensitively against the resolved media_player.* entity; players not listed keep controlling their own volume. Leave the source player at 100% so it stays at unity — if something drops it, you are back to two stages in series.

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)