-
-
Notifications
You must be signed in to change notification settings - Fork 198
Added Local Audio Source Provider #2356
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Conversation
Note this plugin runs a raw PCM streams and will require optimizations in the MA FFMPEG to bring the buffering time closer to 1s to start the audio. Currently without the optimizations, there will be roughly a 10-second start time to play/pause the audio. With optimizations, I was able to get this down to ~1 second (Player side buffer).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull Request Overview
This PR adds a new Local Audio Source provider plugin that enables capturing real-time audio from ALSA input devices and streaming it to Music Assistant players. The plugin acts as a virtual AUX input for Music Assistant, allowing external audio sources to be integrated into the ecosystem.
Key changes:
- New plugin implementation with ALSA audio capture functionality
- Automatic codec management (temporarily switches to WAV format during streaming)
- Player state monitoring with debounce to handle pause/resume operations
Reviewed Changes
Copilot reviewed 3 out of 8 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| manifest.json | Plugin metadata configuration defining it as an alpha-stage audio source provider |
| init.py | Core plugin implementation with audio capture, streaming, and player management logic |
| README.md | Documentation covering setup, configuration, use cases, and Docker requirements |
Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.
| from music_assistant_models.enums import ( | ||
| ContentType, | ||
| ProviderFeature, | ||
| StreamType, | ||
| PlayerState, | ||
| ) |
Copilot
AI
Aug 26, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] Import statements should be alphabetically ordered within their grouping. PlayerState should come before ProviderFeature.
| from music_assistant_models.enums import ( | |
| ContentType, | |
| ProviderFeature, | |
| StreamType, | |
| PlayerState, | |
| ) | |
| from music_assistant_models.enums import ( | |
| ContentType, | |
| PlayerState, | |
| ProviderFeature, | |
| StreamType, | |
| ) |
| async def _get_available_input_devices() -> list[ConfigValueOption]: | ||
| """Scan for available ALSA capture devices using arecord -l. | ||
| Labels are formatted as: 'hw X,Y - <last [] desc>'. | ||
| """ | ||
| devices: list[ConfigValueOption] = [] | ||
| try: | ||
| rc, out = await check_output("arecord", "-l") | ||
| if rc == 0: | ||
| for line in out.decode("utf-8", "ignore").strip().splitlines(): | ||
| # Example: "card 1: USB [USB Audio], device 0: USB Audio [USB Audio]" | ||
| if not line.startswith("card ") or "device " not in line: | ||
| continue | ||
| try: | ||
| after = line.split("card ", 1)[1] | ||
| card = after.split(":", 1)[0].strip() | ||
| dev = after.split("device ", 1)[1].split(":", 1)[0].strip() | ||
| last_desc = line.rsplit("[", 1)[-1].rstrip("]") if "[" in line else f"Card {card} Device {dev}" | ||
| label = f"hw {card},{dev} - {last_desc}" | ||
| devices.append(ConfigValueOption(label, f"alsa:hw:{card},{dev}")) | ||
| except Exception: | ||
| continue | ||
| except Exception: | ||
| pass | ||
|
|
||
| if not devices: | ||
| devices = [ConfigValueOption("Manual Entry (alsa:hw:X,Y)", "alsa:")] | ||
| return devices | ||
|
|
||
|
|
Copilot
AI
Aug 26, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The string parsing logic is complex and fragile. Consider using regex patterns or creating helper functions to parse ALSA device information more reliably.
| async def _get_available_input_devices() -> list[ConfigValueOption]: | |
| """Scan for available ALSA capture devices using arecord -l. | |
| Labels are formatted as: 'hw X,Y - <last [] desc>'. | |
| """ | |
| devices: list[ConfigValueOption] = [] | |
| try: | |
| rc, out = await check_output("arecord", "-l") | |
| if rc == 0: | |
| for line in out.decode("utf-8", "ignore").strip().splitlines(): | |
| # Example: "card 1: USB [USB Audio], device 0: USB Audio [USB Audio]" | |
| if not line.startswith("card ") or "device " not in line: | |
| continue | |
| try: | |
| after = line.split("card ", 1)[1] | |
| card = after.split(":", 1)[0].strip() | |
| dev = after.split("device ", 1)[1].split(":", 1)[0].strip() | |
| last_desc = line.rsplit("[", 1)[-1].rstrip("]") if "[" in line else f"Card {card} Device {dev}" | |
| label = f"hw {card},{dev} - {last_desc}" | |
| devices.append(ConfigValueOption(label, f"alsa:hw:{card},{dev}")) | |
| except Exception: | |
| continue | |
| except Exception: | |
| pass | |
| if not devices: | |
| devices = [ConfigValueOption("Manual Entry (alsa:hw:X,Y)", "alsa:")] | |
| return devices | |
| async def _get_available_input_devices() -> list[ConfigValueOption]: | |
| """Scan for available ALSA capture devices using arecord -l. | |
| Labels are formatted as: 'hw X,Y - <last [] desc>'. | |
| """ | |
| devices: list[ConfigValueOption] = [] | |
| # Regex to match: card <card_num>: ... device <dev_num>: ... [desc] | |
| # Example: "card 1: USB [USB Audio], device 0: USB Audio [USB Audio]" | |
| device_regex = re.compile( | |
| r"card\s+(?P<card>\d+):.*device\s+(?P<dev>\d+):.*\[(?P<desc>[^\]]+)\]" | |
| ) | |
| try: | |
| rc, out = await check_output("arecord", "-l") | |
| if rc == 0: | |
| for line in out.decode("utf-8", "ignore").strip().splitlines(): | |
| # Example: "card 1: USB [USB Audio], device 0: USB Audio [USB Audio]" | |
| if not line.startswith("card ") or "device " not in line: | |
| continue | |
| match = device_regex.search(line) | |
| if match: | |
| card = match.group("card") | |
| dev = match.group("dev") | |
| last_desc = match.group("desc") | |
| else: | |
| # Fallback: try to extract card and dev manually | |
| try: | |
| after = line.split("card ", 1)[1] | |
| card = after.split(":", 1)[0].strip() | |
| dev = after.split("device ", 1)[1].split(":", 1)[0].strip() | |
| last_desc = f"Card {card} Device {dev}" | |
| except Exception: | |
| continue | |
| label = f"hw {card},{dev} - {last_desc}" | |
| devices.append(ConfigValueOption(label, f"alsa:hw:{card},{dev}")) | |
| except Exception: | |
| pass | |
| if not devices: | |
| devices = [ConfigValueOption("Manual Entry (alsa:hw:X,Y)", "alsa:")] | |
| return devices | |
| self._capture_proc: AsyncProcess | None = None | ||
| self._paused = False | ||
| self._stream_active = False | ||
| self._current_player_id: str | None = None | ||
| self._monitor_task: asyncio.Task | None = None # type: ignore[type-arg] | ||
| self._capture_lock = asyncio.Lock() | ||
| self._last_state = None | ||
| self._state_since = time.monotonic() | ||
| self._active_stream_id: int | None = None |
Copilot
AI
Aug 26, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The # type: ignore[type-arg] comment indicates a typing issue. Consider using asyncio.Task[None] instead to properly type the task.
| self._capture_proc: AsyncProcess | None = None | |
| self._paused = False | |
| self._stream_active = False | |
| self._current_player_id: str | None = None | |
| self._monitor_task: asyncio.Task | None = None # type: ignore[type-arg] | |
| self._capture_lock = asyncio.Lock() | |
| self._last_state = None | |
| self._state_since = time.monotonic() | |
| self._active_stream_id: int | None = None | |
| self._capture_proc: AsyncProcess | None = None | |
| self._paused = False | |
| self._stream_active = False | |
| self._current_player_id: str | None = None | |
| self._monitor_task: asyncio.Task[None] | None = None | |
| self._capture_lock = asyncio.Lock() | |
| self._last_state = None | |
| self._state_since = time.monotonic() | |
| self._active_stream_id: int | None = None |
| await self.mass.config.save_player_config(player_id=player_id, values={"output_codec": "wav"}) | ||
| self.mass.config._value_cache.clear() | ||
| await asyncio.sleep(0.5) |
Copilot
AI
Aug 26, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Accessing private attribute _value_cache directly violates encapsulation. Consider using a public API method if available, or request one from the Music Assistant team.
| await self.mass.config.save_player_config(player_id=player_id, values={"output_codec": "wav"}) | |
| self.mass.config._value_cache.clear() | |
| await asyncio.sleep(0.5) | |
| await self.mass.config.save_player_config(player_id=player_id, values={"output_codec": "wav"}) | |
| await self.mass.config.refresh_player_config_cache(player_id) | |
| await asyncio.sleep(0.5) |
Co-authored-by: Copilot <[email protected]>
|
Please note Marcels comment on GitHub about streaming PCM. Consider the AI comments above. I have run the workflow so you can see the Lint issues to be fixed. |
| async def patched_get_plugin_source_url(plugin_source: str, player_id: str) -> str: | ||
| # Ensure WAV before generating URL for this plugin | ||
| if plugin_source == self.instance_id: | ||
| await self._save_and_set_wav_codec(player_id) | ||
| return await original_get_plugin_source_url(plugin_source, player_id) | ||
|
|
||
| self.mass.streams.get_plugin_source_url = patched_get_plugin_source_url |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's not do hacks like this please
| async def _save_and_set_wav_codec(self, player_id: str) -> None: | ||
| """Save current codec and set player to WAV format.""" | ||
| try: | ||
| current_codec = await self.mass.config.get_player_config_value(player_id, "output_codec") | ||
| if current_codec != "wav": | ||
| self._original_codec = current_codec | ||
| self._codec_changed = True | ||
| await self.mass.config.save_player_config(player_id=player_id, values={"output_codec": "wav"}) | ||
| self.mass.config._value_cache.clear() | ||
| await asyncio.sleep(0.5) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, please leave this out. We're not going to change a player's config from a source provider.
| async def _monitor_player_state(self, player_id: str) -> None: | ||
| """Monitor player state with debounce to avoid flapping.""" | ||
| self._current_player_id = player_id | ||
| self._last_state = None | ||
| self._state_since = time.monotonic() | ||
|
|
||
| while self._stream_active: | ||
| try: | ||
| player = self.mass.players.get(player_id) | ||
| if not player: | ||
| break | ||
| now = time.monotonic() | ||
| current_state = player.state | ||
| if current_state != self._last_state: | ||
| self._last_state = current_state | ||
| self._state_since = now | ||
| stable_for = now - self._state_since | ||
|
|
||
| if current_state == PlayerState.PAUSED and stable_for >= PAUSE_DEBOUNCE_S and not self._paused: | ||
| self._paused = True | ||
| elif current_state == PlayerState.PLAYING and stable_for >= RESUME_DEBOUNCE_S and self._paused: | ||
| self._paused = False | ||
| elif current_state == PlayerState.IDLE and stable_for >= PAUSE_DEBOUNCE_S and not self._paused: | ||
| self._paused = True | ||
|
|
||
| await asyncio.sleep(0.2) | ||
| except Exception: | ||
| await asyncio.sleep(1) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please explain why this is needed.
| # Refresh player source lists | ||
| for player in self.mass.players.all(): | ||
| try: | ||
| player.source_list = [s for s in player.source_list if s.id != self.instance_id] | ||
| self.mass.players.update(player.player_id, force_update=True) | ||
| except Exception: | ||
| continue |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
drop this hack please
|
Please slim down the PR to only a plugin source, providing a source of audio (as raw pcm) to players. |
|
@Torrax any update on this one ? As soon as the bare provider implementation is in, we can start tweaking the core to handle the latency issues you experienced. |
|
Marked as draft, awaiting the review comments to be addressed. |
Tip; would like to recommend using this readme guide (which I contributed to) as a basis to write documentation for this feature: Specifically the introduction and the "How It Works (Architecture)" sections there which explain both use case and prerequisites. FYI, I also contributed to these similar guides which can also be used as a reference though don't think those are as good. See: and PS: @Torrax Slightly off-topic but suggest that you also consider adding this feature to this new Linux Voice Assistant project too: |
|
@Hedda I havnt actually wrote any documentation yet. Documentation is usually the last thing you do on the project when they project is actually available to release. The write up I provided is purely for the users that know what they are doing already and are setting my fork up in their source files. At some point I will look at updating the documentation after release. As for the additional features as mentioned before when you brought up pipe wire, this is becomming way out of scope for this project. Again all this project is meant to do is purely run input audio source from a connected local device to that PC and play it over Music Assistant. I will not be expanding this project out at all past this scope. No additional features, no other services im adding this to. Purely ALSA devices playing directly to Music Assistant. |
|
@Torrax the optimizations for PluginSource are now merged in dev so this should help your usecase. |
Note this plugin runs a raw PCM streams and will require optimizations in the MA FFMPEG to bring the buffering time closer to 1s to start the audio.
Currently without the optimizations, there will be roughly a 10-second start time to play/pause the audio. With optimizations, I was able to get this down to ~1 second (Player side buffer).