Skip to content

Add AI Radio Plugin#3407

Draft
swiftbird07 wants to merge 29 commits intomusic-assistant:devfrom
swiftbird07:ai-radio
Draft

Add AI Radio Plugin#3407
swiftbird07 wants to merge 29 commits intomusic-assistant:devfrom
swiftbird07:ai-radio

Conversation

@swiftbird07
Copy link
Copy Markdown

As discussed in Discord here is the pull request for my AI Radio plugin.

The plugin allows a user to define a 'station' and specify a MA playlist and the tool will generate the text and respective TTS mp3 files and insert them into a new playlist (or to a current queue) making it more like a radio like experience with weather reports, song intro and outros, news etc.

Features

  • server adds new API commands for ai_radio if plugin is enabled
  • AI-Radio frontend only shows "AI Radio" menu item if plugin is enabled
  • There are two modes of operation: Playlist mode and Dynamic mode. In playlist mode the station is "pre-built" ahead of time, while in Dynamic mode each section is generated "on the fly" for the current queue.
  • Via the menu you can configure stations and sections and then start the radio
  • Sections are used in station configuration and are basically re-usable prompts with placeholders and a few added config options like if you want to enable (or force) web search for a prompt
  • Stations define how each selected section is used and with what guards; you can specify what happens at the start of the radio, between songs and at the end. For each of these you can configure specific flow rules that define what must happen or what can happen with a chance. E.g. you can define that between songs a song transition will be moderated and then with a 20% chance a weather broadcast is inserted as well
  • If more than one section was selected it will be automatically merged together so it sound more like a continuous moderation

Notes

  • I added a small helper script scripts/update_and_redeploy_container.sh to help me update my local deployment, but if you want I can remove that
  • I tried to follow the DEVELOPMENT.md as close as possible. Though this is the first time contributing to this project so there are certainly things I missed so have mercy 😅
  • I will create a PR for the frontend as well

Screenshots

Backend (API)

Screenshot 2026-03-16 at 21 19 24 Screenshot 2026-03-16 at 21 19 39 Screenshot 2026-03-16 at 21 19 47

Frontend

Screenshot 2026-03-14 at 16 45 04 Screenshot 2026-03-14 at 16 46 57 Screenshot 2026-03-14 at 16 46 48

…time behavior

- refactor AI Radio plugin structure and web UI flows (wizard, sections, run controls)
- improve config clarity/help text and standalone MA-styled web UI experience
- add detailed runtime phases/logging and human-readable session status rendering
- improve dynamic batching with earlier prefetch trigger (`dynamic_prefetch_remaining_tracks`)
- add advanced plugin setting for UI auto-refresh interval (`ui_auto_refresh_seconds`)
- fix typing issues and expand provider tests
…e prompts, and improve styles; add air.png to mp3 metadata.
…tyling for better layout and usability. Adjust grid alignment, input heights, and import label styles for improved user experience.
…o-generate IDs from names, and enhance UI layout for better usability.
…ndling, session cancellation, and normalization logic
Copilot AI review requested due to automatic review settings March 16, 2026 20:23
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 16, 2026

🔒 Dependency Security Report

✅ No dependency changes detected in this PR.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new AI Radio plugin provider to Music Assistant that can generate radio-style moderator sections (LLM + TTS) and insert them into a playlist (playlist mode) or generate them on-the-fly while queueing (dynamic mode).

Changes:

  • Introduces a new ai_radio plugin provider with storage/normalization and runtime execution logic (LLM generation, TTS synthesis, optional weather enrichment).
  • Registers new admin-only API commands for station/section CRUD and run control (start/stop/status).
  • Adds unit tests for storage normalization, runtime session state handling, and provider validation helpers.

Reviewed changes

