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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

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
2 changes: 1 addition & 1 deletion amplipi/streams/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 21 additions & 1 deletion amplipi/streams/base_streams.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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()

Expand Down Expand Up @@ -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)
Expand Down
85 changes: 68 additions & 17 deletions amplipi/streams/spotify_connect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 """
Expand All @@ -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)
Expand All @@ -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()
Expand All @@ -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,
Expand All @@ -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:
Expand All @@ -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(
Expand Down Expand Up @@ -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)})
Loading