From f3def56233e8ff481d8e762ef9744661cd15083b Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Thu, 23 Oct 2025 17:37:53 -0400 Subject: [PATCH 01/49] Add vol_f_buffer --- CHANGELOG.md | 2 + amplipi/ctrl.py | 10 +++- amplipi/defaults.py | 12 ++--- amplipi/models.py | 5 +- web/src/App.jsx | 38 ++++++++------ .../CardVolumeSlider/CardVolumeSlider.jsx | 15 +++--- .../GroupVolumeSlider/GroupVolumeSlider.jsx | 11 +++- .../components/VolumeSlider/VolumeSlider.jsx | 50 +++++++++---------- .../components/VolumeZones/VolumeZones.jsx | 12 ++--- 9 files changed, 94 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e28a52688..8091f3249 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ * System * Update our spotify provider `go-librespot` to `0.5.2` to accomodate spotify's API update +* Web App + * Upgraded volume calculations to preserve relative positions when hitting the min or max setting via source volume bar ## 0.4.8 * System diff --git a/amplipi/ctrl.py b/amplipi/ctrl.py index e3665efdf..7e3f6335a 100644 --- a/amplipi/ctrl.py +++ b/amplipi/ctrl.py @@ -852,9 +852,14 @@ def set_vol(): # Field precedence: vol (db) > vol_delta > vol (float) # NOTE: checks happen in reverse precedence to cover default case of unchanged volume if update.vol_delta_f is not None and update.vol is None: - applied_delta = utils.clamp((vol_delta_f + zone.vol_f), 0, 1) + true_vol_f = zone.vol_f + zone.vol_f_buffer + totaled_delta = update.vol_delta_f + true_vol_f + applied_delta = utils.clamp(totaled_delta, 0, 1) + vol_db = utils.vol_float_to_db(applied_delta, zone.vol_min, zone.vol_max) vol_f_new = applied_delta + zone.vol_f_buffer = 0 if models.MIN_VOL_F < totaled_delta and totaled_delta < models.MAX_VOL_F else utils.clamp((totaled_delta - applied_delta), -1.0, 1.0) + elif update.vol_f is not None and update.vol is None: clamp_vol_f = utils.clamp(vol_f, 0, 1) vol_db = utils.vol_float_to_db(clamp_vol_f, zone.vol_min, zone.vol_max) @@ -866,9 +871,12 @@ def set_vol(): if self._rt.update_zone_vol(idx, vol_db): zone.vol = vol_db zone.vol_f = vol_f_new + else: raise Exception('unable to update zone volume') + zone.vol_f_buffer = 0 if models.MIN_VOL_F < vol_f_new and vol_f_new < models.MAX_VOL_F else zone.vol_f_buffer + # To avoid potential unwanted loud output: # If muting, mute before setting volumes # If un-muting, set desired volume first diff --git a/amplipi/defaults.py b/amplipi/defaults.py index 236e53e4f..5b7bb6661 100644 --- a/amplipi/defaults.py +++ b/amplipi/defaults.py @@ -41,17 +41,17 @@ ], "zones": [ # this is an array of zones, array length depends on # of boxes connected {"id": 0, "name": "Zone 1", "source_id": 0, "mute": True, "disabled": False, - "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, + "vol_f": models.MIN_VOL_F, "vol_f_buffer": 0.0, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, {"id": 1, "name": "Zone 2", "source_id": 0, "mute": True, "disabled": False, - "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, + "vol_f": models.MIN_VOL_F, "vol_f_buffer": 0.0, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, {"id": 2, "name": "Zone 3", "source_id": 0, "mute": True, "disabled": False, - "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, + "vol_f": models.MIN_VOL_F, "vol_f_buffer": 0.0, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, {"id": 3, "name": "Zone 4", "source_id": 0, "mute": True, "disabled": False, - "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, + "vol_f": models.MIN_VOL_F, "vol_f_buffer": 0.0, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, {"id": 4, "name": "Zone 5", "source_id": 0, "mute": True, "disabled": False, - "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, + "vol_f": models.MIN_VOL_F, "vol_f_buffer": 0.0, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, {"id": 5, "name": "Zone 6", "source_id": 0, "mute": True, "disabled": False, - "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, + "vol_f": models.MIN_VOL_F, "vol_f_buffer": 0.0, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, ], "groups": [ ], diff --git a/amplipi/models.py b/amplipi/models.py index e5ac7c2ac..88d45b947 100644 --- a/amplipi/models.py +++ b/amplipi/models.py @@ -87,7 +87,7 @@ class fields(SimpleNamespace): VolumeMax = Field(ge=MIN_VOL_DB, le=MAX_VOL_DB, description='Max output volume in dB') GroupMute = Field(description='Set to true if output is all zones muted') GroupVolume = Field(ge=MIN_VOL_DB, le=MAX_VOL_DB, description='Average output volume') - GroupVolumeF = Field(ge=MIN_VOL_F, le=MAX_VOL_F, description='Average output volume as a floating-point number') + GroupVolumeF = Field(ge=MIN_VOL_F, le=MAX_VOL_F, description='Average output volume as a floating-point number.') Disabled = Field(description='Set to true if not connected to a speaker') Zones = Field(description='Set of zone ids belonging to a group') Groups = Field(description='List of group ids') @@ -111,6 +111,8 @@ class fields_w_default(SimpleNamespace): Volume = Field(default=MIN_VOL_DB, ge=MIN_VOL_DB, le=MAX_VOL_DB, description='Output volume in dB') VolumeF = Field(default=MIN_VOL_F, ge=MIN_VOL_F, le=MAX_VOL_F, description='Output volume as a floating-point scalar from 0.0 to 1.0 representing MIN_VOL_DB to MAX_VOL_DB') + VolumeFBuffer = Field(default=0.0, ge=(MIN_VOL_F - MAX_VOL_F), le=(MAX_VOL_F - MIN_VOL_F), + description='Output volume as a floating-point scalar that has a range equal to MIN_VOL_F - MAX_VOL_F in both directions from zero, and is used to keep track of the relative distance between two or more zone volumes when they would otherwise have to exceed their VOL_F range') VolumeMin = Field(default=MIN_VOL_DB, ge=MIN_VOL_DB, le=MAX_VOL_DB, description='Min output volume in dB') VolumeMax = Field(default=MAX_VOL_DB, ge=MIN_VOL_DB, le=MAX_VOL_DB, @@ -321,6 +323,7 @@ class Zone(Base): mute: bool = fields_w_default.Mute vol: int = fields_w_default.Volume vol_f: float = fields_w_default.VolumeF + vol_f_buffer: float = fields_w_default.VolumeFBuffer vol_min: int = fields_w_default.VolumeMin vol_max: int = fields_w_default.VolumeMax disabled: bool = fields_w_default.Disabled diff --git a/web/src/App.jsx b/web/src/App.jsx index 4e28a7d3c..90a03e481 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -50,7 +50,11 @@ export const useStatusStore = create((set, get) => ({ applyPlayerVol(vol, zones, sourceId, (zone_id, new_vol) => { for (const i in s.status.zones) { if (s.status.zones[i].id === zone_id) { - s.status.zones[i].vol_f = new_vol; + let true_vol = Math.round((new_vol + s.status.zones[i].vol_f_buffer) * 100) / 100; + let clamped = Math.min(Math.max(true_vol, 0), 1); + + s.status.zones[i].vol_f = clamped; + s.status.zones[i].vol_f_buffer = true_vol - clamped; } } }); @@ -61,11 +65,17 @@ export const useStatusStore = create((set, get) => ({ setZonesMute: (mute, zones, source_id) => { set( produce((s) => { - for (const i of getSourceZones(source_id, zones)) { - for (const j of s.status.zones) { - if (j.id === i.id) { - j.mute = mute; - } + const affectedZones = getSourceZones(source_id, zones).map(z => z.id); + for (const j of s.status.zones) { + if (affectedZones.includes(j.id)) { + j.mute = mute; + } + } + + // Also update groups that consist entirely of affected zones + for (const g of s.status.groups) { + if (g.zones.every(zid => affectedZones.includes(zid))) { + g.mute = mute; } } }) @@ -131,7 +141,7 @@ export const useStatusStore = create((set, get) => ({ if (get().skipUpdate) { // Does .then() into skipUpdate and ignores api output to help avoid race conditions set({ skipUpdate: false }); - } else { + } else if(get().status == null){ set({ status: s, loaded: true, disconnected: false }); } }); @@ -164,7 +174,7 @@ export const useStatusStore = create((set, get) => ({ const g = s.status.groups.filter((g) => g.id === groupId)[0]; for (const i of g.zones) { s.skipUpdate = true; - s.status.zones[i].vol_f = new_vol; + s.status.zones[i].vol_f = new_vol + s.status.zones[i].vol_f_buffer; } updateGroupVols(s); @@ -198,7 +208,7 @@ export const useStatusStore = create((set, get) => ({ const updateGroupVols = (s) => { s.status.groups.forEach((g) => { if (g.zones.length > 1) { - const vols = g.zones.map((id) => s.status.zones[id].vol_f); + const vols = g.zones.map((id) => s.status.zones[id].vol_f + s.status.zones[id].vol_f_buffer); let calculated_vol = Math.min(...vols) * 0.5 + Math.max(...vols) * 0.5; g.vol_f = calculated_vol; } else if (g.zones.length == 1) { @@ -226,14 +236,14 @@ Page.propTypes = { const App = ({ selectedPage }) => { return ( -
+
{/* Used to make sure the background doesn't stretch or stop prematurely on scrollable pages */} -
- -
- +
+
+ +
); }; App.propTypes = { diff --git a/web/src/components/CardVolumeSlider/CardVolumeSlider.jsx b/web/src/components/CardVolumeSlider/CardVolumeSlider.jsx index fdb1c0d3c..2e19876de 100644 --- a/web/src/components/CardVolumeSlider/CardVolumeSlider.jsx +++ b/web/src/components/CardVolumeSlider/CardVolumeSlider.jsx @@ -11,7 +11,7 @@ const getPlayerVol = (sourceId, zones) => { let n = 0; for (const i of getSourceZones(sourceId, zones)) { n += 1; - vol += i.vol_f; + vol += i.vol_f + i.vol_f_buffer; // Add buffer to retain proper relative space when doing an action that would un-overload the slider } const avg = vol / n; @@ -27,13 +27,12 @@ export const applyPlayerVol = (vol, zones, sourceId, apply) => { let delta = vol - getPlayerVol(sourceId, zones); for (let i of getSourceZones(sourceId, zones)) { - let set_pt = Math.max(0, Math.min(1, i.vol_f + delta)); - apply(i.id, set_pt); + apply(i.id, i.vol_f + delta); } }; // cumulativeDelta reflects the amount of movement that the -let cumulativeDelta = 0; +let cumulativeDelta = 0.0; let sendingPacketCount = 0; // main volume slider on player and volume slider on player card @@ -41,8 +40,9 @@ const CardVolumeSlider = ({ sourceId }) => { const zones = useStatusStore((s) => s.status.zones); const setZonesVol = useStatusStore((s) => s.setZonesVol); const setZonesMute = useStatusStore((s) => s.setZonesMute); + const setSystemState = useStatusStore((s) => s.setSystemState); - // needed to ensure that polling doesn't cause the delta volume to be made inacurrate during volume slider interactions + // needed to ensure that polling doesn't cause the delta volume to be made inaccurate during volume slider interactions const skipNextUpdate = useStatusStore((s) => s.skipNextUpdate); const value = getPlayerVol(sourceId, zones); @@ -69,15 +69,16 @@ const CardVolumeSlider = ({ sourceId }) => { zones: getSourceZones(sourceId, zones).map((z) => z.id), update: { vol_delta_f: cumulativeDelta, mute: false }, }), - }).then(() => { + }).then(res => { // NOTE: This used to just set cumulativeDelta to 0 // that would skip all accumulated delta from fetch start to backend response time // causing jittering issues cumulativeDelta -= delta; sendingPacketCount -= 1; + if(res.ok){res.json().then(s => setSystemState(s));} }); } - }; + } const mute = getSourceZones(sourceId, zones) .map((z) => z.mute) diff --git a/web/src/components/GroupVolumeSlider/GroupVolumeSlider.jsx b/web/src/components/GroupVolumeSlider/GroupVolumeSlider.jsx index f1edf40cb..22d74b331 100644 --- a/web/src/components/GroupVolumeSlider/GroupVolumeSlider.jsx +++ b/web/src/components/GroupVolumeSlider/GroupVolumeSlider.jsx @@ -16,11 +16,20 @@ let sendingRequestCount = 0; // volume slider for a group in the volumes drawer const GroupVolumeSlider = ({ groupId, sourceId, groupsLeft }) => { const group = useStatusStore(s => s.status.groups.filter(g => g.id === groupId)[0]); - const volume = group.vol_f; + const zones = useStatusStore(s => s.status.zones); const setGroupVol = useStatusStore(s => s.setGroupVol); const setGroupMute = useStatusStore(s => s.setGroupMute); const [slidersOpen, setSlidersOpen] = React.useState(false); + const getVolume = () => { // Make sure group sliders account for vol_f_buffer + let v = 0; + for(let i = 0; i < group.zones.length; i++){ + v += (zones[group.zones[i]].vol_f + zones[group.zones[i]].vol_f_buffer); + } + + return v / group.zones.length; + }; + const volume = getVolume(); // get zones for this group const groupZones = getSourceZones(sourceId, useStatusStore(s => s.status.zones)).filter(z => group.zones.includes(z.id)); diff --git a/web/src/components/VolumeSlider/VolumeSlider.jsx b/web/src/components/VolumeSlider/VolumeSlider.jsx index 8fa967f32..73660fc3f 100644 --- a/web/src/components/VolumeSlider/VolumeSlider.jsx +++ b/web/src/components/VolumeSlider/VolumeSlider.jsx @@ -71,31 +71,31 @@ const VolumeSlider = ({ vol, mute, setVol, setMute, disabled }) => { >
- { - if (isIOS() && e.type === "mousedown") { - return; - } - handleVolChange(val); - }} - onChangeCommitted={(e, val) => { - if (isIOS() && e.type === "mouseup") { - return; - } - handleVolChange(val, true); - }} - /> - + { + if (isIOS() && e.type === "mousedown") { + return; + } + handleVolChange(val); + }} + onChangeCommitted={(e, val) => { + if (isIOS() && e.type === "mouseup") { + return; + } + handleVolChange(val, true); + }} + /> + ); }; diff --git a/web/src/components/VolumeZones/VolumeZones.jsx b/web/src/components/VolumeZones/VolumeZones.jsx index 46f28907f..036382756 100644 --- a/web/src/components/VolumeZones/VolumeZones.jsx +++ b/web/src/components/VolumeZones/VolumeZones.jsx @@ -31,12 +31,12 @@ const VolumeZones = ({ sourceId, open, zones, groups, groupsLeft, alone }) => { }); if(open){ - return ( -
- {groupVolumeSliders} - {zoneVolumeSliders} -
- ); + return ( +
+ {groupVolumeSliders} + {zoneVolumeSliders} +
+ ); } }; VolumeZones.propTypes = { From ac5f73129a3ce8ff5c304321e786d6768ab351de Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Wed, 29 Oct 2025 16:26:15 -0400 Subject: [PATCH 02/49] Reactivate /api calls, add instant polling to all mute changes --- web/src/App.jsx | 2 +- web/src/components/CardVolumeSlider/CardVolumeSlider.jsx | 2 ++ web/src/components/GroupVolumeSlider/GroupVolumeSlider.jsx | 3 +++ web/src/components/ZoneVolumeSlider/ZoneVolumeSlider.jsx | 3 +++ 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/web/src/App.jsx b/web/src/App.jsx index 90a03e481..1af357680 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -141,7 +141,7 @@ export const useStatusStore = create((set, get) => ({ if (get().skipUpdate) { // Does .then() into skipUpdate and ignores api output to help avoid race conditions set({ skipUpdate: false }); - } else if(get().status == null){ + } else { set({ status: s, loaded: true, disconnected: false }); } }); diff --git a/web/src/components/CardVolumeSlider/CardVolumeSlider.jsx b/web/src/components/CardVolumeSlider/CardVolumeSlider.jsx index 2e19876de..7e1f72988 100644 --- a/web/src/components/CardVolumeSlider/CardVolumeSlider.jsx +++ b/web/src/components/CardVolumeSlider/CardVolumeSlider.jsx @@ -96,6 +96,8 @@ const CardVolumeSlider = ({ sourceId }) => { zones: getSourceZones(sourceId, zones).map((z) => z.id), update: { mute: mute }, }), + }).then(res => { + if(res.ok){res.json().then(s => setSystemState(s));} }); }; diff --git a/web/src/components/GroupVolumeSlider/GroupVolumeSlider.jsx b/web/src/components/GroupVolumeSlider/GroupVolumeSlider.jsx index 22d74b331..d6fd9e78b 100644 --- a/web/src/components/GroupVolumeSlider/GroupVolumeSlider.jsx +++ b/web/src/components/GroupVolumeSlider/GroupVolumeSlider.jsx @@ -15,6 +15,7 @@ let sendingRequestCount = 0; // volume slider for a group in the volumes drawer const GroupVolumeSlider = ({ groupId, sourceId, groupsLeft }) => { + const setSystemState = useStatusStore((s) => s.setSystemState); const group = useStatusStore(s => s.status.groups.filter(g => g.id === groupId)[0]); const zones = useStatusStore(s => s.status.zones); const setGroupVol = useStatusStore(s => s.setGroupVol); @@ -77,6 +78,8 @@ const GroupVolumeSlider = ({ groupId, sourceId, groupsLeft }) => { "Content-type": "application/json", }, body: JSON.stringify({ mute: mute }), + }).then(res => { + if(res.ok){res.json().then(s => setSystemState(s));} }); }; diff --git a/web/src/components/ZoneVolumeSlider/ZoneVolumeSlider.jsx b/web/src/components/ZoneVolumeSlider/ZoneVolumeSlider.jsx index 710b215f7..9367c0755 100644 --- a/web/src/components/ZoneVolumeSlider/ZoneVolumeSlider.jsx +++ b/web/src/components/ZoneVolumeSlider/ZoneVolumeSlider.jsx @@ -9,6 +9,7 @@ let sendingRequestCount = 0; // Volume slider for individual zone in volume drawer const ZoneVolumeSlider = ({ zoneId, alone }) => { + const setSystemState = useStatusStore((s) => s.setSystemState); const zoneName = useStatusStore((s) => s.status.zones[zoneId].name); const volume = useStatusStore((s) => s.status.zones[zoneId].vol_f); const mute = useStatusStore((s) => s.status.zones[zoneId].mute); @@ -42,6 +43,8 @@ const ZoneVolumeSlider = ({ zoneId, alone }) => { "Content-type": "application/json", }, body: JSON.stringify({ mute: mute }), + }).then(res => { + if(res.ok){res.json().then(s => setSystemState(s));} }); }; From b0b81021ad6c7a6ce690fd50cafe48826df592a3 Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Wed, 5 Nov 2025 10:22:01 -0500 Subject: [PATCH 03/49] Swap "buffer" verbiage to "overflow" --- amplipi/ctrl.py | 6 +++--- amplipi/defaults.py | 12 ++++++------ amplipi/models.py | 6 +++--- web/src/App.jsx | 8 ++++---- .../components/CardVolumeSlider/CardVolumeSlider.jsx | 2 +- .../GroupVolumeSlider/GroupVolumeSlider.jsx | 4 ++-- 6 files changed, 19 insertions(+), 19 deletions(-) diff --git a/amplipi/ctrl.py b/amplipi/ctrl.py index 7e3f6335a..8240eaab4 100644 --- a/amplipi/ctrl.py +++ b/amplipi/ctrl.py @@ -852,13 +852,13 @@ def set_vol(): # Field precedence: vol (db) > vol_delta > vol (float) # NOTE: checks happen in reverse precedence to cover default case of unchanged volume if update.vol_delta_f is not None and update.vol is None: - true_vol_f = zone.vol_f + zone.vol_f_buffer + true_vol_f = zone.vol_f + zone.vol_f_overflow totaled_delta = update.vol_delta_f + true_vol_f applied_delta = utils.clamp(totaled_delta, 0, 1) vol_db = utils.vol_float_to_db(applied_delta, zone.vol_min, zone.vol_max) vol_f_new = applied_delta - zone.vol_f_buffer = 0 if models.MIN_VOL_F < totaled_delta and totaled_delta < models.MAX_VOL_F else utils.clamp((totaled_delta - applied_delta), -1.0, 1.0) + zone.vol_f_overflow = 0 if models.MIN_VOL_F < totaled_delta and totaled_delta < models.MAX_VOL_F else utils.clamp((totaled_delta - applied_delta), -1.0, 1.0) elif update.vol_f is not None and update.vol is None: clamp_vol_f = utils.clamp(vol_f, 0, 1) @@ -875,7 +875,7 @@ def set_vol(): else: raise Exception('unable to update zone volume') - zone.vol_f_buffer = 0 if models.MIN_VOL_F < vol_f_new and vol_f_new < models.MAX_VOL_F else zone.vol_f_buffer + zone.vol_f_overflow = 0 if models.MIN_VOL_F < vol_f_new and vol_f_new < models.MAX_VOL_F else zone.vol_f_overflow # To avoid potential unwanted loud output: # If muting, mute before setting volumes diff --git a/amplipi/defaults.py b/amplipi/defaults.py index 5b7bb6661..76f69eb3a 100644 --- a/amplipi/defaults.py +++ b/amplipi/defaults.py @@ -41,17 +41,17 @@ ], "zones": [ # this is an array of zones, array length depends on # of boxes connected {"id": 0, "name": "Zone 1", "source_id": 0, "mute": True, "disabled": False, - "vol_f": models.MIN_VOL_F, "vol_f_buffer": 0.0, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, + "vol_f": models.MIN_VOL_F, "vol_f_overflow": 0.0, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, {"id": 1, "name": "Zone 2", "source_id": 0, "mute": True, "disabled": False, - "vol_f": models.MIN_VOL_F, "vol_f_buffer": 0.0, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, + "vol_f": models.MIN_VOL_F, "vol_f_overflow": 0.0, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, {"id": 2, "name": "Zone 3", "source_id": 0, "mute": True, "disabled": False, - "vol_f": models.MIN_VOL_F, "vol_f_buffer": 0.0, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, + "vol_f": models.MIN_VOL_F, "vol_f_overflow": 0.0, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, {"id": 3, "name": "Zone 4", "source_id": 0, "mute": True, "disabled": False, - "vol_f": models.MIN_VOL_F, "vol_f_buffer": 0.0, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, + "vol_f": models.MIN_VOL_F, "vol_f_overflow": 0.0, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, {"id": 4, "name": "Zone 5", "source_id": 0, "mute": True, "disabled": False, - "vol_f": models.MIN_VOL_F, "vol_f_buffer": 0.0, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, + "vol_f": models.MIN_VOL_F, "vol_f_overflow": 0.0, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, {"id": 5, "name": "Zone 6", "source_id": 0, "mute": True, "disabled": False, - "vol_f": models.MIN_VOL_F, "vol_f_buffer": 0.0, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, + "vol_f": models.MIN_VOL_F, "vol_f_overflow": 0.0, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, ], "groups": [ ], diff --git a/amplipi/models.py b/amplipi/models.py index 88d45b947..d13608bdb 100644 --- a/amplipi/models.py +++ b/amplipi/models.py @@ -111,8 +111,8 @@ class fields_w_default(SimpleNamespace): Volume = Field(default=MIN_VOL_DB, ge=MIN_VOL_DB, le=MAX_VOL_DB, description='Output volume in dB') VolumeF = Field(default=MIN_VOL_F, ge=MIN_VOL_F, le=MAX_VOL_F, description='Output volume as a floating-point scalar from 0.0 to 1.0 representing MIN_VOL_DB to MAX_VOL_DB') - VolumeFBuffer = Field(default=0.0, ge=(MIN_VOL_F - MAX_VOL_F), le=(MAX_VOL_F - MIN_VOL_F), - description='Output volume as a floating-point scalar that has a range equal to MIN_VOL_F - MAX_VOL_F in both directions from zero, and is used to keep track of the relative distance between two or more zone volumes when they would otherwise have to exceed their VOL_F range') + VolumeFOverflow = Field(default=0.0, ge=(MIN_VOL_F - MAX_VOL_F), le=(MAX_VOL_F - MIN_VOL_F), + description='Output volume as a floating-point scalar that has a range equal to MIN_VOL_F - MAX_VOL_F in both directions from zero, and is used to keep track of the relative distance between two or more zone volumes when they would otherwise have to exceed their VOL_F range') VolumeMin = Field(default=MIN_VOL_DB, ge=MIN_VOL_DB, le=MAX_VOL_DB, description='Min output volume in dB') VolumeMax = Field(default=MAX_VOL_DB, ge=MIN_VOL_DB, le=MAX_VOL_DB, @@ -323,7 +323,7 @@ class Zone(Base): mute: bool = fields_w_default.Mute vol: int = fields_w_default.Volume vol_f: float = fields_w_default.VolumeF - vol_f_buffer: float = fields_w_default.VolumeFBuffer + vol_f_overflow: float = fields_w_default.VolumeFOverflow vol_min: int = fields_w_default.VolumeMin vol_max: int = fields_w_default.VolumeMax disabled: bool = fields_w_default.Disabled diff --git a/web/src/App.jsx b/web/src/App.jsx index 1af357680..0b82939ca 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -50,11 +50,11 @@ export const useStatusStore = create((set, get) => ({ applyPlayerVol(vol, zones, sourceId, (zone_id, new_vol) => { for (const i in s.status.zones) { if (s.status.zones[i].id === zone_id) { - let true_vol = Math.round((new_vol + s.status.zones[i].vol_f_buffer) * 100) / 100; + let true_vol = Math.round((new_vol + s.status.zones[i].vol_f_overflow) * 100) / 100; let clamped = Math.min(Math.max(true_vol, 0), 1); s.status.zones[i].vol_f = clamped; - s.status.zones[i].vol_f_buffer = true_vol - clamped; + s.status.zones[i].vol_f_overflow = true_vol - clamped; } } }); @@ -174,7 +174,7 @@ export const useStatusStore = create((set, get) => ({ const g = s.status.groups.filter((g) => g.id === groupId)[0]; for (const i of g.zones) { s.skipUpdate = true; - s.status.zones[i].vol_f = new_vol + s.status.zones[i].vol_f_buffer; + s.status.zones[i].vol_f = new_vol + s.status.zones[i].vol_f_overflow; } updateGroupVols(s); @@ -208,7 +208,7 @@ export const useStatusStore = create((set, get) => ({ const updateGroupVols = (s) => { s.status.groups.forEach((g) => { if (g.zones.length > 1) { - const vols = g.zones.map((id) => s.status.zones[id].vol_f + s.status.zones[id].vol_f_buffer); + const vols = g.zones.map((id) => s.status.zones[id].vol_f + s.status.zones[id].vol_f_overflow); let calculated_vol = Math.min(...vols) * 0.5 + Math.max(...vols) * 0.5; g.vol_f = calculated_vol; } else if (g.zones.length == 1) { diff --git a/web/src/components/CardVolumeSlider/CardVolumeSlider.jsx b/web/src/components/CardVolumeSlider/CardVolumeSlider.jsx index 7e1f72988..6dd055884 100644 --- a/web/src/components/CardVolumeSlider/CardVolumeSlider.jsx +++ b/web/src/components/CardVolumeSlider/CardVolumeSlider.jsx @@ -11,7 +11,7 @@ const getPlayerVol = (sourceId, zones) => { let n = 0; for (const i of getSourceZones(sourceId, zones)) { n += 1; - vol += i.vol_f + i.vol_f_buffer; // Add buffer to retain proper relative space when doing an action that would un-overload the slider + vol += i.vol_f + i.vol_f_overflow; // Add buffer to retain proper relative space when doing an action that would un-overload the slider } const avg = vol / n; diff --git a/web/src/components/GroupVolumeSlider/GroupVolumeSlider.jsx b/web/src/components/GroupVolumeSlider/GroupVolumeSlider.jsx index d6fd9e78b..aeda0c234 100644 --- a/web/src/components/GroupVolumeSlider/GroupVolumeSlider.jsx +++ b/web/src/components/GroupVolumeSlider/GroupVolumeSlider.jsx @@ -22,10 +22,10 @@ const GroupVolumeSlider = ({ groupId, sourceId, groupsLeft }) => { const setGroupMute = useStatusStore(s => s.setGroupMute); const [slidersOpen, setSlidersOpen] = React.useState(false); - const getVolume = () => { // Make sure group sliders account for vol_f_buffer + const getVolume = () => { // Make sure group sliders account for vol_f_overflow let v = 0; for(let i = 0; i < group.zones.length; i++){ - v += (zones[group.zones[i]].vol_f + zones[group.zones[i]].vol_f_buffer); + v += (zones[group.zones[i]].vol_f + zones[group.zones[i]].vol_f_overflow); } return v / group.zones.length; From 8680590b4144d75963f3474c80c6f8ebd2512fe5 Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Fri, 7 Nov 2025 17:08:15 -0500 Subject: [PATCH 04/49] Reconfigure check to be not as confusing to read --- amplipi/ctrl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amplipi/ctrl.py b/amplipi/ctrl.py index 8240eaab4..e5de0333b 100644 --- a/amplipi/ctrl.py +++ b/amplipi/ctrl.py @@ -875,7 +875,7 @@ def set_vol(): else: raise Exception('unable to update zone volume') - zone.vol_f_overflow = 0 if models.MIN_VOL_F < vol_f_new and vol_f_new < models.MAX_VOL_F else zone.vol_f_overflow + zone.vol_f_overflow = 0 if vol_f_new != models.MIN_VOL_F or vol_f_new != models.MAX_VOL_F else zone.vol_f_overflow # To avoid potential unwanted loud output: # If muting, mute before setting volumes From b3949e20313f2401f6805c429d17122311abf8b3 Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Mon, 10 Nov 2025 10:46:38 -0500 Subject: [PATCH 05/49] Improve variable names, documentation Add more values to models.py to track the min and max overflow --- amplipi/ctrl.py | 15 ++++++++------- amplipi/models.py | 8 +++++++- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/amplipi/ctrl.py b/amplipi/ctrl.py index e5de0333b..8efbcdf96 100644 --- a/amplipi/ctrl.py +++ b/amplipi/ctrl.py @@ -847,18 +847,17 @@ def set_mute(): def set_vol(): """ Update the zone's volume. Could be triggered by a change in - vol, vol_f, vol_min, or vol_max. + vol, vol_f, vol_f_delta, vol_min, or vol_max. """ # Field precedence: vol (db) > vol_delta > vol (float) - # NOTE: checks happen in reverse precedence to cover default case of unchanged volume + # vol (db) is last in the stack to cover the default case of a None volume change, but when it does have a value it takes overrides the others if update.vol_delta_f is not None and update.vol is None: true_vol_f = zone.vol_f + zone.vol_f_overflow - totaled_delta = update.vol_delta_f + true_vol_f - applied_delta = utils.clamp(totaled_delta, 0, 1) + expected_vol_total = update.vol_delta_f + true_vol_f + vol_f_new = utils.clamp(expected_vol_total, models.MIN_VOL_F, models.MAX_VOL_F) - vol_db = utils.vol_float_to_db(applied_delta, zone.vol_min, zone.vol_max) - vol_f_new = applied_delta - zone.vol_f_overflow = 0 if models.MIN_VOL_F < totaled_delta and totaled_delta < models.MAX_VOL_F else utils.clamp((totaled_delta - applied_delta), -1.0, 1.0) + vol_db = utils.vol_float_to_db(vol_f_new, zone.vol_min, zone.vol_max) + zone.vol_f_overflow = 0 if models.MIN_VOL_F < expected_vol_total and expected_vol_total < models.MAX_VOL_F else utils.clamp((expected_vol_total - vol_f_new), models.MIN_VOL_F_OVERFLOW, models.MAX_VOL_F_OVERFLOW) # Clamp the remaining delta to be between -1 and 1 elif update.vol_f is not None and update.vol is None: clamp_vol_f = utils.clamp(vol_f, 0, 1) @@ -875,6 +874,8 @@ def set_vol(): else: raise Exception('unable to update zone volume') + # If the change made vol f be between the min and max, delete the overflow + # This is useful so that you can click wherever you want on the volume bar and expect it to end up there without rubberbanding back to whatever vol_f + vol_f_overflow value you'd otherwise be at zone.vol_f_overflow = 0 if vol_f_new != models.MIN_VOL_F or vol_f_new != models.MAX_VOL_F else zone.vol_f_overflow # To avoid potential unwanted loud output: diff --git a/amplipi/models.py b/amplipi/models.py index d13608bdb..61a26ede3 100644 --- a/amplipi/models.py +++ b/amplipi/models.py @@ -38,6 +38,12 @@ MAX_VOL_F = 1.0 """ Max volume for slider bar. Will be mapped to dB. """ +MIN_VOL_F_OVERFLOW = MIN_VOL_F - MAX_VOL_F +"""Min overflow for volume sliders, set to be the full range of vol_f below zero""" + +MAX_VOL_F_OVERFLOW = MAX_VOL_F - MIN_VOL_F +"""Max overflow for volume sliders, set to be the full range of vol_f above zero""" + MIN_VOL_DB = -80 """ Min volume in dB. -80 is special and is actually -90 dB (mute). """ @@ -111,7 +117,7 @@ class fields_w_default(SimpleNamespace): Volume = Field(default=MIN_VOL_DB, ge=MIN_VOL_DB, le=MAX_VOL_DB, description='Output volume in dB') VolumeF = Field(default=MIN_VOL_F, ge=MIN_VOL_F, le=MAX_VOL_F, description='Output volume as a floating-point scalar from 0.0 to 1.0 representing MIN_VOL_DB to MAX_VOL_DB') - VolumeFOverflow = Field(default=0.0, ge=(MIN_VOL_F - MAX_VOL_F), le=(MAX_VOL_F - MIN_VOL_F), + VolumeFOverflow = Field(default=0.0, ge=MIN_VOL_F_OVERFLOW, le=MAX_VOL_F_OVERFLOW, description='Output volume as a floating-point scalar that has a range equal to MIN_VOL_F - MAX_VOL_F in both directions from zero, and is used to keep track of the relative distance between two or more zone volumes when they would otherwise have to exceed their VOL_F range') VolumeMin = Field(default=MIN_VOL_DB, ge=MIN_VOL_DB, le=MAX_VOL_DB, description='Min output volume in dB') From 66e4a49362da216a12c3ee1fa5f9bd00c5632331 Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Mon, 17 Nov 2025 17:03:24 -0500 Subject: [PATCH 06/49] Don't always reset overflow to zero immediately by changing "or" to "and" --- amplipi/ctrl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amplipi/ctrl.py b/amplipi/ctrl.py index 8efbcdf96..e158db2b6 100644 --- a/amplipi/ctrl.py +++ b/amplipi/ctrl.py @@ -876,7 +876,7 @@ def set_vol(): # If the change made vol f be between the min and max, delete the overflow # This is useful so that you can click wherever you want on the volume bar and expect it to end up there without rubberbanding back to whatever vol_f + vol_f_overflow value you'd otherwise be at - zone.vol_f_overflow = 0 if vol_f_new != models.MIN_VOL_F or vol_f_new != models.MAX_VOL_F else zone.vol_f_overflow + zone.vol_f_overflow = 0 if vol_f_new != models.MIN_VOL_F and vol_f_new != models.MAX_VOL_F else zone.vol_f_overflow # To avoid potential unwanted loud output: # If muting, mute before setting volumes From 8a96a17b93a5127040c41c6bd835cafbab59d51a Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Wed, 26 Nov 2025 09:40:15 -0500 Subject: [PATCH 07/49] Fix some documentation oddities --- amplipi/ctrl.py | 2 +- amplipi/models.py | 2 +- web/src/components/CardVolumeSlider/CardVolumeSlider.jsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/amplipi/ctrl.py b/amplipi/ctrl.py index e158db2b6..c07efbc45 100644 --- a/amplipi/ctrl.py +++ b/amplipi/ctrl.py @@ -850,7 +850,7 @@ def set_vol(): vol, vol_f, vol_f_delta, vol_min, or vol_max. """ # Field precedence: vol (db) > vol_delta > vol (float) - # vol (db) is last in the stack to cover the default case of a None volume change, but when it does have a value it takes overrides the others + # vol (db) is first in precedence yet last in the stack to cover the default case of a None volume change, but when it does have a value it overrides the other options if update.vol_delta_f is not None and update.vol is None: true_vol_f = zone.vol_f + zone.vol_f_overflow expected_vol_total = update.vol_delta_f + true_vol_f diff --git a/amplipi/models.py b/amplipi/models.py index 61a26ede3..11f1804af 100644 --- a/amplipi/models.py +++ b/amplipi/models.py @@ -93,7 +93,7 @@ class fields(SimpleNamespace): VolumeMax = Field(ge=MIN_VOL_DB, le=MAX_VOL_DB, description='Max output volume in dB') GroupMute = Field(description='Set to true if output is all zones muted') GroupVolume = Field(ge=MIN_VOL_DB, le=MAX_VOL_DB, description='Average output volume') - GroupVolumeF = Field(ge=MIN_VOL_F, le=MAX_VOL_F, description='Average output volume as a floating-point number.') + GroupVolumeF = Field(ge=MIN_VOL_F, le=MAX_VOL_F, description='Average output volume as a floating-point number') Disabled = Field(description='Set to true if not connected to a speaker') Zones = Field(description='Set of zone ids belonging to a group') Groups = Field(description='List of group ids') diff --git a/web/src/components/CardVolumeSlider/CardVolumeSlider.jsx b/web/src/components/CardVolumeSlider/CardVolumeSlider.jsx index 6dd055884..227a7e537 100644 --- a/web/src/components/CardVolumeSlider/CardVolumeSlider.jsx +++ b/web/src/components/CardVolumeSlider/CardVolumeSlider.jsx @@ -31,7 +31,7 @@ export const applyPlayerVol = (vol, zones, sourceId, apply) => { } }; -// cumulativeDelta reflects the amount of movement that the +// cumulativeDelta reflects the amount of movement that the volume bar has had that has gone unreflected in the backend let cumulativeDelta = 0.0; let sendingPacketCount = 0; From 7b703b757e9758b73e535f0f45d32860806c1cbf Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Mon, 1 Dec 2025 14:35:55 -0500 Subject: [PATCH 08/49] Update changelog, add test --- CHANGELOG.md | 4 +--- tests/test_rest.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8091f3249..6b587a702 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,13 +3,11 @@ # Future Release * Web App * Fixed internet radio search functionality + * Upgraded volume calculations to preserve relative positions when hitting the min or max setting via source volume bar # 0.4.9 - * System * Update our spotify provider `go-librespot` to `0.5.2` to accomodate spotify's API update -* Web App - * Upgraded volume calculations to preserve relative positions when hitting the min or max setting via source volume bar ## 0.4.8 * System diff --git a/tests/test_rest.py b/tests/test_rest.py index a474aeda5..c5c34bdf1 100644 --- a/tests/test_rest.py +++ b/tests/test_rest.py @@ -607,6 +607,25 @@ def test_patch_zones_vol_delta(client): if z['id'] in range(6): assert z['vol_f'] - (zones[z['id']]['vol_f'] + 0.1) < 0.0001 + +def test_patch_zones_vol_delta_overflow(client): + """ Try changing multiple zones volume using volume delta with an overflowing vol_f """ + zones = [z for z in base_config()['zones']] + # set each zone to a random initial volume + for z in zones: + z['vol_f'] = random.uniform(0.0, 0.9) # random initial volume + rv = client.patch('/api/zones/{}'.format(z['id']), json={'vol_f': z['vol_f']}) + assert rv.status_code == HTTPStatus.OK + # update each zones volume by 100% + rv = client.patch('/api/zones', json={'zones': [z['id'] for z in zones], 'update': {'vol_delta_f': 1.0}}) + assert rv.status_code == HTTPStatus.OK + jrv = rv.json() + assert len(jrv['zones']) >= 6 + # check that each update worked as expected + for z in jrv['zones']: + if z['id'] in range(6): + assert z['vol_f'] == 1.0 and z['vol_f_overflow'] > 0 + # test oversized deltas rv = client.patch('/api/zones', json={'zones': [z['id'] for z in zones], 'update': {'vol_delta_f': -10.0}}) assert rv.status_code == HTTPStatus.OK From 87e72d6d7aa919c13837b5fc08fbb0ef8693490d Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Mon, 1 Dec 2025 15:50:08 -0500 Subject: [PATCH 09/49] Update tests instead --- tests/test_rest.py | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/tests/test_rest.py b/tests/test_rest.py index c5c34bdf1..7bf21e7ae 100644 --- a/tests/test_rest.py +++ b/tests/test_rest.py @@ -605,39 +605,33 @@ def test_patch_zones_vol_delta(client): # check that each update worked as expected for z in jrv['zones']: if z['id'] in range(6): - assert z['vol_f'] - (zones[z['id']]['vol_f'] + 0.1) < 0.0001 + assert z['vol_f'] - (zones[z['id']]['vol_f'] + 0.1) < 0.0001 and z["vol_f_overflow"] == 0 - -def test_patch_zones_vol_delta_overflow(client): - """ Try changing multiple zones volume using volume delta with an overflowing vol_f """ - zones = [z for z in base_config()['zones']] - # set each zone to a random initial volume - for z in zones: - z['vol_f'] = random.uniform(0.0, 0.9) # random initial volume - rv = client.patch('/api/zones/{}'.format(z['id']), json={'vol_f': z['vol_f']}) - assert rv.status_code == HTTPStatus.OK - # update each zones volume by 100% - rv = client.patch('/api/zones', json={'zones': [z['id'] for z in zones], 'update': {'vol_delta_f': 1.0}}) + # test oversized deltas + rv = client.patch('/api/zones', json={'zones': [z['id'] for z in zones], 'update': {'vol_delta_f': -1.0}}) assert rv.status_code == HTTPStatus.OK jrv = rv.json() assert len(jrv['zones']) >= 6 # check that each update worked as expected for z in jrv['zones']: if z['id'] in range(6): - assert z['vol_f'] == 1.0 and z['vol_f_overflow'] > 0 + assert z['vol_f'] == amplipi.models.MIN_VOL_F + assert z["vol_f_overflow"] == zones[z['id']]['vol_f'] + 0.1 - 1 - # test oversized deltas - rv = client.patch('/api/zones', json={'zones': [z['id'] for z in zones], 'update': {'vol_delta_f': -10.0}}) + # test oveflow reset + mid_vol_f = (amplipi.models.MIN_VOL_F + amplipi.models.MAX_VOL_F) / 2 + rv = client.patch('/api/zones', json={'zones': [z['id'] for z in zones], 'update': {'vol_f': mid_vol_f}}) assert rv.status_code == HTTPStatus.OK jrv = rv.json() assert len(jrv['zones']) >= 6 # check that each update worked as expected for z in jrv['zones']: if z['id'] in range(6): - assert z['vol_f'] == amplipi.models.MIN_VOL_F + assert z['vol_f'] == mid_vol_f + assert z["vol_f_overflow"] == 0 # test precedence - rv = client.patch('/api/zones', json={'zones': [z['id'] for z in zones], 'update': {'vol_delta_f': 10.0, "vol": amplipi.models.MIN_VOL_DB}}) + rv = client.patch('/api/zones', json={'zones': [z['id'] for z in zones], 'update': {'vol_delta_f': 1.0, "vol": amplipi.models.MIN_VOL_DB}}) assert rv.status_code == HTTPStatus.OK jrv = rv.json() assert len(jrv['zones']) >= 6 From 98b164421c092db5a37db8cd409e430f1c503779 Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Mon, 1 Dec 2025 15:52:17 -0500 Subject: [PATCH 10/49] spellcheck --- tests/test_rest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_rest.py b/tests/test_rest.py index 7bf21e7ae..76b841849 100644 --- a/tests/test_rest.py +++ b/tests/test_rest.py @@ -618,7 +618,7 @@ def test_patch_zones_vol_delta(client): assert z['vol_f'] == amplipi.models.MIN_VOL_F assert z["vol_f_overflow"] == zones[z['id']]['vol_f'] + 0.1 - 1 - # test oveflow reset + # test overflow reset mid_vol_f = (amplipi.models.MIN_VOL_F + amplipi.models.MAX_VOL_F) / 2 rv = client.patch('/api/zones', json={'zones': [z['id'] for z in zones], 'update': {'vol_f': mid_vol_f}}) assert rv.status_code == HTTPStatus.OK From 6fc3b896b1ddc52f106f988b59201ea188da8663 Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Mon, 1 Dec 2025 15:55:17 -0500 Subject: [PATCH 11/49] Add test for excessively large overflows --- tests/test_rest.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/test_rest.py b/tests/test_rest.py index 76b841849..867c003bd 100644 --- a/tests/test_rest.py +++ b/tests/test_rest.py @@ -607,7 +607,7 @@ def test_patch_zones_vol_delta(client): if z['id'] in range(6): assert z['vol_f'] - (zones[z['id']]['vol_f'] + 0.1) < 0.0001 and z["vol_f_overflow"] == 0 - # test oversized deltas + # test overflowing deltas rv = client.patch('/api/zones', json={'zones': [z['id'] for z in zones], 'update': {'vol_delta_f': -1.0}}) assert rv.status_code == HTTPStatus.OK jrv = rv.json() @@ -618,6 +618,17 @@ def test_patch_zones_vol_delta(client): assert z['vol_f'] == amplipi.models.MIN_VOL_F assert z["vol_f_overflow"] == zones[z['id']]['vol_f'] + 0.1 - 1 + # test oversized overflowing deltas + rv = client.patch('/api/zones', json={'zones': [z['id'] for z in zones], 'update': {'vol_delta_f': 10.0}}) + assert rv.status_code == HTTPStatus.OK + jrv = rv.json() + assert len(jrv['zones']) >= 6 + # check that each update worked as expected + for z in jrv['zones']: + if z['id'] in range(6): + assert z['vol_f'] == amplipi.models.MAX_VOL_F + assert z["vol_f_overflow"] == amplipi.models.MAX_VOL_F_OVERFLOW + # test overflow reset mid_vol_f = (amplipi.models.MIN_VOL_F + amplipi.models.MAX_VOL_F) / 2 rv = client.patch('/api/zones', json={'zones': [z['id'] for z in zones], 'update': {'vol_f': mid_vol_f}}) From d830e2dbe3800c0ddcd1ec3583c366a341e8badf Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Fri, 31 Oct 2025 15:01:19 -0400 Subject: [PATCH 12/49] Add basic vol change functionality to spotify metadata reader --- streams/spot_connect_meta.py | 153 ++++++++++++++++++++--------------- 1 file changed, 86 insertions(+), 67 deletions(-) diff --git a/streams/spot_connect_meta.py b/streams/spot_connect_meta.py index 329ba8763..e91fac914 100644 --- a/streams/spot_connect_meta.py +++ b/streams/spot_connect_meta.py @@ -12,6 +12,7 @@ import requests from websockets.exceptions import ConnectionClosed, InvalidHandshake from websockets.sync.client import connect +# from ..amplipi import tasks @dataclass @@ -141,7 +142,7 @@ class Event: data: Union[None, Track, PlayChange, VolumeChange, SeekChange, ValueChange] = None @staticmethod - def from_json(json_data: dict) -> "Event": + def from_json(parent: "SpotifyMetadataReader", json_data: dict) -> "Event": """Create an Event object from a JSON payload""" e = Event(event_type=json_data["type"]) if e.event_type in ["active", "inactive"]: @@ -157,76 +158,94 @@ def from_json(json_data: dict) -> "Event": e.data = SeekChange(**json_data["data"]) elif e.event_type == "volume": e.data = VolumeChange(**json_data["data"]) + volume = e.data.value + if volume != parent.last_volume: + url = f"http://localhost:{parent._api_port}" # TODO: Implement spotify-side volume bar to reflect the source volume bar's position + # tasks.post.delay(url + 'volume', data={'volume': int(volume * 100)}) + delta = float((volume - parent.last_volume) / 100) + vol_change = requests.patch("http://localhost/api/zones", json={"zones": [0, 1, 2, 3, 4, 5], "update": {"vol_delta_f": delta}}, timeout=5) # TODO: set zone list correctly + if vol_change.ok: + parent.last_volume = volume # update last_volume for future syncs + elif e.event_type in ["shuffle_context", "repeat_context", "repeat_track"]: e.data = ValueChange(**json_data["data"]) return e -def read_metadata(url) -> Optional[Track]: - """ - Reads metadata from the given URL and writes it to the specified metadata file. - If the metadata file already exists, it will be overwritten. - """ - endpoint = url + "/status" - - # Send a GET request to the URL to retrieve the metadata - response = requests.get(endpoint, timeout=2) - - # Check if the request was successful - if response.status_code == 200: - # Parse the metadata from the response - return Status.from_dict(response.json()) - elif response.status_code == 204: - # the metadata isn't populated yet - return Status(stopped=True) - else: - # If the request failed, print an error message - print(f"Failed to retrieve metadata from {endpoint}. Status code: {response.status_code}") - return Status() - - -def watch_metadata(url, metadata_file, debug=False) -> None: - """ - Watches the api at `url` for metadata updates and writes the current state to `metadata_file`. - If the metadata file already exists, it will be overwritten. - """ - # Get the websocket-based event updates - ws_events = url.replace("http://", "ws://") + "/events" - try: - # read the initial state - metadata = read_metadata(url) - with open(metadata_file, 'w', encoding='utf8') as mf: - mf.write(json.dumps(asdict(metadata))) - if debug: - print(f"Initial metadata: {metadata}") - # Connect to the websocket and listen for state changes - with connect(ws_events, open_timeout=5) as websocket: - while True: - try: - msg = websocket.recv() - if debug: - print(f"Received: {msg}") - event = Event.from_json(json.loads(msg)) - if event.event_type == "metadata": - metadata.track = event.data - elif event.event_type == "playing": - metadata.stopped = False - metadata.paused = False - elif event.event_type == "paused": - metadata.paused = True - elif event.event_type == "stopped": - metadata.stopped = True - metadata.track = Track() - else: - continue - with open(args.metadata_file, 'w', encoding='utf8') as mf: - mf.write(json.dumps(asdict(metadata))) - except (KeyError, ConnectionClosed, json.JSONDecodeError) as e: - print(f"Error: {e}") - break - except (OSError, InvalidHandshake, TimeoutError) as e: - print(f"Error: {e}") - return +class SpotifyMetadataReader: + + def __init__(self, url, metadata_file, debug=False): + self.url: str = url + self.metadata_file: str = metadata_file + self.debug: bool = debug + + self.last_volume: float = 0 + self._api_port: int = 3678 + int([char for char in metadata_file if char.isdigit()][0]) + + def read_metadata(self) -> Optional[Track]: + """ + Reads metadata from the given URL and writes it to the specified metadata file. + If the metadata file already exists, it will be overwritten. + """ + endpoint = self.url + "/status" + + # Send a GET request to the URL to retrieve the metadata + response = requests.get(endpoint, timeout=2) + + # Check if the request was successful + if response.status_code == 200: + # Parse the metadata from the response + return Status.from_dict(response.json()) + elif response.status_code == 204: + # the metadata isn't populated yet + return Status(stopped=True) + else: + # If the request failed, print an error message + print(f"Failed to retrieve metadata from {endpoint}. Status code: {response.status_code}") + return Status() + + def watch_metadata(self) -> None: + """ + Watches the api at `url` for metadata updates and writes the current state to `metadata_file`. + If the metadata file already exists, it will be overwritten. + """ + # Get the websocket-based event updates + ws_events = self.url.replace("http://", "ws://") + "/events" + try: + # read the initial state + metadata = self.read_metadata() + with open(self.metadata_file, 'w', encoding='utf8') as mf: + mf.write(json.dumps(asdict(metadata))) + if self.debug: + print(f"Initial metadata: {metadata}") + # Connect to the websocket and listen for state changes + with connect(ws_events, open_timeout=5) as websocket: + while True: + try: + msg = websocket.recv() + if self.debug: + print(f"Received: {msg}") + event = Event.from_json(self, json.loads(msg)) + if event.event_type == "metadata": + metadata.track = event.data + elif event.event_type == "playing": + metadata.stopped = False + metadata.paused = False + elif event.event_type == "paused": + metadata.paused = True + elif event.event_type == "stopped": + metadata.stopped = True + metadata.track = Track() + else: + continue + with open(args.metadata_file, 'w', encoding='utf8') as mf: + mf.write(json.dumps(asdict(metadata))) + except (KeyError, ConnectionClosed, json.JSONDecodeError) as e: + print(f"Error: {e}") + break + except (OSError, InvalidHandshake, TimeoutError) as e: + print(f"Error: {e}") + return if __name__ == "__main__": @@ -241,7 +260,7 @@ def watch_metadata(url, metadata_file, debug=False) -> None: while (True): try: - watch_metadata(args.url, args.metadata_file, args.debug) + SpotifyMetadataReader(args.url, args.metadata_file, args.debug).watch_metadata() except (KeyboardInterrupt, SystemExit): print("Exiting...") break From b1a0e3176adba3ac8f6ef432ca68830c8953f952 Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Mon, 3 Nov 2025 18:12:25 -0500 Subject: [PATCH 13/49] Add secondary script that handles volume changes much cleaner, remove most of the changes to the metadata script --- streams/spot_connect_meta.py | 14 +--- streams/spotify_volume_handler.py | 127 ++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 13 deletions(-) create mode 100644 streams/spotify_volume_handler.py diff --git a/streams/spot_connect_meta.py b/streams/spot_connect_meta.py index e91fac914..a3515fe04 100644 --- a/streams/spot_connect_meta.py +++ b/streams/spot_connect_meta.py @@ -12,7 +12,6 @@ import requests from websockets.exceptions import ConnectionClosed, InvalidHandshake from websockets.sync.client import connect -# from ..amplipi import tasks @dataclass @@ -158,15 +157,6 @@ def from_json(parent: "SpotifyMetadataReader", json_data: dict) -> "Event": e.data = SeekChange(**json_data["data"]) elif e.event_type == "volume": e.data = VolumeChange(**json_data["data"]) - volume = e.data.value - if volume != parent.last_volume: - url = f"http://localhost:{parent._api_port}" # TODO: Implement spotify-side volume bar to reflect the source volume bar's position - # tasks.post.delay(url + 'volume', data={'volume': int(volume * 100)}) - delta = float((volume - parent.last_volume) / 100) - vol_change = requests.patch("http://localhost/api/zones", json={"zones": [0, 1, 2, 3, 4, 5], "update": {"vol_delta_f": delta}}, timeout=5) # TODO: set zone list correctly - if vol_change.ok: - parent.last_volume = volume # update last_volume for future syncs - elif e.event_type in ["shuffle_context", "repeat_context", "repeat_track"]: e.data = ValueChange(**json_data["data"]) return e @@ -178,11 +168,9 @@ def __init__(self, url, metadata_file, debug=False): self.url: str = url self.metadata_file: str = metadata_file self.debug: bool = debug - - self.last_volume: float = 0 self._api_port: int = 3678 + int([char for char in metadata_file if char.isdigit()][0]) - def read_metadata(self) -> Optional[Track]: + def read_metadata(self) -> Optional[Status]: """ Reads metadata from the given URL and writes it to the specified metadata file. If the metadata file already exists, it will be overwritten. diff --git a/streams/spotify_volume_handler.py b/streams/spotify_volume_handler.py new file mode 100644 index 000000000..214dffe9c --- /dev/null +++ b/streams/spotify_volume_handler.py @@ -0,0 +1,127 @@ + +import argparse +from time import sleep +import requests + + +class SpotifyData: + + def __init__(self, api_port, debug=False): + self.api_port: int = api_port + self.debug = debug + + self.status: dict = self.get_status() + self.last_volume: int = self.get_volume() + + def get_status(self): + self.status = requests.get(f"http://localhost:{self.api_port}/status", timeout=5).json() + + def get_volume(self): # Not terribly useful, but it's nice to have parity with the AmpliPiData object + if self.status: + if self.debug: + print(f"Got Spotify volume: {self.status['volume']}") + return self.status["volume"] + + +class AmpliPiData: + + def __init__(self, source_id, debug=False): + self.source_id = source_id + self.debug = debug + + self.status: dict = self.get_status() + self.last_volume: float = self.get_volume() + + self.connected_zones: list = [] + + def get_status(self): + self.status = requests.get("http://localhost/api", timeout=5).json() + + self.connected_zones = [zone for zone in self.status["zones"] if zone["source_id"] == self.source_id] + + def get_volume(self): + if self.connected_zones: + total_vol_f = 0 + for zone in self.connected_zones: + total_vol_f += zone["vol_f"] + if self.debug: + print(f"Got AmpliPi volume: {total_vol_f / len(self.connected_zones)}") + return total_vol_f / len(self.connected_zones) + + +class SpotifyVolumeHandler: + + def __init__(self, port, debug=False): + self.amplipi = AmpliPiData(port - 3679, debug) + self.spotify = SpotifyData(port, debug) + self.debug: bool = debug + + def update_amplipi_volume(self, volume): + """Update AmpliPi's volume via the Spotify client volume slider""" + delta = float((volume - self.spotify.last_volume) / 100) + requests.patch("http://localhost/api/zones", json={"zones": [zone["id"] for zone in self.amplipi.connected_zones], "update": {"vol_delta_f": delta, "mute": False}}, timeout=5) + self.spotify.last_volume = volume + + def update_spotify_volume(self, volume): + """Update the Spotify client's volume slider position based on the averaged volume of all connected zones in AmpliPi""" + if self.debug: + print(f"Spotify vol updated: {volume}") + url = f"http://localhost:{self.spotify.api_port}" + new_vol = int(volume * 100) + print(f"vol: {volume}, last_vol: {self.amplipi.last_volume}") + requests.post(url + '/player/volume', json={"volume": new_vol}, timeout=5) + self.amplipi.last_volume = volume + self.spotify.last_volume = new_vol + + def get_statuses(self): + self.amplipi.get_status() + if self.amplipi.last_volume is None: + self.amplipi.last_volume = self.amplipi.get_volume() + + self.spotify.get_status() + if self.spotify.last_volume is None: + self.spotify.last_volume = self.spotify.get_volume() + + def handle_volumes(self): + while True: + try: + self.get_statuses() + amplipi_volume = self.amplipi.get_volume() + spotify_volume = self.spotify.get_volume() + + if self.debug: + print(f"amplipi: {amplipi_volume}, last: {self.amplipi.last_volume}") + print(f"spotify: {spotify_volume}, last: {self.spotify.last_volume}") + print("\n\n\n") + if spotify_volume != self.spotify.last_volume: + if self.debug: + print("Updating spotify vol...") + self.update_amplipi_volume(spotify_volume) + elif amplipi_volume != self.amplipi.last_volume or int(amplipi_volume * 100) != spotify_volume: + if self.debug: + print("Updating amplipi vol...") + self.update_spotify_volume(amplipi_volume) + except Exception as e: + print(f"ERROR: {e}") + sleep(2) + + +if __name__ == "__main__": + + parser = argparse.ArgumentParser(description="Read metadata from a given URL and write it to a file.") + + parser.add_argument("port", help="The port that go-librespot is running on", type=int) + parser.add_argument("--debug", action="store_true", help="Enable debug output") + + args = parser.parse_args() + + while (True): + try: + SpotifyVolumeHandler(args.port, args.debug).handle_volumes() + except (KeyboardInterrupt, SystemExit): + print("Exiting...") + break + except Exception as e: + print(f"Error: {e}") + sleep(5) + continue From f8c5a6dadcb5a388a8913dbf8032b8067966d181 Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Tue, 4 Nov 2025 15:30:18 -0500 Subject: [PATCH 14/49] Revert all changes to spot connect meta --- streams/spot_connect_meta.py | 141 ++++++++++++++---------------- streams/spotify_volume_handler.py | 9 +- 2 files changed, 71 insertions(+), 79 deletions(-) diff --git a/streams/spot_connect_meta.py b/streams/spot_connect_meta.py index a3515fe04..329ba8763 100644 --- a/streams/spot_connect_meta.py +++ b/streams/spot_connect_meta.py @@ -141,7 +141,7 @@ class Event: data: Union[None, Track, PlayChange, VolumeChange, SeekChange, ValueChange] = None @staticmethod - def from_json(parent: "SpotifyMetadataReader", json_data: dict) -> "Event": + def from_json(json_data: dict) -> "Event": """Create an Event object from a JSON payload""" e = Event(event_type=json_data["type"]) if e.event_type in ["active", "inactive"]: @@ -162,78 +162,71 @@ def from_json(parent: "SpotifyMetadataReader", json_data: dict) -> "Event": return e -class SpotifyMetadataReader: - - def __init__(self, url, metadata_file, debug=False): - self.url: str = url - self.metadata_file: str = metadata_file - self.debug: bool = debug - self._api_port: int = 3678 + int([char for char in metadata_file if char.isdigit()][0]) - - def read_metadata(self) -> Optional[Status]: - """ - Reads metadata from the given URL and writes it to the specified metadata file. - If the metadata file already exists, it will be overwritten. - """ - endpoint = self.url + "/status" - - # Send a GET request to the URL to retrieve the metadata - response = requests.get(endpoint, timeout=2) - - # Check if the request was successful - if response.status_code == 200: - # Parse the metadata from the response - return Status.from_dict(response.json()) - elif response.status_code == 204: - # the metadata isn't populated yet - return Status(stopped=True) - else: - # If the request failed, print an error message - print(f"Failed to retrieve metadata from {endpoint}. Status code: {response.status_code}") - return Status() - - def watch_metadata(self) -> None: - """ - Watches the api at `url` for metadata updates and writes the current state to `metadata_file`. - If the metadata file already exists, it will be overwritten. - """ - # Get the websocket-based event updates - ws_events = self.url.replace("http://", "ws://") + "/events" - try: - # read the initial state - metadata = self.read_metadata() - with open(self.metadata_file, 'w', encoding='utf8') as mf: - mf.write(json.dumps(asdict(metadata))) - if self.debug: - print(f"Initial metadata: {metadata}") - # Connect to the websocket and listen for state changes - with connect(ws_events, open_timeout=5) as websocket: - while True: - try: - msg = websocket.recv() - if self.debug: - print(f"Received: {msg}") - event = Event.from_json(self, json.loads(msg)) - if event.event_type == "metadata": - metadata.track = event.data - elif event.event_type == "playing": - metadata.stopped = False - metadata.paused = False - elif event.event_type == "paused": - metadata.paused = True - elif event.event_type == "stopped": - metadata.stopped = True - metadata.track = Track() - else: - continue - with open(args.metadata_file, 'w', encoding='utf8') as mf: - mf.write(json.dumps(asdict(metadata))) - except (KeyError, ConnectionClosed, json.JSONDecodeError) as e: - print(f"Error: {e}") - break - except (OSError, InvalidHandshake, TimeoutError) as e: - print(f"Error: {e}") - return +def read_metadata(url) -> Optional[Track]: + """ + Reads metadata from the given URL and writes it to the specified metadata file. + If the metadata file already exists, it will be overwritten. + """ + endpoint = url + "/status" + + # Send a GET request to the URL to retrieve the metadata + response = requests.get(endpoint, timeout=2) + + # Check if the request was successful + if response.status_code == 200: + # Parse the metadata from the response + return Status.from_dict(response.json()) + elif response.status_code == 204: + # the metadata isn't populated yet + return Status(stopped=True) + else: + # If the request failed, print an error message + print(f"Failed to retrieve metadata from {endpoint}. Status code: {response.status_code}") + return Status() + + +def watch_metadata(url, metadata_file, debug=False) -> None: + """ + Watches the api at `url` for metadata updates and writes the current state to `metadata_file`. + If the metadata file already exists, it will be overwritten. + """ + # Get the websocket-based event updates + ws_events = url.replace("http://", "ws://") + "/events" + try: + # read the initial state + metadata = read_metadata(url) + with open(metadata_file, 'w', encoding='utf8') as mf: + mf.write(json.dumps(asdict(metadata))) + if debug: + print(f"Initial metadata: {metadata}") + # Connect to the websocket and listen for state changes + with connect(ws_events, open_timeout=5) as websocket: + while True: + try: + msg = websocket.recv() + if debug: + print(f"Received: {msg}") + event = Event.from_json(json.loads(msg)) + if event.event_type == "metadata": + metadata.track = event.data + elif event.event_type == "playing": + metadata.stopped = False + metadata.paused = False + elif event.event_type == "paused": + metadata.paused = True + elif event.event_type == "stopped": + metadata.stopped = True + metadata.track = Track() + else: + continue + with open(args.metadata_file, 'w', encoding='utf8') as mf: + mf.write(json.dumps(asdict(metadata))) + except (KeyError, ConnectionClosed, json.JSONDecodeError) as e: + print(f"Error: {e}") + break + except (OSError, InvalidHandshake, TimeoutError) as e: + print(f"Error: {e}") + return if __name__ == "__main__": @@ -248,7 +241,7 @@ def watch_metadata(self) -> None: while (True): try: - SpotifyMetadataReader(args.url, args.metadata_file, args.debug).watch_metadata() + watch_metadata(args.url, args.metadata_file, args.debug) except (KeyboardInterrupt, SystemExit): print("Exiting...") break diff --git a/streams/spotify_volume_handler.py b/streams/spotify_volume_handler.py index 214dffe9c..112d7e967 100644 --- a/streams/spotify_volume_handler.py +++ b/streams/spotify_volume_handler.py @@ -41,12 +41,11 @@ def get_status(self): def get_volume(self): if self.connected_zones: - total_vol_f = 0 - for zone in self.connected_zones: - total_vol_f += zone["vol_f"] + total_vol_f = sum([zone["vol_f"] for zone in self.connected_zones]) + avg_vol = total_vol_f / len(self.connected_zones) if self.debug: - print(f"Got AmpliPi volume: {total_vol_f / len(self.connected_zones)}") - return total_vol_f / len(self.connected_zones) + print(f"Got AmpliPi volume: {avg_vol}") + return avg_vol class SpotifyVolumeHandler: From 44b8c79023b29ebf0ebf361e046f21d0aba98660 Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Wed, 5 Nov 2025 10:14:15 -0500 Subject: [PATCH 15/49] Make the volume handler multithreaded and event based on the spotify side, combine both prior volumes into a more authoritative "shared volume" that is checked against as the authority for what is true or what should become true --- streams/spotify_volume_handler.py | 101 ++++++++++++++++-------------- 1 file changed, 55 insertions(+), 46 deletions(-) diff --git a/streams/spotify_volume_handler.py b/streams/spotify_volume_handler.py index 112d7e967..0de6630de 100644 --- a/streams/spotify_volume_handler.py +++ b/streams/spotify_volume_handler.py @@ -2,6 +2,11 @@ import argparse from time import sleep import requests +import websockets +from spot_connect_meta import Event +import json +import asyncio +import threading class SpotifyData: @@ -10,17 +15,31 @@ def __init__(self, api_port, debug=False): self.api_port: int = api_port self.debug = debug - self.status: dict = self.get_status() - self.last_volume: int = self.get_volume() + self.volume: int = None - def get_status(self): - self.status = requests.get(f"http://localhost:{self.api_port}/status", timeout=5).json() + threading.Thread(target=self.run_async_watch, daemon=True).start() + + def run_async_watch(self): + asyncio.run(self.watch_vol()) - def get_volume(self): # Not terribly useful, but it's nice to have parity with the AmpliPiData object - if self.status: - if self.debug: - print(f"Got Spotify volume: {self.status['volume']}") - return self.status["volume"] + async def watch_vol(self): + try: + # Connect to the websocket and listen for state changes + async with websockets.connect(f"ws://localhost:{self.api_port}/events", open_timeout=5) as websocket: + while True: + print("Watching volume!") + try: + msg = await websocket.recv() + event = Event.from_json(json.loads(msg)) + if event.event_type == "volume": + self.volume = event.data.value + + except Exception as e: + print(f"Error: {e}") + return + except Exception as e: + print(f"Error: {e}") + return class AmpliPiData: @@ -28,9 +47,7 @@ class AmpliPiData: def __init__(self, source_id, debug=False): self.source_id = source_id self.debug = debug - - self.status: dict = self.get_status() - self.last_volume: float = self.get_volume() + self.status: dict = None self.connected_zones: list = [] @@ -40,12 +57,12 @@ def get_status(self): self.connected_zones = [zone for zone in self.status["zones"] if zone["source_id"] == self.source_id] def get_volume(self): + if not self.status: + self.get_status() + if self.connected_zones: total_vol_f = sum([zone["vol_f"] for zone in self.connected_zones]) - avg_vol = total_vol_f / len(self.connected_zones) - if self.debug: - print(f"Got AmpliPi volume: {avg_vol}") - return avg_vol + return round(total_vol_f / len(self.connected_zones), 3) # Round down to 2 decimals class SpotifyVolumeHandler: @@ -54,54 +71,45 @@ def __init__(self, port, debug=False): self.amplipi = AmpliPiData(port - 3679, debug) self.spotify = SpotifyData(port, debug) self.debug: bool = debug + self.shared_volume = self.amplipi.get_volume() - def update_amplipi_volume(self, volume): + def update_amplipi_volume(self, amplipi_volume): """Update AmpliPi's volume via the Spotify client volume slider""" - delta = float((volume - self.spotify.last_volume) / 100) + delta = float((self.spotify.volume / 100) - amplipi_volume) requests.patch("http://localhost/api/zones", json={"zones": [zone["id"] for zone in self.amplipi.connected_zones], "update": {"vol_delta_f": delta, "mute": False}}, timeout=5) - self.spotify.last_volume = volume + self.shared_volume = self.spotify.volume / 100 - def update_spotify_volume(self, volume): + def update_spotify_volume(self, amplipi_volume): """Update the Spotify client's volume slider position based on the averaged volume of all connected zones in AmpliPi""" if self.debug: - print(f"Spotify vol updated: {volume}") + print(f"Spotify vol updated: {amplipi_volume}") url = f"http://localhost:{self.spotify.api_port}" - new_vol = int(volume * 100) - print(f"vol: {volume}, last_vol: {self.amplipi.last_volume}") + new_vol = int(amplipi_volume * 100) requests.post(url + '/player/volume', json={"volume": new_vol}, timeout=5) - self.amplipi.last_volume = volume - self.spotify.last_volume = new_vol - - def get_statuses(self): - self.amplipi.get_status() - if self.amplipi.last_volume is None: - self.amplipi.last_volume = self.amplipi.get_volume() - - self.spotify.get_status() - if self.spotify.last_volume is None: - self.spotify.last_volume = self.spotify.get_volume() + self.shared_volume = amplipi_volume def handle_volumes(self): while True: try: - self.get_statuses() + self.amplipi.get_status() amplipi_volume = self.amplipi.get_volume() - spotify_volume = self.spotify.get_volume() if self.debug: - print(f"amplipi: {amplipi_volume}, last: {self.amplipi.last_volume}") - print(f"spotify: {spotify_volume}, last: {self.spotify.last_volume}") + print(f"amplipi: {amplipi_volume}, spotify: {self.spotify.volume}, shared: {self.shared_volume}") print("\n\n\n") - if spotify_volume != self.spotify.last_volume: + + if self.spotify.volume is None: # Useful for getting spotify's initial state (by forcibly setting it) + self.update_spotify_volume(amplipi_volume) + elif self.spotify.volume != (self.shared_volume * 100): if self.debug: - print("Updating spotify vol...") - self.update_amplipi_volume(spotify_volume) - elif amplipi_volume != self.amplipi.last_volume or int(amplipi_volume * 100) != spotify_volume: + print("Updating AmpliPi vol...") + self.update_amplipi_volume(amplipi_volume) + elif amplipi_volume != self.shared_volume or (int(amplipi_volume * 100) != self.spotify.volume): if self.debug: - print("Updating amplipi vol...") + print("Updating Spotify vol...") self.update_spotify_volume(amplipi_volume) except Exception as e: - print(f"ERROR: {e}") + print(f"Error: {e}") sleep(2) @@ -114,13 +122,14 @@ def handle_volumes(self): args = parser.parse_args() + handler = SpotifyVolumeHandler(args.port, args.debug) while (True): try: - SpotifyVolumeHandler(args.port, args.debug).handle_volumes() + handler.handle_volumes() except (KeyboardInterrupt, SystemExit): print("Exiting...") break except Exception as e: - print(f"Error: {e}") + print(f"Error 139: {e}") sleep(5) continue From eb3ac183f579be55fb7ecdfef3c469416bf27a7f Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Wed, 5 Nov 2025 10:17:17 -0500 Subject: [PATCH 16/49] Change prints, add documentation --- streams/spotify_volume_handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/streams/spotify_volume_handler.py b/streams/spotify_volume_handler.py index 0de6630de..b751446b1 100644 --- a/streams/spotify_volume_handler.py +++ b/streams/spotify_volume_handler.py @@ -61,7 +61,7 @@ def get_volume(self): self.get_status() if self.connected_zones: - total_vol_f = sum([zone["vol_f"] for zone in self.connected_zones]) + total_vol_f = sum([zone["vol_f"] for zone in self.connected_zones]) # Note that accounting for the vol_f overflow variables here would make it impossible to use those overflows while also using this volume bar return round(total_vol_f / len(self.connected_zones), 3) # Round down to 2 decimals @@ -130,6 +130,6 @@ def handle_volumes(self): print("Exiting...") break except Exception as e: - print(f"Error 139: {e}") + print(f"Error: {e}") sleep(5) continue From 191cf2ad7690632d03a02755b31cbc5d8cc5c1a1 Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Wed, 5 Nov 2025 10:24:06 -0500 Subject: [PATCH 17/49] Remove unused print --- streams/spotify_volume_handler.py | 1 - 1 file changed, 1 deletion(-) diff --git a/streams/spotify_volume_handler.py b/streams/spotify_volume_handler.py index b751446b1..b13fdf081 100644 --- a/streams/spotify_volume_handler.py +++ b/streams/spotify_volume_handler.py @@ -27,7 +27,6 @@ async def watch_vol(self): # Connect to the websocket and listen for state changes async with websockets.connect(f"ws://localhost:{self.api_port}/events", open_timeout=5) as websocket: while True: - print("Watching volume!") try: msg = await websocket.recv() event = Event.from_json(json.loads(msg)) From a7c76277588eeb639fd4d96d9a71b3da06cea107 Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Wed, 5 Nov 2025 10:35:44 -0500 Subject: [PATCH 18/49] Add ignore to false linting error --- streams/spotify_volume_handler.py | 1 + 1 file changed, 1 insertion(+) diff --git a/streams/spotify_volume_handler.py b/streams/spotify_volume_handler.py index b13fdf081..b5ea2c56a 100644 --- a/streams/spotify_volume_handler.py +++ b/streams/spotify_volume_handler.py @@ -25,6 +25,7 @@ def run_async_watch(self): async def watch_vol(self): try: # Connect to the websocket and listen for state changes + # E1101: ignore websockets.connect does not exist async with websockets.connect(f"ws://localhost:{self.api_port}/events", open_timeout=5) as websocket: while True: try: From b0afb89ac9fb21f155293cb5167544d6864d068a Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Wed, 5 Nov 2025 16:00:41 -0500 Subject: [PATCH 19/49] Refactor spotify_volume_handler to be multithreaded, event based, and more accurate --- streams/spotify_volume_handler.py | 143 +++++++++++++++++++++--------- 1 file changed, 99 insertions(+), 44 deletions(-) diff --git a/streams/spotify_volume_handler.py b/streams/spotify_volume_handler.py index b5ea2c56a..68e4f67c2 100644 --- a/streams/spotify_volume_handler.py +++ b/streams/spotify_volume_handler.py @@ -1,21 +1,24 @@ - +"""Script for synchronizing AmpliPi and Spotify volumes""" import argparse from time import sleep import requests -import websockets -from spot_connect_meta import Event import json import asyncio import threading +import websockets +import queue +from spot_connect_meta import Event class SpotifyData: + """A class that watches and tracks changes to spotify-side volume""" - def __init__(self, api_port, debug=False): + def __init__(self, api_port: int, callback, debug: bool = False): self.api_port: int = api_port + self.callback = callback self.debug = debug - self.volume: int = None + self.volume: float = None threading.Thread(target=self.run_async_watch, daemon=True).start() @@ -32,7 +35,8 @@ async def watch_vol(self): msg = await websocket.recv() event = Event.from_json(json.loads(msg)) if event.event_type == "volume": - self.volume = event.data.value + self.volume = event.data.value / 100 # AmpliPi volume is between 0 and 1, Spotify is between 0 and 100. Dividing by 100 is more accurate than multiplying by 100 due to floating point errors. + self.callback("spotify_volume_changed") except Exception as e: print(f"Error: {e}") @@ -43,74 +47,120 @@ async def watch_vol(self): class AmpliPiData: + """A class to record amplipi's api output and calculate the volume of a given source""" - def __init__(self, source_id, debug=False): + def __init__(self, source_id: int, callback, debug: bool = False): self.source_id = source_id + self.callback = callback self.debug = debug self.status: dict = None + self.volume: float = None self.connected_zones: list = [] + threading.Thread(target=self.get_status, daemon=True).start() + def get_status(self): - self.status = requests.get("http://localhost/api", timeout=5).json() + while True: + last_vol = float(self.volume) if self.volume is not None else None + + self.consume_status(requests.get("http://localhost/api", timeout=5).json()) + + if last_vol != self.volume: + self.callback("amplipi_volume_changed") + + def consume_status(self, status): + self.status = status self.connected_zones = [zone for zone in self.status["zones"] if zone["source_id"] == self.source_id] + self.volume = self.get_volume() def get_volume(self): - if not self.status: - self.get_status() - if self.connected_zones: total_vol_f = sum([zone["vol_f"] for zone in self.connected_zones]) # Note that accounting for the vol_f overflow variables here would make it impossible to use those overflows while also using this volume bar - return round(total_vol_f / len(self.connected_zones), 3) # Round down to 2 decimals + return round(total_vol_f / len(self.connected_zones), 2) # Round down to 2 decimals to assist with float accuracy class SpotifyVolumeHandler: + """Volume synchronizer for Spotify and AmpliPi volume sliders""" def __init__(self, port, debug=False): - self.amplipi = AmpliPiData(port - 3679, debug) - self.spotify = SpotifyData(port, debug) + self.event_queue = queue.Queue() + self.amplipi = AmpliPiData(port - 3679, self.on_child_event, debug) + self.spotify = SpotifyData(port, self.on_child_event, debug) self.debug: bool = debug + self.shared_volume = self.amplipi.get_volume() + self.tolerance = 0.005 # Reduces jitters from floating point inaccuracy from either side - def update_amplipi_volume(self, amplipi_volume): + def on_child_event(self, event_type): + self.event_queue.put(event_type) + + def update_amplipi_volume(self): """Update AmpliPi's volume via the Spotify client volume slider""" - delta = float((self.spotify.volume / 100) - amplipi_volume) - requests.patch("http://localhost/api/zones", json={"zones": [zone["id"] for zone in self.amplipi.connected_zones], "update": {"vol_delta_f": delta, "mute": False}}, timeout=5) - self.shared_volume = self.spotify.volume / 100 + spotify_volume = self.spotify.volume + if spotify_volume is None: + return + + if abs(spotify_volume - self.shared_volume) <= self.tolerance: + if self.debug: + print("Ignored minor Spotify→AmpliPi change") + return - def update_spotify_volume(self, amplipi_volume): - """Update the Spotify client's volume slider position based on the averaged volume of all connected zones in AmpliPi""" + delta = float(spotify_volume - self.shared_volume) + self.amplipi.consume_status(requests.patch( + "http://localhost/api/zones", + json={ + "zones": [zone["id"] for zone in self.amplipi.connected_zones], + "update": {"vol_delta_f": delta, "mute": False}, + }, + timeout=5, + ).json()) + + self.shared_volume = spotify_volume if self.debug: - print(f"Spotify vol updated: {amplipi_volume}") + print(f"AmpliPi updated, shared_volume={self.shared_volume:.3f}") + + def update_spotify_volume(self): + """Update Spotify's volume slider to match AmpliPi""" + amplipi_volume = self.amplipi.volume + if amplipi_volume is None: + return + + if abs(amplipi_volume - self.shared_volume) <= self.tolerance: + if self.debug: + print("Ignored minor AmpliPi -> Spotify change") + return + url = f"http://localhost:{self.spotify.api_port}" new_vol = int(amplipi_volume * 100) requests.post(url + '/player/volume', json={"volume": new_vol}, timeout=5) self.shared_volume = amplipi_volume - def handle_volumes(self): - while True: - try: - self.amplipi.get_status() - amplipi_volume = self.amplipi.get_volume() + def handle_volume_changes(self): + """Handle volume changes and synchronize sides""" + if self.shared_volume is None: + self.shared_volume = self.amplipi.get_volume() + try: + amplipi_volume = self.amplipi.volume + spotify_volume = self.spotify.volume + if self.debug: + print(f"amplipi={amplipi_volume}, spotify={spotify_volume}, shared={self.shared_volume}") + + if spotify_volume is None: if self.debug: - print(f"amplipi: {amplipi_volume}, spotify: {self.spotify.volume}, shared: {self.shared_volume}") - print("\n\n\n") - - if self.spotify.volume is None: # Useful for getting spotify's initial state (by forcibly setting it) - self.update_spotify_volume(amplipi_volume) - elif self.spotify.volume != (self.shared_volume * 100): - if self.debug: - print("Updating AmpliPi vol...") - self.update_amplipi_volume(amplipi_volume) - elif amplipi_volume != self.shared_volume or (int(amplipi_volume * 100) != self.spotify.volume): - if self.debug: - print("Updating Spotify vol...") - self.update_spotify_volume(amplipi_volume) - except Exception as e: - print(f"Error: {e}") - sleep(2) + print("Spotify volume uninitialized, syncing from AmpliPi") + self.update_spotify_volume() + + elif abs(spotify_volume - self.shared_volume) > self.tolerance: + self.update_amplipi_volume() + + elif abs(amplipi_volume - self.shared_volume) > self.tolerance: + self.update_spotify_volume() + + except Exception as e: + print(f"Error: {e}") if __name__ == "__main__": @@ -123,9 +173,13 @@ def handle_volumes(self): args = parser.parse_args() handler = SpotifyVolumeHandler(args.port, args.debug) - while (True): + while True: try: - handler.handle_volumes() + event = handler.event_queue.get(timeout=2) + if event in ("spotify_volume_changed", "amplipi_volume_changed"): + handler.handle_volume_changes() + except queue.Empty: + continue except (KeyboardInterrupt, SystemExit): print("Exiting...") break @@ -133,3 +187,4 @@ def handle_volumes(self): print(f"Error: {e}") sleep(5) continue + sleep(2) From 9adc2eb9763e23a8fcccb2e05b023ed1c64c921c Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Wed, 5 Nov 2025 16:07:41 -0500 Subject: [PATCH 20/49] Linting --- streams/spotify_volume_handler.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/streams/spotify_volume_handler.py b/streams/spotify_volume_handler.py index 68e4f67c2..8cb282991 100644 --- a/streams/spotify_volume_handler.py +++ b/streams/spotify_volume_handler.py @@ -1,12 +1,14 @@ """Script for synchronizing AmpliPi and Spotify volumes""" import argparse from time import sleep -import requests import json import asyncio import threading -import websockets import queue + +import websockets +import requests + from spot_connect_meta import Event @@ -23,12 +25,15 @@ def __init__(self, api_port: int, callback, debug: bool = False): threading.Thread(target=self.run_async_watch, daemon=True).start() def run_async_watch(self): + """Middleman function for creating an asyncio run inside of a new thread""" asyncio.run(self.watch_vol()) async def watch_vol(self): + """Watch the go-librespot websocket endpoint for volume change events and update local volume info accordingly""" try: # Connect to the websocket and listen for state changes - # E1101: ignore websockets.connect does not exist + # pylint: disable=E1101 + # E1101: Module 'websockets' has no 'connect' member (no-member) async with websockets.connect(f"ws://localhost:{self.api_port}/events", open_timeout=5) as websocket: while True: try: @@ -61,6 +66,7 @@ def __init__(self, source_id: int, callback, debug: bool = False): threading.Thread(target=self.get_status, daemon=True).start() def get_status(self): + """Call up the amplipi API and send the output to self.consume_status""" while True: last_vol = float(self.volume) if self.volume is not None else None @@ -70,15 +76,18 @@ def get_status(self): self.callback("amplipi_volume_changed") def consume_status(self, status): + """Consume an API response into the local object""" self.status = status self.connected_zones = [zone for zone in self.status["zones"] if zone["source_id"] == self.source_id] self.volume = self.get_volume() def get_volume(self): + """Calculate the average vol_f from all connected zones. If no zones are connected, or if the api has yet to be hit up, return 0.""" if self.connected_zones: total_vol_f = sum([zone["vol_f"] for zone in self.connected_zones]) # Note that accounting for the vol_f overflow variables here would make it impossible to use those overflows while also using this volume bar return round(total_vol_f / len(self.connected_zones), 2) # Round down to 2 decimals to assist with float accuracy + return 0 class SpotifyVolumeHandler: @@ -94,6 +103,7 @@ def __init__(self, port, debug=False): self.tolerance = 0.005 # Reduces jitters from floating point inaccuracy from either side def on_child_event(self, event_type): + """When an event occurs in a child, that child can use this callback function to schedule the response to said event in the event queue""" self.event_queue.put(event_type) def update_amplipi_volume(self): From 741cf6ffcb8d38941d8423178cd3e731e00ec5ed Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Wed, 5 Nov 2025 16:23:36 -0500 Subject: [PATCH 21/49] Reduce complexity of SpotifyVolumeHandler by splitting up the events so they each call their own function --- streams/spotify_volume_handler.py | 36 +++++-------------------------- 1 file changed, 5 insertions(+), 31 deletions(-) diff --git a/streams/spotify_volume_handler.py b/streams/spotify_volume_handler.py index 8cb282991..56b59214c 100644 --- a/streams/spotify_volume_handler.py +++ b/streams/spotify_volume_handler.py @@ -114,7 +114,7 @@ def update_amplipi_volume(self): if abs(spotify_volume - self.shared_volume) <= self.tolerance: if self.debug: - print("Ignored minor Spotify→AmpliPi change") + print("Ignored minor Spotify -> AmpliPi change") return delta = float(spotify_volume - self.shared_volume) @@ -126,10 +126,7 @@ def update_amplipi_volume(self): }, timeout=5, ).json()) - self.shared_volume = spotify_volume - if self.debug: - print(f"AmpliPi updated, shared_volume={self.shared_volume:.3f}") def update_spotify_volume(self): """Update Spotify's volume slider to match AmpliPi""" @@ -147,31 +144,6 @@ def update_spotify_volume(self): requests.post(url + '/player/volume', json={"volume": new_vol}, timeout=5) self.shared_volume = amplipi_volume - def handle_volume_changes(self): - """Handle volume changes and synchronize sides""" - if self.shared_volume is None: - self.shared_volume = self.amplipi.get_volume() - try: - amplipi_volume = self.amplipi.volume - spotify_volume = self.spotify.volume - - if self.debug: - print(f"amplipi={amplipi_volume}, spotify={spotify_volume}, shared={self.shared_volume}") - - if spotify_volume is None: - if self.debug: - print("Spotify volume uninitialized, syncing from AmpliPi") - self.update_spotify_volume() - - elif abs(spotify_volume - self.shared_volume) > self.tolerance: - self.update_amplipi_volume() - - elif abs(amplipi_volume - self.shared_volume) > self.tolerance: - self.update_spotify_volume() - - except Exception as e: - print(f"Error: {e}") - if __name__ == "__main__": @@ -186,8 +158,10 @@ def handle_volume_changes(self): while True: try: event = handler.event_queue.get(timeout=2) - if event in ("spotify_volume_changed", "amplipi_volume_changed"): - handler.handle_volume_changes() + if event in "spotify_volume_changed": + handler.update_amplipi_volume() + elif event in "amplipi_volume_changed": + handler.update_spotify_volume() except queue.Empty: continue except (KeyboardInterrupt, SystemExit): From d8dcd3ac7a44168eb192ed2b562d542d3e3f85a3 Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Thu, 6 Nov 2025 11:07:39 -0500 Subject: [PATCH 22/49] Explicitly provide source rather than attempting to calculate it swap from debug prints to loggers add commented out WIP of a direct house.json reader --- amplipi/streams/spotify_connect.py | 6 ++++++ streams/spotify_volume_handler.py | 30 +++++++++++++++++++++--------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/amplipi/streams/spotify_connect.py b/amplipi/streams/spotify_connect.py index 34e8b105d..49ffadbb5 100644 --- a/amplipi/streams/spotify_connect.py +++ b/amplipi/streams/spotify_connect.py @@ -33,6 +33,7 @@ def __init__(self, name: str, disabled: bool = False, mock: bool = False, valida self._log_file: Optional[io.TextIOBase] = None self._api_port: int self.proc2: Optional[subprocess.Popen] = None + self.proc3: Optional[subprocess.Popen] = None self.meta_file: str = '' self.max_volume: int = 100 # default configuration from 'volume_steps' self.last_volume: float = 0 @@ -99,6 +100,11 @@ def _activate(self, vsrc: int): logger.info(f'{self.name}: starting metadata reader: {meta_args}') self.proc2 = subprocess.Popen(args=meta_args, stdout=self._log_file, stderr=self._log_file) + vol_sync = f"{utils.get_folder('streams')}/spotify_volume_handler.py" + vol_args = [sys.executable, vol_sync, str(self._api_port), src_config_folder[-1], "--debug"] + logger.info(f'{self.name}: starting vol synchronizer: {vol_args}') + self.proc3 = subprocess.Popen(args=vol_args, stdout=self._log_file, stderr=self._log_file) + def _deactivate(self): if self._is_running(): self.proc.stdin.close() diff --git a/streams/spotify_volume_handler.py b/streams/spotify_volume_handler.py index 56b59214c..6cb8c90a9 100644 --- a/streams/spotify_volume_handler.py +++ b/streams/spotify_volume_handler.py @@ -5,6 +5,8 @@ import asyncio import threading import queue +import logging +import sys import websockets import requests @@ -12,6 +14,12 @@ from spot_connect_meta import Event +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) +sh = logging.StreamHandler(sys.stdout) +logger.addHandler(sh) + + class SpotifyData: """A class that watches and tracks changes to spotify-side volume""" @@ -44,10 +52,10 @@ async def watch_vol(self): self.callback("spotify_volume_changed") except Exception as e: - print(f"Error: {e}") + logger.exception(f"Error: {e}") return except Exception as e: - print(f"Error: {e}") + logger.exception(f"Error: {e}") return @@ -70,6 +78,9 @@ def get_status(self): while True: last_vol = float(self.volume) if self.volume is not None else None + # with open("/home/pi/.config/amplipi/house.json", "r", encoding="utf-8") as f: + # self.consume_status(json.loads(f)) + self.consume_status(requests.get("http://localhost/api", timeout=5).json()) if last_vol != self.volume: @@ -93,9 +104,9 @@ def get_volume(self): class SpotifyVolumeHandler: """Volume synchronizer for Spotify and AmpliPi volume sliders""" - def __init__(self, port, debug=False): + def __init__(self, port, source, debug=False): self.event_queue = queue.Queue() - self.amplipi = AmpliPiData(port - 3679, self.on_child_event, debug) + self.amplipi = AmpliPiData(source, self.on_child_event, debug) self.spotify = SpotifyData(port, self.on_child_event, debug) self.debug: bool = debug @@ -114,7 +125,7 @@ def update_amplipi_volume(self): if abs(spotify_volume - self.shared_volume) <= self.tolerance: if self.debug: - print("Ignored minor Spotify -> AmpliPi change") + logger.debug("Ignored minor Spotify -> AmpliPi change") return delta = float(spotify_volume - self.shared_volume) @@ -136,7 +147,7 @@ def update_spotify_volume(self): if abs(amplipi_volume - self.shared_volume) <= self.tolerance: if self.debug: - print("Ignored minor AmpliPi -> Spotify change") + logger.debug("Ignored minor AmpliPi -> Spotify change") return url = f"http://localhost:{self.spotify.api_port}" @@ -150,11 +161,12 @@ def update_spotify_volume(self): parser = argparse.ArgumentParser(description="Read metadata from a given URL and write it to a file.") parser.add_argument("port", help="The port that go-librespot is running on", type=int) + parser.add_argument("source", help="The source that the spotify stream is playing to", type=int) parser.add_argument("--debug", action="store_true", help="Enable debug output") args = parser.parse_args() - handler = SpotifyVolumeHandler(args.port, args.debug) + handler = SpotifyVolumeHandler(args.port, args.source, args.debug) while True: try: event = handler.event_queue.get(timeout=2) @@ -165,10 +177,10 @@ def update_spotify_volume(self): except queue.Empty: continue except (KeyboardInterrupt, SystemExit): - print("Exiting...") + logger.exception("Exiting...") break except Exception as e: - print(f"Error: {e}") + logger.exception(f"Error: {e}") sleep(5) continue sleep(2) From 91b1569106411d88a63a5416ab8711938fced036 Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Thu, 6 Nov 2025 14:57:21 -0500 Subject: [PATCH 23/49] Implement vol script Gate a few things in the vol script better --- CHANGELOG.md | 2 ++ amplipi/streams/spotify_connect.py | 8 +++++++- streams/spotify_volume_handler.py | 16 ++++++++++------ 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b587a702..ee2156519 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ * Web App * Fixed internet radio search functionality * Upgraded volume calculations to preserve relative positions when hitting the min or max setting via source volume bar +* Streams: + * Upgraded Spotify to sync Spotify's volume with AmpliPi and vice-versa # 0.4.9 * System diff --git a/amplipi/streams/spotify_connect.py b/amplipi/streams/spotify_connect.py index 49ffadbb5..390052867 100644 --- a/amplipi/streams/spotify_connect.py +++ b/amplipi/streams/spotify_connect.py @@ -101,7 +101,7 @@ def _activate(self, vsrc: int): self.proc2 = subprocess.Popen(args=meta_args, stdout=self._log_file, stderr=self._log_file) vol_sync = f"{utils.get_folder('streams')}/spotify_volume_handler.py" - vol_args = [sys.executable, vol_sync, str(self._api_port), src_config_folder[-1], "--debug"] + vol_args = [sys.executable, vol_sync, str(self._api_port), str(self.vsrc), "--debug"] logger.info(f'{self.name}: starting vol synchronizer: {vol_args}') self.proc3 = subprocess.Popen(args=vol_args, stdout=self._log_file, stderr=self._log_file) @@ -111,14 +111,19 @@ def _deactivate(self): logger.info(f'{self.name}: stopping player') self.proc.terminate() self.proc2.terminate() + self.proc3.terminate() if self.proc.wait(1) != 0: logger.info(f'{self.name}: killing player') self.proc.kill() if self.proc2.wait(1) != 0: logger.info(f'{self.name}: killing metadata reader') self.proc2.kill() + if self.proc3.wait(1) != 0: + logger.info(f'{self.name}: killing volume synchronizer') + self.proc3.kill() self.proc.communicate() self.proc2.communicate() + self.proc3.communicate() if self.proc and self._log_file: # prevent checking _log_file when it may not exist, thanks validation! self._log_file.close() if self.src: @@ -129,6 +134,7 @@ def _deactivate(self): self._disconnect() self.proc = None self.proc2 = None + self.proc3 = None def info(self) -> models.SourceInfo: source = models.SourceInfo( diff --git a/streams/spotify_volume_handler.py b/streams/spotify_volume_handler.py index 6cb8c90a9..390ca017e 100644 --- a/streams/spotify_volume_handler.py +++ b/streams/spotify_volume_handler.py @@ -67,6 +67,7 @@ def __init__(self, source_id: int, callback, debug: bool = False): self.callback = callback self.debug = debug self.status: dict = None + self.last_volume: float = None self.volume: float = None self.connected_zones: list = [] @@ -76,15 +77,14 @@ def __init__(self, source_id: int, callback, debug: bool = False): def get_status(self): """Call up the amplipi API and send the output to self.consume_status""" while True: - last_vol = float(self.volume) if self.volume is not None else None - - # with open("/home/pi/.config/amplipi/house.json", "r", encoding="utf-8") as f: - # self.consume_status(json.loads(f)) - self.consume_status(requests.get("http://localhost/api", timeout=5).json()) - if last_vol != self.volume: + print(f"last: {self.last_volume}, vol: {self.volume}") + + if self.last_volume != self.volume: self.callback("amplipi_volume_changed") + self.last_volume = float(self.volume) + sleep(2) def consume_status(self, status): """Consume an API response into the local object""" @@ -98,6 +98,8 @@ def get_volume(self): if self.connected_zones: total_vol_f = sum([zone["vol_f"] for zone in self.connected_zones]) # Note that accounting for the vol_f overflow variables here would make it impossible to use those overflows while also using this volume bar return round(total_vol_f / len(self.connected_zones), 2) # Round down to 2 decimals to assist with float accuracy + else: + logger.warning("Could not find any associated zones") return 0 @@ -169,6 +171,8 @@ def update_spotify_volume(self): handler = SpotifyVolumeHandler(args.port, args.source, args.debug) while True: try: + if handler.spotify.volume is None: + handler.update_spotify_volume() event = handler.event_queue.get(timeout=2) if event in "spotify_volume_changed": handler.update_amplipi_volume() From 877dd8c92da475841af26ac918b81724b4977858 Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Fri, 7 Nov 2025 13:47:43 -0500 Subject: [PATCH 24/49] Improve event gating --- streams/spotify_volume_handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/streams/spotify_volume_handler.py b/streams/spotify_volume_handler.py index 390ca017e..56c13c882 100644 --- a/streams/spotify_volume_handler.py +++ b/streams/spotify_volume_handler.py @@ -174,9 +174,9 @@ def update_spotify_volume(self): if handler.spotify.volume is None: handler.update_spotify_volume() event = handler.event_queue.get(timeout=2) - if event in "spotify_volume_changed": + if event == "spotify_volume_changed": handler.update_amplipi_volume() - elif event in "amplipi_volume_changed": + elif event == "amplipi_volume_changed": handler.update_spotify_volume() except queue.Empty: continue From a1150a8d11602bf08d858a45fa53241bfce1c46e Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Fri, 7 Nov 2025 15:43:44 -0500 Subject: [PATCH 25/49] Add more try-catches --- streams/spotify_volume_handler.py | 72 +++++++++++++++++-------------- 1 file changed, 39 insertions(+), 33 deletions(-) diff --git a/streams/spotify_volume_handler.py b/streams/spotify_volume_handler.py index 56c13c882..207c5ec2f 100644 --- a/streams/spotify_volume_handler.py +++ b/streams/spotify_volume_handler.py @@ -121,41 +121,47 @@ def on_child_event(self, event_type): def update_amplipi_volume(self): """Update AmpliPi's volume via the Spotify client volume slider""" - spotify_volume = self.spotify.volume - if spotify_volume is None: - return - - if abs(spotify_volume - self.shared_volume) <= self.tolerance: - if self.debug: - logger.debug("Ignored minor Spotify -> AmpliPi change") - return - - delta = float(spotify_volume - self.shared_volume) - self.amplipi.consume_status(requests.patch( - "http://localhost/api/zones", - json={ - "zones": [zone["id"] for zone in self.amplipi.connected_zones], - "update": {"vol_delta_f": delta, "mute": False}, - }, - timeout=5, - ).json()) - self.shared_volume = spotify_volume + try: + spotify_volume = self.spotify.volume + if spotify_volume is None: + return + + if abs(spotify_volume - self.shared_volume) <= self.tolerance: + if self.debug: + logger.debug("Ignored minor Spotify -> AmpliPi change") + return + + delta = float(spotify_volume - self.shared_volume) + self.amplipi.consume_status(requests.patch( + "http://localhost/api/zones", + json={ + "zones": [zone["id"] for zone in self.amplipi.connected_zones], + "update": {"vol_delta_f": delta, "mute": False}, + }, + timeout=5, + ).json()) + self.shared_volume = spotify_volume + except Exception as e: + logger.exception(f"Exception: {e}") def update_spotify_volume(self): """Update Spotify's volume slider to match AmpliPi""" - amplipi_volume = self.amplipi.volume - if amplipi_volume is None: - return - - if abs(amplipi_volume - self.shared_volume) <= self.tolerance: - if self.debug: - logger.debug("Ignored minor AmpliPi -> Spotify change") - return - - url = f"http://localhost:{self.spotify.api_port}" - new_vol = int(amplipi_volume * 100) - requests.post(url + '/player/volume', json={"volume": new_vol}, timeout=5) - self.shared_volume = amplipi_volume + try: + amplipi_volume = self.amplipi.volume + if amplipi_volume is None: + return + + if abs(amplipi_volume - self.shared_volume) <= self.tolerance: + if self.debug: + logger.debug("Ignored minor AmpliPi -> Spotify change") + return + + url = f"http://localhost:{self.spotify.api_port}" + new_vol = int(amplipi_volume * 100) + requests.post(url + '/player/volume', json={"volume": new_vol}, timeout=5) + self.shared_volume = amplipi_volume + except Exception as e: + logger.exception(f"Exception: {e}") if __name__ == "__main__": @@ -184,7 +190,7 @@ def update_spotify_volume(self): logger.exception("Exiting...") break except Exception as e: - logger.exception(f"Error: {e}") + logger.exception(f"Exception: {e}") sleep(5) continue sleep(2) From 83ee5593fac3bc9f761e3e475d6994e0a56186ff Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Thu, 13 Nov 2025 15:23:55 -0500 Subject: [PATCH 26/49] Plumb stream id all the way down into BaseStream so that SpotifyConnect can more accurately select its own info Remove poll suppressor from App.jsx that was used during testing --- amplipi/ctrl.py | 4 +-- amplipi/streams/__init__.py | 30 +++++++++++------------ amplipi/streams/airplay.py | 4 +-- amplipi/streams/aux.py | 4 +-- amplipi/streams/base_streams.py | 8 +++--- amplipi/streams/bluetooth.py | 4 +-- amplipi/streams/dlna.py | 4 +-- amplipi/streams/file_player.py | 4 +-- amplipi/streams/fm_radio.py | 4 +-- amplipi/streams/internet_radio.py | 4 +-- amplipi/streams/lms.py | 4 +-- amplipi/streams/media_device.py | 4 +-- amplipi/streams/pandora.py | 4 +-- amplipi/streams/plexamp.py | 4 +-- amplipi/streams/rca.py | 4 +-- amplipi/streams/spotify_connect.py | 39 ++++++++++++++---------------- amplipi/utils.py | 7 ++++++ streams/spotify_volume_handler.py | 22 +++++++++++------ 18 files changed, 86 insertions(+), 72 deletions(-) diff --git a/amplipi/ctrl.py b/amplipi/ctrl.py index c07efbc45..fe214cfa3 100644 --- a/amplipi/ctrl.py +++ b/amplipi/ctrl.py @@ -338,7 +338,7 @@ def reinit(self, settings: models.AppSettings = models.AppSettings(), change_not assert stream.id is not None if stream.id: try: - self.streams[stream.id] = amplipi.streams.build_stream(stream, self._mock_streams, validate=False) + self.streams[stream.id] = amplipi.streams.build_stream(stream, stream.id, self._mock_streams, validate=False) # If we're in LMS mode, we need to start these clients on each boot, not when they get assigned to a # particular source; the client+server connection bootstrapping takes a while, which is a less than ideal # user experience. @@ -1059,8 +1059,8 @@ def create_stream(self, data: models.Stream, internal=False) -> models.Stream: raise Exception( f'Unable to create protected RCA stream, the RCA streams for each RCA input {defaults.RCAs} already exist') # Make a new stream and add it to streams - stream = amplipi.streams.build_stream(data, mock=self._mock_streams) sid = self._new_stream_id() + stream = amplipi.streams.build_stream(data, sid, mock=self._mock_streams) self.streams[sid] = stream self.sync_stream_info() # Use get state to populate the contents of the newly created stream and find it in the stream list diff --git a/amplipi/streams/__init__.py b/amplipi/streams/__init__.py index f094c9bd8..46b2c52e2 100644 --- a/amplipi/streams/__init__.py +++ b/amplipi/streams/__init__.py @@ -62,41 +62,41 @@ Aux, FilePlayer, FMRadio, LMS, Bluetooth, MediaDevice] -def build_stream(stream: models.Stream, mock: bool = False, validate: bool = True) -> AnyStream: +def build_stream(stream: models.Stream, stream_id: int, mock: bool = False, validate: bool = True) -> AnyStream: """ Build a stream from the generic arguments given in stream, discriminated by stream.type - we are waiting on Pydantic's implemenatation of discriminators to fully integrate streams into our model definitions + we are waiting on Pydantic's implementation of discriminators to fully integrate streams into our model definitions """ # pylint: disable=too-many-return-statements args = stream.dict(exclude_none=True) name: str = args.pop('name') disabled = args.pop('disabled', False) if stream.type == 'rca': - return RCA(name, args['index'], disabled=disabled, mock=mock) + return RCA(name, stream_id, args['index'], disabled=disabled, mock=mock) if stream.type == 'pandora': - return Pandora(name, args['user'], args['password'], station=args.get('station', None), disabled=disabled, mock=mock, validate=validate) + return Pandora(name, stream_id, args['user'], args['password'], station=args.get('station', None), disabled=disabled, mock=mock, validate=validate) if stream.type in ['shairport', 'airplay']: # handle older configs - return AirPlay(name, args.get('ap2', False), disabled=disabled, mock=mock, validate=validate) + return AirPlay(name, stream_id, args.get('ap2', False), disabled=disabled, mock=mock, validate=validate) if stream.type == 'spotify': - return SpotifyConnect(name, disabled=disabled, mock=mock, validate=validate) + return SpotifyConnect(name, stream_id, disabled=disabled, mock=mock, validate=validate) if stream.type == 'dlna': - return DLNA(name, disabled=disabled, mock=mock) + return DLNA(name, stream_id, disabled=disabled, mock=mock) if stream.type == 'internetradio': - return InternetRadio(name, args['url'], args.get('logo'), disabled=disabled, mock=mock, validate=validate) + return InternetRadio(name, stream_id, args['url'], args.get('logo'), disabled=disabled, mock=mock, validate=validate) if stream.type == 'plexamp': - return Plexamp(name, args['client_id'], args['token'], disabled=disabled, mock=mock) + return Plexamp(name, stream_id, args['client_id'], args['token'], disabled=disabled, mock=mock) if stream.type == 'aux': - return Aux(name, disabled=disabled, mock=mock) + return Aux(name, stream_id, disabled=disabled, mock=mock) if stream.type == 'fileplayer': - return FilePlayer(name, args.get('url', None), args.get('temporary', None), args.get('timeout', None), args.get('has_pause', True), disabled=disabled, mock=mock) + return FilePlayer(name, stream_id, args.get('url', None), args.get('temporary', None), args.get('timeout', None), args.get('has_pause', True), disabled=disabled, mock=mock) if stream.type == 'fmradio': - return FMRadio(name, args['freq'], args.get('logo'), disabled=disabled, mock=mock) + return FMRadio(name, stream_id, args['freq'], args.get('logo'), disabled=disabled, mock=mock) if stream.type == 'lms': - return LMS(name, args.get('server'), args.get("port"), disabled=disabled, mock=mock) + return LMS(name, stream_id, args.get('server'), args.get("port"), disabled=disabled, mock=mock) elif stream.type == 'bluetooth': - return Bluetooth(name, disabled=disabled, mock=mock) + return Bluetooth(name, stream_id, disabled=disabled, mock=mock) elif stream.type == 'mediadevice': - return MediaDevice(name, args.get('url'), disabled=disabled, mock=mock) + return MediaDevice(name, stream_id, args.get('url'), disabled=disabled, mock=mock) raise NotImplementedError(stream.type) diff --git a/amplipi/streams/airplay.py b/amplipi/streams/airplay.py index a000ae097..b6a8d1ad2 100644 --- a/amplipi/streams/airplay.py +++ b/amplipi/streams/airplay.py @@ -27,8 +27,8 @@ class AirPlay(PersistentStream): stream_type: ClassVar[str] = 'airplay' - def __init__(self, name: str, ap2: bool, disabled: bool = False, mock: bool = False, validate: bool = True): - super().__init__(self.stream_type, name, disabled=disabled, mock=mock, validate=validate) + def __init__(self, name: str, stream_id: int, ap2: bool, disabled: bool = False, mock: bool = False, validate: bool = True): + super().__init__(self.stream_type, name, stream_id, disabled=disabled, mock=mock, validate=validate) self.mpris: Optional[MPRIS] = None self.ap2 = ap2 self.ap2_exists = False diff --git a/amplipi/streams/aux.py b/amplipi/streams/aux.py index af30764cc..ff401b2b2 100644 --- a/amplipi/streams/aux.py +++ b/amplipi/streams/aux.py @@ -9,8 +9,8 @@ class Aux(BaseStream): stream_type: ClassVar[str] = 'aux' - def __init__(self, name: str, disabled: bool = False, mock: bool = False): - super().__init__(self.stream_type, name, disabled=disabled, mock=mock) + def __init__(self, name: str, stream_id: int, disabled: bool = False, mock: bool = False): + super().__init__(self.stream_type, name, stream_id, disabled=disabled, mock=mock) def reconfig(self, **kwargs): if 'disabled' in kwargs: diff --git a/amplipi/streams/base_streams.py b/amplipi/streams/base_streams.py index b8645d237..8ae822dcd 100644 --- a/amplipi/streams/base_streams.py +++ b/amplipi/streams/base_streams.py @@ -49,7 +49,9 @@ class Browsable: class BaseStream: """ BaseStream class containing methods that all other streams inherit """ - def __init__(self, stype: str, name: str, only_src=None, disabled: bool = False, mock: bool = False, validate: bool = True, **kwargs): + def __init__(self, stype: str, name: str, stream_id: int, only_src=None, disabled: bool = False, mock: bool = False, validate: bool = True, **kwargs): + + self.id = stream_id self.name = name self.disabled = disabled self.proc: Optional[subprocess.Popen] = None @@ -192,8 +194,8 @@ def free(self, vsrc: int): class PersistentStream(BaseStream): """ Base class for streams that are able to persist without a direct connection to an output """ - def __init__(self, stype: str, name: str, disabled: bool = False, mock: bool = False, validate: bool = True, **kwargs): - super().__init__(stype, name, None, disabled, mock, validate, **kwargs) + def __init__(self, stype: str, name: str, stream_id: int, disabled: bool = False, mock: bool = False, validate: bool = True, **kwargs): + super().__init__(stype, name, stream_id, None, disabled, mock, validate, **kwargs) self.vsrc: Optional[int] = None self._cproc: Optional[subprocess.Popen] = None self.device: Optional[str] = None diff --git a/amplipi/streams/bluetooth.py b/amplipi/streams/bluetooth.py index 00cca3655..c8685c605 100644 --- a/amplipi/streams/bluetooth.py +++ b/amplipi/streams/bluetooth.py @@ -14,8 +14,8 @@ class Bluetooth(BaseStream): stream_type: ClassVar[str] = 'bluetooth' - def __init__(self, name, disabled=False, mock=False): - super().__init__(self.stream_type, name, disabled=disabled, mock=mock) + def __init__(self, name, stream_id: int, disabled=False, mock=False): + super().__init__(self.stream_type, name, stream_id, disabled=disabled, mock=mock) self.logo = "static/imgs/bluetooth.png" self.bt_proc = None self.supported_cmds = ['play', 'pause', 'next', 'prev', 'stop'] diff --git a/amplipi/streams/dlna.py b/amplipi/streams/dlna.py index f5dd24a26..9d24f0c12 100644 --- a/amplipi/streams/dlna.py +++ b/amplipi/streams/dlna.py @@ -14,8 +14,8 @@ class DLNA(BaseStream): # TODO: make DLNA a persistent stream to fix the uuid i stream_type: ClassVar[str] = 'dlna' - def __init__(self, name: str, disabled: bool = False, mock: bool = False): - super().__init__('dlna', name, disabled=disabled, mock=mock) + def __init__(self, name: str, stream_id: int, disabled: bool = False, mock: bool = False): + super().__init__('dlna', name, stream_id, disabled=disabled, mock=mock) self.supported_cmds = ['play', 'pause'] self._metadata_proc = None self._uuid = 0 diff --git a/amplipi/streams/file_player.py b/amplipi/streams/file_player.py index 279b5aebe..ad6659580 100644 --- a/amplipi/streams/file_player.py +++ b/amplipi/streams/file_player.py @@ -14,8 +14,8 @@ class FilePlayer(BaseStream): stream_type: ClassVar[str] = 'fileplayer' - def __init__(self, name: str, url: str, temporary: bool = False, timeout: Optional[int] = None, has_pause: bool = True, disabled: bool = False, mock: bool = False): - super().__init__(self.stream_type, name, disabled=disabled, mock=mock) + def __init__(self, name: str, stream_id: int, url: str, temporary: bool = False, timeout: Optional[int] = None, has_pause: bool = True, disabled: bool = False, mock: bool = False): + super().__init__(self.stream_type, name, stream_id, disabled=disabled, mock=mock) self.url = url self.bkg_thread = None if has_pause: diff --git a/amplipi/streams/fm_radio.py b/amplipi/streams/fm_radio.py index b5c9b2ecf..79f30313d 100644 --- a/amplipi/streams/fm_radio.py +++ b/amplipi/streams/fm_radio.py @@ -15,8 +15,8 @@ class FMRadio(BaseStream): stream_type: ClassVar[str] = 'fmradio' - def __init__(self, name: str, freq, logo: Optional[str] = None, disabled: bool = False, mock: bool = False): - super().__init__(self.stream_type, name, disabled=disabled, mock=mock) + def __init__(self, name: str, stream_id: int, freq, logo: Optional[str] = None, disabled: bool = False, mock: bool = False): + super().__init__(self.stream_type, name, stream_id, disabled=disabled, mock=mock) self.freq = freq self.logo = logo diff --git a/amplipi/streams/internet_radio.py b/amplipi/streams/internet_radio.py index b1f2118a6..f7c6dea78 100644 --- a/amplipi/streams/internet_radio.py +++ b/amplipi/streams/internet_radio.py @@ -17,8 +17,8 @@ class InternetRadio(BaseStream): stream_type: ClassVar[str] = 'internetradio' - def __init__(self, name: str, url: str, logo: Optional[str], disabled: bool = False, mock: bool = False, validate: bool = True): - super().__init__(self.stream_type, name, disabled=disabled, mock=mock, validate=validate, url=url, logo=logo) + def __init__(self, name: str, stream_id: int, url: str, logo: Optional[str], disabled: bool = False, mock: bool = False, validate: bool = True): + super().__init__(self.stream_type, name, stream_id, disabled=disabled, mock=mock, validate=validate, url=url, logo=logo) self.url = url self.logo = logo self.supported_cmds = ['play', 'stop'] diff --git a/amplipi/streams/lms.py b/amplipi/streams/lms.py index db9b097f5..195183e1d 100644 --- a/amplipi/streams/lms.py +++ b/amplipi/streams/lms.py @@ -15,8 +15,8 @@ class LMS(PersistentStream): stream_type: ClassVar[str] = 'lms' - def __init__(self, name: str, server: Optional[str] = None, port: Optional[int] = 9000, disabled: bool = False, mock: bool = False): - super().__init__(self.stream_type, name, disabled=disabled, mock=mock) + def __init__(self, name: str, stream_id: int, server: Optional[str] = None, port: Optional[int] = 9000, disabled: bool = False, mock: bool = False): + super().__init__(self.stream_type, name, stream_id, disabled=disabled, mock=mock) self.server: Optional[str] = server self.port: Optional[int] = port self.meta_proc: Optional[subprocess.Popen] = None diff --git a/amplipi/streams/media_device.py b/amplipi/streams/media_device.py index 1e2c28234..0ff98a417 100644 --- a/amplipi/streams/media_device.py +++ b/amplipi/streams/media_device.py @@ -18,8 +18,8 @@ class MediaDevice(PersistentStream, Browsable): stream_type: ClassVar[str] = 'mediadevice' - def __init__(self, name: str, url: Optional[str], disabled: bool = False, mock: bool = False): - super().__init__(self.stream_type, name, disabled=disabled, mock=mock) + def __init__(self, name: str, stream_id: int, url: Optional[str], disabled: bool = False, mock: bool = False): + super().__init__(self.stream_type, name, stream_id, disabled=disabled, mock=mock) self.url = url self.directory = '/media' self.local_directory = '/media' diff --git a/amplipi/streams/pandora.py b/amplipi/streams/pandora.py index 548a5e254..78bc8a3e6 100644 --- a/amplipi/streams/pandora.py +++ b/amplipi/streams/pandora.py @@ -16,7 +16,7 @@ class Pandora(PersistentStream, Browsable): stream_type: ClassVar[str] = 'pandora' - def __init__(self, name: str, user, password: str, station: str, disabled: bool = False, mock: bool = False, validate: bool = True): + def __init__(self, name: str, stream_id: int, user, password: str, station: str, disabled: bool = False, mock: bool = False, validate: bool = True): # pandora api client, the values in here come from the pandora android app # We set this up here because it permits early account validation during the parent's constructor self.pyd_client = SettingsDictBuilder({ @@ -27,7 +27,7 @@ def __init__(self, name: str, user, password: str, station: str, disabled: bool "DEVICE": "android-generic", }).build() - super().__init__(self.stream_type, name, disabled=disabled, mock=mock, validate=validate, user=user, password=password) + super().__init__(self.stream_type, name, stream_id, disabled=disabled, mock=mock, validate=validate, user=user, password=password) self.user = user self.password = password diff --git a/amplipi/streams/plexamp.py b/amplipi/streams/plexamp.py index ee3f98103..1146ba773 100644 --- a/amplipi/streams/plexamp.py +++ b/amplipi/streams/plexamp.py @@ -10,8 +10,8 @@ class Plexamp(BaseStream): stream_type: ClassVar[str] = 'plexamp' - def __init__(self, name: str, client_id, token, disabled: bool = False, mock: bool = False): - super().__init__(self.stream_type, name, disabled=disabled, mock=mock) + def __init__(self, name: str, stream_id: int, client_id, token, disabled: bool = False, mock: bool = False): + super().__init__(self.stream_type, name, stream_id, disabled=disabled, mock=mock) def reconfig(self, **kwargs): if 'disabled' in kwargs: diff --git a/amplipi/streams/rca.py b/amplipi/streams/rca.py index d4ee31734..8dbd0923f 100644 --- a/amplipi/streams/rca.py +++ b/amplipi/streams/rca.py @@ -8,8 +8,8 @@ class RCA(BaseStream): stream_type: ClassVar[str] = 'rca' - def __init__(self, name: str, index: int, disabled: bool = False, mock: bool = False): - super().__init__(self.stream_type, name, only_src=index, disabled=disabled, mock=mock) + def __init__(self, name: str, stream_id: int, index: int, disabled: bool = False, mock: bool = False): + super().__init__(self.stream_type, name, stream_id, only_src=index, disabled=disabled, mock=mock) # for serialiation the stream model's field needs to map to a stream's fields # index is needed for serialization self.index = index diff --git a/amplipi/streams/spotify_connect.py b/amplipi/streams/spotify_connect.py index 390052867..30812888f 100644 --- a/amplipi/streams/spotify_connect.py +++ b/amplipi/streams/spotify_connect.py @@ -21,8 +21,8 @@ class SpotifyConnect(PersistentStream): stream_type: ClassVar[str] = 'spotify' - def __init__(self, name: str, disabled: bool = False, mock: bool = False, validate: bool = True): - super().__init__(self.stream_type, name, disabled=disabled, mock=mock, validate=validate) + def __init__(self, name: str, stream_id: int, disabled: bool = False, mock: bool = False, validate: bool = True): + super().__init__(self.stream_type, name, stream_id, disabled=disabled, mock=mock, validate=validate) self.supported_cmds = [ 'play', 'pause', @@ -35,8 +35,6 @@ def __init__(self, name: str, disabled: bool = False, mock: bool = False, valida self.proc2: Optional[subprocess.Popen] = None self.proc3: Optional[subprocess.Popen] = None self.meta_file: str = '' - self.max_volume: int = 100 # default configuration from 'volume_steps' - self.last_volume: float = 0 def reconfig(self, **kwargs): self.validate_stream(**kwargs) @@ -101,29 +99,36 @@ def _activate(self, vsrc: int): self.proc2 = subprocess.Popen(args=meta_args, stdout=self._log_file, stderr=self._log_file) vol_sync = f"{utils.get_folder('streams')}/spotify_volume_handler.py" - vol_args = [sys.executable, vol_sync, str(self._api_port), str(self.vsrc), "--debug"] + vol_args = [sys.executable, vol_sync, str(self._api_port), str(self.id), "--debug"] logger.info(f'{self.name}: starting vol synchronizer: {vol_args}') - self.proc3 = subprocess.Popen(args=vol_args, stdout=self._log_file, stderr=self._log_file) + self.proc3 = subprocess.Popen(args=vol_args, stdout=sys.stdout, stderr=sys.stderr) + # self.proc3 = subprocess.Popen(args=vol_args, stdout=self._log_file, stderr=self._log_file) def _deactivate(self): if self._is_running(): self.proc.stdin.close() logger.info(f'{self.name}: stopping player') + self.proc.terminate() - self.proc2.terminate() - self.proc3.terminate() if self.proc.wait(1) != 0: logger.info(f'{self.name}: killing player') self.proc.kill() + self.proc.communicate() + + self.proc2.terminate() if self.proc2.wait(1) != 0: logger.info(f'{self.name}: killing metadata reader') self.proc2.kill() - if self.proc3.wait(1) != 0: - logger.info(f'{self.name}: killing volume synchronizer') - self.proc3.kill() - self.proc.communicate() self.proc2.communicate() - self.proc3.communicate() + + if self.proc3: + self.proc3.terminate() + if self.proc3.wait(1) != 0: + logger.info(f'{self.name}: killing volume synchronizer') + self.proc3.kill() + self.proc3.communicate() + self.proc3 = None + if self.proc and self._log_file: # prevent checking _log_file when it may not exist, thanks validation! self._log_file.close() if self.src: @@ -134,7 +139,6 @@ def _deactivate(self): self._disconnect() self.proc = None self.proc2 = None - self.proc3 = None def info(self) -> models.SourceInfo: source = models.SourceInfo( @@ -202,10 +206,3 @@ def validate_stream(self, **kwargs): NAME = r"[a-zA-Z0-9][A-Za-z0-9\- ]*[a-zA-Z0-9]" if 'name' in kwargs and not re.fullmatch(NAME, kwargs['name']): raise InvalidStreamField("name", "Invalid stream name") - - def sync_volume(self, volume: float) -> None: - """ Set the volume of amplipi to the Spotify Connect stream""" - if volume != self.last_volume: - url = f"http://localhost:{self._api_port}/" - self.last_volume = volume # update last_volume for future syncs - tasks.post.delay(url + 'volume', data={'volume': int(volume * self.max_volume)}) diff --git a/amplipi/utils.py b/amplipi/utils.py index 6cfd5e66e..07eac3128 100644 --- a/amplipi/utils.py +++ b/amplipi/utils.py @@ -250,6 +250,13 @@ def enabled_zones(status: models.Status, zones: Set[int]) -> Set[int]: return zones.difference(z_disabled) +def get_next_stream_id(status: models.Status) -> int: + """Get the next sequential stream ID""" + logger.error(len(status.streams)) + status.streams[len(status.streams) - 1].id + 1 + return status.streams[len(status.streams) - 1].id + 1 + + @functools.lru_cache(maxsize=8) def get_folder(relative_folder, mock=False): """ Get a directory diff --git a/streams/spotify_volume_handler.py b/streams/spotify_volume_handler.py index 207c5ec2f..a112765c5 100644 --- a/streams/spotify_volume_handler.py +++ b/streams/spotify_volume_handler.py @@ -62,8 +62,8 @@ async def watch_vol(self): class AmpliPiData: """A class to record amplipi's api output and calculate the volume of a given source""" - def __init__(self, source_id: int, callback, debug: bool = False): - self.source_id = source_id + def __init__(self, stream_id: int, callback, debug: bool = False): + self.stream_id = stream_id self.callback = callback self.debug = debug self.status: dict = None @@ -89,8 +89,16 @@ def get_status(self): def consume_status(self, status): """Consume an API response into the local object""" self.status = status + source_id = None + for source in self.status["sources"]: + if source["input"] == f"stream={self.stream_id}": + source_id = source["id"] - self.connected_zones = [zone for zone in self.status["zones"] if zone["source_id"] == self.source_id] + logger.error(self.status['sources'][source_id]) + logger.error(self.status['zones']) + + self.connected_zones = [zone for zone in self.status["zones"] if zone["source_id"] == source_id] + logger.error(self.connected_zones) self.volume = self.get_volume() def get_volume(self): @@ -106,9 +114,9 @@ def get_volume(self): class SpotifyVolumeHandler: """Volume synchronizer for Spotify and AmpliPi volume sliders""" - def __init__(self, port, source, debug=False): + def __init__(self, port, stream_id, debug=False): self.event_queue = queue.Queue() - self.amplipi = AmpliPiData(source, self.on_child_event, debug) + self.amplipi = AmpliPiData(stream_id, self.on_child_event, debug) self.spotify = SpotifyData(port, self.on_child_event, debug) self.debug: bool = debug @@ -169,12 +177,12 @@ def update_spotify_volume(self): parser = argparse.ArgumentParser(description="Read metadata from a given URL and write it to a file.") parser.add_argument("port", help="The port that go-librespot is running on", type=int) - parser.add_argument("source", help="The source that the spotify stream is playing to", type=int) + parser.add_argument("stream_id", help="The AmpliPi ID of the Spotify Stream being listened to for volume changes", type=int) parser.add_argument("--debug", action="store_true", help="Enable debug output") args = parser.parse_args() - handler = SpotifyVolumeHandler(args.port, args.source, args.debug) + handler = SpotifyVolumeHandler(args.port, args.stream_id, args.debug) while True: try: if handler.spotify.volume is None: From b0db283102a8132553c87319bd3a498a576ceb8f Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Thu, 13 Nov 2025 17:11:28 -0500 Subject: [PATCH 27/49] Improve initial state sync --- streams/spotify_volume_handler.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/streams/spotify_volume_handler.py b/streams/spotify_volume_handler.py index a112765c5..8d5b92e24 100644 --- a/streams/spotify_volume_handler.py +++ b/streams/spotify_volume_handler.py @@ -50,6 +50,8 @@ async def watch_vol(self): if event.event_type == "volume": self.volume = event.data.value / 100 # AmpliPi volume is between 0 and 1, Spotify is between 0 and 100. Dividing by 100 is more accurate than multiplying by 100 due to floating point errors. self.callback("spotify_volume_changed") + elif event.event_type == "will_play" and self.volume is None: + self.callback("amplipi_volume_changed") # Intercept the event that occurs when a song starts playing and use that as a trigger for the initial state sync except Exception as e: logger.exception(f"Error: {e}") @@ -79,7 +81,7 @@ def get_status(self): while True: self.consume_status(requests.get("http://localhost/api", timeout=5).json()) - print(f"last: {self.last_volume}, vol: {self.volume}") + logger.debug(f"last: {self.last_volume}, vol: {self.volume}") if self.last_volume != self.volume: self.callback("amplipi_volume_changed") @@ -94,11 +96,7 @@ def consume_status(self, status): if source["input"] == f"stream={self.stream_id}": source_id = source["id"] - logger.error(self.status['sources'][source_id]) - logger.error(self.status['zones']) - self.connected_zones = [zone for zone in self.status["zones"] if zone["source_id"] == source_id] - logger.error(self.connected_zones) self.volume = self.get_volume() def get_volume(self): @@ -159,14 +157,15 @@ def update_spotify_volume(self): if amplipi_volume is None: return - if abs(amplipi_volume - self.shared_volume) <= self.tolerance: + if abs(amplipi_volume - self.shared_volume) <= self.tolerance and self.spotify.volume is not None: if self.debug: logger.debug("Ignored minor AmpliPi -> Spotify change") return - url = f"http://localhost:{self.spotify.api_port}" + url = f"http://localhost:{self.spotify.api_port}/player/volume" new_vol = int(amplipi_volume * 100) - requests.post(url + '/player/volume', json={"volume": new_vol}, timeout=5) + logger.error(f"Setting spotify volume to {new_vol}") + requests.post(url, json={"volume": new_vol}, timeout=5) self.shared_volume = amplipi_volume except Exception as e: logger.exception(f"Exception: {e}") @@ -185,8 +184,6 @@ def update_spotify_volume(self): handler = SpotifyVolumeHandler(args.port, args.stream_id, args.debug) while True: try: - if handler.spotify.volume is None: - handler.update_spotify_volume() event = handler.event_queue.get(timeout=2) if event == "spotify_volume_changed": handler.update_amplipi_volume() From 3b0f603da37bbf1d62b4699d3ff9140520437baf Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Fri, 14 Nov 2025 15:51:50 -0500 Subject: [PATCH 28/49] Generalize volume synchronizer script for future use with airplay and other streams --- amplipi/streams/spotify_connect.py | 5 +- streams/spotify_volume_handler.py | 165 +++++------------------------ streams/volume_synchronizer.py | 152 ++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 144 deletions(-) create mode 100644 streams/volume_synchronizer.py diff --git a/amplipi/streams/spotify_connect.py b/amplipi/streams/spotify_connect.py index 30812888f..dc89878b4 100644 --- a/amplipi/streams/spotify_connect.py +++ b/amplipi/streams/spotify_connect.py @@ -99,10 +99,9 @@ def _activate(self, vsrc: int): self.proc2 = subprocess.Popen(args=meta_args, stdout=self._log_file, stderr=self._log_file) vol_sync = f"{utils.get_folder('streams')}/spotify_volume_handler.py" - vol_args = [sys.executable, vol_sync, str(self._api_port), str(self.id), "--debug"] + vol_args = [sys.executable, vol_sync, str(self._api_port), str(self.id)] logger.info(f'{self.name}: starting vol synchronizer: {vol_args}') - self.proc3 = subprocess.Popen(args=vol_args, stdout=sys.stdout, stderr=sys.stderr) - # self.proc3 = subprocess.Popen(args=vol_args, stdout=self._log_file, stderr=self._log_file) + self.proc3 = subprocess.Popen(args=vol_args, stdout=self._log_file, stderr=self._log_file) def _deactivate(self): if self._is_running(): diff --git a/streams/spotify_volume_handler.py b/streams/spotify_volume_handler.py index 8d5b92e24..b6d3169bc 100644 --- a/streams/spotify_volume_handler.py +++ b/streams/spotify_volume_handler.py @@ -1,16 +1,13 @@ """Script for synchronizing AmpliPi and Spotify volumes""" import argparse -from time import sleep import json -import asyncio -import threading -import queue import logging import sys import websockets import requests +from volume_synchronizer import VolumeSynchronizer, StreamData from spot_connect_meta import Event @@ -20,21 +17,12 @@ logger.addHandler(sh) -class SpotifyData: +class SpotifyData(StreamData): """A class that watches and tracks changes to spotify-side volume""" - def __init__(self, api_port: int, callback, debug: bool = False): + def __init__(self, api_port: int): self.api_port: int = api_port - self.callback = callback - self.debug = debug - - self.volume: float = None - - threading.Thread(target=self.run_async_watch, daemon=True).start() - - def run_async_watch(self): - """Middleman function for creating an asyncio run inside of a new thread""" - asyncio.run(self.watch_vol()) + super().__init__() async def watch_vol(self): """Watch the go-librespot websocket endpoint for volume change events and update local volume info accordingly""" @@ -48,154 +36,49 @@ async def watch_vol(self): msg = await websocket.recv() event = Event.from_json(json.loads(msg)) if event.event_type == "volume": + last_volume = float(self.volume) if self.volume is not None else None self.volume = event.data.value / 100 # AmpliPi volume is between 0 and 1, Spotify is between 0 and 100. Dividing by 100 is more accurate than multiplying by 100 due to floating point errors. - self.callback("spotify_volume_changed") + + self.logger.debug(f"Spotify volume changed from {last_volume} to {self.volume}") + if last_volume is not None and self.volume != last_volume: + self.callback("stream_volume_changed") elif event.event_type == "will_play" and self.volume is None: self.callback("amplipi_volume_changed") # Intercept the event that occurs when a song starts playing and use that as a trigger for the initial state sync except Exception as e: - logger.exception(f"Error: {e}") + self.logger.exception(f"Error: {e}") return except Exception as e: - logger.exception(f"Error: {e}") + self.logger.exception(f"Error: {e}") return - -class AmpliPiData: - """A class to record amplipi's api output and calculate the volume of a given source""" - - def __init__(self, stream_id: int, callback, debug: bool = False): - self.stream_id = stream_id - self.callback = callback - self.debug = debug - self.status: dict = None - self.last_volume: float = None - self.volume: float = None - - self.connected_zones: list = [] - - threading.Thread(target=self.get_status, daemon=True).start() - - def get_status(self): - """Call up the amplipi API and send the output to self.consume_status""" - while True: - self.consume_status(requests.get("http://localhost/api", timeout=5).json()) - - logger.debug(f"last: {self.last_volume}, vol: {self.volume}") - - if self.last_volume != self.volume: - self.callback("amplipi_volume_changed") - self.last_volume = float(self.volume) - sleep(2) - - def consume_status(self, status): - """Consume an API response into the local object""" - self.status = status - source_id = None - for source in self.status["sources"]: - if source["input"] == f"stream={self.stream_id}": - source_id = source["id"] - - self.connected_zones = [zone for zone in self.status["zones"] if zone["source_id"] == source_id] - self.volume = self.get_volume() - - def get_volume(self): - """Calculate the average vol_f from all connected zones. If no zones are connected, or if the api has yet to be hit up, return 0.""" - if self.connected_zones: - total_vol_f = sum([zone["vol_f"] for zone in self.connected_zones]) # Note that accounting for the vol_f overflow variables here would make it impossible to use those overflows while also using this volume bar - return round(total_vol_f / len(self.connected_zones), 2) # Round down to 2 decimals to assist with float accuracy - else: - logger.warning("Could not find any associated zones") - return 0 - - -class SpotifyVolumeHandler: - """Volume synchronizer for Spotify and AmpliPi volume sliders""" - - def __init__(self, port, stream_id, debug=False): - self.event_queue = queue.Queue() - self.amplipi = AmpliPiData(stream_id, self.on_child_event, debug) - self.spotify = SpotifyData(port, self.on_child_event, debug) - self.debug: bool = debug - - self.shared_volume = self.amplipi.get_volume() - self.tolerance = 0.005 # Reduces jitters from floating point inaccuracy from either side - - def on_child_event(self, event_type): - """When an event occurs in a child, that child can use this callback function to schedule the response to said event in the event queue""" - self.event_queue.put(event_type) - - def update_amplipi_volume(self): - """Update AmpliPi's volume via the Spotify client volume slider""" - try: - spotify_volume = self.spotify.volume - if spotify_volume is None: - return - - if abs(spotify_volume - self.shared_volume) <= self.tolerance: - if self.debug: - logger.debug("Ignored minor Spotify -> AmpliPi change") - return - - delta = float(spotify_volume - self.shared_volume) - self.amplipi.consume_status(requests.patch( - "http://localhost/api/zones", - json={ - "zones": [zone["id"] for zone in self.amplipi.connected_zones], - "update": {"vol_delta_f": delta, "mute": False}, - }, - timeout=5, - ).json()) - self.shared_volume = spotify_volume - except Exception as e: - logger.exception(f"Exception: {e}") - - def update_spotify_volume(self): + def set_vol(self, amplipi_volume: float, shared_volume: float) -> float: """Update Spotify's volume slider to match AmpliPi""" try: - amplipi_volume = self.amplipi.volume if amplipi_volume is None: - return + return shared_volume - if abs(amplipi_volume - self.shared_volume) <= self.tolerance and self.spotify.volume is not None: - if self.debug: - logger.debug("Ignored minor AmpliPi -> Spotify change") - return + if abs(amplipi_volume - shared_volume) <= 0.005 and self.volume is not None: + self.logger.debug("Ignored minor AmpliPi -> Spotify change") + return shared_volume - url = f"http://localhost:{self.spotify.api_port}/player/volume" + url = f"http://localhost:{self.api_port}/player/volume" new_vol = int(amplipi_volume * 100) - logger.error(f"Setting spotify volume to {new_vol}") + self.logger.debug(f"Setting Spotify volume to {new_vol / 100} from {self.volume}") requests.post(url, json={"volume": new_vol}, timeout=5) - self.shared_volume = amplipi_volume + return amplipi_volume except Exception as e: - logger.exception(f"Exception: {e}") + self.logger.exception(f"Exception: {e}") if __name__ == "__main__": parser = argparse.ArgumentParser(description="Read metadata from a given URL and write it to a file.") - parser.add_argument("port", help="The port that go-librespot is running on", type=int) - parser.add_argument("stream_id", help="The AmpliPi ID of the Spotify Stream being listened to for volume changes", type=int) - parser.add_argument("--debug", action="store_true", help="Enable debug output") + parser.add_argument("port", help="port that go-librespot is running on", type=int) + parser.add_argument("stream_id", help="The stream's amplipi side stream_id", type=int) + parser.add_argument("--debug", action="store_true", help="Change log level from WARNING to DEBUG") args = parser.parse_args() - handler = SpotifyVolumeHandler(args.port, args.stream_id, args.debug) - while True: - try: - event = handler.event_queue.get(timeout=2) - if event == "spotify_volume_changed": - handler.update_amplipi_volume() - elif event == "amplipi_volume_changed": - handler.update_spotify_volume() - except queue.Empty: - continue - except (KeyboardInterrupt, SystemExit): - logger.exception("Exiting...") - break - except Exception as e: - logger.exception(f"Exception: {e}") - sleep(5) - continue - sleep(2) + handler = VolumeSynchronizer(SpotifyData, {"api_port": args.port}, args.stream_id, args.debug).watcher_loop() diff --git a/streams/volume_synchronizer.py b/streams/volume_synchronizer.py new file mode 100644 index 000000000..b7d01a057 --- /dev/null +++ b/streams/volume_synchronizer.py @@ -0,0 +1,152 @@ +"""Script for synchronizing AmpliPi and Spotify volumes""" +from time import sleep +import asyncio +import threading +import queue +import logging +import sys +from typing import Callable + +import requests + + +class StreamData: + """A class that is used as a blueprint for other classes volume watcher""" + + def __init__(self): + self.callback: Callable + + self.volume: float = None + self.logger: logging.Logger + + self.thread: threading.Thread = threading.Thread(target=self.run_async_watch, daemon=True).start() + + def run_async_watch(self): + """Middleman function for creating an asyncio run inside of a new threading.Thread""" + asyncio.run(self.watch_vol()) + + async def watch_vol(self): + """A function to be implemented by child classes that must contain a while True loop and do self.callback('stream_vol_changed') when new_vol != old_vol""" + raise NotImplementedError("Function must be implemented by child classes") + + def set_vol(self, new_vol: float, shared_vol: float) -> float: + """A function to be implemented by child classes to update the stream's volume and returns the new shared volume""" + raise NotImplementedError("Function must be implemented by child classes") + + +class AmpliPiData: + """A class to record amplipi's api output and calculate the volume of a given source""" + + def __init__(self, stream_id: int, callback: Callable, logger: logging.Logger): + self.stream_id: int = stream_id + self.callback: Callable = callback + self.logger: logging.Logger = logger + self.status: dict = None + self.last_volume: float = None + self.volume: float = None + + self.connected_zones: list = [] + + threading.Thread(target=self.get_status, daemon=True).start() + + def get_status(self): + """Call up the amplipi API and send the output to self.consume_status""" + while True: + self.consume_status(requests.get("http://localhost/api", timeout=5).json()) + + if self.last_volume != self.volume: + self.logger.debug(f"AmpliPi volume changed from {self.last_volume} to {self.volume}") + if self.last_volume is not None: + self.callback("amplipi_volume_changed") + self.last_volume = float(self.volume) + sleep(2) + + def consume_status(self, status): + """Consume an API response into the local object""" + self.status = status + source_id = None + for source in self.status["sources"]: + if source["input"] == f"stream={self.stream_id}": + source_id = source["id"] + + self.connected_zones = [zone for zone in self.status["zones"] if zone["source_id"] == source_id] + self.volume = self.get_vol() + + def get_vol(self): + """Calculate the average vol_f from all connected zones. If no zones are connected, or if the api has yet to be hit up, return 0.""" + if self.connected_zones: + total_vol_f = sum([zone["vol_f"] for zone in self.connected_zones]) # Note that accounting for the vol_f overflow variables here would make it impossible to use those overflows while also using this volume bar + return round(total_vol_f / len(self.connected_zones), 2) # Round down to 2 decimals to assist with float accuracy + else: + self.logger.warning("Could not find any associated zones") + return 0 + + def set_vol(self, stream_volume: float, shared_volume: float): + """Update AmpliPi's volume to match the stream volume""" + try: + if stream_volume is None: + return shared_volume + + if abs(stream_volume - shared_volume) <= 0.005: + self.logger.debug("Ignored minor Stream -> AmpliPi change") + return shared_volume + + delta = float(stream_volume - shared_volume) + expected_volume = self.volume + delta + self.logger.debug(f"Setting AmpliPi volume to {expected_volume} from {self.volume}") + self.consume_status(requests.patch( + "http://localhost/api/zones", + json={ + "zones": [zone["id"] for zone in self.connected_zones], + "update": {"vol_delta_f": delta, "mute": False}, + }, + timeout=5, + ).json()) + return expected_volume + except Exception as e: + self.logger.exception(f"Exception: {e}") + + +class VolumeSynchronizer: + """Volume synchronizer for AmpliPi and another volume-providing stream""" + + def __init__(self, stream: StreamData, stream_kwargs: dict, stream_id: int, debug=False): + + self.logger = logging.getLogger(__name__) + self.logger.setLevel(logging.DEBUG if debug else logging.WARNING) + sh = logging.StreamHandler(sys.stdout) + self.logger.addHandler(sh) + + self.event_queue = queue.Queue() + self.amplipi = AmpliPiData(stream_id, self.on_child_event, self.logger) + + self.stream: StreamData = stream(**stream_kwargs) + + # Set these directly so children don't need to add them to their constructors + self.stream.logger = self.logger + self.stream.callback = self.on_child_event + + self.shared_volume = self.amplipi.get_vol() + + def on_child_event(self, event_type): + """When an event occurs in a child, that child can use this callback function to schedule the response to said event in the event queue""" + self.event_queue.put(event_type) + + def watcher_loop(self): + while True: + try: + event = self.event_queue.get(timeout=2) + if event == "stream_volume_changed": + self.shared_volume = self.amplipi.set_vol(self.stream.volume, self.shared_volume) + elif event == "amplipi_volume_changed": + self.shared_volume = self.stream.set_vol(self.amplipi.volume, self.shared_volume) + except queue.Empty: + continue + except (KeyboardInterrupt, SystemExit): + self.logger.exception("Exiting...") + break + except Exception as e: + self.logger.exception(f"Exception: {e}") + sleep(5) + continue + sleep(2) From 541826b55163f0c4c38f68b3418ed2bb6a0c0d0c Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Fri, 14 Nov 2025 15:53:32 -0500 Subject: [PATCH 29/49] Remove unused utils function --- amplipi/utils.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/amplipi/utils.py b/amplipi/utils.py index 07eac3128..6cfd5e66e 100644 --- a/amplipi/utils.py +++ b/amplipi/utils.py @@ -250,13 +250,6 @@ def enabled_zones(status: models.Status, zones: Set[int]) -> Set[int]: return zones.difference(z_disabled) -def get_next_stream_id(status: models.Status) -> int: - """Get the next sequential stream ID""" - logger.error(len(status.streams)) - status.streams[len(status.streams) - 1].id + 1 - return status.streams[len(status.streams) - 1].id + 1 - - @functools.lru_cache(maxsize=8) def get_folder(relative_folder, mock=False): """ Get a directory From 79cd961cf406573bf34cfd7bc2b1d5222ed4de10 Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Fri, 14 Nov 2025 16:00:13 -0500 Subject: [PATCH 30/49] Lint --- streams/spotify_volume_handler.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/streams/spotify_volume_handler.py b/streams/spotify_volume_handler.py index b6d3169bc..d4f42be59 100644 --- a/streams/spotify_volume_handler.py +++ b/streams/spotify_volume_handler.py @@ -81,4 +81,5 @@ def set_vol(self, amplipi_volume: float, shared_volume: float) -> float: args = parser.parse_args() - handler = VolumeSynchronizer(SpotifyData, {"api_port": args.port}, args.stream_id, args.debug).watcher_loop() + handler = VolumeSynchronizer(SpotifyData, {"api_port": args.port}, args.stream_id, args.debug) + handler.watcher_loop() From 11c8f3476074897855160c90605b4368ab21b664 Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Mon, 17 Nov 2025 14:12:41 -0500 Subject: [PATCH 31/49] Improve variable name --- streams/volume_synchronizer.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/streams/volume_synchronizer.py b/streams/volume_synchronizer.py index b7d01a057..b9b883e4e 100644 --- a/streams/volume_synchronizer.py +++ b/streams/volume_synchronizer.py @@ -81,17 +81,17 @@ def get_vol(self): self.logger.warning("Could not find any associated zones") return 0 - def set_vol(self, stream_volume: float, shared_volume: float): + def set_vol(self, stream_volume: float, vol_set_point: float): """Update AmpliPi's volume to match the stream volume""" try: if stream_volume is None: - return shared_volume + return vol_set_point - if abs(stream_volume - shared_volume) <= 0.005: + if abs(stream_volume - vol_set_point) <= 0.005: self.logger.debug("Ignored minor Stream -> AmpliPi change") - return shared_volume + return vol_set_point - delta = float(stream_volume - shared_volume) + delta = float(stream_volume - vol_set_point) expected_volume = self.volume + delta self.logger.debug(f"Setting AmpliPi volume to {expected_volume} from {self.volume}") self.consume_status(requests.patch( @@ -126,7 +126,7 @@ def __init__(self, stream: StreamData, stream_kwargs: dict, stream_id: int, debu self.stream.logger = self.logger self.stream.callback = self.on_child_event - self.shared_volume = self.amplipi.get_vol() + self.vol_set_point = self.amplipi.get_vol() def on_child_event(self, event_type): """When an event occurs in a child, that child can use this callback function to schedule the response to said event in the event queue""" @@ -137,9 +137,9 @@ def watcher_loop(self): try: event = self.event_queue.get(timeout=2) if event == "stream_volume_changed": - self.shared_volume = self.amplipi.set_vol(self.stream.volume, self.shared_volume) + self.vol_set_point = self.amplipi.set_vol(self.stream.volume, self.vol_set_point) elif event == "amplipi_volume_changed": - self.shared_volume = self.stream.set_vol(self.amplipi.volume, self.shared_volume) + self.vol_set_point = self.stream.set_vol(self.amplipi.volume, self.vol_set_point) except queue.Empty: continue except (KeyboardInterrupt, SystemExit): From e8ca331e92497a76866333adf76567882f22e207 Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Mon, 17 Nov 2025 16:47:13 -0500 Subject: [PATCH 32/49] Reduce the lag time between changing volume and seeing it reflected on the other side --- streams/volume_synchronizer.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/streams/volume_synchronizer.py b/streams/volume_synchronizer.py index b9b883e4e..46763a0e8 100644 --- a/streams/volume_synchronizer.py +++ b/streams/volume_synchronizer.py @@ -147,6 +147,5 @@ def watcher_loop(self): break except Exception as e: self.logger.exception(f"Exception: {e}") - sleep(5) continue - sleep(2) + sleep(1) From fa8679099c96451c42025984fd83d671be44ce2c Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Wed, 19 Nov 2025 14:24:44 -0500 Subject: [PATCH 33/49] Pass a fully formed object to VolumeSynchronizer instead of the constructor and the kwargs, update documentation --- streams/spotify_volume_handler.py | 4 ++-- streams/volume_synchronizer.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/streams/spotify_volume_handler.py b/streams/spotify_volume_handler.py index d4f42be59..6a04851f6 100644 --- a/streams/spotify_volume_handler.py +++ b/streams/spotify_volume_handler.py @@ -25,7 +25,7 @@ def __init__(self, api_port: int): super().__init__() async def watch_vol(self): - """Watch the go-librespot websocket endpoint for volume change events and update local volume info accordingly""" + """Watch the go-librespot websocket endpoint for volume change events and update AmpliPi volume info accordingly""" try: # Connect to the websocket and listen for state changes # pylint: disable=E1101 @@ -81,5 +81,5 @@ def set_vol(self, amplipi_volume: float, shared_volume: float) -> float: args = parser.parse_args() - handler = VolumeSynchronizer(SpotifyData, {"api_port": args.port}, args.stream_id, args.debug) + handler = VolumeSynchronizer(SpotifyData(api_port=args.port), args.stream_id, args.debug) handler.watcher_loop() diff --git a/streams/volume_synchronizer.py b/streams/volume_synchronizer.py index 46763a0e8..fe5428c08 100644 --- a/streams/volume_synchronizer.py +++ b/streams/volume_synchronizer.py @@ -110,7 +110,7 @@ def set_vol(self, stream_volume: float, vol_set_point: float): class VolumeSynchronizer: """Volume synchronizer for AmpliPi and another volume-providing stream""" - def __init__(self, stream: StreamData, stream_kwargs: dict, stream_id: int, debug=False): + def __init__(self, stream: StreamData, stream_id: int, debug=False): self.logger = logging.getLogger(__name__) self.logger.setLevel(logging.DEBUG if debug else logging.WARNING) @@ -120,7 +120,7 @@ def __init__(self, stream: StreamData, stream_kwargs: dict, stream_id: int, debu self.event_queue = queue.Queue() self.amplipi = AmpliPiData(stream_id, self.on_child_event, self.logger) - self.stream: StreamData = stream(**stream_kwargs) + self.stream: StreamData = stream # Set these directly so children don't need to add them to their constructors self.stream.logger = self.logger From 86cf823dcb4a0bb644d32ad29b803e6d461d27fa Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Wed, 19 Nov 2025 16:20:48 -0500 Subject: [PATCH 34/49] Change event_queue.get() to be a blocking call, meaning that any new events in the queue will instantly reflect their requested changes rather than waiting for 1-2 seconds due to the time.sleep() --- streams/volume_synchronizer.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/streams/volume_synchronizer.py b/streams/volume_synchronizer.py index fe5428c08..54f7107b7 100644 --- a/streams/volume_synchronizer.py +++ b/streams/volume_synchronizer.py @@ -135,7 +135,7 @@ def on_child_event(self, event_type): def watcher_loop(self): while True: try: - event = self.event_queue.get(timeout=2) + event = self.event_queue.get() if event == "stream_volume_changed": self.vol_set_point = self.amplipi.set_vol(self.stream.volume, self.vol_set_point) elif event == "amplipi_volume_changed": @@ -148,4 +148,3 @@ def watcher_loop(self): except Exception as e: self.logger.exception(f"Exception: {e}") continue - sleep(1) From 78432e22362fb984d813939779ea0021fd0a4583 Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Mon, 1 Dec 2025 11:33:24 -0500 Subject: [PATCH 35/49] Add FIFO-based volume listening for amplipi streams --- amplipi/streams/base_streams.py | 19 +++++ amplipi/streams/spotify_connect.py | 70 ++++++++++++++----- streams/spotify_volume_handler.py | 3 +- streams/volume_synchronizer.py | 60 ++++++---------- .../components/StreamsModal/StreamsModal.jsx | 2 +- web/src/pages/Home/Home.jsx | 4 +- 6 files changed, 99 insertions(+), 59 deletions(-) diff --git a/amplipi/streams/base_streams.py b/amplipi/streams/base_streams.py index 8ae822dcd..b550c3b9c 100644 --- a/amplipi/streams/base_streams.py +++ b/amplipi/streams/base_streams.py @@ -5,6 +5,7 @@ import logging from amplipi import models from amplipi import utils +from amplipi import app logger = logging.getLogger(__name__) logger.level = logging.DEBUG @@ -64,6 +65,24 @@ def __init__(self, stype: str, name: str, stream_id: int, only_src=None, disable if validate: self.validate_stream(name=name, mock=mock, **kwargs) + def get_zone_data(self): + if self.src is not None: + ctrl = app.get_ctrl() + state = ctrl.get_state() + return [zone for zone in state.zones if zone.source_id == self.src] + + @property + def connected_zones(self) -> List[int]: + connected_zones = self.get_zone_data() + return [zone.id for zone in connected_zones] + + @property + def volume(self) -> float: + connected_zones = self.get_zone_data() + if connected_zones: + return sum([zone.vol_f for zone in connected_zones]) / len(connected_zones) + return 0 + def __del__(self): self.disconnect() diff --git a/amplipi/streams/spotify_connect.py b/amplipi/streams/spotify_connect.py index dc89878b4..ecac05346 100644 --- a/amplipi/streams/spotify_connect.py +++ b/amplipi/streams/spotify_connect.py @@ -2,19 +2,27 @@ import io import os +import threading import re import sys import subprocess import time from typing import ClassVar, Optional import yaml +import logging +import json from amplipi import models, utils -from .base_streams import PersistentStream, InvalidStreamField, logger +from .base_streams import PersistentStream, InvalidStreamField from .. import tasks # Our subprocesses run behind the scenes, is there a more standard way to do this? # pylint: disable=consider-using-with +logger = logging.getLogger(__name__) +logger.level = logging.DEBUG +sh = logging.StreamHandler(sys.stdout) +logger.addHandler(sh) + class SpotifyConnect(PersistentStream): """ A SpotifyConnect Stream based off librespot-go """ @@ -33,8 +41,30 @@ def __init__(self, name: str, stream_id: int, disabled: bool = False, mock: bool self._log_file: Optional[io.TextIOBase] = None self._api_port: int self.proc2: Optional[subprocess.Popen] = None - self.proc3: Optional[subprocess.Popen] = None + self.volume_process: Optional[subprocess.Popen] = None + self.volume_process2: Optional[threading.Thread] = threading.Thread(target=self.watch_vol, daemon=True) + self.src_config_folder: str = None self.meta_file: str = '' + self._fifo = None + + def watch_vol(self): + """Creates and supplies a FIFO with volume data for volume sync""" + while True: + try: + if self.src is not None: + if not self._fifo is not None and self.src_config_folder is not None: + fifo_path = f"{self.src_config_folder}/vol" + if not os.path.isfile(fifo_path): + os.mkfifo(fifo_path) + self._fifo = os.open(fifo_path, os.O_WRONLY, os.O_NONBLOCK) + data = json.dumps({ + 'zones': self.connected_zones, + 'volume': self.volume, + }) + os.write(self._fifo, bytearray(f"{data}\r\n", encoding="utf8")) + except Exception as e: + logger.error(f"{self.name} volume thread ran into exception: {e}") + time.sleep(1) def reconfig(self, **kwargs): self.validate_stream(**kwargs) @@ -51,9 +81,9 @@ def _activate(self, vsrc: int): """ Connect to a given audio source """ - src_config_folder = f'{utils.get_folder("config")}/srcs/v{vsrc}' + self.src_config_folder = f'{utils.get_folder("config")}/srcs/v{vsrc}' try: - os.remove(f'{src_config_folder}/currentSong') + os.remove(f'{self.src_config_folder}/currentSong') except FileNotFoundError: pass self._connect_time = time.time() @@ -77,16 +107,16 @@ def _activate(self, vsrc: int): } # make all of the necessary dir(s) & files - os.makedirs(src_config_folder, exist_ok=True) + os.makedirs(self.src_config_folder, exist_ok=True) - config_file = f'{src_config_folder}/config.yml' + config_file = f'{self.src_config_folder}/config.yml' with open(config_file, 'w', encoding='utf8') as f: f.write(yaml.dump(config)) - self.meta_file = f'{src_config_folder}/metadata.json' + self.meta_file = f'{self.src_config_folder}/metadata.json' - self._log_file = open(f'{src_config_folder}/log', mode='w', encoding='utf8') - player_args = f"{utils.get_folder('streams')}/go-librespot --config_dir {src_config_folder}".split(' ') + self._log_file = open(f'{self.src_config_folder}/log', mode='w', encoding='utf8') + player_args = f"{utils.get_folder('streams')}/go-librespot --config_dir {self.src_config_folder}".split(' ') logger.debug(f'spotify player args: {player_args}') self.proc = subprocess.Popen(args=player_args, stdin=subprocess.PIPE, @@ -99,9 +129,11 @@ def _activate(self, vsrc: int): self.proc2 = subprocess.Popen(args=meta_args, stdout=self._log_file, stderr=self._log_file) vol_sync = f"{utils.get_folder('streams')}/spotify_volume_handler.py" - vol_args = [sys.executable, vol_sync, str(self._api_port), str(self.id)] + vol_args = [sys.executable, vol_sync, str(self._api_port), str(self.id), self.src_config_folder, "--debug"] logger.info(f'{self.name}: starting vol synchronizer: {vol_args}') - self.proc3 = subprocess.Popen(args=vol_args, stdout=self._log_file, stderr=self._log_file) + self.volume_process = subprocess.Popen(args=vol_args, stdout=self._log_file, stderr=self._log_file) + + self.volume_process2.start() def _deactivate(self): if self._is_running(): @@ -120,13 +152,17 @@ def _deactivate(self): self.proc2.kill() self.proc2.communicate() - if self.proc3: - self.proc3.terminate() - if self.proc3.wait(1) != 0: + if self.volume_process: + self.volume_process.terminate() + if self.volume_process.wait(1) != 0: logger.info(f'{self.name}: killing volume synchronizer') - self.proc3.kill() - self.proc3.communicate() - self.proc3 = None + self.volume_process.kill() + self.volume_process.communicate() + self.volume_process = None + + if self._fifo is not None: + self._fifo.close() + self._fifo = None if self.proc and self._log_file: # prevent checking _log_file when it may not exist, thanks validation! self._log_file.close() diff --git a/streams/spotify_volume_handler.py b/streams/spotify_volume_handler.py index 6a04851f6..c4212e47a 100644 --- a/streams/spotify_volume_handler.py +++ b/streams/spotify_volume_handler.py @@ -77,9 +77,10 @@ def set_vol(self, amplipi_volume: float, shared_volume: float) -> float: parser.add_argument("port", help="port that go-librespot is running on", type=int) parser.add_argument("stream_id", help="The stream's amplipi side stream_id", type=int) + parser.add_argument("config_dir", help="The directory of the vsrc config", type=str) parser.add_argument("--debug", action="store_true", help="Change log level from WARNING to DEBUG") args = parser.parse_args() - handler = VolumeSynchronizer(SpotifyData(api_port=args.port), args.stream_id, args.debug) + handler = VolumeSynchronizer(SpotifyData(api_port=args.port), args.stream_id, args.config_dir, args.debug) handler.watcher_loop() diff --git a/streams/volume_synchronizer.py b/streams/volume_synchronizer.py index 54f7107b7..b2da646df 100644 --- a/streams/volume_synchronizer.py +++ b/streams/volume_synchronizer.py @@ -1,5 +1,6 @@ """Script for synchronizing AmpliPi and Spotify volumes""" from time import sleep +import json import asyncio import threading import queue @@ -37,49 +38,29 @@ def set_vol(self, new_vol: float, shared_vol: float) -> float: class AmpliPiData: """A class to record amplipi's api output and calculate the volume of a given source""" - def __init__(self, stream_id: int, callback: Callable, logger: logging.Logger): + def __init__(self, stream_id: int, config_dir: str, callback: Callable, logger: logging.Logger): self.stream_id: int = stream_id self.callback: Callable = callback self.logger: logging.Logger = logger self.status: dict = None - self.last_volume: float = None self.volume: float = None + self.config_dir = config_dir - self.connected_zones: list = [] + self.status_file: str = "" - threading.Thread(target=self.get_status, daemon=True).start() + self.connected_zones: list[int] = [] - def get_status(self): - """Call up the amplipi API and send the output to self.consume_status""" - while True: - self.consume_status(requests.get("http://localhost/api", timeout=5).json()) - - if self.last_volume != self.volume: - self.logger.debug(f"AmpliPi volume changed from {self.last_volume} to {self.volume}") - if self.last_volume is not None: - self.callback("amplipi_volume_changed") - self.last_volume = float(self.volume) - sleep(2) - - def consume_status(self, status): - """Consume an API response into the local object""" - self.status = status - source_id = None - for source in self.status["sources"]: - if source["input"] == f"stream={self.stream_id}": - source_id = source["id"] - - self.connected_zones = [zone for zone in self.status["zones"] if zone["source_id"] == source_id] - self.volume = self.get_vol() + threading.Thread(target=self.get_vol, daemon=True).start() def get_vol(self): """Calculate the average vol_f from all connected zones. If no zones are connected, or if the api has yet to be hit up, return 0.""" - if self.connected_zones: - total_vol_f = sum([zone["vol_f"] for zone in self.connected_zones]) # Note that accounting for the vol_f overflow variables here would make it impossible to use those overflows while also using this volume bar - return round(total_vol_f / len(self.connected_zones), 2) # Round down to 2 decimals to assist with float accuracy - else: - self.logger.warning("Could not find any associated zones") - return 0 + with open(f'{self.config_dir}/vol', 'r') as fifo: + while True: + data = json.loads(fifo.readline().strip()) + if self.volume != data["volume"]: + self.callback("amplipi_volume_changed") + self.volume = data["volume"] + self.connected_zones = data["zones"] def set_vol(self, stream_volume: float, vol_set_point: float): """Update AmpliPi's volume to match the stream volume""" @@ -94,14 +75,14 @@ def set_vol(self, stream_volume: float, vol_set_point: float): delta = float(stream_volume - vol_set_point) expected_volume = self.volume + delta self.logger.debug(f"Setting AmpliPi volume to {expected_volume} from {self.volume}") - self.consume_status(requests.patch( + requests.patch( "http://localhost/api/zones", json={ - "zones": [zone["id"] for zone in self.connected_zones], + "zones": self.connected_zones, "update": {"vol_delta_f": delta, "mute": False}, }, timeout=5, - ).json()) + ) return expected_volume except Exception as e: self.logger.exception(f"Exception: {e}") @@ -110,7 +91,7 @@ def set_vol(self, stream_volume: float, vol_set_point: float): class VolumeSynchronizer: """Volume synchronizer for AmpliPi and another volume-providing stream""" - def __init__(self, stream: StreamData, stream_id: int, debug=False): + def __init__(self, stream: StreamData, stream_id: int, config_dir: str, debug=False): self.logger = logging.getLogger(__name__) self.logger.setLevel(logging.DEBUG if debug else logging.WARNING) @@ -118,7 +99,7 @@ def __init__(self, stream: StreamData, stream_id: int, debug=False): self.logger.addHandler(sh) self.event_queue = queue.Queue() - self.amplipi = AmpliPiData(stream_id, self.on_child_event, self.logger) + self.amplipi = AmpliPiData(stream_id, config_dir, self.on_child_event, self.logger) self.stream: StreamData = stream @@ -126,7 +107,7 @@ def __init__(self, stream: StreamData, stream_id: int, debug=False): self.stream.logger = self.logger self.stream.callback = self.on_child_event - self.vol_set_point = self.amplipi.get_vol() + self.vol_set_point = self.amplipi.volume def on_child_event(self, event_type): """When an event occurs in a child, that child can use this callback function to schedule the response to said event in the event queue""" @@ -135,6 +116,9 @@ def on_child_event(self, event_type): def watcher_loop(self): while True: try: + if self.vol_set_point is None: + self.vol_set_point = self.amplipi.volume + event = self.event_queue.get() if event == "stream_volume_changed": self.vol_set_point = self.amplipi.set_vol(self.stream.volume, self.vol_set_point) diff --git a/web/src/components/StreamsModal/StreamsModal.jsx b/web/src/components/StreamsModal/StreamsModal.jsx index 86c6bfba0..9ee40021c 100644 --- a/web/src/components/StreamsModal/StreamsModal.jsx +++ b/web/src/components/StreamsModal/StreamsModal.jsx @@ -27,7 +27,7 @@ export const executeApplyAction = async (customSourceId) => { let ret = undefined; while(ret == undefined){ ret = await temp(customSourceId); - }; + } let sliced = parseInt(String(ret.url).slice(-1)); setSelectedSource(sliced); setAutoselectSource(false); diff --git a/web/src/pages/Home/Home.jsx b/web/src/pages/Home/Home.jsx index 0f28f48bc..b80b0a72b 100644 --- a/web/src/pages/Home/Home.jsx +++ b/web/src/pages/Home/Home.jsx @@ -119,7 +119,7 @@ const Home = () => { // on apply, we want to call onApply={async (customSourceId) => { const ret = await executeApplyAction(customSourceId); - if(ret.ok){ret.json().then(s => setSystemState(s))}; + if(ret.ok){ret.json().then(s => setSystemState(s));} }} onClose={() => setZonesModalOpen(false)} /> @@ -129,7 +129,7 @@ const Home = () => { sourceId={nextAvailableSource} onApply={async (customSourceId) => { const ret = await executeApplyAction(customSourceId); - if(ret.ok){ret.json().then(s => setSystemState(s))}; + if(ret.ok){ret.json().then(s => setSystemState(s));} }} onClose={() => setStreamerOutputModalOpen(false)} /> From e1c737c2b8e95dae70ae3d6fedc658bb5c8e8f6b Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Mon, 1 Dec 2025 11:36:06 -0500 Subject: [PATCH 36/49] Stop filtering flow by "If not is not None" --- amplipi/streams/spotify_connect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amplipi/streams/spotify_connect.py b/amplipi/streams/spotify_connect.py index ecac05346..5eb490979 100644 --- a/amplipi/streams/spotify_connect.py +++ b/amplipi/streams/spotify_connect.py @@ -52,7 +52,7 @@ def watch_vol(self): while True: try: if self.src is not None: - if not self._fifo is not None and self.src_config_folder is not None: + if self._fifo is None and self.src_config_folder is not None: fifo_path = f"{self.src_config_folder}/vol" if not os.path.isfile(fifo_path): os.mkfifo(fifo_path) From ec6f264261db741c0e417dc654c47743ace32d2a Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Mon, 1 Dec 2025 13:20:49 -0500 Subject: [PATCH 37/49] Move typehint "list" to "Typing.List" to fit with our python version and linting practices --- streams/volume_synchronizer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/streams/volume_synchronizer.py b/streams/volume_synchronizer.py index b2da646df..7220f94d3 100644 --- a/streams/volume_synchronizer.py +++ b/streams/volume_synchronizer.py @@ -6,7 +6,7 @@ import queue import logging import sys -from typing import Callable +from typing import Callable, List import requests @@ -48,7 +48,7 @@ def __init__(self, stream_id: int, config_dir: str, callback: Callable, logger: self.status_file: str = "" - self.connected_zones: list[int] = [] + self.connected_zones: List[int] = [] threading.Thread(target=self.get_vol, daemon=True).start() From a0b5209e4f5fff2646e4563e9b93ad84ed98f2f8 Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Mon, 1 Dec 2025 13:25:42 -0500 Subject: [PATCH 38/49] linting --- amplipi/streams/spotify_connect.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/amplipi/streams/spotify_connect.py b/amplipi/streams/spotify_connect.py index 5eb490979..e745f8f47 100644 --- a/amplipi/streams/spotify_connect.py +++ b/amplipi/streams/spotify_connect.py @@ -42,8 +42,8 @@ def __init__(self, name: str, stream_id: int, disabled: bool = False, mock: bool self._api_port: int self.proc2: Optional[subprocess.Popen] = None self.volume_process: Optional[subprocess.Popen] = None - self.volume_process2: Optional[threading.Thread] = threading.Thread(target=self.watch_vol, daemon=True) - self.src_config_folder: str = None + self.volume_process2: threading.Thread = threading.Thread(target=self.watch_vol, daemon=True) + self.src_config_folder: Optional[str] = None self.meta_file: str = '' self._fifo = None From 0c67268192885315d905da36fa06a86584c29f39 Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Mon, 1 Dec 2025 13:35:47 -0500 Subject: [PATCH 39/49] Close the fifo cleanly --- amplipi/streams/spotify_connect.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/amplipi/streams/spotify_connect.py b/amplipi/streams/spotify_connect.py index e745f8f47..1cb74d422 100644 --- a/amplipi/streams/spotify_connect.py +++ b/amplipi/streams/spotify_connect.py @@ -160,9 +160,7 @@ def _deactivate(self): self.volume_process.communicate() self.volume_process = None - if self._fifo is not None: - self._fifo.close() - self._fifo = None + self._fifo = None if self.proc and self._log_file: # prevent checking _log_file when it may not exist, thanks validation! self._log_file.close() From 0b7cfee8d2cebabf9a12a692d1804e2c2b7be25e Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Mon, 1 Dec 2025 16:49:27 -0500 Subject: [PATCH 40/49] Increase the speed of volume gathering --- amplipi/streams/spotify_connect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amplipi/streams/spotify_connect.py b/amplipi/streams/spotify_connect.py index 1cb74d422..eb2833bde 100644 --- a/amplipi/streams/spotify_connect.py +++ b/amplipi/streams/spotify_connect.py @@ -64,7 +64,7 @@ def watch_vol(self): os.write(self._fifo, bytearray(f"{data}\r\n", encoding="utf8")) except Exception as e: logger.error(f"{self.name} volume thread ran into exception: {e}") - time.sleep(1) + time.sleep(0.1) def reconfig(self, **kwargs): self.validate_stream(**kwargs) From 8492f08f00355d6631de1b4cc0a9667e3678fa78 Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Mon, 1 Dec 2025 17:18:05 -0500 Subject: [PATCH 41/49] Remove unnecessary stream_id plumbing through every stream type Change names of attributes in spotify_connect.py Update documentation in volume_synchronizer.py --- amplipi/streams/__init__.py | 28 ++++++++++++------------ amplipi/streams/airplay.py | 4 ++-- amplipi/streams/aux.py | 4 ++-- amplipi/streams/base_streams.py | 3 +-- amplipi/streams/bluetooth.py | 4 ++-- amplipi/streams/dlna.py | 4 ++-- amplipi/streams/file_player.py | 4 ++-- amplipi/streams/fm_radio.py | 4 ++-- amplipi/streams/internet_radio.py | 4 ++-- amplipi/streams/lms.py | 4 ++-- amplipi/streams/media_device.py | 4 ++-- amplipi/streams/pandora.py | 4 ++-- amplipi/streams/plexamp.py | 4 ++-- amplipi/streams/rca.py | 4 ++-- amplipi/streams/spotify_connect.py | 34 +++++++++++++++--------------- streams/spotify_volume_handler.py | 3 +-- streams/volume_synchronizer.py | 9 ++++---- 17 files changed, 61 insertions(+), 64 deletions(-) diff --git a/amplipi/streams/__init__.py b/amplipi/streams/__init__.py index 46b2c52e2..6b8bf46a0 100644 --- a/amplipi/streams/__init__.py +++ b/amplipi/streams/__init__.py @@ -1,4 +1,4 @@ -# AmpliPi Home Audio +# AmpliPi Home Audioself.src_config_folder # Copyright (C) 2022 MicroNova LLC # # This program is free software: you can redistribute it and/or modify @@ -72,31 +72,31 @@ def build_stream(stream: models.Stream, stream_id: int, mock: bool = False, vali name: str = args.pop('name') disabled = args.pop('disabled', False) if stream.type == 'rca': - return RCA(name, stream_id, args['index'], disabled=disabled, mock=mock) + return RCA(name, args['index'], disabled=disabled, mock=mock) if stream.type == 'pandora': - return Pandora(name, stream_id, args['user'], args['password'], station=args.get('station', None), disabled=disabled, mock=mock, validate=validate) + return Pandora(name, args['user'], args['password'], station=args.get('station', None), disabled=disabled, mock=mock, validate=validate) if stream.type in ['shairport', 'airplay']: # handle older configs - return AirPlay(name, stream_id, args.get('ap2', False), disabled=disabled, mock=mock, validate=validate) + return AirPlay(name, args.get('ap2', False), disabled=disabled, mock=mock, validate=validate) if stream.type == 'spotify': - return SpotifyConnect(name, stream_id, disabled=disabled, mock=mock, validate=validate) + return SpotifyConnect(name, disabled=disabled, mock=mock, validate=validate) if stream.type == 'dlna': - return DLNA(name, stream_id, disabled=disabled, mock=mock) + return DLNA(name, disabled=disabled, mock=mock) if stream.type == 'internetradio': - return InternetRadio(name, stream_id, args['url'], args.get('logo'), disabled=disabled, mock=mock, validate=validate) + return InternetRadio(name, args['url'], args.get('logo'), disabled=disabled, mock=mock, validate=validate) if stream.type == 'plexamp': - return Plexamp(name, stream_id, args['client_id'], args['token'], disabled=disabled, mock=mock) + return Plexamp(name, args['client_id'], args['token'], disabled=disabled, mock=mock) if stream.type == 'aux': - return Aux(name, stream_id, disabled=disabled, mock=mock) + return Aux(name, disabled=disabled, mock=mock) if stream.type == 'fileplayer': - return FilePlayer(name, stream_id, args.get('url', None), args.get('temporary', None), args.get('timeout', None), args.get('has_pause', True), disabled=disabled, mock=mock) + return FilePlayer(name, args.get('url', None), args.get('temporary', None), args.get('timeout', None), args.get('has_pause', True), disabled=disabled, mock=mock) if stream.type == 'fmradio': - return FMRadio(name, stream_id, args['freq'], args.get('logo'), disabled=disabled, mock=mock) + return FMRadio(name, args['freq'], args.get('logo'), disabled=disabled, mock=mock) if stream.type == 'lms': - return LMS(name, stream_id, args.get('server'), args.get("port"), disabled=disabled, mock=mock) + return LMS(name, args.get('server'), args.get("port"), disabled=disabled, mock=mock) elif stream.type == 'bluetooth': - return Bluetooth(name, stream_id, disabled=disabled, mock=mock) + return Bluetooth(name, disabled=disabled, mock=mock) elif stream.type == 'mediadevice': - return MediaDevice(name, stream_id, args.get('url'), disabled=disabled, mock=mock) + return MediaDevice(name, args.get('url'), disabled=disabled, mock=mock) raise NotImplementedError(stream.type) diff --git a/amplipi/streams/airplay.py b/amplipi/streams/airplay.py index b6a8d1ad2..a000ae097 100644 --- a/amplipi/streams/airplay.py +++ b/amplipi/streams/airplay.py @@ -27,8 +27,8 @@ class AirPlay(PersistentStream): stream_type: ClassVar[str] = 'airplay' - def __init__(self, name: str, stream_id: int, ap2: bool, disabled: bool = False, mock: bool = False, validate: bool = True): - super().__init__(self.stream_type, name, stream_id, disabled=disabled, mock=mock, validate=validate) + def __init__(self, name: str, ap2: bool, disabled: bool = False, mock: bool = False, validate: bool = True): + super().__init__(self.stream_type, name, disabled=disabled, mock=mock, validate=validate) self.mpris: Optional[MPRIS] = None self.ap2 = ap2 self.ap2_exists = False diff --git a/amplipi/streams/aux.py b/amplipi/streams/aux.py index ff401b2b2..af30764cc 100644 --- a/amplipi/streams/aux.py +++ b/amplipi/streams/aux.py @@ -9,8 +9,8 @@ class Aux(BaseStream): stream_type: ClassVar[str] = 'aux' - def __init__(self, name: str, stream_id: int, disabled: bool = False, mock: bool = False): - super().__init__(self.stream_type, name, stream_id, disabled=disabled, mock=mock) + def __init__(self, name: str, disabled: bool = False, mock: bool = False): + super().__init__(self.stream_type, name, disabled=disabled, mock=mock) def reconfig(self, **kwargs): if 'disabled' in kwargs: diff --git a/amplipi/streams/base_streams.py b/amplipi/streams/base_streams.py index b550c3b9c..4e7fbdf10 100644 --- a/amplipi/streams/base_streams.py +++ b/amplipi/streams/base_streams.py @@ -50,9 +50,8 @@ class Browsable: class BaseStream: """ BaseStream class containing methods that all other streams inherit """ - def __init__(self, stype: str, name: str, stream_id: int, only_src=None, disabled: bool = False, mock: bool = False, validate: bool = True, **kwargs): + def __init__(self, stype: str, name: str, only_src=None, disabled: bool = False, mock: bool = False, validate: bool = True, **kwargs): - self.id = stream_id self.name = name self.disabled = disabled self.proc: Optional[subprocess.Popen] = None diff --git a/amplipi/streams/bluetooth.py b/amplipi/streams/bluetooth.py index c8685c605..00cca3655 100644 --- a/amplipi/streams/bluetooth.py +++ b/amplipi/streams/bluetooth.py @@ -14,8 +14,8 @@ class Bluetooth(BaseStream): stream_type: ClassVar[str] = 'bluetooth' - def __init__(self, name, stream_id: int, disabled=False, mock=False): - super().__init__(self.stream_type, name, stream_id, disabled=disabled, mock=mock) + def __init__(self, name, disabled=False, mock=False): + super().__init__(self.stream_type, name, disabled=disabled, mock=mock) self.logo = "static/imgs/bluetooth.png" self.bt_proc = None self.supported_cmds = ['play', 'pause', 'next', 'prev', 'stop'] diff --git a/amplipi/streams/dlna.py b/amplipi/streams/dlna.py index 9d24f0c12..f5dd24a26 100644 --- a/amplipi/streams/dlna.py +++ b/amplipi/streams/dlna.py @@ -14,8 +14,8 @@ class DLNA(BaseStream): # TODO: make DLNA a persistent stream to fix the uuid i stream_type: ClassVar[str] = 'dlna' - def __init__(self, name: str, stream_id: int, disabled: bool = False, mock: bool = False): - super().__init__('dlna', name, stream_id, disabled=disabled, mock=mock) + def __init__(self, name: str, disabled: bool = False, mock: bool = False): + super().__init__('dlna', name, disabled=disabled, mock=mock) self.supported_cmds = ['play', 'pause'] self._metadata_proc = None self._uuid = 0 diff --git a/amplipi/streams/file_player.py b/amplipi/streams/file_player.py index ad6659580..279b5aebe 100644 --- a/amplipi/streams/file_player.py +++ b/amplipi/streams/file_player.py @@ -14,8 +14,8 @@ class FilePlayer(BaseStream): stream_type: ClassVar[str] = 'fileplayer' - def __init__(self, name: str, stream_id: int, url: str, temporary: bool = False, timeout: Optional[int] = None, has_pause: bool = True, disabled: bool = False, mock: bool = False): - super().__init__(self.stream_type, name, stream_id, disabled=disabled, mock=mock) + def __init__(self, name: str, url: str, temporary: bool = False, timeout: Optional[int] = None, has_pause: bool = True, disabled: bool = False, mock: bool = False): + super().__init__(self.stream_type, name, disabled=disabled, mock=mock) self.url = url self.bkg_thread = None if has_pause: diff --git a/amplipi/streams/fm_radio.py b/amplipi/streams/fm_radio.py index 79f30313d..b5c9b2ecf 100644 --- a/amplipi/streams/fm_radio.py +++ b/amplipi/streams/fm_radio.py @@ -15,8 +15,8 @@ class FMRadio(BaseStream): stream_type: ClassVar[str] = 'fmradio' - def __init__(self, name: str, stream_id: int, freq, logo: Optional[str] = None, disabled: bool = False, mock: bool = False): - super().__init__(self.stream_type, name, stream_id, disabled=disabled, mock=mock) + def __init__(self, name: str, freq, logo: Optional[str] = None, disabled: bool = False, mock: bool = False): + super().__init__(self.stream_type, name, disabled=disabled, mock=mock) self.freq = freq self.logo = logo diff --git a/amplipi/streams/internet_radio.py b/amplipi/streams/internet_radio.py index f7c6dea78..b1f2118a6 100644 --- a/amplipi/streams/internet_radio.py +++ b/amplipi/streams/internet_radio.py @@ -17,8 +17,8 @@ class InternetRadio(BaseStream): stream_type: ClassVar[str] = 'internetradio' - def __init__(self, name: str, stream_id: int, url: str, logo: Optional[str], disabled: bool = False, mock: bool = False, validate: bool = True): - super().__init__(self.stream_type, name, stream_id, disabled=disabled, mock=mock, validate=validate, url=url, logo=logo) + def __init__(self, name: str, url: str, logo: Optional[str], disabled: bool = False, mock: bool = False, validate: bool = True): + super().__init__(self.stream_type, name, disabled=disabled, mock=mock, validate=validate, url=url, logo=logo) self.url = url self.logo = logo self.supported_cmds = ['play', 'stop'] diff --git a/amplipi/streams/lms.py b/amplipi/streams/lms.py index 195183e1d..db9b097f5 100644 --- a/amplipi/streams/lms.py +++ b/amplipi/streams/lms.py @@ -15,8 +15,8 @@ class LMS(PersistentStream): stream_type: ClassVar[str] = 'lms' - def __init__(self, name: str, stream_id: int, server: Optional[str] = None, port: Optional[int] = 9000, disabled: bool = False, mock: bool = False): - super().__init__(self.stream_type, name, stream_id, disabled=disabled, mock=mock) + def __init__(self, name: str, server: Optional[str] = None, port: Optional[int] = 9000, disabled: bool = False, mock: bool = False): + super().__init__(self.stream_type, name, disabled=disabled, mock=mock) self.server: Optional[str] = server self.port: Optional[int] = port self.meta_proc: Optional[subprocess.Popen] = None diff --git a/amplipi/streams/media_device.py b/amplipi/streams/media_device.py index 0ff98a417..1e2c28234 100644 --- a/amplipi/streams/media_device.py +++ b/amplipi/streams/media_device.py @@ -18,8 +18,8 @@ class MediaDevice(PersistentStream, Browsable): stream_type: ClassVar[str] = 'mediadevice' - def __init__(self, name: str, stream_id: int, url: Optional[str], disabled: bool = False, mock: bool = False): - super().__init__(self.stream_type, name, stream_id, disabled=disabled, mock=mock) + def __init__(self, name: str, url: Optional[str], disabled: bool = False, mock: bool = False): + super().__init__(self.stream_type, name, disabled=disabled, mock=mock) self.url = url self.directory = '/media' self.local_directory = '/media' diff --git a/amplipi/streams/pandora.py b/amplipi/streams/pandora.py index 78bc8a3e6..548a5e254 100644 --- a/amplipi/streams/pandora.py +++ b/amplipi/streams/pandora.py @@ -16,7 +16,7 @@ class Pandora(PersistentStream, Browsable): stream_type: ClassVar[str] = 'pandora' - def __init__(self, name: str, stream_id: int, user, password: str, station: str, disabled: bool = False, mock: bool = False, validate: bool = True): + def __init__(self, name: str, user, password: str, station: str, disabled: bool = False, mock: bool = False, validate: bool = True): # pandora api client, the values in here come from the pandora android app # We set this up here because it permits early account validation during the parent's constructor self.pyd_client = SettingsDictBuilder({ @@ -27,7 +27,7 @@ def __init__(self, name: str, stream_id: int, user, password: str, station: str, "DEVICE": "android-generic", }).build() - super().__init__(self.stream_type, name, stream_id, disabled=disabled, mock=mock, validate=validate, user=user, password=password) + super().__init__(self.stream_type, name, disabled=disabled, mock=mock, validate=validate, user=user, password=password) self.user = user self.password = password diff --git a/amplipi/streams/plexamp.py b/amplipi/streams/plexamp.py index 1146ba773..ee3f98103 100644 --- a/amplipi/streams/plexamp.py +++ b/amplipi/streams/plexamp.py @@ -10,8 +10,8 @@ class Plexamp(BaseStream): stream_type: ClassVar[str] = 'plexamp' - def __init__(self, name: str, stream_id: int, client_id, token, disabled: bool = False, mock: bool = False): - super().__init__(self.stream_type, name, stream_id, disabled=disabled, mock=mock) + def __init__(self, name: str, client_id, token, disabled: bool = False, mock: bool = False): + super().__init__(self.stream_type, name, disabled=disabled, mock=mock) def reconfig(self, **kwargs): if 'disabled' in kwargs: diff --git a/amplipi/streams/rca.py b/amplipi/streams/rca.py index 8dbd0923f..d4ee31734 100644 --- a/amplipi/streams/rca.py +++ b/amplipi/streams/rca.py @@ -8,8 +8,8 @@ class RCA(BaseStream): stream_type: ClassVar[str] = 'rca' - def __init__(self, name: str, stream_id: int, index: int, disabled: bool = False, mock: bool = False): - super().__init__(self.stream_type, name, stream_id, only_src=index, disabled=disabled, mock=mock) + def __init__(self, name: str, index: int, disabled: bool = False, mock: bool = False): + super().__init__(self.stream_type, name, only_src=index, disabled=disabled, mock=mock) # for serialiation the stream model's field needs to map to a stream's fields # index is needed for serialization self.index = index diff --git a/amplipi/streams/spotify_connect.py b/amplipi/streams/spotify_connect.py index eb2833bde..4f1a455eb 100644 --- a/amplipi/streams/spotify_connect.py +++ b/amplipi/streams/spotify_connect.py @@ -29,8 +29,8 @@ class SpotifyConnect(PersistentStream): stream_type: ClassVar[str] = 'spotify' - def __init__(self, name: str, stream_id: int, disabled: bool = False, mock: bool = False, validate: bool = True): - super().__init__(self.stream_type, name, stream_id, disabled=disabled, mock=mock, validate=validate) + def __init__(self, name: str, disabled: bool = False, mock: bool = False, validate: bool = True): + super().__init__(self.stream_type, name, disabled=disabled, mock=mock, validate=validate) self.supported_cmds = [ 'play', 'pause', @@ -41,27 +41,27 @@ def __init__(self, name: str, stream_id: int, disabled: bool = False, mock: bool self._log_file: Optional[io.TextIOBase] = None self._api_port: int self.proc2: Optional[subprocess.Popen] = None - self.volume_process: Optional[subprocess.Popen] = None - self.volume_process2: threading.Thread = threading.Thread(target=self.watch_vol, daemon=True) + self.volume_sync_process: Optional[subprocess.Popen] = None + self.volume_watcher_process: threading.Thread = threading.Thread(target=self.watch_vol, daemon=True) self.src_config_folder: Optional[str] = None self.meta_file: str = '' - self._fifo = None + self._volume_fifo = None def watch_vol(self): """Creates and supplies a FIFO with volume data for volume sync""" while True: try: if self.src is not None: - if self._fifo is None and self.src_config_folder is not None: + if self._volume_fifo is None and self.src_config_folder is not None: fifo_path = f"{self.src_config_folder}/vol" if not os.path.isfile(fifo_path): os.mkfifo(fifo_path) - self._fifo = os.open(fifo_path, os.O_WRONLY, os.O_NONBLOCK) + self._volume_fifo = os.open(fifo_path, os.O_WRONLY, os.O_NONBLOCK) data = json.dumps({ 'zones': self.connected_zones, 'volume': self.volume, }) - os.write(self._fifo, bytearray(f"{data}\r\n", encoding="utf8")) + os.write(self._volume_fifo, bytearray(f"{data}\r\n", encoding="utf8")) except Exception as e: logger.error(f"{self.name} volume thread ran into exception: {e}") time.sleep(0.1) @@ -131,9 +131,9 @@ def _activate(self, vsrc: int): vol_sync = f"{utils.get_folder('streams')}/spotify_volume_handler.py" vol_args = [sys.executable, vol_sync, str(self._api_port), str(self.id), self.src_config_folder, "--debug"] logger.info(f'{self.name}: starting vol synchronizer: {vol_args}') - self.volume_process = subprocess.Popen(args=vol_args, stdout=self._log_file, stderr=self._log_file) + self.volume_sync_process = subprocess.Popen(args=vol_args, stdout=self._log_file, stderr=self._log_file) - self.volume_process2.start() + self.volume_watcher_process.start() def _deactivate(self): if self._is_running(): @@ -152,15 +152,15 @@ def _deactivate(self): self.proc2.kill() self.proc2.communicate() - if self.volume_process: - self.volume_process.terminate() - if self.volume_process.wait(1) != 0: + if self.volume_sync_process: + self.volume_sync_process.terminate() + if self.volume_sync_process.wait(1) != 0: logger.info(f'{self.name}: killing volume synchronizer') - self.volume_process.kill() - self.volume_process.communicate() - self.volume_process = None + self.volume_sync_process.kill() + self.volume_sync_process.communicate() + self.volume_sync_process = None - self._fifo = None + self._volume_fifo = None if self.proc and self._log_file: # prevent checking _log_file when it may not exist, thanks validation! self._log_file.close() diff --git a/streams/spotify_volume_handler.py b/streams/spotify_volume_handler.py index c4212e47a..3b88137e2 100644 --- a/streams/spotify_volume_handler.py +++ b/streams/spotify_volume_handler.py @@ -76,11 +76,10 @@ def set_vol(self, amplipi_volume: float, shared_volume: float) -> float: parser = argparse.ArgumentParser(description="Read metadata from a given URL and write it to a file.") parser.add_argument("port", help="port that go-librespot is running on", type=int) - parser.add_argument("stream_id", help="The stream's amplipi side stream_id", type=int) parser.add_argument("config_dir", help="The directory of the vsrc config", type=str) parser.add_argument("--debug", action="store_true", help="Change log level from WARNING to DEBUG") args = parser.parse_args() - handler = VolumeSynchronizer(SpotifyData(api_port=args.port), args.stream_id, args.config_dir, args.debug) + handler = VolumeSynchronizer(SpotifyData(api_port=args.port), args.config_dir, args.debug) handler.watcher_loop() diff --git a/streams/volume_synchronizer.py b/streams/volume_synchronizer.py index 7220f94d3..d86fda64f 100644 --- a/streams/volume_synchronizer.py +++ b/streams/volume_synchronizer.py @@ -12,7 +12,7 @@ class StreamData: - """A class that is used as a blueprint for other classes volume watcher""" + """A class that is used as a blueprint for stream volume watchers""" def __init__(self): self.callback: Callable @@ -38,8 +38,7 @@ def set_vol(self, new_vol: float, shared_vol: float) -> float: class AmpliPiData: """A class to record amplipi's api output and calculate the volume of a given source""" - def __init__(self, stream_id: int, config_dir: str, callback: Callable, logger: logging.Logger): - self.stream_id: int = stream_id + def __init__(self, config_dir: str, callback: Callable, logger: logging.Logger): self.callback: Callable = callback self.logger: logging.Logger = logger self.status: dict = None @@ -91,7 +90,7 @@ def set_vol(self, stream_volume: float, vol_set_point: float): class VolumeSynchronizer: """Volume synchronizer for AmpliPi and another volume-providing stream""" - def __init__(self, stream: StreamData, stream_id: int, config_dir: str, debug=False): + def __init__(self, stream: StreamData, config_dir: str, debug=False): self.logger = logging.getLogger(__name__) self.logger.setLevel(logging.DEBUG if debug else logging.WARNING) @@ -99,7 +98,7 @@ def __init__(self, stream: StreamData, stream_id: int, config_dir: str, debug=Fa self.logger.addHandler(sh) self.event_queue = queue.Queue() - self.amplipi = AmpliPiData(stream_id, config_dir, self.on_child_event, self.logger) + self.amplipi = AmpliPiData(config_dir, self.on_child_event, self.logger) self.stream: StreamData = stream From 19d9273e6fca409d0afb94031a03bdd7e365059e Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Mon, 1 Dec 2025 17:21:54 -0500 Subject: [PATCH 42/49] Remove the last vestiges of stream_id plumbing --- amplipi/ctrl.py | 4 ++-- amplipi/streams/__init__.py | 4 ++-- amplipi/streams/base_streams.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/amplipi/ctrl.py b/amplipi/ctrl.py index fe214cfa3..c07efbc45 100644 --- a/amplipi/ctrl.py +++ b/amplipi/ctrl.py @@ -338,7 +338,7 @@ def reinit(self, settings: models.AppSettings = models.AppSettings(), change_not assert stream.id is not None if stream.id: try: - self.streams[stream.id] = amplipi.streams.build_stream(stream, stream.id, self._mock_streams, validate=False) + self.streams[stream.id] = amplipi.streams.build_stream(stream, self._mock_streams, validate=False) # If we're in LMS mode, we need to start these clients on each boot, not when they get assigned to a # particular source; the client+server connection bootstrapping takes a while, which is a less than ideal # user experience. @@ -1059,8 +1059,8 @@ def create_stream(self, data: models.Stream, internal=False) -> models.Stream: raise Exception( f'Unable to create protected RCA stream, the RCA streams for each RCA input {defaults.RCAs} already exist') # Make a new stream and add it to streams + stream = amplipi.streams.build_stream(data, mock=self._mock_streams) sid = self._new_stream_id() - stream = amplipi.streams.build_stream(data, sid, mock=self._mock_streams) self.streams[sid] = stream self.sync_stream_info() # Use get state to populate the contents of the newly created stream and find it in the stream list diff --git a/amplipi/streams/__init__.py b/amplipi/streams/__init__.py index 6b8bf46a0..d141999a6 100644 --- a/amplipi/streams/__init__.py +++ b/amplipi/streams/__init__.py @@ -1,4 +1,4 @@ -# AmpliPi Home Audioself.src_config_folder +# AmpliPi Home Audio # Copyright (C) 2022 MicroNova LLC # # This program is free software: you can redistribute it and/or modify @@ -62,7 +62,7 @@ Aux, FilePlayer, FMRadio, LMS, Bluetooth, MediaDevice] -def build_stream(stream: models.Stream, stream_id: int, mock: bool = False, validate: bool = True) -> AnyStream: +def build_stream(stream: models.Stream, mock: bool = False, validate: bool = True) -> AnyStream: """ Build a stream from the generic arguments given in stream, discriminated by stream.type we are waiting on Pydantic's implementation of discriminators to fully integrate streams into our model definitions diff --git a/amplipi/streams/base_streams.py b/amplipi/streams/base_streams.py index 4e7fbdf10..87d38892b 100644 --- a/amplipi/streams/base_streams.py +++ b/amplipi/streams/base_streams.py @@ -212,8 +212,8 @@ def free(self, vsrc: int): class PersistentStream(BaseStream): """ Base class for streams that are able to persist without a direct connection to an output """ - def __init__(self, stype: str, name: str, stream_id: int, disabled: bool = False, mock: bool = False, validate: bool = True, **kwargs): - super().__init__(stype, name, stream_id, None, disabled, mock, validate, **kwargs) + def __init__(self, stype: str, name: str, disabled: bool = False, mock: bool = False, validate: bool = True, **kwargs): + super().__init__(stype, name, None, disabled, mock, validate, **kwargs) self.vsrc: Optional[int] = None self._cproc: Optional[subprocess.Popen] = None self.device: Optional[str] = None From 2999e441e9c1937a7f8a89ab4cbd124bd648e737 Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Mon, 1 Dec 2025 17:26:04 -0500 Subject: [PATCH 43/49] Remove last bit of stream_id drilling for real this time --- amplipi/streams/spotify_connect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amplipi/streams/spotify_connect.py b/amplipi/streams/spotify_connect.py index 4f1a455eb..512ca0325 100644 --- a/amplipi/streams/spotify_connect.py +++ b/amplipi/streams/spotify_connect.py @@ -129,7 +129,7 @@ def _activate(self, vsrc: int): self.proc2 = subprocess.Popen(args=meta_args, stdout=self._log_file, stderr=self._log_file) vol_sync = f"{utils.get_folder('streams')}/spotify_volume_handler.py" - vol_args = [sys.executable, vol_sync, str(self._api_port), str(self.id), self.src_config_folder, "--debug"] + vol_args = [sys.executable, vol_sync, str(self._api_port), self.src_config_folder, "--debug"] logger.info(f'{self.name}: starting vol synchronizer: {vol_args}') self.volume_sync_process = subprocess.Popen(args=vol_args, stdout=self._log_file, stderr=self._log_file) From 7be75c54b40b5e8173727e31cfe9749eaac6e330 Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Tue, 2 Dec 2025 12:05:11 -0500 Subject: [PATCH 44/49] Add a lot of documentation Change variable names Add VolEvent enum --- streams/spotify_volume_handler.py | 13 ++-- streams/volume_synchronizer.py | 102 +++++++++++++++++++++++------- 2 files changed, 85 insertions(+), 30 deletions(-) diff --git a/streams/spotify_volume_handler.py b/streams/spotify_volume_handler.py index 3b88137e2..924c70199 100644 --- a/streams/spotify_volume_handler.py +++ b/streams/spotify_volume_handler.py @@ -7,7 +7,7 @@ import websockets import requests -from volume_synchronizer import VolumeSynchronizer, StreamData +from volume_synchronizer import VolumeSynchronizer, StreamData, VolEvents from spot_connect_meta import Event @@ -21,13 +21,14 @@ class SpotifyData(StreamData): """A class that watches and tracks changes to spotify-side volume""" def __init__(self, api_port: int): - self.api_port: int = api_port super().__init__() + self.api_port: int = api_port + """What port is go-librespot running on? Typically set to 3678 + vsrc.""" + async def watch_vol(self): """Watch the go-librespot websocket endpoint for volume change events and update AmpliPi volume info accordingly""" try: - # Connect to the websocket and listen for state changes # pylint: disable=E1101 # E1101: Module 'websockets' has no 'connect' member (no-member) async with websockets.connect(f"ws://localhost:{self.api_port}/events", open_timeout=5) as websocket: @@ -37,13 +38,13 @@ async def watch_vol(self): event = Event.from_json(json.loads(msg)) if event.event_type == "volume": last_volume = float(self.volume) if self.volume is not None else None - self.volume = event.data.value / 100 # AmpliPi volume is between 0 and 1, Spotify is between 0 and 100. Dividing by 100 is more accurate than multiplying by 100 due to floating point errors. + self.volume = event.data.value / 100 # Translate spotify volume (0 - 100) to amplipi volume (0 - 1) self.logger.debug(f"Spotify volume changed from {last_volume} to {self.volume}") if last_volume is not None and self.volume != last_volume: - self.callback("stream_volume_changed") + self.schedule_event(VolEvents.CHANGE_AMPLIPI) elif event.event_type == "will_play" and self.volume is None: - self.callback("amplipi_volume_changed") # Intercept the event that occurs when a song starts playing and use that as a trigger for the initial state sync + self.schedule_event(VolEvents.CHANGE_STREAM) # Intercept the event that occurs when a song starts playing and use that as a trigger for the initial state sync except Exception as e: self.logger.exception(f"Error: {e}") diff --git a/streams/volume_synchronizer.py b/streams/volume_synchronizer.py index d86fda64f..414e3e48f 100644 --- a/streams/volume_synchronizer.py +++ b/streams/volume_synchronizer.py @@ -1,24 +1,39 @@ """Script for synchronizing AmpliPi and Spotify volumes""" -from time import sleep import json import asyncio import threading import queue import logging import sys -from typing import Callable, List - +from typing import Callable, List, Annotated +from enum import Enum import requests +class VolEvents(Enum): + CHANGE_STREAM = "change_stream" + CHANGE_AMPLIPI = "change_amplipi" + + class StreamData: - """A class that is used as a blueprint for stream volume watchers""" + """ + A class that is used as a blueprint for stream volume watchers + Child classes must provide the following functions. Both of these functions are automatically used by the VolumeSynchronizer, so there's no need to do anything with them: + + watch_vol: a function that contains a while True loop that collects the remote volume, sets self.volume, and calls self.schedule_event(VolEvents.CHANGE_AMPLIPI) when the volume changes + + set_vol: a function that takes the new_volume as well as the previous volume_set_point (both floats) and returns the new set point volume (either the previous set point if the change was unsuccessful or the newly recognized volume) + """ def __init__(self): - self.callback: Callable + self.schedule_event: Callable[[VolEvents]] + """Event scheduler function provided by VolumeSynchronizer, has limited valid inputs that can be seen in the VolEvents enum""" + + self.volume: Annotated[float | None, "float 0 <= value <= 1 or None"] = None + """Value between 0 and 1, or None if not yet initialized by the upstream""" - self.volume: float = None self.logger: logging.Logger + """logging.Logger instance provided by VolumeSynchronizer,""" self.thread: threading.Thread = threading.Thread(target=self.run_async_watch, daemon=True).start() @@ -27,38 +42,43 @@ def run_async_watch(self): asyncio.run(self.watch_vol()) async def watch_vol(self): - """A function to be implemented by child classes that must contain a while True loop and do self.callback('stream_vol_changed') when new_vol != old_vol""" + """A function to be implemented by child classes that must contain a while True loop and do self.schedule_event(VolEvents.CHANGE_AMPLIPI) when new_vol != old_vol""" raise NotImplementedError("Function must be implemented by child classes") def set_vol(self, new_vol: float, shared_vol: float) -> float: - """A function to be implemented by child classes to update the stream's volume and returns the new shared volume""" + """A function to be implemented by child classes to update the stream's volume and returns the new set point volume""" raise NotImplementedError("Function must be implemented by child classes") class AmpliPiData: - """A class to record amplipi's api output and calculate the volume of a given source""" + """ + A class to watch changes to a streams vol fifo and change the volume of connected zones + Already fully handled by volumeSynchronizer and should not be used by itself + """ + + def __init__(self, config_dir: str, schedule_event: Callable, logger: logging.Logger): + self.schedule_event: Callable[[VolEvents]] = schedule_event + """Event scheduler function provided by VolumeSynchronizer, has limited valid inputs that can be seen in the VolEvents enum""" - def __init__(self, config_dir: str, callback: Callable, logger: logging.Logger): - self.callback: Callable = callback self.logger: logging.Logger = logger - self.status: dict = None self.volume: float = None - self.config_dir = config_dir - - self.status_file: str = "" + self.config_dir: str = config_dir - self.connected_zones: List[int] = [] + self.connected_zones: List[int] = [] # List of zone ids, used to send volume change requests to these connected zones threading.Thread(target=self.get_vol, daemon=True).start() def get_vol(self): - """Calculate the average vol_f from all connected zones. If no zones are connected, or if the api has yet to be hit up, return 0.""" + """ + Read the volume FIFO from .config/amplipi/srcs/v{vsrc}/vol to load the currently connected zones and the averaged volume of them + If the read volume is different than the previous volume, send a volume change event to the stream + """ with open(f'{self.config_dir}/vol', 'r') as fifo: while True: data = json.loads(fifo.readline().strip()) if self.volume != data["volume"]: - self.callback("amplipi_volume_changed") self.volume = data["volume"] + self.schedule_event(VolEvents.CHANGE_STREAM) self.connected_zones = data["zones"] def set_vol(self, stream_volume: float, vol_set_point: float): @@ -88,7 +108,40 @@ def set_vol(self, stream_volume: float, vol_set_point: float): class VolumeSynchronizer: - """Volume synchronizer for AmpliPi and another volume-providing stream""" + """ + Volume synchronizer for AmpliPi and another volume-providing stream + + Takes a few args: + + stream: A fully constructed instance of a class that extends StreamData + + config_dir: the path to the .config/amplipi/srcs/v{vsrc} folder for this persistent stream + + debug: bool that decides whether log level is DEBUG (if True) or WARNING (if False). False by default. + + + Example Usage: + + class SomeStream(StreamData): + __init__(**kwargs): + super().__init__() + ... + + def get_vol(self): + ... + + def set_vol(self, new_vol: float, vol_set_point: float): + ... + + { + build argsparser here + } + + handler = VolumeSynchronizer(SomeStream(**kwargs), args.config_dir, args.debug) + handler.watcher_loop() + """ + + # All you need to do to use this class is build a StreamData extension and then follow the above example with a simple argsparse flow, everything else is handled automatically def __init__(self, stream: StreamData, config_dir: str, debug=False): @@ -98,30 +151,31 @@ def __init__(self, stream: StreamData, config_dir: str, debug=False): self.logger.addHandler(sh) self.event_queue = queue.Queue() - self.amplipi = AmpliPiData(config_dir, self.on_child_event, self.logger) + self.amplipi = AmpliPiData(config_dir, self.schedule_event, self.logger) self.stream: StreamData = stream # Set these directly so children don't need to add them to their constructors self.stream.logger = self.logger - self.stream.callback = self.on_child_event + self.stream.schedule_event = self.schedule_event self.vol_set_point = self.amplipi.volume - def on_child_event(self, event_type): + def schedule_event(self, event_type: VolEvents): """When an event occurs in a child, that child can use this callback function to schedule the response to said event in the event queue""" self.event_queue.put(event_type) def watcher_loop(self): + """Watch for events coming from amplipi and the stream to then change the volume of the other""" while True: try: if self.vol_set_point is None: self.vol_set_point = self.amplipi.volume event = self.event_queue.get() - if event == "stream_volume_changed": + if event == VolEvents.CHANGE_AMPLIPI: self.vol_set_point = self.amplipi.set_vol(self.stream.volume, self.vol_set_point) - elif event == "amplipi_volume_changed": + elif event == VolEvents.CHANGE_STREAM: self.vol_set_point = self.stream.set_vol(self.amplipi.volume, self.vol_set_point) except queue.Empty: continue From 94c18c45b3ba6c526f503e755bb0b6b9a123972e Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Tue, 2 Dec 2025 12:20:45 -0500 Subject: [PATCH 45/49] Remove typing.Annotated --- streams/volume_synchronizer.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/streams/volume_synchronizer.py b/streams/volume_synchronizer.py index 414e3e48f..ff9cddc58 100644 --- a/streams/volume_synchronizer.py +++ b/streams/volume_synchronizer.py @@ -5,7 +5,7 @@ import queue import logging import sys -from typing import Callable, List, Annotated +from typing import Callable, List, Optional from enum import Enum import requests @@ -29,7 +29,7 @@ def __init__(self): self.schedule_event: Callable[[VolEvents]] """Event scheduler function provided by VolumeSynchronizer, has limited valid inputs that can be seen in the VolEvents enum""" - self.volume: Annotated[float | None, "float 0 <= value <= 1 or None"] = None + self._volume: float = None """Value between 0 and 1, or None if not yet initialized by the upstream""" self.logger: logging.Logger @@ -37,6 +37,17 @@ def __init__(self): self.thread: threading.Thread = threading.Thread(target=self.run_async_watch, daemon=True).start() + @property + def volume(self) -> Optional[float]: + """Value between 0 and 1, or None if not yet initialized by the upstream""" + return self._volume + + @volume.setter + def volume(self, value: float) -> None: + if 0 > value or value > 1: + raise ValueError("Volume must be between 0 and 1") + self._volume = value + def run_async_watch(self): """Middleman function for creating an asyncio run inside of a new threading.Thread""" asyncio.run(self.watch_vol()) From 4d2b723646734623d008424fde72c4d89467b4d2 Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Tue, 2 Dec 2025 12:29:21 -0500 Subject: [PATCH 46/49] Update docstring of volume_synchronizer.py --- streams/volume_synchronizer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/streams/volume_synchronizer.py b/streams/volume_synchronizer.py index ff9cddc58..6c8255aa4 100644 --- a/streams/volume_synchronizer.py +++ b/streams/volume_synchronizer.py @@ -1,4 +1,4 @@ -"""Script for synchronizing AmpliPi and Spotify volumes""" +"""Classes for synchronizing AmpliPi volume with the internal volume of a given stream""" import json import asyncio import threading From f0f51bf3a6bfcbd474f08c2588c8244376457ce2 Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Wed, 3 Dec 2025 11:42:39 -0500 Subject: [PATCH 47/49] Close things properly Add more docs Rename some classes, attributes, and variables --- amplipi/streams/spotify_connect.py | 27 ++++++++----- streams/spotify_volume_handler.py | 27 ++++++------- streams/volume_synchronizer.py | 64 +++++++++++++++--------------- 3 files changed, 63 insertions(+), 55 deletions(-) diff --git a/amplipi/streams/spotify_connect.py b/amplipi/streams/spotify_connect.py index 512ca0325..460d8ed12 100644 --- a/amplipi/streams/spotify_connect.py +++ b/amplipi/streams/spotify_connect.py @@ -41,8 +41,8 @@ def __init__(self, name: str, disabled: bool = False, mock: bool = False, valida self._log_file: Optional[io.TextIOBase] = None self._api_port: int self.proc2: Optional[subprocess.Popen] = None - self.volume_sync_process: Optional[subprocess.Popen] = None - self.volume_watcher_process: threading.Thread = threading.Thread(target=self.watch_vol, daemon=True) + self.volume_sync_process: Optional[subprocess.Popen] = None # Runs the actual vol sync script + self.volume_watcher_process: Optional[threading.Thread] = None # Populates the fifo that the vol sync script depends on self.src_config_folder: Optional[str] = None self.meta_file: str = '' self._volume_fifo = None @@ -133,6 +133,7 @@ def _activate(self, vsrc: int): logger.info(f'{self.name}: starting vol synchronizer: {vol_args}') self.volume_sync_process = subprocess.Popen(args=vol_args, stdout=self._log_file, stderr=self._log_file) + self.volume_watcher_process = threading.Thread(target=self.watch_vol, daemon=True) self.volume_watcher_process.start() def _deactivate(self): @@ -140,27 +141,31 @@ def _deactivate(self): self.proc.stdin.close() logger.info(f'{self.name}: stopping player') + # Call terminate on all processes self.proc.terminate() + self.proc2.terminate() + if self.volume_sync_process: + self.volume_sync_process.terminate() + + # Ensure the processes have closed, by force if necessary if self.proc.wait(1) != 0: logger.info(f'{self.name}: killing player') self.proc.kill() - self.proc.communicate() - self.proc2.terminate() if self.proc2.wait(1) != 0: logger.info(f'{self.name}: killing metadata reader') self.proc2.kill() - self.proc2.communicate() if self.volume_sync_process: - self.volume_sync_process.terminate() if self.volume_sync_process.wait(1) != 0: logger.info(f'{self.name}: killing volume synchronizer') self.volume_sync_process.kill() - self.volume_sync_process.communicate() - self.volume_sync_process = None - self._volume_fifo = None + # Validate on the way out + self.proc.communicate() + self.proc2.communicate() + if self.volume_sync_process: + self.volume_sync_process.communicate() if self.proc and self._log_file: # prevent checking _log_file when it may not exist, thanks validation! self._log_file.close() @@ -170,8 +175,12 @@ def _deactivate(self): except Exception as e: logger.exception(f'{self.name}: Error removing config files: {e}') self._disconnect() + self.proc = None self.proc2 = None + self.volume_sync_process = None + self.volume_watcher_process = None + self._volume_fifo = None def info(self) -> models.SourceInfo: source = models.SourceInfo( diff --git a/streams/spotify_volume_handler.py b/streams/spotify_volume_handler.py index 924c70199..209c60723 100644 --- a/streams/spotify_volume_handler.py +++ b/streams/spotify_volume_handler.py @@ -7,7 +7,7 @@ import websockets import requests -from volume_synchronizer import VolumeSynchronizer, StreamData, VolEvents +from volume_synchronizer import VolSyncDispatcher, StreamWatcher, VolEvents from spot_connect_meta import Event @@ -17,7 +17,7 @@ logger.addHandler(sh) -class SpotifyData(StreamData): +class SpotifyWatcher(StreamWatcher): """A class that watches and tracks changes to spotify-side volume""" def __init__(self, api_port: int): @@ -53,21 +53,21 @@ async def watch_vol(self): self.logger.exception(f"Error: {e}") return - def set_vol(self, amplipi_volume: float, shared_volume: float) -> float: - """Update Spotify's volume slider to match AmpliPi""" + def set_vol(self, new_vol: float, vol_set_point: float) -> float: + """Update Spotify's volume slider""" try: - if amplipi_volume is None: - return shared_volume + if new_vol is None: + return vol_set_point - if abs(amplipi_volume - shared_volume) <= 0.005 and self.volume is not None: + if abs(new_vol - vol_set_point) <= 0.005 and self.volume is not None: self.logger.debug("Ignored minor AmpliPi -> Spotify change") - return shared_volume + return vol_set_point url = f"http://localhost:{self.api_port}/player/volume" - new_vol = int(amplipi_volume * 100) - self.logger.debug(f"Setting Spotify volume to {new_vol / 100} from {self.volume}") - requests.post(url, json={"volume": new_vol}, timeout=5) - return amplipi_volume + spot_vol = int(new_vol * 100) + self.logger.debug(f"Setting Spotify volume to {new_vol} from {self.volume}") + requests.post(url, json={"volume": spot_vol}, timeout=5) + return new_vol except Exception as e: self.logger.exception(f"Exception: {e}") @@ -82,5 +82,4 @@ def set_vol(self, amplipi_volume: float, shared_volume: float) -> float: args = parser.parse_args() - handler = VolumeSynchronizer(SpotifyData(api_port=args.port), args.config_dir, args.debug) - handler.watcher_loop() + handler = VolSyncDispatcher(SpotifyWatcher(api_port=args.port), args.config_dir, args.debug) diff --git a/streams/volume_synchronizer.py b/streams/volume_synchronizer.py index 6c8255aa4..ec26c623a 100644 --- a/streams/volume_synchronizer.py +++ b/streams/volume_synchronizer.py @@ -15,10 +15,10 @@ class VolEvents(Enum): CHANGE_AMPLIPI = "change_amplipi" -class StreamData: +class StreamWatcher: """ A class that is used as a blueprint for stream volume watchers - Child classes must provide the following functions. Both of these functions are automatically used by the VolumeSynchronizer, so there's no need to do anything with them: + Child classes must provide the following functions. Both of these functions are automatically used by the VolSyncDispatcher, so there's no need to do anything with them: watch_vol: a function that contains a while True loop that collects the remote volume, sets self.volume, and calls self.schedule_event(VolEvents.CHANGE_AMPLIPI) when the volume changes @@ -27,15 +27,16 @@ class StreamData: def __init__(self): self.schedule_event: Callable[[VolEvents]] - """Event scheduler function provided by VolumeSynchronizer, has limited valid inputs that can be seen in the VolEvents enum""" + """Event scheduler function provided by VolSyncDispatcher, has limited valid inputs that can be seen in the VolEvents enum""" self._volume: float = None """Value between 0 and 1, or None if not yet initialized by the upstream""" self.logger: logging.Logger - """logging.Logger instance provided by VolumeSynchronizer,""" + """logging.Logger instance provided by VolSyncDispatcher""" - self.thread: threading.Thread = threading.Thread(target=self.run_async_watch, daemon=True).start() + self.thread: threading.Thread = threading.Thread(target=self.run_async_watch, daemon=True) + self.thread.start() @property def volume(self) -> Optional[float]: @@ -56,28 +57,30 @@ async def watch_vol(self): """A function to be implemented by child classes that must contain a while True loop and do self.schedule_event(VolEvents.CHANGE_AMPLIPI) when new_vol != old_vol""" raise NotImplementedError("Function must be implemented by child classes") - def set_vol(self, new_vol: float, shared_vol: float) -> float: + def set_vol(self, new_vol: float, vol_set_point: float) -> float: """A function to be implemented by child classes to update the stream's volume and returns the new set point volume""" raise NotImplementedError("Function must be implemented by child classes") -class AmpliPiData: +class AmpliPiWatcher: """ A class to watch changes to a streams vol fifo and change the volume of connected zones - Already fully handled by volumeSynchronizer and should not be used by itself + Already fully handled by VolSyncDispatcher and should not be used by itself """ def __init__(self, config_dir: str, schedule_event: Callable, logger: logging.Logger): self.schedule_event: Callable[[VolEvents]] = schedule_event - """Event scheduler function provided by VolumeSynchronizer, has limited valid inputs that can be seen in the VolEvents enum""" + """Event scheduler function provided by VolSyncDispatcher, has limited valid inputs that can be seen in the VolEvents enum""" self.logger: logging.Logger = logger self.volume: float = None self.config_dir: str = config_dir - self.connected_zones: List[int] = [] # List of zone ids, used to send volume change requests to these connected zones + self.connected_zones: List[int] = [] + """List of zone ids, used to send volume change requests to these connected zones""" - threading.Thread(target=self.get_vol, daemon=True).start() + self.thread = threading.Thread(target=self.get_vol, daemon=True) + self.thread.start() def get_vol(self): """ @@ -98,11 +101,11 @@ def set_vol(self, stream_volume: float, vol_set_point: float): if stream_volume is None: return vol_set_point - if abs(stream_volume - vol_set_point) <= 0.005: + if abs(stream_volume - self.volume) <= 0.005: self.logger.debug("Ignored minor Stream -> AmpliPi change") return vol_set_point - delta = float(stream_volume - vol_set_point) + delta = float(stream_volume - self.volume) expected_volume = self.volume + delta self.logger.debug(f"Setting AmpliPi volume to {expected_volume} from {self.volume}") requests.patch( @@ -118,43 +121,39 @@ def set_vol(self, stream_volume: float, vol_set_point: float): self.logger.exception(f"Exception: {e}") -class VolumeSynchronizer: +class VolSyncDispatcher: """ - Volume synchronizer for AmpliPi and another volume-providing stream + Volume synchronizer for AmpliPi and another volume-providing stream. - Takes a few args: - - stream: A fully constructed instance of a class that extends StreamData + stream: A fully constructed instance of a class that extends StreamWatcher config_dir: the path to the .config/amplipi/srcs/v{vsrc} folder for this persistent stream debug: bool that decides whether log level is DEBUG (if True) or WARNING (if False). False by default. - Example Usage: - class SomeStream(StreamData): + class SomeStreamWatcher(StreamWatcher): __init__(**kwargs): super().__init__() ... - def get_vol(self): + async def get_vol(self) -> None: ... + self.volume = new_volume - def set_vol(self, new_vol: float, vol_set_point: float): + def set_vol(self, new_vol: float, vol_set_point: float) -> float: ... + return new_vol if change_successful else vol_set_point - { - build argsparser here - } + {build standard argparse flow here, containing args for your constructor as well as config_dir and --debug} - handler = VolumeSynchronizer(SomeStream(**kwargs), args.config_dir, args.debug) - handler.watcher_loop() + handler = VolSyncDispatcher(SomeStreamWatcher(**kwargs), args.config_dir, args.debug) """ - # All you need to do to use this class is build a StreamData extension and then follow the above example with a simple argsparse flow, everything else is handled automatically + # All you need to do to use this class is build a StreamWatcher extension and then follow the above example with a simple argsparse flow, everything else is handled automatically - def __init__(self, stream: StreamData, config_dir: str, debug=False): + def __init__(self, stream: StreamWatcher, config_dir: str, debug=False): self.logger = logging.getLogger(__name__) self.logger.setLevel(logging.DEBUG if debug else logging.WARNING) @@ -162,21 +161,22 @@ def __init__(self, stream: StreamData, config_dir: str, debug=False): self.logger.addHandler(sh) self.event_queue = queue.Queue() - self.amplipi = AmpliPiData(config_dir, self.schedule_event, self.logger) + self.amplipi = AmpliPiWatcher(config_dir, self.schedule_event, self.logger) - self.stream: StreamData = stream + self.stream: StreamWatcher = stream # Set these directly so children don't need to add them to their constructors self.stream.logger = self.logger self.stream.schedule_event = self.schedule_event self.vol_set_point = self.amplipi.volume + self.event_loop() def schedule_event(self, event_type: VolEvents): """When an event occurs in a child, that child can use this callback function to schedule the response to said event in the event queue""" self.event_queue.put(event_type) - def watcher_loop(self): + def event_loop(self): """Watch for events coming from amplipi and the stream to then change the volume of the other""" while True: try: From 97b4be15a3258bc15c99b37862bb6a3fc2be0a4d Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Fri, 5 Dec 2025 14:22:27 -0500 Subject: [PATCH 48/49] Fix vsrc allocation bug --- amplipi/streams/base_streams.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amplipi/streams/base_streams.py b/amplipi/streams/base_streams.py index 87d38892b..01afae4b7 100644 --- a/amplipi/streams/base_streams.py +++ b/amplipi/streams/base_streams.py @@ -262,7 +262,7 @@ def deactivate(self): raise Exception(f'Failed to deactivate {self.name}: {e}') from e finally: self.state = "disconnected" # make this look like a normal stream for now - if 'vsrc' in self.__dir__() and self.vsrc: + if 'vsrc' in self.__dir__() and self.vsrc is not None: vsrc = self.vsrc self.vsrc = None vsources.free(vsrc) From 7b41571229e9a5d68bbc1a1a2724832f20955132 Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Thu, 6 Nov 2025 14:57:21 -0500 Subject: [PATCH 49/49] Add airplay volume synchronizer --- amplipi/streams/airplay.py | 68 ++++++++++++++++++++---- streams/shairport_volume_handler.py | 80 +++++++++++++++++++++++++++++ streams/spotify_volume_handler.py | 2 +- streams/volume_synchronizer.py | 35 +++++++++---- 4 files changed, 165 insertions(+), 20 deletions(-) create mode 100644 streams/shairport_volume_handler.py diff --git a/amplipi/streams/airplay.py b/amplipi/streams/airplay.py index a000ae097..cfa83eb34 100644 --- a/amplipi/streams/airplay.py +++ b/amplipi/streams/airplay.py @@ -7,6 +7,9 @@ import time import os import io +import sys +import threading +import json def write_sp_config_file(filename, config): @@ -42,6 +45,29 @@ def __init__(self, name: str, ap2: bool, disabled: bool = False, mock: bool = Fa self._connect_time = 0.0 self._coverart_dir = '' self._log_file: Optional[io.TextIOBase] = None + self.src_config_folder: Optional[str] = None + self.volume_watcher_process: Optional[threading.Thread] = None # Populates the fifo that the vol sync script depends on + self.volume_sync_process: Optional[subprocess.Popen] = None + self._volume_fifo: Optional[str] = None + + def watch_vol(self): + """Creates and supplies a FIFO with volume data for volume sync""" + while True: + try: + if self.src is not None: + if self._volume_fifo is None and self.src_config_folder is not None: + fifo_path = f"{self.src_config_folder}/vol" + if not os.path.isfile(fifo_path): + os.mkfifo(fifo_path) + self._volume_fifo = os.open(fifo_path, os.O_WRONLY, os.O_NONBLOCK) + data = json.dumps({ + 'zones': self.connected_zones, + 'volume': self.volume, + }) + os.write(self._volume_fifo, bytearray(f"{data}\r\n", encoding="utf8")) + except Exception as e: + logger.error(f"{self.name} volume thread ran into exception: {e}") + time.sleep(0.1) def reconfig(self, **kwargs): self.validate_stream(**kwargs) @@ -71,9 +97,9 @@ def _activate(self, vsrc: int): logger.info(f'Another Airplay 2 stream is already in use, unable to start {self.name}, mocking connection') return - src_config_folder = f'{utils.get_folder("config")}/srcs/v{vsrc}' + self.src_config_folder = f'{utils.get_folder("config")}/srcs/v{vsrc}' try: - os.remove(f'{src_config_folder}/currentSong') + os.remove(f'{self.src_config_folder}/currentSong') except FileNotFoundError: pass self._connect_time = time.time() @@ -86,9 +112,9 @@ def _activate(self, vsrc: int): 'name': self.name, 'port': 5100 + 100 * vsrc, # Listen for service requests on this port 'udp_port_base': 6101 + 100 * vsrc, # start allocating UDP ports from this port number when needed - 'drift': 2000, # allow this number of frames of drift away from exact synchronisation before attempting to correct it - 'resync_threshold': 0, # a synchronisation error greater than this will cause resynchronisation; 0 disables it - 'log_verbosity': 0, # "0" means no debug verbosity, "3" is most verbose. + 'drift_in_seconds': 2, # allow this number of frames of drift away from exact synchronisation before attempting to correct it + 'resync_threshold_in_seconds': 0, # a synchronisation error greater than this will cause resynchronisation; 0 disables it + 'log_verbosity': "diagnostics", # "none" means no debug verbosity, "diagnostics" is most verbose. 'mpris_service_bus': 'Session', }, 'metadata': { @@ -99,7 +125,7 @@ def _activate(self, vsrc: int): 'alsa': { 'output_device': utils.virtual_output_device(vsrc), # alsa output device # If set too small, buffer underflow occurs on low-powered machines. Too long and the response times with software mixer become annoying. - 'audio_backend_buffer_desired_length': 11025 + 'audio_backend_buffer_desired_length': 11025, }, } @@ -109,10 +135,10 @@ def _activate(self, vsrc: int): except FileNotFoundError: pass os.makedirs(self._coverart_dir, exist_ok=True) - os.makedirs(src_config_folder, exist_ok=True) - config_file = f'{src_config_folder}/shairport.conf' + os.makedirs(self.src_config_folder, exist_ok=True) + config_file = f'{self.src_config_folder}/shairport.conf' write_sp_config_file(config_file, config) - self._log_file = open(f'{src_config_folder}/log', mode='w') + self._log_file = open(f'{self.src_config_folder}/log', mode='w') shairport_args = f"{utils.get_folder('streams')}/shairport-sync{'-ap2' if self.ap2 else ''} -c {config_file}".split(' ') logger.info(f'shairport_args: {shairport_args}') @@ -125,7 +151,15 @@ def _activate(self, vsrc: int): # shairport sync only adds the pid to the mpris name if it cannot use the default name if len(os.popen("pgrep shairport-sync").read().strip().splitlines()) > 1: mpris_name += f".i{self.proc.pid}" - self.mpris = MPRIS(mpris_name, f'{src_config_folder}/metadata.txt') + self.mpris = MPRIS(mpris_name, f'{self.src_config_folder}/metadata.txt') + + vol_sync = f"{utils.get_folder('streams')}/shairport_volume_handler.py" + vol_args = [sys.executable, vol_sync, mpris_name, f"{utils.get_folder('config')}/srcs/v{self.vsrc}"] + + logger.info(f'{self.name}: starting vol synchronizer: {vol_args}') + self.volume_watcher_process = threading.Thread(target=self.watch_vol, daemon=True) + self.volume_watcher_process.start() + self.volume_sync_process = subprocess.Popen(args=vol_args, stdout=self._log_file, stderr=self._log_file) except Exception as exc: logger.exception(f'Error starting airplay MPRIS reader: {exc}') @@ -135,12 +169,22 @@ def _deactivate(self): self.mpris = None if self._is_running(): self.proc.stdin.close() + logger.info('stopping shairport-sync') self.proc.terminate() + if self.volume_sync_process is not None: + self.volume_sync_process.terminate() + if self.proc.wait(1) != 0: logger.info('killing shairport-sync') self.proc.kill() self.proc.communicate() + + if self.volume_sync_process is not None: + if self.volume_sync_process.wait(1) != 0: + logger.info('killing shairport vol sync') + self.volume_sync_process.kill() + if '_log_file' in self.__dir__() and self._log_file: self._log_file.close() if self.src: @@ -149,7 +193,11 @@ def _deactivate(self): except Exception as e: logger.exception(f'Error removing airplay config files: {e}') self._disconnect() + self.proc = None + self.volume_sync_process = None + self.volume_watcher_process = None + self._volume_fifo = None def info(self) -> models.SourceInfo: source = models.SourceInfo( diff --git a/streams/shairport_volume_handler.py b/streams/shairport_volume_handler.py new file mode 100644 index 000000000..3ef564ae1 --- /dev/null +++ b/streams/shairport_volume_handler.py @@ -0,0 +1,80 @@ +"""Script for synchronizing AmpliPi and Spotify volumes""" +import argparse +from time import sleep +from dasbus.connection import SessionMessageBus +from dasbus.typing import Variant +from volume_synchronizer import VolSyncDispatcher, StreamWatcher, VolEvents + + +class ShairportWatcher(StreamWatcher): + """A class that watches and tracks changes to airplay-side volume""" + + def __init__(self, service_suffix: str): + super().__init__() + self.mpris = SessionMessageBus().get_proxy( + service_name=f"org.mpris.MediaPlayer2.{service_suffix}", + object_path="/org/mpris/MediaPlayer2", + interface_name="org.mpris.MediaPlayer2.Player" + ) + + self.dbus = SessionMessageBus().get_proxy( + service_name=f"org.mpris.MediaPlayer2.{service_suffix}", + object_path="/org/mpris/MediaPlayer2", + interface_name="org.freedesktop.DBus.Properties" + ) + + async def watch_vol(self): + """Watch the shairport mpris stream for volume changes and update amplipi volume info accordingly""" + while True: + try: + if self.volume != self.mpris.Volume: + self.logger.debug(f"Airplay volume changed from {self.volume} to {self.mpris.Volume}") + self.volume = float(self.mpris.Volume) + self.schedule_event(VolEvents.CHANGE_AMPLIPI) + # self.delta = self.mpris.Volume - self.volume + + except Exception as e: + self.logger.exception(f"Error: {e}") + return + sleep(0.1) + + def set_vol(self, amplipi_volume: float, vol_set_point: float) -> float: # This has unused variable vol_set_point to keep up with the underlying StreamData.set_vol function schema + """Update Airplay's volume slider to match AmpliPi""" + try: + # Airplay does not allow external devices to set the volume of a users system + + # Airplay is a fully authoritative volume source, meaning it forces amplipi volume to equal its volume now. If that ever changes, this will be relevant: + # There are two values this could realistically be returned and become the new vol_set_point, and they each have their own drawbacks: + + # amplipi_volume: If amplipi_volume is the new set point, any changes to airplay volume will send the volume to an odd + # spot as it just sets the vol average of amplipi to be the same as the value of airplay's vol + + # vol_set_point: if vol_set_point is retained as the set point, any changes to amplipi will reflect for 1-2 seconds at most and then + # bounce back to where it had been, resulting in a glitchy front end interface + + # In any future MPRIS based volume synchronizers, you can check if self.mpris.CanControl is true and then potentially directly set self.mpris.Volume + # Note that we cannot do this due to this line: + # That exists in the MPRIS config xml at https://github.com/mikebrady/shairport-sync/blob/master/org.mpris.MediaPlayer2.xml + + # self.dbus.Set( + # 'org.mpris.MediaPlayer2', + # 'Volume', + # Variant("d", amplipi_volume) + # ) + + return amplipi_volume + except Exception as e: + self.logger.exception(f"Exception: {e}") + + +if __name__ == "__main__": + + parser = argparse.ArgumentParser(description="Read metadata from a given URL and write it to a file.") + + parser.add_argument("service_suffix", help="Name of mpris instance", type=str) + parser.add_argument("config_dir", help="The directory of the vsrc config", type=str) + parser.add_argument("--debug", action="store_true", help="Change log level from WARNING to DEBUG") + + args = parser.parse_args() + + handler = VolSyncDispatcher(ShairportWatcher(service_suffix=args.service_suffix), args.config_dir, args.debug) diff --git a/streams/spotify_volume_handler.py b/streams/spotify_volume_handler.py index 209c60723..4a7ffff8d 100644 --- a/streams/spotify_volume_handler.py +++ b/streams/spotify_volume_handler.py @@ -22,13 +22,13 @@ class SpotifyWatcher(StreamWatcher): def __init__(self, api_port: int): super().__init__() - self.api_port: int = api_port """What port is go-librespot running on? Typically set to 3678 + vsrc.""" async def watch_vol(self): """Watch the go-librespot websocket endpoint for volume change events and update AmpliPi volume info accordingly""" try: + # Connect to the websocket and listen for state changes # pylint: disable=E1101 # E1101: Module 'websockets' has no 'connect' member (no-member) async with websockets.connect(f"ws://localhost:{self.api_port}/events", open_timeout=5) as websocket: diff --git a/streams/volume_synchronizer.py b/streams/volume_synchronizer.py index ec26c623a..0ba5e52bd 100644 --- a/streams/volume_synchronizer.py +++ b/streams/volume_synchronizer.py @@ -32,6 +32,7 @@ def __init__(self): self._volume: float = None """Value between 0 and 1, or None if not yet initialized by the upstream""" + self.delta: Optional[float] = None self.logger: logging.Logger """logging.Logger instance provided by VolSyncDispatcher""" @@ -73,7 +74,7 @@ def __init__(self, config_dir: str, schedule_event: Callable, logger: logging.Lo """Event scheduler function provided by VolSyncDispatcher, has limited valid inputs that can be seen in the VolEvents enum""" self.logger: logging.Logger = logger - self.volume: float = None + self.volume: Optional[float] = None self.config_dir: str = config_dir self.connected_zones: List[int] = [] @@ -87,13 +88,16 @@ def get_vol(self): Read the volume FIFO from .config/amplipi/srcs/v{vsrc}/vol to load the currently connected zones and the averaged volume of them If the read volume is different than the previous volume, send a volume change event to the stream """ - with open(f'{self.config_dir}/vol', 'r') as fifo: - while True: - data = json.loads(fifo.readline().strip()) - if self.volume != data["volume"]: - self.volume = data["volume"] - self.schedule_event(VolEvents.CHANGE_STREAM) - self.connected_zones = data["zones"] + try: + with open(f'{self.config_dir}/vol', 'r') as fifo: + while True: + data = json.loads(fifo.readline().strip()) + if self.volume != data["volume"]: + self.volume = data["volume"] + self.schedule_event(VolEvents.CHANGE_STREAM) + self.connected_zones = data["zones"] + except Exception as e: + self.logger.exception(f"Error while getting writing to {self.config_dir}/vol fifo: {e}") def set_vol(self, stream_volume: float, vol_set_point: float): """Update AmpliPi's volume to match the stream volume""" @@ -106,6 +110,13 @@ def set_vol(self, stream_volume: float, vol_set_point: float): return vol_set_point delta = float(stream_volume - self.volume) + return self.set_vol_delta(delta) + except Exception as e: + self.logger.exception(f"Exception: {e}") + + def set_vol_delta(self, delta: float): + """Update AmpliPi's volume by delta""" + try: expected_volume = self.volume + delta self.logger.debug(f"Setting AmpliPi volume to {expected_volume} from {self.volume}") requests.patch( @@ -185,7 +196,13 @@ def event_loop(self): event = self.event_queue.get() if event == VolEvents.CHANGE_AMPLIPI: - self.vol_set_point = self.amplipi.set_vol(self.stream.volume, self.vol_set_point) + if self.stream.delta is not None: + # Reduce race condition potential by decoupling the value from the variable + delta = float(self.stream.delta) + self.vol_set_point = self.amplipi.set_vol_delta(delta) + self.stream.delta -= delta + else: + self.vol_set_point = self.amplipi.set_vol(self.stream.volume, self.vol_set_point) elif event == VolEvents.CHANGE_STREAM: self.vol_set_point = self.stream.set_vol(self.amplipi.volume, self.vol_set_point) except queue.Empty: