diff --git a/CHANGELOG.md b/CHANGELOG.md index d82bddb6f..51a145b0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # AmpliPi Software Releases # Future Release +* System + * Upgraded volume calculations to preserve relative positions when hitting the min or max setting via source volume bar # 0.4.11 @@ -11,14 +13,12 @@ ## 0.4.10 - * Web App * Fixed internet radio search functionality * System * Changed apt source from `http://raspbian.raspberrypi.org/raspbian/` to `http://archive.raspberrypi.org/raspbian/` -## 0.4.9 - +# 0.4.9 * System * Update our spotify provider `go-librespot` to `0.5.2` to accomodate spotify's API update diff --git a/amplipi/app.py b/amplipi/app.py index 150f09078..7b37658b1 100644 --- a/amplipi/app.py +++ b/amplipi/app.py @@ -966,7 +966,7 @@ async def get_response(self, path, scope): # Website -app.mount('/', CachelessFiles(directory=WEB_DIR, html=True), name='web') +app.mount('/', StaticFiles(directory=WEB_DIR, html=True), name='web') def create_app(mock_ctrl=None, mock_streams=None, config_file=None, delay_saves=None, settings: models.AppSettings = models.AppSettings()) -> FastAPI: diff --git a/amplipi/ctrl.py b/amplipi/ctrl.py index e3665efdf..d4d79b2ab 100644 --- a/amplipi/ctrl.py +++ b/amplipi/ctrl.py @@ -847,14 +847,20 @@ 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 first in precedence yet last in the stack to cover the default case of no volume change 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) - vol_db = utils.vol_float_to_db(applied_delta, zone.vol_min, zone.vol_max) - vol_f_new = applied_delta + true_vol_f = zone.vol_f + zone.vol_f_overflow + 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(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) vol_db = utils.vol_float_to_db(clamp_vol_f, zone.vol_min, zone.vol_max) @@ -866,9 +872,14 @@ 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') + # Reset the overflow when vol_f goes in bounds, there is no longer an overflow + # Avoids reporting spurious volume oscillations + 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 # If un-muting, set desired volume first diff --git a/amplipi/defaults.py b/amplipi/defaults.py index 236e53e4f..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_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_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_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_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_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_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 e5ac7c2ac..11f1804af 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,6 +117,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') + 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') VolumeMax = Field(default=MAX_VOL_DB, ge=MIN_VOL_DB, le=MAX_VOL_DB, @@ -321,6 +329,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_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/tests/test_rest.py b/tests/test_rest.py index a474aeda5..867c003bd 100644 --- a/tests/test_rest.py +++ b/tests/test_rest.py @@ -605,10 +605,10 @@ 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 - # test oversized deltas - rv = client.patch('/api/zones', json={'zones': [z['id'] for z in zones], 'update': {'vol_delta_f': -10.0}}) + # 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() assert len(jrv['zones']) >= 6 @@ -616,9 +616,33 @@ def test_patch_zones_vol_delta(client): for z in jrv['zones']: if z['id'] in range(6): 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}}) + 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'] == 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 diff --git a/web/src/App.jsx b/web/src/App.jsx index 4e28a7d3c..b2678c287 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -50,7 +50,12 @@ 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; + // Calculate vol_f and vol_f_overflow to match expected future API polled state + let combined_vol = new_vol + s.status.zones[i].vol_f_overflow; + let new_vol_f = Math.min(Math.max(combined_vol, 0), 1); + + s.status.zones[i].vol_f = new_vol_f; + s.status.zones[i].vol_f_overflow = combined_vol - new_vol_f; } } }); @@ -61,11 +66,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; + } + } + + // Mute groups if they are now completely muted + for (const g of s.status.groups) { + if (g.zones.every(zid => affectedZones.includes(zid))) { + g.mute = mute; } } }) @@ -164,6 +175,8 @@ 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; + // vol_f_overflow is set to 0 whenever vol_f is between 0 and 1, groups authoritatively set the volume so we reflect that here too + s.status.zones[i].vol_f_overflow = 0; s.status.zones[i].vol_f = new_vol; } @@ -198,7 +211,8 @@ 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); + // Combine vol_f with vol_f_overflow to ensure the group volume slider moves at the same relative speed even when a zone overflows + 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) { @@ -226,14 +240,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..9972e13ee 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_overflow; // Add buffer to retain proper relative space when doing an action that would un-overload the slider } const avg = vol / n; @@ -27,13 +27,13 @@ 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; +// cumulativeDelta reflects the amount of movement that the volume bar has had that has gone unreflected in the backend +let cumulativeDelta = 0.0; +let previous_delta = null; let sendingPacketCount = 0; // main volume slider on player and volume slider on player card @@ -41,8 +41,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); @@ -52,32 +53,42 @@ const CardVolumeSlider = ({ sourceId }) => { setZonesMute(false, zones, sourceId); }; - function setPlayerVol(vol, val) { - cumulativeDelta += vol - val; - - if(sendingPacketCount <= 0){ + function setPlayerVol(force = false) { + if(sendingPacketCount <= 0 || force){ // Sometimes slide and release interactions can send the same request twice, this check helps prevent that from doubling the intended change sendingPacketCount += 1; const delta = cumulativeDelta; - - fetch("/api/zones", { - method: "PATCH", - headers: { - "Content-type": "application/json", - }, - body: JSON.stringify({ - zones: getSourceZones(sourceId, zones).map((z) => z.id), - update: { vol_delta_f: cumulativeDelta, mute: false }, - }), - }).then(() => { - // 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; + if(previous_delta !== delta){ + previous_delta = delta; + + fetch("/api/zones", { + method: "PATCH", + headers: { + "Content-type": "application/json", + }, + body: JSON.stringify({ + zones: getSourceZones(sourceId, zones).map((z) => z.id), + update: { vol_delta_f: delta, mute: false }, + }), + }).then(() => { + // 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 + if(force){ + cumulativeDelta = 0.0; // If you force push a change, do not store any unrecognized changes + } else { + cumulativeDelta -= delta; // If this is a normal change, there is likely a change coming up and setting to zero would lose part of that upcoming delta + } + sendingPacketCount -= 1; + // In many similar requests we instantly consume the response by doing something like this: + // if(res.ok){res.json().then(s=> setSystemState(s));} + // This cannot be done here due to how rapid fire the requests can be when sliding the slider rather than just tapping it + }); + } else { sendingPacketCount -= 1; - }); + } } - }; + } const mute = getSourceZones(sourceId, zones) .map((z) => z.mute) @@ -95,6 +106,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));} }); }; @@ -107,9 +120,9 @@ const CardVolumeSlider = ({ sourceId }) => { setVol={(val, force) => { // Cannot use value directly as that changes during the request when setValue() is called // Cannot call setValue() as a .then() after the request as that causes the ui to feel unresponsive and choppy - let current_val = value; - setPlayerVol(val, current_val); + cumulativeDelta += Math.round((val - value) * 1000) / 1000; setValue(val); + setPlayerVol(force); skipNextUpdate(); }} disabled={getSourceZones(sourceId, zones) == 0} diff --git a/web/src/components/GroupVolumeSlider/GroupVolumeSlider.jsx b/web/src/components/GroupVolumeSlider/GroupVolumeSlider.jsx index f1edf40cb..aeda0c234 100644 --- a/web/src/components/GroupVolumeSlider/GroupVolumeSlider.jsx +++ b/web/src/components/GroupVolumeSlider/GroupVolumeSlider.jsx @@ -15,12 +15,22 @@ 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 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_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_overflow); + } + + 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)); @@ -68,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/PlayerCard/PlayerCardFb.jsx b/web/src/components/PlayerCard/PlayerCardFb.jsx index 7bfc1d79b..c610fe538 100644 --- a/web/src/components/PlayerCard/PlayerCardFb.jsx +++ b/web/src/components/PlayerCard/PlayerCardFb.jsx @@ -82,7 +82,7 @@ const PlayerCardFb = ({ sourceId, setVol }) => { { !is_streamer && (
- +
)} { is_streamer && ( 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 = { 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));} }); };