Copilot reviewed 15 out of 17 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
music_assistant/providers/ai_radio/provider.py Plugin provider entrypoint, API route registration, run lifecycle management.
music_assistant/providers/ai_radio/runtime.py Core runtime for playlist/dynamic modes, planning/generation/TTS, weather token prep.
music_assistant/providers/ai_radio/storage.py Persistent storage + normalization for stations/sections and defaults.
music_assistant/providers/ai_radio/models.py Dataclasses/utilities for session state, slot planning, text limiting, etc.
music_assistant/providers/ai_radio/constants.py Plugin constants and defaults.
music_assistant/providers/ai_radio/config.py Provider config entries (API keys, UI refresh interval, UI link label).
music_assistant/providers/ai_radio/manifest.json Declares the plugin manifest.
music_assistant/providers/ai_radio/example_station.json Example station definition for reference/testing.
tests/providers/ai_radio/* Unit tests covering storage/runtime/provider helper behavior.
scripts/update_and_redeploy_container.sh Local dev helper script for building and redeploying a container.
DEVELOPMENT.md Adds instructions for building a local Python artifact for Docker builds.

Comment on lines +393 to +394
track_entry_local_indices = [
index for index, uri in enumerate(entries) if "://track/" in uri
Comment thread scripts/update_and_redeploy_container.sh Outdated
Comment on lines +78 to +79
parsed = {item["id"]: item for item in defaults}
await self._write_sections()
raw_constraints = section.get("constraints")
max_chars = 0
if isinstance(raw_constraints, dict):
max_chars = int(raw_constraints.get("max_chars", 0) or 0)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings March 16, 2026 20:41
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new AI Radio plugin provider that can generate LLM-driven “radio host” sections (with optional weather/news) and synthesize them to TTS MP3s, supporting both prebuilt playlist mode and dynamic queue mode.

Changes:

  • Introduces the ai_radio plugin provider (storage, runtime execution, config entries, manifest, example station).
  • Adds unit tests covering storage normalization, runtime session state/logging, and provider helper validation.
  • Adds a local Docker redeploy helper script and a short DEVELOPMENT.md note about building Python artifacts.

Reviewed changes

Copilot reviewed 15 out of 17 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
music_assistant/providers/ai_radio/provider.py Plugin provider wiring: API commands, station/section CRUD, session lifecycle.
music_assistant/providers/ai_radio/runtime.py Core runtime: planning sections, LLM calls, weather tokens, TTS synthesis, queue/playlist publishing.
music_assistant/providers/ai_radio/storage.py Disk persistence + normalization/validation for stations/sections/general settings.
music_assistant/providers/ai_radio/models.py Dataclasses/utilities used across runtime/provider (slots, session state, helpers).
music_assistant/providers/ai_radio/config.py Provider config entries (API keys, UI refresh, UI URL label).
music_assistant/providers/ai_radio/constants.py Plugin constants/defaults (models, providers, limits, tokens).
music_assistant/providers/ai_radio/manifest.json Declares the plugin provider and its metadata.
music_assistant/providers/ai_radio/example_station.json Example station payload for reference/testing.
music_assistant/providers/ai_radio/init.py Exposes setup + config entries for provider loading.
tests/providers/ai_radio/test_storage.py Unit tests for storage normalization/materialization behavior.
tests/providers/ai_radio/test_runtime.py Unit tests for session execution state changes and weather-token prep warnings.
tests/providers/ai_radio/test_provider.py Unit tests for provider validation logic (dynamic-mode requirements, UI settings).
tests/providers/ai_radio/init.py Test package marker.
scripts/update_and_redeploy_container.sh Local helper to build artifacts and redeploy a Docker container.
DEVELOPMENT.md Adds short instructions for building dist/ artifacts for Docker builds.

Comment on lines +78 to +79
parsed = {item["id"]: item for item in defaults}
await self._write_sections()
raw_constraints = section.get("constraints")
max_chars = 0
if isinstance(raw_constraints, dict):
max_chars = int(raw_constraints.get("max_chars", 0) or 0)
Comment on lines +224 to +242
"instructions": str(source_general.get("instructions", defaults["instructions"])),
"openai_base_url": _text("openai_base_url"),
"section_store_path": _text("section_store_path"),
"weather_provider": _text("weather_provider"),
"weather_timeout_seconds": _number("weather_timeout_seconds", int),
"tts_provider": _text("tts_provider"),
"openai_tts_model": _text("openai_tts_model"),
"openai_tts_voice": _text("openai_tts_voice"),
"openai_tts_instructions": str(
source_general.get(
"openai_tts_instructions",
defaults["openai_tts_instructions"],
)
),
"elevenlabs_model": _text("elevenlabs_model"),
"elevenlabs_voice_id": str(
source_general.get("elevenlabs_voice_id", defaults["elevenlabs_voice_id"])
).strip(),
}
"authorization": f"Bearer {api_key}",
"content-type": "application/json",
"accept": accept,
},
Comment on lines +21 to +33
CONTAINER_NAME="${CONTAINER_NAME:-music-assistant-local}"
IMAGE_NAME="${IMAGE_NAME:-ma-server-local}"
MASS_VERSION="${MASS_VERSION:-0.0.0}"
NETWORK_MODE="${NETWORK_MODE:-host}"

DATA_DIR="${DATA_DIR:-/path/to/mass_data}"
MUSIC_DIR="${MUSIC_DIR:-/path/to/music}"
TIMEZONE_FILE="${TIMEZONE_FILE:-/path/to/timezone/file}"

TOTAL_STEPS=5
if [[ -n "$FRONTEND_PATH_ARG" ]]; then
TOTAL_STEPS=6
fi
@OzGav OzGav added this to the 2.9.0 milestone Mar 17, 2026
Comment thread music_assistant/providers/ai_radio/icon.svg
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the use of this png? I don't think it's used anywhere?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its used to generate the cover images of the inserted MP3 files:

            title = f"{section.section_name} [{run_id}]"
            cover_art_path = Path(__file__).with_name("air.png")
            await asyncio.to_thread(
                write_id3_tags,
                str(file_path),
                title,
                "AI Radio",
                str(cover_art_path) if cover_art_path.exists() else None,
            )

https://github.com/swiftbird07/ma-server/blob/149f26b6bb9c1db52078fc7890751eb4f07282b8/music_assistant/providers/ai_radio/runtime.py#L893-L901

ConfigEntry(
key=CONF_ELEVENLABS_API_KEY,
type=ConfigEntryType.SECURE_STRING,
label="ElevenLabs API Key",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just wondering about pricing here. I see ElevenLabs offers a free tier of 10k tokens. Is that enough to run the radio DJ daily?

Copy link
Copy Markdown
Author

@swiftbird07 swiftbird07 Mar 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah it's tricky, and the tokens run out surprisingly fast when debugging stuff 😅 For daily usage the paid tier is something you cant really avoid (except when keeping the text characters to a minimum and using the cheaper model only).

Alternatively OpenAI TTS is not too bad as well and there you pay only a few cents per usage.

Comment thread music_assistant/providers/ai_radio/provider.py Outdated
Comment thread music_assistant/providers/ai_radio/storage.py Outdated
Comment thread music_assistant/providers/ai_radio/storage.py Outdated
Comment thread music_assistant/providers/ai_radio/runtime.py Outdated
Comment thread scripts/update_and_redeploy_container.sh Outdated
Comment thread DEVELOPMENT.md Outdated
Comment thread music_assistant/providers/ai_radio/runtime.py Outdated
Copilot AI review requested due to automatic review settings March 17, 2026 12:43
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new AI Radio plugin provider to Music Assistant, enabling “radio-like” playback by generating LLM-based spoken segments (and TTS MP3s) that can be inserted into a generated playlist or dynamically into a queue at runtime.

Changes:

  • Introduces the ai_radio plugin provider with storage/normalization, runtime generation (LLM + TTS), and admin-only API commands.
  • Adds default section/station templates plus an example station JSON.
  • Adds unit tests covering storage normalization, runtime session handling, and provider helper behaviors.

Reviewed changes

Copilot reviewed 13 out of 15 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
tests/providers/ai_radio/init.py Adds test package marker for AI Radio tests.
tests/providers/ai_radio/test_storage.py Unit tests for storage normalization/materialization logic.
tests/providers/ai_radio/test_runtime.py Unit tests for runtime session flow and weather-token prep behavior.
tests/providers/ai_radio/test_provider.py Unit tests for provider session resolution and run-start validation.
music_assistant/providers/ai_radio/init.py Exposes provider setup and config entry factory.
music_assistant/providers/ai_radio/config.py Adds plugin config entries (API keys, UI refresh interval, UI URL label).
music_assistant/providers/ai_radio/constants.py Defines plugin constants/defaults (models, providers, paths, modes).
music_assistant/providers/ai_radio/models.py Adds AI Radio dataclasses and helper utilities (slots, coercion, ID3 tagging).
music_assistant/providers/ai_radio/provider.py Implements plugin provider: API endpoints, station/section CRUD, run lifecycle.
music_assistant/providers/ai_radio/runtime.py Implements run execution logic: planning, LLM generation, TTS, weather lookup, queue/playlist composition.
music_assistant/providers/ai_radio/storage.py Implements persistence and normalization for stations/sections JSON storage.
music_assistant/providers/ai_radio/manifest.json Registers the AI Radio plugin manifest (alpha stage).
music_assistant/providers/ai_radio/example_station.json Provides an example station configuration payload for reference/testing.

Comment thread music_assistant/providers/ai_radio/runtime.py Outdated
Comment thread music_assistant/providers/ai_radio/runtime.py
Comment thread music_assistant/providers/ai_radio/runtime.py
await super().unload(is_removed)

async def update_config(self, config: ProviderConfig, changed_keys: set[str]) -> None:
"""Apply config updates without forcing a provider reload."""
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot has a point here. Since you're not doing anything fancy, why override this function at all?

Comment on lines +79 to +80
parsed = {item["id"]: item for item in defaults}
await self._write_sections()
Comment on lines +96 to +100
if not stations_file_exists:
default_station = self._default_station_template()
self._stations = {default_station["id"]: default_station}
await self._write_stations()
return
Comment on lines +312 to +319
"target_playlist_provider": str(station.get("target_playlist_provider", "builtin")),
"default_player_id": str(station.get("default_player_id", "")),
"max_duration_minutes": float(station.get("max_duration_minutes", 0) or 0),
"dynamic_batch_size": max(1, int(station.get("dynamic_batch_size", 1) or 1)),
"dynamic_poll_seconds": max(1, int(station.get("dynamic_poll_seconds", 5) or 5)),
"dynamic_prefetch_remaining_tracks": max(
1, int(station.get("dynamic_prefetch_remaining_tracks", 2) or 2)
),
Copy link
Copy Markdown
Contributor

@MarvinSchenkel MarvinSchenkel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First off, amazing job, this is a very cool feature 👏 .

Please also have a look at the copilot comments. Most of them are valid.

As for a rule of thumb on storage, any data that should be persisted for a longer time (e.g. the json files containing stations and segments, but also the audio files for the permanent playlists) can we saved to mass.storage_path. The audio files for the dynamic radio mode should be saved to the cache path (mass.cache_path) to ensure they are clean up and not included in backups.

Marking this PR as draft, please have a look at the feedback and let us know when we can have another look by clicking 'ready for review' 🙏

from .constants import EMPTY_SECTION_ID


class AIRadioError(RuntimeError):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any specific reason we need to create a new error here instead of reusing the MusicAssistantError?

"""Raised when AI Radio encounters an unrecoverable error."""


def utc_now_iso() -> str:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have helpers available for datetime in helpers/datetime.py. Please use those.

}


def slugify(value: str) -> str:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would keep this file exclusively for models. So just move all the functions below this comment into 'helpers.py'

)


if TYPE_CHECKING:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be at the top of the file.

await super().unload(is_removed)

async def update_config(self, config: ProviderConfig, changed_keys: set[str]) -> None:
"""Apply config updates without forcing a provider reload."""
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot has a point here. Since you're not doing anything fancy, why override this function at all?

Comment thread music_assistant/providers/ai_radio/runtime.py Outdated
@MarvinSchenkel MarvinSchenkel marked this pull request as draft March 30, 2026 10:01
@OzGav
Copy link
Copy Markdown
Contributor

OzGav commented Apr 5, 2026

Also when this gets closer to being merged please raise a PR against the beta branch of our docs repo. See the existing pages for inspiration. As this would appear to be an advanced plugin I would expect some clear instructions for how to setup the AI part (or link to existing instructions)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants