From e1cbfdd84bd93d12d86f44a92225b9ca5e030adf Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 28 Feb 2020 17:52:12 +0100 Subject: [PATCH] Support mute + applications from pulse (#1541) * Support mute + applications from pulse * Fix lint * Fix application parser * Fix type * Add application endpoints * error handling * Fix --- API.md | 84 ++++++++++++++++++++- supervisor/api/__init__.py | 3 + supervisor/api/audio.py | 34 ++++++++- supervisor/const.py | 3 + supervisor/host/sound.py | 149 +++++++++++++++++++++++++++++++++---- 5 files changed, 253 insertions(+), 20 deletions(-) diff --git a/API.md b/API.md index db3487105..8e61cdf63 100644 --- a/API.md +++ b/API.md @@ -866,6 +866,7 @@ return: "card": [ { "name": "...", + "index": 1, "driver": "...", "profiles": [ { @@ -879,17 +880,56 @@ return: "input": [ { "name": "...", + "index": 0, "description": "...", "volume": 0.3, - "default": false + "mute": false, + "default": false, + "card": "null|int", + "applications": [ + { + "name": "...", + "index": 0, + "stream_index": 0, + "stream_type": "INPUT", + "volume": 0.3, + "mute": false, + "addon": "" + } + ] } ], "output": [ { "name": "...", + "index": 0, "description": "...", "volume": 0.3, - "default": false + "mute": false, + "default": false, + "card": "null|int", + "applications": [ + { + "name": "...", + "index": 0, + "stream_index": 0, + "stream_type": "OUTPUT", + "volume": 0.3, + "mute": false, + "addon": "" + } + ] + } + ], + "application": [ + { + "name": "...", + "index": 0, + "stream_index": 0, + "stream_type": "OUTPUT", + "volume": 0.3, + "mute": false, + "addon": "" } ] } @@ -914,7 +954,7 @@ return: ```json { - "name": "...", + "index": "...", "volume": 0.5 } ``` @@ -923,11 +963,47 @@ return: ```json { - "name": "...", + "index": "...", "volume": 0.5 } ``` +- POST `/audio/volume/{output|input}/application` + +```json +{ + "index": "...", + "volume": 0.5 +} +``` + +- POST `/audio/mute/input` + +```json +{ + "index": "...", + "active": false +} +``` + +- POST `/audio/mute/output` + +```json +{ + "index": "...", + "active": false +} +``` + +- POST `/audio/mute/{output|input}/application` + +```json +{ + "index": "...", + "active": false +} +``` + - POST `/audio/default/input` ```json diff --git a/supervisor/api/__init__.py b/supervisor/api/__init__.py index 300b8b94c..055af90b3 100644 --- a/supervisor/api/__init__.py +++ b/supervisor/api/__init__.py @@ -330,7 +330,10 @@ class RestAPI(CoreSysAttributes): web.post("/audio/restart", api_audio.restart), web.post("/audio/reload", api_audio.reload), web.post("/audio/profile", api_audio.set_profile), + web.post("/audio/volume/{source}/application", api_audio.set_volume), web.post("/audio/volume/{source}", api_audio.set_volume), + web.post("/audio/mute/{source}/application", api_audio.set_mute), + web.post("/audio/mute/{source}", api_audio.set_mute), web.post("/audio/default/{source}", api_audio.set_default), ] ) diff --git a/supervisor/api/audio.py b/supervisor/api/audio.py index 7260de76d..80fde81d9 100644 --- a/supervisor/api/audio.py +++ b/supervisor/api/audio.py @@ -8,12 +8,15 @@ import attr import voluptuous as vol from ..const import ( + ATTR_ACTIVE, + ATTR_APPLICATION, ATTR_AUDIO, ATTR_BLK_READ, ATTR_BLK_WRITE, ATTR_CARD, ATTR_CPU_PERCENT, ATTR_HOST, + ATTR_INDEX, ATTR_INPUT, ATTR_LATEST_VERSION, ATTR_MEMORY_LIMIT, @@ -38,11 +41,19 @@ SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): vol.Coerce(str)}) SCHEMA_VOLUME = vol.Schema( { - vol.Required(ATTR_NAME): vol.Coerce(str), + vol.Required(ATTR_INDEX): vol.Coerce(int), vol.Required(ATTR_VOLUME): vol.Coerce(float), } ) +# pylint: disable=no-value-for-parameter +SCHEMA_MUTE = vol.Schema( + { + vol.Required(ATTR_INDEX): vol.Coerce(int), + vol.Required(ATTR_ACTIVE): vol.Boolean(), + } +) + SCHEMA_DEFAULT = vol.Schema({vol.Required(ATTR_NAME): vol.Coerce(str)}) SCHEMA_PROFILE = vol.Schema( @@ -68,6 +79,9 @@ class APIAudio(CoreSysAttributes): ATTR_OUTPUT: [ attr.asdict(stream) for stream in self.sys_host.sound.outputs ], + ATTR_APPLICATION: [ + attr.asdict(stream) for stream in self.sys_host.sound.applications + ], }, } @@ -116,10 +130,26 @@ class APIAudio(CoreSysAttributes): async def set_volume(self, request: web.Request) -> None: """Set audio volume on stream.""" source: StreamType = StreamType(request.match_info.get("source")) + application: bool = request.path.endswith("application") body = await api_validate(SCHEMA_VOLUME, request) await asyncio.shield( - self.sys_host.sound.set_volume(source, body[ATTR_NAME], body[ATTR_VOLUME]) + self.sys_host.sound.set_volume( + source, body[ATTR_INDEX], body[ATTR_VOLUME], application + ) + ) + + @api_process + async def set_mute(self, request: web.Request) -> None: + """Mute audio volume on stream.""" + source: StreamType = StreamType(request.match_info.get("source")) + application: bool = request.path.endswith("application") + body = await api_validate(SCHEMA_MUTE, request) + + await asyncio.shield( + self.sys_host.sound.set_mute( + source, body[ATTR_INDEX], body[ATTR_ACTIVE], application + ) ) @api_process diff --git a/supervisor/const.py b/supervisor/const.py index efeb19e56..dbc22fb0c 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -234,6 +234,9 @@ ATTR_CLI = "cli" ATTR_DEFAULT = "default" ATTR_VOLUME = "volume" ATTR_CARD = "card" +ATTR_INDEX = "index" +ATTR_ACTIVE = "active" +ATTR_APPLICATION = "application" PROVIDE_SERVICE = "provide" NEED_SERVICE = "need" diff --git a/supervisor/host/sound.py b/supervisor/host/sound.py index 610dfc9ad..c4097f287 100644 --- a/supervisor/host/sound.py +++ b/supervisor/host/sound.py @@ -2,7 +2,7 @@ from datetime import timedelta from enum import Enum import logging -from typing import List +from typing import List, Optional import attr from pulsectl import Pulse, PulseError, PulseIndexError, PulseOperationFailed @@ -24,13 +24,30 @@ class StreamType(str, Enum): @attr.s(frozen=True) -class AudioStream: - """Represent a input/output profile.""" +class AudioApplication: + """Represent a application on the stream.""" name: str = attr.ib() + index: int = attr.ib() + stream_index: str = attr.ib() + stream_type: StreamType = attr.ib() + volume: float = attr.ib() + mute: bool = attr.ib() + addon: str = attr.ib() + + +@attr.s(frozen=True) +class AudioStream: + """Represent a input/output stream.""" + + name: str = attr.ib() + index: int = attr.ib() description: str = attr.ib() volume: float = attr.ib() + mute: bool = attr.ib() default: bool = attr.ib() + card: Optional[int] = attr.ib() + applications: List[AudioApplication] = attr.ib() @attr.s(frozen=True) @@ -47,6 +64,7 @@ class SoundCard: """Represent a Sound Card.""" name: str = attr.ib() + index: int = attr.ib() driver: str = attr.ib() profiles: List[SoundProfile] = attr.ib() @@ -60,6 +78,7 @@ class SoundControl(CoreSysAttributes): self._cards: List[SoundCard] = [] self._inputs: List[AudioStream] = [] self._outputs: List[AudioStream] = [] + self._applications: List[AudioApplication] = [] @property def cards(self) -> List[SoundCard]: @@ -76,6 +95,11 @@ class SoundControl(CoreSysAttributes): """Return a list of available output streams.""" return self._outputs + @property + def applications(self) -> List[AudioApplication]: + """Return a list of available application streams.""" + return self._applications + async def set_default(self, stream_type: StreamType, name: str) -> None: """Set a stream to default input/output.""" @@ -90,11 +114,12 @@ class SoundControl(CoreSysAttributes): # Get sink and set it as default sink = pulse.get_sink_by_name(name) pulse.sink_default_set(sink) + except PulseIndexError: - _LOGGER.error("Can't find %s profile %s", source, name) + _LOGGER.error("Can't find %s stream %s", source, name) raise PulseAudioError() from None except PulseError as err: - _LOGGER.error("Can't set %s as default: %s", name, err) + _LOGGER.error("Can't set %s as stream: %s", name, err) raise PulseAudioError() from None # Run and Reload data @@ -102,32 +127,73 @@ class SoundControl(CoreSysAttributes): await self.update() async def set_volume( - self, stream_type: StreamType, name: str, volume: float + self, stream_type: StreamType, index: int, volume: float, application: bool ) -> None: - """Set a stream to volume input/output.""" + """Set a stream to volume input/output/application.""" def _set_volume(): try: with Pulse(PULSE_NAME) as pulse: if stream_type == StreamType.INPUT: - # Get source and set it as default - stream = pulse.get_source_by_name(name) + if application: + stream = pulse.source_output_info(index) + else: + stream = pulse.source_info(index) else: - # Get sink and set it as default - stream = pulse.get_sink_by_name(name) + if application: + stream = pulse.sink_input_info(index) + else: + stream = pulse.sink_info(index) + # Set volume pulse.volume_set_all_chans(stream, volume) except PulseIndexError: - _LOGGER.error("Can't find %s profile %s", stream_type, name) + _LOGGER.error( + "Can't find %s stream %d (App: %s)", stream_type, index, application + ) raise PulseAudioError() from None except PulseError as err: - _LOGGER.error("Can't set %s volume: %s", name, err) + _LOGGER.error("Can't set %d volume: %s", index, err) raise PulseAudioError() from None # Run and Reload data await self.sys_run_in_executor(_set_volume) await self.update() + async def set_mute( + self, stream_type: StreamType, index: int, mute: bool, application: bool + ) -> None: + """Set a stream to mute input/output/application.""" + + def _set_mute(): + try: + with Pulse(PULSE_NAME) as pulse: + if stream_type == StreamType.INPUT: + if application: + stream = pulse.source_output_info(index) + else: + stream = pulse.source_info(index) + else: + if application: + stream = pulse.sink_input_info(index) + else: + stream = pulse.sink_info(index) + + # Mute stream + pulse.mute(stream, mute) + except PulseIndexError: + _LOGGER.error( + "Can't find %s stream %d (App: %s)", stream_type, index, application + ) + raise PulseAudioError() from None + except PulseError as err: + _LOGGER.error("Can't set %d volume: %s", index, err) + raise PulseAudioError() from None + + # Run and Reload data + await self.sys_run_in_executor(_set_mute) + await self.update() + async def ativate_profile(self, card_name: str, profile_name: str) -> None: """Set a profile to volume input/output.""" @@ -160,15 +226,59 @@ class SoundControl(CoreSysAttributes): with Pulse(PULSE_NAME) as pulse: server = pulse.server_info() + # Update applications + self._applications.clear() + for application in pulse.sink_input_list(): + self._applications.append( + AudioApplication( + application.proplist.get( + "application.name", application.name + ), + application.index, + application.sink, + StreamType.OUTPUT, + application.volume.value_flat, + bool(application.mute), + application.proplist.get( + "application.process.machine_id", "" + ).replace("-", "_"), + ) + ) + for application in pulse.source_output_list(): + self._applications.append( + AudioApplication( + application.proplist.get( + "application.name", application.name + ), + application.index, + application.source, + StreamType.INPUT, + application.volume.value_flat, + bool(application.mute), + application.proplist.get( + "application.process.machine_id", "" + ).replace("-", "_"), + ) + ) + # Update output self._outputs.clear() for sink in pulse.sink_list(): self._outputs.append( AudioStream( sink.name, + sink.index, sink.description, sink.volume.value_flat, + bool(sink.mute), sink.name == server.default_sink_name, + sink.card if sink.card != 0xFFFFFFFF else None, + [ + application + for application in self._applications + if application.stream_index == sink.index + and application.stream_type == StreamType.OUTPUT + ], ) ) @@ -181,9 +291,18 @@ class SoundControl(CoreSysAttributes): self._inputs.append( AudioStream( source.name, + source.index, source.description, source.volume.value_flat, + bool(source.mute), source.name == server.default_source_name, + source.card if source.card != 0xFFFFFFFF else None, + [ + application + for application in self._applications + if application.stream_index == source.index + and application.stream_type == StreamType.INPUT + ], ) ) @@ -205,7 +324,9 @@ class SoundControl(CoreSysAttributes): ) self._cards.append( - SoundCard(card.name, card.driver, sound_profiles) + SoundCard( + card.name, card.index, card.driver, sound_profiles + ) ) except PulseOperationFailed as err: