Skip to content

Conversation

@Torrax
Copy link

@Torrax Torrax commented Aug 26, 2025

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).

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).
Copilot AI review requested due to automatic review settings August 26, 2025 15:19
Copy link
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

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.

Comment on lines +23 to +28
from music_assistant_models.enums import (
ContentType,
ProviderFeature,
StreamType,
PlayerState,
)
Copy link

Copilot AI Aug 26, 2025

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.

Suggested change
from music_assistant_models.enums import (
ContentType,
ProviderFeature,
StreamType,
PlayerState,
)
from music_assistant_models.enums import (
ContentType,
PlayerState,
ProviderFeature,
StreamType,
)

Copilot uses AI. Check for mistakes.
Comment on lines +108 to +137
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


Copy link

Copilot AI Aug 26, 2025

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment on lines +169 to +177
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
Copy link

Copilot AI Aug 26, 2025

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment on lines +234 to +236
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)
Copy link

Copilot AI Aug 26, 2025

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.
@OzGav
Copy link
Contributor

OzGav commented Aug 27, 2025

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.

Comment on lines +219 to +225
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
Copy link
Member

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

Comment on lines +227 to +236
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)
Copy link
Member

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.

Comment on lines +255 to +282
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)
Copy link
Member

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.

Comment on lines +303 to +309
# 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
Copy link
Member

Choose a reason for hiding this comment

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

drop this hack please

@marcelveldt
Copy link
Member

Please slim down the PR to only a plugin source, providing a source of audio (as raw pcm) to players.
Then we will work on improving the internal machinery to get better latency.

@marcelveldt
Copy link
Member

@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.

@marcelveldt marcelveldt marked this pull request as draft September 10, 2025 18:38
@marcelveldt
Copy link
Member

Marked as draft, awaiting the review comments to be addressed.
Once these are resolved, please mark the PR ready for review again, thanks!

@Hedda
Copy link

Hedda commented Oct 15, 2025

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.

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:

@Torrax
Copy link
Author

Torrax commented Oct 15, 2025

@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.

@marcelveldt
Copy link
Member

@Torrax the optimizations for PluginSource are now merged in dev so this should help your usecase.
If you can cleanup the temporary hacks for enforcing wav in your code so the code is isolated to your plugin source provider only, we can give this another review and aim for merging soon

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