Skip to content
Draft
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
53da7c3
feat(ai_radio): add standalone plugin UI and simplify station runtime
swiftbird07 Feb 22, 2026
095ff9c
Merge branch 'dev' into ai-radio
swiftbird07 Feb 22, 2026
121b3d4
feat(ai_radio): add logging and testing
swiftbird07 Feb 22, 2026
ca2350b
feat(ai_radio): improve plugin UX, status visibility, and dynamic run…
swiftbird07 Feb 22, 2026
e283e9b
Add logo and api docs
swiftbird07 Feb 24, 2026
ad0ecdb
Merge branch 'dev' into ai-radio
swiftbird07 Feb 24, 2026
5a8566f
Add instructions for building local Python artifact for Docker image
swiftbird07 Feb 24, 2026
74c3fb2
Add placeholder overview and styles to AI Radio control panel
swiftbird07 Feb 24, 2026
920126e
Remove custom API Docs references and update styles for improved UI c…
swiftbird07 Feb 28, 2026
ce3504f
Enhance AI Radio functionality and UI: add global news section, refin…
swiftbird07 Feb 28, 2026
adc30e4
Remove validation button from AI Radio station controls and enhance s…
swiftbird07 Feb 28, 2026
0ebd06d
Refactor AI Radio wizard and section handling: remove unused IDs, aut…
swiftbird07 Feb 28, 2026
c6394ac
Add unit tests for AI Radio provider and runtime: validate station ha…
swiftbird07 Mar 14, 2026
43acf80
Merge branch 'dev' into ai-radio
swiftbird07 Mar 14, 2026
a0658cb
Remove old frontend code (moved to ../ma-frontend)
swiftbird07 Mar 14, 2026
9206e5d
Fix dynamic_source_playtime_cap_override type
swiftbird07 Mar 14, 2026
8c261f0
update_and_redeploy_container.sh: add frontend build support and impr…
swiftbird07 Mar 14, 2026
0885cc3
Merge branch 'dev' into ai-radio
swiftbird07 Mar 16, 2026
0b5860c
Revert .vscode changes
swiftbird07 Mar 16, 2026
149f26b
Fix paths in update script
swiftbird07 Mar 16, 2026
2e1e67a
Remove unnecessary logging configuration from provider.py
swiftbird07 Mar 17, 2026
c12f698
- improve file existence checks with asyncio
swiftbird07 Mar 17, 2026
f2b1944
Remove docker scripts / documentation
swiftbird07 Mar 17, 2026
423695e
Add section store path resolution safety checks
swiftbird07 Mar 17, 2026
198fb93
Remove ability to configure OpenAI base URL via station config
swiftbird07 Mar 17, 2026
42088b1
FIx icon.svg
swiftbird07 Mar 17, 2026
d40a395
Validate OPTIONAL chance values as numeric and handle non-numeric cas…
swiftbird07 Mar 30, 2026
f424660
Add DEFAULT_TTS_TIMEOUT_SECONDS constant and use it in TTS request ti…
swiftbird07 Mar 30, 2026
85cb4b8
Add DEFAULT_OPENAI_TIMEOUT_SECONDS constant and use it in API request…
swiftbird07 Mar 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions music_assistant/providers/ai_radio/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""AI Radio plugin package."""

from .config import get_config_entries
from .provider import AIRadioProvider, setup

__all__ = ["AIRadioProvider", "get_config_entries", "setup"]
Binary file added music_assistant/providers/ai_radio/air.png
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

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
61 changes: 61 additions & 0 deletions music_assistant/providers/ai_radio/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Configuration entries for the AI Radio plugin."""

from __future__ import annotations

from typing import TYPE_CHECKING

from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
from music_assistant_models.enums import ConfigEntryType

from .constants import (
CONF_ELEVENLABS_API_KEY,
CONF_OPENAI_API_KEY,
CONF_UI_AUTO_REFRESH_SECONDS,
)


async def get_config_entries(
mass: MusicAssistant,
instance_id: str | None = None, # noqa: ARG001
action: str | None = None, # noqa: ARG001
values: dict[str, ConfigValueType] | None = None, # noqa: ARG001
) -> tuple[ConfigEntry, ...]:
"""Return Config entries to setup this provider."""
base_url = mass.webserver.base_url.rstrip("/")
web_ui_url = f"{base_url}/#/ai-radio"
return (
ConfigEntry(
key="web_ui_url",
type=ConfigEntryType.LABEL,
label="Click (?) to open AI Radio User Interface",
description=web_ui_url,
),
ConfigEntry(
key=CONF_OPENAI_API_KEY,
type=ConfigEntryType.SECURE_STRING,
label="OpenAI API Key",
description="Required for AI text generation and OpenAI TTS.",
required=False,
),
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.

required=False,
),
ConfigEntry(
key=CONF_UI_AUTO_REFRESH_SECONDS,
type=ConfigEntryType.INTEGER,
default_value=2,
range=(1, 30),
label="Web UI Auto Refresh Interval (seconds)",
description=(
"How often the AI Radio web UI refreshes session/player status automatically."
),
category="advanced",
),
)


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.

from music_assistant.mass import MusicAssistant
39 changes: 39 additions & 0 deletions music_assistant/providers/ai_radio/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Constants for the AI Radio plugin."""

from __future__ import annotations

from typing import Any

CONF_OPENAI_API_KEY = "openai_api_key"
CONF_ELEVENLABS_API_KEY = "elevenlabs_api_key"
CONF_UI_AUTO_REFRESH_SECONDS = "ui_auto_refresh_seconds"

DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"
DEFAULT_LLM_MODEL = "gpt-4o-mini"
DEFAULT_LLM_INSTRUCTIONS = (
"Host personality: warm, sharp, music-literate, and slightly premium "
"without sounding formal. Program instructions: write for spoken delivery, "
"keep segments concise, avoid bullet-point phrasing, avoid clichés, "
"mention concrete details when available, and maintain a believable "
"radio flow between sections."
)
DEFAULT_TEMPERATURE = 0.7
DEFAULT_MAX_TOKENS = 900
DEFAULT_TTS_PROVIDER = "openai"
DEFAULT_OPENAI_TTS_MODEL = "gpt-4o-mini-tts"
DEFAULT_OPENAI_TTS_VOICE = "ballad"
DEFAULT_OPENAI_TTS_INSTRUCTIONS = (
"Delivery instructions: confident radio host, natural pacing, "
"clear sentence endings, subtle energy lift on intros and transitions, "
"and a warm close without exaggerated theatrics."
)
DEFAULT_ELEVENLABS_MODEL = "eleven_multilingual_v2"
DEFAULT_SECTION_STORE_PATH = "ai_radio_sections"
DEFAULT_WEATHER_PROVIDER = "open_meteo"
DEFAULT_WEATHER_TIMEOUT_SECONDS = 20
DEFAULT_MAX_CONCURRENT_RUNS = 1

SUPPORTED_FEATURES: set[Any] = set()
EMPTY_SECTION_ID = "EMPTY_SECTION"
VALID_WEB_SEARCH_MODES = {"disabled", "allow", "force"}
WEB_SEARCH_MODE_RANK = {"disabled": 0, "allow": 1, "force": 2}
150 changes: 150 additions & 0 deletions music_assistant/providers/ai_radio/example_station.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
{
"id": "example_station",
"name": "Example AI Radio Station",
"source_playlist_id": "4",
"source_playlist_provider": "library",
"target_playlist_provider": "builtin",
"default_player_id": "",
"max_duration_minutes": 60,
"dynamic_batch_size": 1,
"dynamic_prefetch_remaining_tracks": 2,
"dynamic_poll_seconds": 5,
"clear_queue_on_start": true,
"general": {
"timezone": "UTC",
"location": {
"city": "Berlin",
"country": "Germany"
},
"weather_provider": "open_meteo",
"weather_timeout_seconds": 20,
"model": "gpt-4o-mini",
"temperature": 0.7,
"max_tokens": 900,
"instructions": "Host personality: warm, sharp, music-literate, and slightly premium without sounding formal. Program instructions: write for spoken delivery, keep segments concise, avoid bullet-point phrasing, avoid clichés, mention concrete details when available, and maintain a believable radio flow between sections.",
"section_store_path": "ai_radio_sections",
"tts_provider": "openai",
"openai_tts_model": "gpt-4o-mini-tts",
"openai_tts_voice": "ballad",
"openai_tts_instructions": "Delivery instructions: confident radio host, natural pacing, clear sentence endings, subtle energy lift on intros and transitions, and a warm close without exaggerated theatrics.",
"elevenlabs_model": "eleven_multilingual_v2",
"elevenlabs_voice_id": ""
},
"sections": [
{
"id": "Song_Introduction_Start",
"name": "Song Introduction Start",
"type": "ai_text",
"web_search": "disabled",
"prompt": "The next track is <next_songinfo>. Open the program like a polished radio host: brief welcome, confident energy, one concrete hook about the song or artist, and a clean handoff into the music.",
"constraints": {
"max_chars": 650
}
},
{
"id": "Song_Transition",
"name": "Song Transition",
"type": "ai_text",
"web_search": "allow",
"prompt": "The previous track was <prev_songinfo> and the next track is <next_songinfo>. Create a natural radio transition that connects both songs, sounds informed but concise, and avoids filler or repetition.",
"constraints": {
"max_chars": 650
}
},
{
"id": "Global_News",
"name": "Global News",
"type": "ai_text",
"web_search": "force",
"prompt": "Create a short global news bulletin anchored to <timestamp>. Use web search. Include two or three current items that are broadly relevant, clearly separated, fact-focused, and written for spoken delivery.",
"constraints": {
"max_chars": 700
}
},
{
"id": "Weather_Short",
"name": "Weather Short",
"type": "ai_text",
"web_search": "disabled",
"prompt": "Using <weather_hourly> and <timestamp>, deliver a short spoken weather update with the current outlook, a useful next-hours summary, and smooth radio phrasing.",
"constraints": {
"max_chars": 500
}
},
{
"id": "Song_Introduction_End",
"name": "Song Introduction End",
"type": "ai_text",
"web_search": "disabled",
"prompt": "The last track played was <prev_songinfo>. Close the program with a memorable sign-off: brief reflection, warm farewell, and language that sounds like the end of a real radio segment.",
"constraints": {
"max_chars": 650
}
},
{
"id": "Between_Songs_Smoother",
"name": "Between Songs Mix",
"type": "ai_meta",
"prompt": "Merge the drafts below into one coherent radio break. Preserve factual content, remove duplication, and make the final segment sound like one host speaking naturally.\n<section_drafts>"
}
],
"section_order": [
{
"when": "start_of_playlist",
"flow": [
{
"MUST": "Song_Introduction_Start"
}
]
},
{
"when": "between_songs",
"flow": [
{
"ALTERNATIVE": {
"choices": [
{
"section": "Song_Transition",
"weight": 100
}
]
}
},
{
"OPTIONAL": {
"section": "Weather_Short",
"chance": 0.2,
"guards": {
"min_gap_songs": 3,
"max_per_60min": 1,
"require_placeholders_present": [
"<weather_hourly>"
]
}
}
},
{
"OPTIONAL": {
"section": "Global_News",
"chance": 0.12,
"guards": {
"min_gap_songs": 4,
"max_per_60min": 1,
"require_placeholders_present": [
"<timestamp>"
]
}
}
}
]
},
{
"when": "end_of_playlist",
"flow": [
{
"MUST": "Song_Introduction_End"
}
]
}
]
}
18 changes: 18 additions & 0 deletions music_assistant/providers/ai_radio/icon.svg
Comment thread
swiftbird07 marked this conversation as resolved.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions music_assistant/providers/ai_radio/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"type": "plugin",
"stage": "alpha",
"domain": "ai_radio",
"name": "AI Radio",
"description": "Generate AI moderator sections for playlists and run dynamic radio-style queue generation. Open AI Radio from the main frontend navigation.",
"codeowners": ["@swiftbird07"],
"requirements": [],
"documentation": "https://music-assistant.io/plugins/ai-radio",
"multi_instance": false,
"builtin": false
}
Loading
Loading