Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
# AmpliPi Software Releases

# Future Release
## Future Release
* System
* Upgraded volume calculations to preserve relative positions when hitting the min or max setting via source volume bar


## 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

Expand Down
21 changes: 16 additions & 5 deletions amplipi/ctrl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
12 changes: 6 additions & 6 deletions amplipi/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
],
Expand Down
9 changes: 9 additions & 0 deletions amplipi/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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). """

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
32 changes: 28 additions & 4 deletions tests/test_rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -605,20 +605,44 @@ 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
# 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_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
Comment on lines +608 to +642
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed this test to account for valid overflows, oversized overflows, and overflow resets


# 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
Expand Down
38 changes: 26 additions & 12 deletions web/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 out future 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;
}
}
});
Expand All @@ -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;
}
}
})
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -226,14 +240,14 @@ Page.propTypes = {

const App = ({ selectedPage }) => {
return (
<div className="app">
<div className="app">
<DisconnectedIcon />
<div className="background-gradient"></div> {/* Used to make sure the background doesn't stretch or stop prematurely on scrollable pages */}
<div className="app-body">
<Page selectedPage={selectedPage} />
</div>
<MenuBar pageNumber={selectedPage} />
<div className="app-body">
<Page selectedPage={selectedPage} />
</div>
<MenuBar pageNumber={selectedPage} />
</div>
);
};
App.propTypes = {
Expand Down
31 changes: 17 additions & 14 deletions web/src/components/CardVolumeSlider/CardVolumeSlider.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,22 +27,22 @@ 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 sendingPacketCount = 0;

// main volume slider on player and volume slider on player card
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);
Expand All @@ -52,10 +52,8 @@ 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){
sendingPacketCount += 1;

const delta = cumulativeDelta;
Expand All @@ -67,17 +65,20 @@ const CardVolumeSlider = ({ sourceId }) => {
},
body: JSON.stringify({
zones: getSourceZones(sourceId, zones).map((z) => z.id),
update: { vol_delta_f: cumulativeDelta, mute: false },
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
cumulativeDelta -= 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
});
}
};
}

const mute = getSourceZones(sourceId, zones)
.map((z) => z.mute)
Expand All @@ -95,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));}
});
};

Expand All @@ -107,8 +110,8 @@ 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 += val - value;
setPlayerVol(force);
setValue(val);
skipNextUpdate();
}}
Expand Down
14 changes: 13 additions & 1 deletion web/src/components/GroupVolumeSlider/GroupVolumeSlider.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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));}
});
};

Expand Down
Loading