Fix group volume ratio drift and Spotify Connect feedback loop#3399
Draft
theturtle32 wants to merge 1 commit intomusic-assistant:devfrom
Draft
Fix group volume ratio drift and Spotify Connect feedback loop#3399theturtle32 wants to merge 1 commit intomusic-assistant:devfrom
theturtle32 wants to merge 1 commit intomusic-assistant:devfrom
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_volumeimplementation 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_volumecallbacks to Spotify's cloud API, each sending a different volume value. Spotify picks one, fires avolume_changedevent 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:
ATTR_GROUP_VOLUME_LEVEL): the user-controlled group volume, set once at group formation and changed only by explicit user actionATTR_GROUP_CHILD_RATIOS): a dict mapping each child player ID to its volume ratio relative to the group volumeWhen 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:
Plugin source isolation
set_group_volumenow fires the plugin source callback (e.g. Spotify Connecton_volume) once at the group level with the group volume value, rather than per-child. A newfrom_group_volumeparameter on_handle_cmd_volume_setsuppresses 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
max(child_volumes)as the initial group volume (ensures all ratios start ≤ 1.0)ATTR_GROUP_MEMBERSchanged_values insignal_player_state_updatefor proactive ratio management on add/remove/form/dissolvePlayerType.GROUP): Group volume and ratios are persisted to player config viaset_raw_player_config_value, surviving reboots without precision loss. Config writes are debounced by the existing 5-secondDEFAULT_SAVE_DELAY.extra_dataonly; ratios are re-derived when the group re-formsChanges
music_assistant/constants.py: AddATTR_GROUP_VOLUME_LEVELandATTR_GROUP_CHILD_RATIOSconstantsmusic_assistant/controllers/players/controller.py:set_group_volumewith ratio-based algorithm_initialize_group_ratios,_update_group_ratios_on_membership_change,_persist_group_volume_data,_load_persisted_group_volume_datahelpersfrom_group_volumeparameter to_handle_cmd_volume_set; skip plugin callbacks and update child ratios in parent groups accordingly_update_group_ratios_on_membership_changeintoATTR_GROUP_MEMBERSstate changesmusic_assistant/models/player.py:group_volumeproperty returns stored group volume level; old average-based derivation from child volumes removed entirelytests/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 casesTest plan
ruff checkpasses on all changed filesruff format --checkpasses on all changed filesmypypasses on all changed filestests/core/test_group_volume.py)test_tags.pyunrelated to this change)Made with Cursor