diff --git a/CHANGELOG.md b/CHANGELOG.md
index e28a52688..ee2156519 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,9 +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
+* Streams:
+ * Upgraded Spotify to sync Spotify's volume with AmpliPi and vice-versa
# 0.4.9
-
* System
* Update our spotify provider `go-librespot` to `0.5.2` to accomodate spotify's API update
diff --git a/amplipi/ctrl.py b/amplipi/ctrl.py
index e3665efdf..c07efbc45 100644
--- a/amplipi/ctrl.py
+++ b/amplipi/ctrl.py
@@ -847,14 +847,18 @@ 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 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:
- 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 +870,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')
+ # 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 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/amplipi/streams/__init__.py b/amplipi/streams/__init__.py
index f094c9bd8..d141999a6 100644
--- a/amplipi/streams/__init__.py
+++ b/amplipi/streams/__init__.py
@@ -65,7 +65,7 @@
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 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)
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/amplipi/streams/base_streams.py b/amplipi/streams/base_streams.py
index b8645d237..01afae4b7 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
@@ -50,6 +51,7 @@ 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):
+
self.name = name
self.disabled = disabled
self.proc: Optional[subprocess.Popen] = None
@@ -62,6 +64,24 @@ def __init__(self, stype: str, name: str, only_src=None, disabled: bool = False,
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()
@@ -242,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)
diff --git a/amplipi/streams/spotify_connect.py b/amplipi/streams/spotify_connect.py
index 34e8b105d..460d8ed12 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,9 +41,30 @@ 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 # 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.max_volume: int = 100 # default configuration from 'volume_steps'
- self.last_volume: float = 0
+ 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._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)
@@ -52,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()
@@ -78,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,20 +128,45 @@ 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), 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)
+
+ self.volume_watcher_process = threading.Thread(target=self.watch_vol, daemon=True)
+ self.volume_watcher_process.start()
+
def _deactivate(self):
if self._is_running():
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()
+
if self.proc2.wait(1) != 0:
logger.info(f'{self.name}: killing metadata reader')
self.proc2.kill()
+
+ if self.volume_sync_process:
+ if self.volume_sync_process.wait(1) != 0:
+ logger.info(f'{self.name}: killing volume synchronizer')
+ self.volume_sync_process.kill()
+
+ # 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()
if self.src:
@@ -121,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(
@@ -190,10 +248,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/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: