How sync_group Synchronizes Playback Across Multiple Players in Music Assistant

The sync_group feature in Music Assistant creates a virtual SyncGroupPlayer that delegates all media commands to a single sync leader, which streams audio to member players through protocol-level grouping to maintain perfect synchronization.

The Music Assistant server implements multi-room audio synchronization through the SyncGroupPlayer class located in music_assistant/providers/sync_group/player.py. This virtual player abstracts multiple physical devices into a single logical entity, ensuring that playback commands execute simultaneously across all grouped speakers by forwarding control to a designated leader while managing protocol-level membership behind the scenes.

The Virtual Player Architecture

The SyncGroupPlayer acts as a proxy rather than a physical playback device. It maintains a reference to a sync leader—one real player in the group that handles the actual media stream. All other members are attached to this leader's protocol-level group (such as AirPlay, Cast, or native speaker groups), allowing them to receive the identical audio stream with synchronized timing.

When you issue a command to the group, the virtual player intercepts it and forwards the instruction to the leader. The leader then distributes the audio to its attached members, creating the illusion of a single cohesive device to the Music Assistant core.

Group Formation and Leader Selection

Synchronization begins when any command requiring a leader is issued, such as play(), play_media(), or set_members(). The group calls _form_syncgroup() to establish the leadership structure.

First, the method cancels any pending idle grace timer via _cancel_idle_grace_timer to prevent premature dissolution. If no leader exists, it invokes _select_sync_leader() (lines 91–95) to choose the most appropriate player based on current state and capabilities. The selected leader is then placed first in the _attr_group_members list (lines 100–103).

Before members can join, the leader must be "unsynced"—meaning it cannot already be part of another group. The code waits for sync_leader.state.synced_to to become None (lines 106–115). If the leader remains stuck in a synced state, formation aborts to prevent conflicting group memberships.

Protocol-Level Member Synchronization

Once the leader is ready, the group determines which members need synchronization. The method calculates already_synced status and builds a members_to_sync list (lines 126–134).

If the leader is currently playing, it is stopped first (lines 136–148) to ensure a clean state for group attachment. Then the group issues _handle_set_members directly on the leader, adding the missing members to its protocol-level group (lines 149–156). This low-level call ensures the underlying protocol (AirPlay, Cast, etc.) recognizes the grouped relationship.

Executing Playback Commands

All playback flows through the leader to maintain synchronization. Both play() and play_media() call _form_syncgroup() to guarantee a leader is present (lines 98–102 and 120–124).

The actual command is sent to the leader via the internal player controller—either mass.players.cmd_resume or _handle_play_media—inside the _await_leader_playback context manager (lines 103–106 and 128–138). This context manager waits for the leader to report PlaybackState.PLAYING (lines 66–78), preventing race conditions where members might join before the stream is actually active.

Dynamic Membership and Leader Switching

When set_members() is invoked on a dynamic group, the method handles additions and removals, potentially including the current leader itself (lines 48–73).

If the current leader is removed, the code evaluates whether to perform a dynamic leader switch using _dynamic_leader_switch or to completely dissolve and reform the group via _dissolve_and_reform (lines 150–166). This decision depends on whether the remaining members can support seamless leadership transfer without interrupting playback.

Dissolution and Idle Grace Handling

When playback stops naturally, the group schedules an idle grace timer. If this timer elapses and the group is not "pinned" (lacking fake power control), _dissolve_syncgroup() is called (lines 18–28).

This dissolution method removes all members from the leader's protocol group by calling _handle_set_members with player_ids_to_remove, then clears the leader reference (lines 30–39). This teardown ensures resources are freed and players return to independent operation.

Protocol Compatibility and Session Management

The group maintains awareness of which protocol carries the active stream to ensure compatibility when adding members. Helper methods such as _active_session_player(), active_protocol_domain, and _member_supports_protocol_domain() (lines 144–170) verify that the leader supports the required protocol domain for all members.

This protocol tracking enables seamless hand-offs when members requiring specific protocols join or leave, ensuring the leader selection remains compatible with the group's technical requirements.

Practical Implementation Examples

Creating a SyncGroup and Playing Media


# Assume `mass` is the Music Assistant core instance.

# Create a SyncGroup provider (handled by the server automatically) and a player:

sg_player: SyncGroupPlayer = mass.players.get_player("syncgroup_mygroup")

# Add members (dynamic groups only)

await sg_player.set_members(
    player_ids_to_add=["sonos_livingroom", "cast_kitchen"]
)

# Play a track – the group will form, pick a leader, and start playback

await sg_player.play_media(
    PlayerMedia(
        uri="https://example.com/song.mp3",
        title="Example Song",
        album="Demo Album",
        artist="Demo Artist",
        duration=210,
        source_id=None,
    )
)

Powering On a Static Group

sg_player = mass.players.get_player("syncgroup_static")

# Turn the group on – this will capture the preset members (configured in the UI)

await sg_player.power(True)   # forms the syncgroup automatically

await sg_player.play()        # resumes playback on the current leader

Handling Leader Removal

sg_player = mass.players.get_player("syncgroup_dynamic")

# Remove the current leader; the group will switch to a new one if possible

await sg_player.set_members(player_ids_to_remove=[sg_player.sync_leader.player_id])

# The group will automatically reform and continue playback if supported

Summary

  • The SyncGroupPlayer acts as a virtual proxy that delegates all media control to a single sync leader, creating the illusion of a unified playback device.
  • Group formation via _form_syncgroup() ensures the leader is unsynced before adding members, preventing conflicting group memberships.
  • Playback commands utilize the _await_leader_playback context manager to wait for PlaybackState.PLAYING, eliminating race conditions during startup.
  • Dynamic groups support runtime member changes and can switch leaders using _dynamic_leader_switch or _dissolve_and_reform without stopping playback when possible.
  • Idle grace timers automatically dissolve inactive groups through _dissolve_syncgroup(), returning members to independent operation and freeing resources.

Frequently Asked Questions

What happens if the sync leader is removed from the group?

If the current leader is removed via set_members(), the group either performs a dynamic leader switch using _dynamic_leader_switch() or dissolves and reforms with _dissolve_and_reform() to select a new leader from the remaining members. This ensures playback can continue if supported by the underlying protocol.

How does Music Assistant prevent race conditions during playback startup?

The _await_leader_playback context manager waits for the leader to report PlaybackState.PLAYING before proceeding. This ensures the leader is actively streaming audio before the virtual player considers the command complete, preventing members from joining a stream that hasn't started yet.

Can a sync group contain players using different protocols?

The group tracks protocol compatibility through active_protocol_domain and _member_supports_protocol_domain(). While the implementation allows mixing protocols, the leader must support the protocol required by all members. The system typically selects leaders based on the most compatible protocol for the current group composition.

What triggers the automatic dissolution of a sync group?

When playback stops naturally, an idle grace timer begins. If the timer elapses and the group is not "pinned" with fake power control, _dissolve_syncgroup() is automatically called. This removes all members from the leader's protocol group and clears the internal leader reference, returning players to standalone operation.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →