Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
f3def56
Add vol_f_buffer
SteveMicroNova Oct 23, 2025
ac5f731
Reactivate /api calls, add instant polling to all mute changes
SteveMicroNova Oct 29, 2025
b0b8102
Swap "buffer" verbiage to "overflow"
SteveMicroNova Nov 5, 2025
8680590
Reconfigure check to be not as confusing to read
SteveMicroNova Nov 7, 2025
b3949e2
Improve variable names, documentation
SteveMicroNova Nov 10, 2025
66e4a49
Don't always reset overflow to zero immediately by changing "or" to "…
SteveMicroNova Nov 17, 2025
8a96a17
Fix some documentation oddities
SteveMicroNova Nov 26, 2025
7b703b7
Update changelog, add test
SteveMicroNova Dec 1, 2025
87e72d6
Update tests instead
SteveMicroNova Dec 1, 2025
98b1644
spellcheck
SteveMicroNova Dec 1, 2025
6fc3b89
Add test for excessively large overflows
SteveMicroNova Dec 1, 2025
d830e2d
Add basic vol change functionality to spotify metadata reader
SteveMicroNova Oct 31, 2025
b1a0e31
Add secondary script that handles volume changes much cleaner, remove…
SteveMicroNova Nov 3, 2025
f8c5a6d
Revert all changes to spot connect meta
SteveMicroNova Nov 4, 2025
44b8c79
Make the volume handler multithreaded and event based on the spotify …
SteveMicroNova Nov 5, 2025
eb3ac18
Change prints, add documentation
SteveMicroNova Nov 5, 2025
191cf2a
Remove unused print
SteveMicroNova Nov 5, 2025
a7c7627
Add ignore to false linting error
SteveMicroNova Nov 5, 2025
b0afb89
Refactor spotify_volume_handler to be multithreaded, event based, and…
SteveMicroNova Nov 5, 2025
9adc2eb
Linting
SteveMicroNova Nov 5, 2025
741cf6f
Reduce complexity of SpotifyVolumeHandler by splitting up the events …
SteveMicroNova Nov 5, 2025
d8dcd3a
Explicitly provide source rather than attempting to calculate it
SteveMicroNova Nov 6, 2025
91b1569
Implement vol script
SteveMicroNova Nov 6, 2025
877dd8c
Improve event gating
SteveMicroNova Nov 7, 2025
a1150a8
Add more try-catches
SteveMicroNova Nov 7, 2025
83ee559
Plumb stream id all the way down into BaseStream so that SpotifyConne…
SteveMicroNova Nov 13, 2025
b0db283
Improve initial state sync
SteveMicroNova Nov 13, 2025
3b0f603
Generalize volume synchronizer script for future use with airplay and…
SteveMicroNova Nov 14, 2025
541826b
Remove unused utils function
SteveMicroNova Nov 14, 2025
79cd961
Lint
SteveMicroNova Nov 14, 2025
11c8f34
Improve variable name
SteveMicroNova Nov 17, 2025
e8ca331
Reduce the lag time between changing volume and seeing it reflected o…
SteveMicroNova Nov 17, 2025
fa86790
Pass a fully formed object to VolumeSynchronizer instead of the const…
SteveMicroNova Nov 19, 2025
86cf823
Change event_queue.get() to be a blocking call, meaning that any new …
SteveMicroNova Nov 19, 2025
78432e2
Add FIFO-based volume listening for amplipi streams
SteveMicroNova Dec 1, 2025
e1c737c
Stop filtering flow by "If not is not None"
SteveMicroNova Dec 1, 2025
ec6f264
Move typehint "list" to "Typing.List" to fit with our python version …
SteveMicroNova Dec 1, 2025
a0b5209
linting
SteveMicroNova Dec 1, 2025
0c67268
Close the fifo cleanly
SteveMicroNova Dec 1, 2025
0b7cfee
Increase the speed of volume gathering
SteveMicroNova Dec 1, 2025
8492f08
Remove unnecessary stream_id plumbing through every stream type
SteveMicroNova Dec 1, 2025
19d9273
Remove the last vestiges of stream_id plumbing
SteveMicroNova Dec 1, 2025
2999e44
Remove last bit of stream_id drilling for real this time
SteveMicroNova Dec 1, 2025
7be75c5
Add a lot of documentation
SteveMicroNova Dec 2, 2025
94c18c4
Remove typing.Annotated
SteveMicroNova Dec 2, 2025
4d2b723
Update docstring of volume_synchronizer.py
SteveMicroNova Dec 2, 2025
f0f51bf
Close things properly
SteveMicroNova Dec 3, 2025
97b4be1
Fix vsrc allocation bug
SteveMicroNova Dec 5, 2025
7b41571
Add airplay volume synchronizer
SteveMicroNova Nov 6, 2025
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
19 changes: 14 additions & 5 deletions amplipi/ctrl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
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
68 changes: 58 additions & 10 deletions amplipi/streams/airplay.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
import time
import os
import io
import sys
import threading
import json


def write_sp_config_file(filename, config):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand All @@ -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': {
Expand All @@ -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,
},
}

Expand All @@ -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}')

Expand All @@ -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}')

Expand All @@ -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:
Expand All @@ -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(
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
Loading