Skip to content

Fix group volume ratio drift and Spotify Connect feedback loop#3399

Draft
theturtle32 wants to merge 1 commit intomusic-assistant:devfrom
theturtle32:fix/ratio-based-group-volume
Draft

Fix group volume ratio drift and Spotify Connect feedback loop#3399
theturtle32 wants to merge 1 commit intomusic-assistant:devfrom
theturtle32:fix/ratio-based-group-volume

Conversation

@theturtle32
Copy link

Summary

This PR replaces the group volume algorithm with a ratio-based gain staging model that fixes two interconnected bugs:

Bug 1: Group volume ratio drift from clamping

The existing set_group_volume implementation uses an additive-delta algorithm: it computes a volume difference (new_group_vol - current_group_vol) and applies that flat delta to every child player, clamping each result to 0–100. This design is fundamentally flawed because clamping is a lossy, irreversible operation — once a child's volume is clamped, the relative balance between children is permanently destroyed. There is no stored state representing the intended ratios, so balance can never be restored.

Example: Children A=80, B=40. Raise group by +30 → A clamps to 100 (lost 10), B=70. Lower group by -30 → A=70, B=40. A was 80, now 70 — the balance has drifted irreversibly.

Bug 2: Spotify Connect group volume feedback loop

When Spotify Connect is the active source on a group, every group volume change fires per-child on_volume callbacks to Spotify's cloud API, each sending a different volume value. Spotify picks one, fires a volume_changed event back to Music Assistant, which triggers another group volume change, creating an oscillating feedback loop that continues until Spotify rate-limits the requests (HTTP 429).

Approach

Ratio-based gain staging

The new algorithm stores two pieces of state per group:

  • Group volume level (ATTR_GROUP_VOLUME_LEVEL): the user-controlled group volume, set once at group formation and changed only by explicit user action
  • Child ratios (ATTR_GROUP_CHILD_RATIOS): a dict mapping each child player ID to its volume ratio relative to the group volume

When the group volume changes, each child's target volume is computed as round(group_volume × child_ratio), with clamping applied only to the output — the stored ratio is never modified by group volume changes. This means raising the group volume until a child clamps at 100, then lowering it back, restores the original balance exactly.

Ratios are updated only when:

  • A child's volume is changed individually (direct user action on a single child)
  • A child joins or leaves the group (ratio derived from its current volume)

Plugin source isolation

set_group_volume now fires the plugin source callback (e.g. Spotify Connect on_volume) once at the group level with the group volume value, rather than per-child. A new from_group_volume parameter on _handle_cmd_volume_set suppresses per-child plugin callbacks during group volume changes, breaking the feedback loop. The existing dedup guard in Spotify Connect (_last_volume_sent_to_spotify) handles the echo from the single group-level callback.

Initialization and persistence

  • Group formation: Ratios are bootstrapped from current child volumes using max(child_volumes) as the initial group volume (ensures all ratios start ≤ 1.0)
  • Membership changes: Hooked into ATTR_GROUP_MEMBERS changed_values in signal_player_state_update for proactive ratio management on add/remove/form/dissolve
  • Static groups (PlayerType.GROUP): Group volume and ratios are persisted to player config via set_raw_player_config_value, surviving reboots without precision loss. Config writes are debounced by the existing 5-second DEFAULT_SAVE_DELAY.
  • Dynamic sync groups: Use runtime extra_data only; ratios are re-derived when the group re-forms

Changes

  • music_assistant/constants.py: Add ATTR_GROUP_VOLUME_LEVEL and ATTR_GROUP_CHILD_RATIOS constants
  • music_assistant/controllers/players/controller.py:
    • Rewrite set_group_volume with ratio-based algorithm
    • Add _initialize_group_ratios, _update_group_ratios_on_membership_change, _persist_group_volume_data, _load_persisted_group_volume_data helpers
    • Add from_group_volume parameter to _handle_cmd_volume_set; skip plugin callbacks and update child ratios in parent groups accordingly
    • Hook _update_group_ratios_on_membership_change into ATTR_GROUP_MEMBERS state changes
  • music_assistant/models/player.py:
    • group_volume property returns stored group volume level; old average-based derivation from child volumes removed entirely
  • tests/core/test_group_volume.py (new): 22 tests across 6 test classes covering ratio preservation through clamping, individual child ratio updates, plugin source callback isolation, membership change initialization, static group persistence, and edge cases

Test plan

  • ruff check passes on all changed files
  • ruff format --check passes on all changed files
  • mypy passes on all changed files
  • All 22 new tests pass (tests/core/test_group_volume.py)
  • All existing tests pass (943 passed, 3 pre-existing failures in test_tags.py unrelated to this change)
  • Manual test: create a static group with children at different volumes, adjust group volume up past clamping point and back down, verify original balance is restored
  • Manual test: with Spotify Connect as active source on a group, adjust group volume and verify no feedback loop / HTTP 429 errors
  • Manual test: add/remove a child from an active group and verify its ratio is derived correctly
  • Manual test: restart the server and verify persisted ratios are restored for static groups

Made with Cursor

Replace the additive-delta group volume algorithm with a ratio-based
gain staging model. The old algorithm applied a flat volume delta to
every child player and clamped the result, permanently destroying the
relative balance between children whenever any child hit the 0 or 100
boundary. There was no memory of intended ratios, so balance could
never be restored.

The new algorithm stores a group volume level and per-child ratios
(child_vol / group_vol). Group volume changes compute each child's
target as group_volume * ratio, clamping only the output while
preserving the stored ratio. This means lowering group volume after
a clamp event restores the original balance exactly.

Additionally, isolate plugin source (e.g. Spotify Connect) volume
callbacks during group volume changes. The old code fired per-child
on_volume callbacks to Spotify's API with conflicting values, causing
a feedback loop (Spotify picks one value, fires volume_changed back,
repeat until HTTP 429). Now set_group_volume fires the plugin callback
once at the group level and passes from_group_volume=True to child
volume calls to suppress per-child plugin callbacks.

Key changes:
- Rewrite set_group_volume with ratio-based algorithm
- Add _initialize_group_ratios for bootstrap from current child volumes
- Hook into ATTR_GROUP_MEMBERS changes for proactive ratio management
- Add from_group_volume parameter to _handle_cmd_volume_set
- Persist ratios to player config for static (PlayerType.GROUP) groups
- Remove average-based group_volume fallback from Player model
- Add 22 tests covering ratios, clamping, plugin isolation, membership
  changes, persistence, and edge cases

Made-with: Cursor
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant