"""All constants for Music Assistant."""
-__version__ = "0.2.2"
+__version__ = "0.2.3"
REQUIRED_PYTHON_VER = "3.8"
# configuration keys/attributes
EVENT_PLAYLIST_ADDED = "playlist added"
EVENT_RADIO_ADDED = "radio added"
EVENT_TASK_UPDATED = "task updated"
-EVENT_ALERT_FINISHED = "alert finished"
# player attributes
ATTR_PLAYER_ID = "player_id"
"""
if queue_item.provider == "url":
# special case: a plain url was added to the queue
- if queue_item.streamdetails is not None:
- streamdetails = queue_item.streamdetails
- else:
- streamdetails = StreamDetails(
- type=StreamType.URL,
- provider="url",
- item_id=queue_item.item_id,
- path=queue_item.uri,
- content_type=ContentType(queue_item.uri.split(".")[-1]),
- )
+ streamdetails = StreamDetails(
+ type=StreamType.URL,
+ provider="url",
+ item_id=queue_item.item_id,
+ path=queue_item.uri if queue_item.uri else queue_item.item_id,
+ content_type=ContentType(queue_item.uri.split(".")[-1]),
+ )
else:
# always request the full db track as there might be other qualities available
# except for radio
filter_args = []
if streamdetails.gain_correct:
filter_args += ["vol", str(streamdetails.gain_correct), "dB"]
- if resample:
+ if resample and resample > 48000:
filter_args += ["rate", "-v", str(resample)]
+ elif resample:
+ filter_args += ["rate", str(resample)]
# TODO: still not sure about the order of the filter arguments in the chain
# assumption is they need to be at the end of the chain
return input_args + output_args + filter_args
CONF_CROSSFADE_DURATION,
CONF_POWER_CONTROL,
CONF_VOLUME_CONTROL,
- EVENT_ALERT_FINISHED,
EVENT_PLAYER_ADDED,
EVENT_PLAYER_REMOVED,
)
POLL_INTERVAL = 30
LOGGER = logging.getLogger("player_manager")
+RESOURCES_DIR = (
+ pathlib.Path(__file__).parent.resolve().parent.resolve().joinpath("resources")
+)
+ALERT_ANNOUNCE_FILE = str(RESOURCES_DIR.joinpath("alert_announce.flac"))
+ALERT_FINISH_FILE = str(RESOURCES_DIR.joinpath("alert_finish.flac"))
class PlayerManager:
QueueOption.NEXT -> Play item(s) after current playing item
QueueOption.ADD -> Append new items at end of the queue
"""
+ # turn on player
+ player = self.get_player(player_id)
+ if not player:
+ raise FileNotFoundError("Player not found %s" % player_id)
+ if not player.calculated_state.powered:
+ await self.cmd_power_on(player_id)
+ player_queue = self.get_active_player_queue(player_id)
# a single item or list of items may be provided
if not isinstance(items, list):
items = [items]
for track in tracks:
if not track.available:
continue
- queue_item = QueueItem.from_track(track)
- # generate url for this queue item
- queue_item.stream_url = "%s/queue/%s/%s" % (
- self.mass.web.stream_url,
- player_id,
- queue_item.queue_item_id,
- )
+ queue_item = player_queue.create_queue_item(track)
queue_items.append(queue_item)
- # turn on player
- player = self.get_player(player_id)
- if not player:
- raise FileNotFoundError("Player not found %s" % player_id)
- if not player.calculated_state.powered:
- await self.cmd_power_on(player_id)
+
# load items into the queue
- player_queue = self.get_active_player_queue(player_id)
if queue_opt == QueueOption.REPLACE:
return await player_queue.load(queue_items)
if queue_opt in [QueueOption.PLAY, QueueOption.NEXT] and len(queue_items) > 100:
if item:
return await self.play_media(player_id, item, queue_opt)
raise FileNotFoundError("Invalid uri: %s" % uri)
- # fallback to regular url
- queue_item = QueueItem(item_id=uri, provider="url", name=uri, uri=uri)
- # generate url for this queue item
- queue_item.stream_url = "%s/queue/%s/%s" % (
- self.mass.web.stream_url,
- player_id,
- queue_item.queue_item_id,
- )
# turn on player
player = self.get_player(player_id)
if not player:
await self.cmd_power_on(player_id)
# load items into the queue
player_queue = self.get_active_player_queue(player_id)
+ queue_item = player_queue.create_queue_item(
+ item_id=uri, provider="url", name=uri, uri=uri
+ )
if queue_opt == QueueOption.REPLACE:
return await player_queue.load([queue_item])
if queue_opt == QueueOption.NEXT:
# load alert items in player queue
queue_items = []
if announce:
- alert_announce = (
- pathlib.Path(__file__)
- .parent.resolve()
- .parent.resolve()
- .joinpath("helpers", "alert.mp3")
- )
- queue_item = QueueItem(
- item_id="alert_announce",
- provider="url",
- name="alert",
- duration=3,
- uri=str(alert_announce),
+ queue_items.append(
+ player_queue.create_queue_item(
+ item_id="alert_announce",
+ provider="url",
+ name="alert_announce",
+ uri=ALERT_ANNOUNCE_FILE,
+ )
)
- queue_item.stream_url = "%s/queue/%s/%s" % (
- self.mass.web.stream_url,
- player_queue.queue_id,
- queue_item.queue_item_id,
+ queue_items.append(
+ player_queue.create_queue_item(
+ item_id="alert", provider="url", name="alert", uri=url
)
- queue_items.append(queue_item)
-
- queue_item = QueueItem(
- item_id="alert_sound",
- provider="url",
- name="alert",
- duration=10,
- uri=url,
)
- queue_item.stream_url = "%s/queue/%s/%s" % (
- self.mass.web.stream_url,
- player_queue.queue_id,
- queue_item.queue_item_id,
+ queue_items.append(
+ # add a special (silent) file so we can detect finishing of the alert
+ player_queue.create_queue_item(
+ item_id="alert_finish",
+ provider="url",
+ name="alert_finish",
+ uri=ALERT_FINISH_FILE,
+ )
)
- queue_items.append(queue_item)
-
# load queue items
await player_queue.load(queue_items)
# add listener when playback of alert finishes
- async def restore_queue_listener(event: str, event_data: str):
- """Restore queue after the alert was played."""
- if event_data != queue_item.queue_item_id:
- return
- # player stopped playing
- remove_cb()
+ async def restore_queue():
+ count = 0
+ while count < 30:
+ if (
+ player_queue.cur_item == queue_items[-1]
+ and player_queue.cur_item_time > 2
+ ):
+ break
+ count += 1
+ await asyncio.sleep(1)
# restore queue
if volume:
await self.cmd_volume_set(player_id, prev_volume)
await self.cmd_power_off(player_id)
player_queue.signal_update()
- remove_cb = self.mass.eventbus.add_listener(
- restore_queue_listener, EVENT_ALERT_FINISHED
- )
+ create_task(restore_queue)
@api_route("players/{player_id}/cmd/stop", method="PUT")
async def cmd_stop(self, player_id: str) -> None:
from music_assistant.constants import (
CONF_CROSSFADE_DURATION,
- EVENT_ALERT_FINISHED,
EVENT_QUEUE_ITEMS_UPDATED,
EVENT_QUEUE_UPDATED,
)
"""Load (overwrite) queue with new items."""
for index, item in enumerate(queue_items):
item.sort_index = index
- if self._shuffle_enabled and len(queue_items) > 2:
+ if self._shuffle_enabled and len(queue_items) > 5:
queue_items = self.__shuffle_items(queue_items)
self._items = queue_items
if self.use_queue_stream:
insert_at_index = self.cur_index + offset
for index, item in enumerate(queue_items):
item.sort_index = insert_at_index + index
- if self.shuffle_enabled and len(queue_items) > 10:
+ if self.shuffle_enabled and len(queue_items) > 5:
queue_items = self.__shuffle_items(queue_items)
if offset == 0:
# replace current item with new
prev_item_time = int(self._cur_item_time)
self._cur_item_time = int(track_time)
if self._last_playback_state != self.state:
- # handle special usecase where an alert is played
- if (
- self._last_playback_state == PlayerState.PLAYING
- and self.state == PlayerState.IDLE
- and self.cur_item
- and self.cur_item.name == "alert"
- ):
- self.mass.eventbus.signal(
- EVENT_ALERT_FINISHED,
- self.cur_item.queue_item_id,
- )
- # fire regular event with updated state
+ # fire event with updated state
self.signal_update()
self._last_playback_state = self.state
elif abs(prev_item_time - self._cur_item_time) > 3:
self._cur_index = cache_data.get("cur_index", 0)
self._queue_stream_next_index = self._cur_index
+ def create_queue_item(self, *args, **kwargs):
+ """Create QueueItem including correct stream URL."""
+ if args and isinstance(args[0], (Track, Radio)):
+ new_item = QueueItem.from_track(args[0])
+ else:
+ new_item = QueueItem(*args, **kwargs)
+ new_item.stream_url = "%s/queue/%s/%s" % (
+ self.mass.web.stream_url,
+ self.queue_id,
+ new_item.queue_item_id,
+ )
+ return new_item
+
# pylint: enable=unused-argument
async def _save_state(self) -> None:
mass: MusicAssistant, media_type: MediaType, provider: str, item_id: str
) -> str:
"""Return image URL for given media item."""
+ if provider == "url":
+ return None
return await get_image_url(mass, item_id, provider, media_type)
await resp.prepare(request)
player_conf = player_queue.player.config
- pcm_format = "f64"
- sample_rate = min(player_conf.get(CONF_MAX_SAMPLE_RATE, 96000), 96000)
+ # determine sample rate and pcm format for the queue stream, depending on player capabilities
+ player_max_sample_rate = player_conf.get(CONF_MAX_SAMPLE_RATE, 48000)
+ sample_rate = min(player_max_sample_rate, 96000)
+ if player_max_sample_rate > 96000:
+ # assume that highest possible quality is needed
+ # if player supports sample rates > 96000
+ # we use float64 PCM format internally which is heavy on CPU
+ pcm_format = "f64"
+ elif sample_rate > 48000:
+ # prefer internal PCM_S32LE format
+ pcm_format = "s32"
+ else:
+ # fallback to 24 bits
+ pcm_format = "s24"
args = [
"sox",
# feed stdin with pcm samples
async def fill_buffer():
"""Feed audio data into sox stdin for processing."""
- async for audio_chunk in get_queue_stream(
+ async for audio_chunk in get_pcm_queue_stream(
mass, player_queue, sample_rate, pcm_format
):
await sox_proc.write(audio_chunk)
)
-async def get_queue_stream(
+async def get_pcm_queue_stream(
mass: MusicAssistant,
player_queue: PlayerQueue,
sample_rate=96000,
queue_index = None
# get crossfade details
fade_length = player_queue.crossfade_duration
- if pcm_format in ["s64", "f64"]:
+ if "64" in pcm_format:
bit_depth = 64
- elif pcm_format in ["s32", "f32"]:
+ elif "32" in pcm_format:
bit_depth = 32
- elif pcm_format == "s16":
- bit_depth = 16
- elif pcm_format == "s24":
+ elif "24" in pcm_format:
bit_depth = 24
else:
- raise NotImplementedError("Unsupported PCM format: %s" % pcm_format)
+ bit_depth = 16
pcm_args = [pcm_format, "-c", "2", "-r", str(sample_rate)]
sample_size = int(sample_rate * (bit_depth / 8) * channels) # 1 second
buffer_size = sample_size * fade_length if fade_length else sample_size * 10