Skip to content

Commit adc957e

Browse files
Add vol_f_buffer functionality, imporving relativistic volume changes
Add vol_f_buffer functionality, imporving relativistic volume changes Add spotify volume synchronization
1 parent 3788d90 commit adc957e

File tree

19 files changed

+554
-114
lines changed

19 files changed

+554
-114
lines changed

CHANGELOG.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,19 @@
33
# Future Release
44
* System
55
* Update our spotify provider `go-librespot` to `0.7.1`
6+
* Upgraded volume calculations to preserve relative positions when hitting the min or max setting via source volume bar
7+
* Added volume matching between AmpliPi and Spotify and vice-versa
68
* Web App
79
* Changed caching rules to ensure that users don't get stuck with old versions of the webapp post update
810

911

1012
## 0.4.10
11-
1213
* Web App
1314
* Fixed internet radio search functionality
1415
* System
1516
* Changed apt source from `http://raspbian.raspberrypi.org/raspbian/` to `http://archive.raspberrypi.org/raspbian/`
1617

17-
## 0.4.9
18-
18+
# 0.4.9
1919
* System
2020
* Update our spotify provider `go-librespot` to `0.5.2` to accomodate spotify's API update
2121

amplipi/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -966,7 +966,7 @@ async def get_response(self, path, scope):
966966

967967

968968
# Website
969-
app.mount('/', CachelessFiles(directory=WEB_DIR, html=True), name='web')
969+
app.mount('/', StaticFiles(directory=WEB_DIR, html=True), name='web')
970970

971971

972972
def create_app(mock_ctrl=None, mock_streams=None, config_file=None, delay_saves=None, settings: models.AppSettings = models.AppSettings()) -> FastAPI:

amplipi/ctrl.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -847,14 +847,20 @@ def set_mute():
847847

848848
def set_vol():
849849
""" Update the zone's volume. Could be triggered by a change in
850-
vol, vol_f, vol_min, or vol_max.
850+
vol, vol_f, vol_f_delta, vol_min, or vol_max.
851851
"""
852852
# Field precedence: vol (db) > vol_delta > vol (float)
853-
# NOTE: checks happen in reverse precedence to cover default case of unchanged volume
853+
# 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
854854
if update.vol_delta_f is not None and update.vol is None:
855-
applied_delta = utils.clamp((vol_delta_f + zone.vol_f), 0, 1)
856-
vol_db = utils.vol_float_to_db(applied_delta, zone.vol_min, zone.vol_max)
857-
vol_f_new = applied_delta
855+
true_vol_f = zone.vol_f + zone.vol_f_overflow
856+
expected_vol_total = update.vol_delta_f + true_vol_f
857+
vol_f_new = utils.clamp(expected_vol_total, models.MIN_VOL_F, models.MAX_VOL_F)
858+
859+
vol_db = utils.vol_float_to_db(vol_f_new, zone.vol_min, zone.vol_max)
860+
zone.vol_f_overflow = 0 if models.MIN_VOL_F < expected_vol_total and expected_vol_total < models.MAX_VOL_F \
861+
else utils.clamp((expected_vol_total - vol_f_new), models.MIN_VOL_F_OVERFLOW, models.MAX_VOL_F_OVERFLOW)
862+
# Clamp the remaining delta to be between -1 and 1
863+
858864
elif update.vol_f is not None and update.vol is None:
859865
clamp_vol_f = utils.clamp(vol_f, 0, 1)
860866
vol_db = utils.vol_float_to_db(clamp_vol_f, zone.vol_min, zone.vol_max)
@@ -866,9 +872,14 @@ def set_vol():
866872
if self._rt.update_zone_vol(idx, vol_db):
867873
zone.vol = vol_db
868874
zone.vol_f = vol_f_new
875+
869876
else:
870877
raise Exception('unable to update zone volume')
871878

879+
# Reset the overflow when vol_f goes in bounds, there is no longer an overflow
880+
# Avoids reporting spurious volume oscillations
881+
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
882+
872883
# To avoid potential unwanted loud output:
873884
# If muting, mute before setting volumes
874885
# If un-muting, set desired volume first

amplipi/defaults.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,17 +41,17 @@
4141
],
4242
"zones": [ # this is an array of zones, array length depends on # of boxes connected
4343
{"id": 0, "name": "Zone 1", "source_id": 0, "mute": True, "disabled": False,
44-
"vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB},
44+
"vol_f": models.MIN_VOL_F, "vol_f_overflow": 0.0, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB},
4545
{"id": 1, "name": "Zone 2", "source_id": 0, "mute": True, "disabled": False,
46-
"vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB},
46+
"vol_f": models.MIN_VOL_F, "vol_f_overflow": 0.0, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB},
4747
{"id": 2, "name": "Zone 3", "source_id": 0, "mute": True, "disabled": False,
48-
"vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB},
48+
"vol_f": models.MIN_VOL_F, "vol_f_overflow": 0.0, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB},
4949
{"id": 3, "name": "Zone 4", "source_id": 0, "mute": True, "disabled": False,
50-
"vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB},
50+
"vol_f": models.MIN_VOL_F, "vol_f_overflow": 0.0, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB},
5151
{"id": 4, "name": "Zone 5", "source_id": 0, "mute": True, "disabled": False,
52-
"vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB},
52+
"vol_f": models.MIN_VOL_F, "vol_f_overflow": 0.0, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB},
5353
{"id": 5, "name": "Zone 6", "source_id": 0, "mute": True, "disabled": False,
54-
"vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB},
54+
"vol_f": models.MIN_VOL_F, "vol_f_overflow": 0.0, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB},
5555
],
5656
"groups": [
5757
],

amplipi/models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@
3838
MAX_VOL_F = 1.0
3939
""" Max volume for slider bar. Will be mapped to dB. """
4040

41+
MIN_VOL_F_OVERFLOW = MIN_VOL_F - MAX_VOL_F
42+
"""Min overflow for volume sliders, set to be the full range of vol_f below zero"""
43+
44+
MAX_VOL_F_OVERFLOW = MAX_VOL_F - MIN_VOL_F
45+
"""Max overflow for volume sliders, set to be the full range of vol_f above zero"""
46+
4147
MIN_VOL_DB = -80
4248
""" Min volume in dB. -80 is special and is actually -90 dB (mute). """
4349

@@ -111,6 +117,8 @@ class fields_w_default(SimpleNamespace):
111117
Volume = Field(default=MIN_VOL_DB, ge=MIN_VOL_DB, le=MAX_VOL_DB, description='Output volume in dB')
112118
VolumeF = Field(default=MIN_VOL_F, ge=MIN_VOL_F, le=MAX_VOL_F,
113119
description='Output volume as a floating-point scalar from 0.0 to 1.0 representing MIN_VOL_DB to MAX_VOL_DB')
120+
VolumeFOverflow = Field(default=0.0, ge=MIN_VOL_F_OVERFLOW, le=MAX_VOL_F_OVERFLOW,
121+
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')
114122
VolumeMin = Field(default=MIN_VOL_DB, ge=MIN_VOL_DB, le=MAX_VOL_DB,
115123
description='Min output volume in dB')
116124
VolumeMax = Field(default=MAX_VOL_DB, ge=MIN_VOL_DB, le=MAX_VOL_DB,
@@ -321,6 +329,7 @@ class Zone(Base):
321329
mute: bool = fields_w_default.Mute
322330
vol: int = fields_w_default.Volume
323331
vol_f: float = fields_w_default.VolumeF
332+
vol_f_overflow: float = fields_w_default.VolumeFOverflow
324333
vol_min: int = fields_w_default.VolumeMin
325334
vol_max: int = fields_w_default.VolumeMax
326335
disabled: bool = fields_w_default.Disabled

amplipi/streams/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565
def build_stream(stream: models.Stream, mock: bool = False, validate: bool = True) -> AnyStream:
6666
""" Build a stream from the generic arguments given in stream, discriminated by stream.type
6767
68-
we are waiting on Pydantic's implemenatation of discriminators to fully integrate streams into our model definitions
68+
we are waiting on Pydantic's implementation of discriminators to fully integrate streams into our model definitions
6969
"""
7070
# pylint: disable=too-many-return-statements
7171
args = stream.dict(exclude_none=True)

amplipi/streams/base_streams.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import logging
66
from amplipi import models
77
from amplipi import utils
8+
from amplipi import app
89

910
logger = logging.getLogger(__name__)
1011
logger.level = logging.DEBUG
@@ -62,6 +63,24 @@ def __init__(self, stype: str, name: str, only_src=None, disabled: bool = False,
6263
if validate:
6364
self.validate_stream(name=name, mock=mock, **kwargs)
6465

66+
def get_zone_data(self):
67+
if self.src is not None:
68+
ctrl = app.get_ctrl()
69+
state = ctrl.get_state()
70+
return [zone for zone in state.zones if zone.source_id == self.src]
71+
72+
@property
73+
def connected_zones(self) -> List[int]:
74+
connected_zones = self.get_zone_data()
75+
return [zone.id for zone in connected_zones]
76+
77+
@property
78+
def volume(self) -> float:
79+
connected_zones = self.get_zone_data()
80+
if connected_zones:
81+
return sum([zone.vol_f for zone in connected_zones]) / len(connected_zones)
82+
return 0
83+
6584
def __del__(self):
6685
self.disconnect()
6786

@@ -242,7 +261,7 @@ def deactivate(self):
242261
raise Exception(f'Failed to deactivate {self.name}: {e}') from e
243262
finally:
244263
self.state = "disconnected" # make this look like a normal stream for now
245-
if 'vsrc' in self.__dir__() and self.vsrc:
264+
if 'vsrc' in self.__dir__() and self.vsrc is not None:
246265
vsrc = self.vsrc
247266
self.vsrc = None
248267
vsources.free(vsrc)

amplipi/streams/spotify_connect.py

Lines changed: 68 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,27 @@
22

33
import io
44
import os
5+
import threading
56
import re
67
import sys
78
import subprocess
89
import time
910
from typing import ClassVar, Optional
1011
import yaml
12+
import logging
13+
import json
1114
from amplipi import models, utils
12-
from .base_streams import PersistentStream, InvalidStreamField, logger
15+
from .base_streams import PersistentStream, InvalidStreamField
1316
from .. import tasks
1417

1518
# Our subprocesses run behind the scenes, is there a more standard way to do this?
1619
# pylint: disable=consider-using-with
1720

21+
logger = logging.getLogger(__name__)
22+
logger.level = logging.DEBUG
23+
sh = logging.StreamHandler(sys.stdout)
24+
logger.addHandler(sh)
25+
1826

1927
class SpotifyConnect(PersistentStream):
2028
""" A SpotifyConnect Stream based off librespot-go """
@@ -33,9 +41,30 @@ def __init__(self, name: str, disabled: bool = False, mock: bool = False, valida
3341
self._log_file: Optional[io.TextIOBase] = None
3442
self._api_port: int
3543
self.proc2: Optional[subprocess.Popen] = None
44+
self.volume_sync_process: Optional[subprocess.Popen] = None # Runs the actual vol sync script
45+
self.volume_watcher_process: Optional[threading.Thread] = None # Populates the fifo that the vol sync script depends on
46+
self.src_config_folder: Optional[str] = None
3647
self.meta_file: str = ''
37-
self.max_volume: int = 100 # default configuration from 'volume_steps'
38-
self.last_volume: float = 0
48+
self._volume_fifo = None
49+
50+
def watch_vol(self):
51+
"""Creates and supplies a FIFO with volume data for volume sync"""
52+
while True:
53+
try:
54+
if self.src is not None:
55+
if self._volume_fifo is None and self.src_config_folder is not None:
56+
fifo_path = f"{self.src_config_folder}/vol"
57+
if not os.path.isfile(fifo_path):
58+
os.mkfifo(fifo_path)
59+
self._volume_fifo = os.open(fifo_path, os.O_WRONLY, os.O_NONBLOCK)
60+
data = json.dumps({
61+
'zones': self.connected_zones,
62+
'volume': self.volume,
63+
})
64+
os.write(self._volume_fifo, bytearray(f"{data}\r\n", encoding="utf8"))
65+
except Exception as e:
66+
logger.error(f"{self.name} volume thread ran into exception: {e}")
67+
time.sleep(0.1)
3968

4069
def reconfig(self, **kwargs):
4170
self.validate_stream(**kwargs)
@@ -52,9 +81,9 @@ def _activate(self, vsrc: int):
5281
""" Connect to a given audio source
5382
"""
5483

55-
src_config_folder = f'{utils.get_folder("config")}/srcs/v{vsrc}'
84+
self.src_config_folder = f'{utils.get_folder("config")}/srcs/v{vsrc}'
5685
try:
57-
os.remove(f'{src_config_folder}/currentSong')
86+
os.remove(f'{self.src_config_folder}/currentSong')
5887
except FileNotFoundError:
5988
pass
6089
self._connect_time = time.time()
@@ -78,16 +107,16 @@ def _activate(self, vsrc: int):
78107
}
79108

80109
# make all of the necessary dir(s) & files
81-
os.makedirs(src_config_folder, exist_ok=True)
110+
os.makedirs(self.src_config_folder, exist_ok=True)
82111

83-
config_file = f'{src_config_folder}/config.yml'
112+
config_file = f'{self.src_config_folder}/config.yml'
84113
with open(config_file, 'w', encoding='utf8') as f:
85114
f.write(yaml.dump(config))
86115

87-
self.meta_file = f'{src_config_folder}/metadata.json'
116+
self.meta_file = f'{self.src_config_folder}/metadata.json'
88117

89-
self._log_file = open(f'{src_config_folder}/log', mode='w', encoding='utf8')
90-
player_args = f"{utils.get_folder('streams')}/go-librespot --config_dir {src_config_folder}".split(' ')
118+
self._log_file = open(f'{self.src_config_folder}/log', mode='w', encoding='utf8')
119+
player_args = f"{utils.get_folder('streams')}/go-librespot --config_dir {self.src_config_folder}".split(' ')
91120
logger.debug(f'spotify player args: {player_args}')
92121

93122
self.proc = subprocess.Popen(args=player_args, stdin=subprocess.PIPE,
@@ -99,20 +128,45 @@ def _activate(self, vsrc: int):
99128
logger.info(f'{self.name}: starting metadata reader: {meta_args}')
100129
self.proc2 = subprocess.Popen(args=meta_args, stdout=self._log_file, stderr=self._log_file)
101130

131+
vol_sync = f"{utils.get_folder('streams')}/spotify_volume_handler.py"
132+
vol_args = [sys.executable, vol_sync, str(self._api_port), self.src_config_folder, "--debug"]
133+
logger.info(f'{self.name}: starting vol synchronizer: {vol_args}')
134+
self.volume_sync_process = subprocess.Popen(args=vol_args, stdout=self._log_file, stderr=self._log_file)
135+
136+
self.volume_watcher_process = threading.Thread(target=self.watch_vol, daemon=True)
137+
self.volume_watcher_process.start()
138+
102139
def _deactivate(self):
103140
if self._is_running():
104141
self.proc.stdin.close()
105142
logger.info(f'{self.name}: stopping player')
143+
144+
# Call terminate on all processes
106145
self.proc.terminate()
107146
self.proc2.terminate()
147+
if self.volume_sync_process:
148+
self.volume_sync_process.terminate()
149+
150+
# Ensure the processes have closed, by force if necessary
108151
if self.proc.wait(1) != 0:
109152
logger.info(f'{self.name}: killing player')
110153
self.proc.kill()
154+
111155
if self.proc2.wait(1) != 0:
112156
logger.info(f'{self.name}: killing metadata reader')
113157
self.proc2.kill()
158+
159+
if self.volume_sync_process:
160+
if self.volume_sync_process.wait(1) != 0:
161+
logger.info(f'{self.name}: killing volume synchronizer')
162+
self.volume_sync_process.kill()
163+
164+
# Validate on the way out
114165
self.proc.communicate()
115166
self.proc2.communicate()
167+
if self.volume_sync_process:
168+
self.volume_sync_process.communicate()
169+
116170
if self.proc and self._log_file: # prevent checking _log_file when it may not exist, thanks validation!
117171
self._log_file.close()
118172
if self.src:
@@ -121,8 +175,12 @@ def _deactivate(self):
121175
except Exception as e:
122176
logger.exception(f'{self.name}: Error removing config files: {e}')
123177
self._disconnect()
178+
124179
self.proc = None
125180
self.proc2 = None
181+
self.volume_sync_process = None
182+
self.volume_watcher_process = None
183+
self._volume_fifo = None
126184

127185
def info(self) -> models.SourceInfo:
128186
source = models.SourceInfo(
@@ -190,10 +248,3 @@ def validate_stream(self, **kwargs):
190248
NAME = r"[a-zA-Z0-9][A-Za-z0-9\- ]*[a-zA-Z0-9]"
191249
if 'name' in kwargs and not re.fullmatch(NAME, kwargs['name']):
192250
raise InvalidStreamField("name", "Invalid stream name")
193-
194-
def sync_volume(self, volume: float) -> None:
195-
""" Set the volume of amplipi to the Spotify Connect stream"""
196-
if volume != self.last_volume:
197-
url = f"http://localhost:{self._api_port}/"
198-
self.last_volume = volume # update last_volume for future syncs
199-
tasks.post.delay(url + 'volume', data={'volume': int(volume * self.max_volume)})

0 commit comments

Comments
 (0)