diff --git a/__init__.py b/__init__.py index 10407df..059a595 100644 --- a/__init__.py +++ b/__init__.py @@ -12,7 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. # -from alsaaudio import Mixer, mixers as alsa_mixers from os.path import dirname, join from adapt.intent import IntentBuilder @@ -22,6 +21,8 @@ from mycroft.util import play_wav from mycroft.util.parse import extract_number +from .hal import HALFactory + ALSA_PLATFORMS = ['mycroft_mark_1', 'picroft', 'unknown'] @@ -54,99 +55,31 @@ def __init__(self): else: self.settings["max_volume"] = 100 # can be 0 to 100 self.volume_sound = join(dirname(__file__), "blop-mark-diangelo.wav") - self.vol_before_mute = None - self._mixer = None - - def _clear_mixer(self): - """For Unknown platforms reinstantiate the mixer. - - For mycroft_mark_1 do not reinstantiate the mixer. - """ - platform = self.config_core['enclosure'].get('platform', 'unknown') - if platform != 'mycroft_mark_1': - self._mixer = None - - def _get_mixer(self): - self.log.debug('Finding Alsa Mixer for control...') - mixer = None - try: - # If there are only 1 mixer use that one - mixers = alsa_mixers() - if len(mixers) == 1: - mixer = Mixer(mixers[0]) - elif 'Master' in mixers: - # Try using the default mixer (Master) - mixer = Mixer('Master') - elif 'PCM' in mixers: - # PCM is another common one - mixer = Mixer('PCM') - elif 'Digital' in mixers: - # My mixer is called 'Digital' (JustBoom DAC) - mixer = Mixer('Digital') - else: - # should be equivalent to 'Master' - mixer = Mixer() - except Exception: - # Retry instanciating the mixer with the built-in default - try: - mixer = Mixer() - except Exception as e: - self.log.error('Couldn\'t allocate mixer, {}'.format(repr(e))) - self._mixer = mixer - return mixer + # Instantiate the HAL emulator + self.platform = self.config_core['enclosure'].get('platform', 'unknown') + if self.platform in ALSA_PLATFORMS: + self.HAL = HALFactory.create('ALSA', self.settings) + else: + self.HAL = None def initialize(self): # Register handlers to detect percentages as reported by STT for i in range(101): # numbers 0 to 100 self.register_vocabulary(str(i) + '%', 'Percent') - # Register handlers for messagebus events - self.add_event('mycroft.volume.increase', - self.handle_increase_volume) - self.add_event('mycroft.volume.decrease', - self.handle_decrease_volume) - self.add_event('mycroft.volume.mute', - self.handle_mute_volume) - self.add_event('mycroft.volume.unmute', - self.handle_unmute_volume) - self.add_event('recognizer_loop:record_begin', - self.duck) - self.add_event('recognizer_loop:record_end', - self.unduck) - - self.vol_before_mute = self.__get_system_volume() - - @property - def mixer(self): - platform = self.config_core['enclosure'].get('platform', 'unknown') - if platform in ALSA_PLATFORMS: - return self._mixer or self._get_mixer() - else: - return None - - def _setvolume(self, vol, emit=True): - # Update ALSA - if self.mixer: - self.log.debug(vol) - self.mixer.setvolume(vol) - # TODO: Remove this and control volume at the Enclosure level in - # response to the mycroft.volume.set message. - - if emit: - # Notify non-ALSA systems of volume change - self.bus.emit(Message('mycroft.volume.set', - data={"percent": vol/100.0})) + def _set_volume(self, vol, emit=True): + self.bus.emit(Message('mycroft.volume.set', + data={"percent": vol/100.0})) # Change Volume to X (Number 0 to) Intent Handlers @intent_handler(IntentBuilder("SetVolume").require("Volume") .optionally("Increase").optionally("Decrease") .optionally("To").require("Level")) def handle_set_volume(self, message): - self._clear_mixer() default_vol = self.__get_system_volume(50) level = self.__get_volume_level(message, default_vol) - self._setvolume(self.__level_to_volume(level)) + self._set_volume(self.__level_to_volume(level)) if level == self.MAX_LEVEL: self.speak_dialog('max.volume') else: @@ -157,17 +90,15 @@ def handle_set_volume(self, message): .optionally("Increase").optionally("Decrease") .optionally("To").require("Percent")) def handle_set_volume_percent(self, message): - self._clear_mixer() percent = extract_number(message.data['utterance'].replace('%', '')) percent = int(percent) - self._setvolume(percent) + self._set_volume(percent) self.speak_dialog('set.volume.percent', data={'level': percent}) # Volume Status Intent Handlers @intent_handler(IntentBuilder("QueryVolume").optionally("Query") .require("Volume")) def handle_query_volume(self, message): - self._clear_mixer() level = self.__volume_to_level(self.__get_system_volume(0, show=True)) self.speak_dialog('volume.is', data={'volume': round(level)}) @@ -195,21 +126,24 @@ def handle_increase_volume(self, message): @intent_handler(IntentBuilder("IncreaseVolumeSet").require("Set") .optionally("Volume").require("Increase")) def handle_increase_volume_set(self, message): - self._clear_mixer() self.handle_increase_volume(message) @intent_handler(IntentBuilder("IncreaseVolumePhrase") .require("IncreasePhrase")) def handle_increase_volume_phrase(self, message): - self._clear_mixer() self.handle_increase_volume(message) # Decrease Volume Intent Handlers @intent_handler(IntentBuilder("DecreaseVolume").require("Volume") .require("Decrease")) def handle_decrease_volume(self, message): - self.__communicate_volume_change(message, 'decrease.volume', - *self.__update_volume(-1)) + self.log.info("DECREASING VOLUME") + response = self.bus.wait_for_response(Message('mycroft.volume.decrease')) + self.log.error(response) + if response and response.data['success']: + self.speak_dialog('decrease.volume', response.data) + # self.__communicate_volume_change(message, 'decrease.volume', + # *self.__update_volume(-1)) @intent_handler(IntentBuilder("DecreaseVolumeSet").require("Set") .optionally("Volume").require("Decrease")) @@ -226,8 +160,7 @@ def handle_decrease_volume_phrase(self, message): .require("Volume").optionally("Increase") .require("MaxVolume")) def handle_max_volume(self, message): - self._clear_mixer() - self._setvolume(self.settings["max_volume"]) + self._set_volume(self.settings["max_volume"]) speak_message = message.data.get('speak_message', True) if speak_message: self.speak_dialog('max.volume') @@ -241,42 +174,27 @@ def handle_max_volume_increase_to_max(self, message): self.handle_max_volume(message) def duck(self, message): - self._clear_mixer() if self.settings.get('ducking', True): self._mute_volume() def unduck(self, message): - self._clear_mixer() if self.settings.get('ducking', True): self._unmute_volume() def _mute_volume(self, message=None, speak=False): - self.log.debug('MUTING!') - self.vol_before_mute = self.__get_system_volume() - self.log.debug(self.vol_before_mute) if speak: self.speak_dialog('mute.volume') wait_while_speaking() - self._setvolume(0, emit=False) - self.bus.emit(Message('mycroft.volume.duck')) + self.bus.emit(Message('mycroft.volume.mute')) # Mute Volume Intent Handlers @intent_handler(IntentBuilder("MuteVolume").require( "Volume").require("Mute")) def handle_mute_volume(self, message): - self._clear_mixer() self._mute_volume(speak=message.data.get('speak_message', True)) def _unmute_volume(self, message=None, speak=False): - if self.vol_before_mute is None: - vol = self.__level_to_volume(self.settings["default_level"]) - else: - vol = self.vol_before_mute - self.vol_before_mute = None - - self._setvolume(vol, emit=False) - self.bus.emit(Message('mycroft.volume.unduck')) - + self.bus.emit(Message('mycroft.volume.unmute')) if speak: self.speak_dialog('reset.volume', data={'volume': @@ -286,7 +204,6 @@ def _unmute_volume(self, message=None, speak=False): @intent_handler(IntentBuilder("UnmuteVolume").require("Volume") .require("Unmute")) def handle_unmute_volume(self, message): - self._clear_mixer() self._unmute_volume(speak=message.data.get('speak_message', True)) def __volume_to_level(self, volume): @@ -345,25 +262,20 @@ def __update_volume(self, change=0): old_level = self.__volume_to_level(self.__get_system_volume(0)) new_level = self.__bound_level(old_level + change) self.enclosure.eyes_volume(new_level) - self._setvolume(self.__level_to_volume(new_level)) + self._set_volume(self.__level_to_volume(new_level)) return new_level, new_level != old_level def __get_system_volume(self, default=50, show=False): - """ Get volume, either from mixer or ask on messagebus. + """Get volume from message bus. The show parameter should only be True when a user is requesting the volume and not the system. - TODO: Remove usage of Mixer and move that stuff to enclosure. """ vol = default - if self.mixer: - vol = min(self.mixer.getvolume()[0], 100) - self.log.debug('Volume before mute: {}'.format(vol)) - else: - vol_msg = self.bus.wait_for_response( - Message("mycroft.volume.get", {'show': show})) - if vol_msg: - vol = int(vol_msg.data["percent"] * 100) + vol_msg = self.bus.wait_for_response( + Message("mycroft.volume.get", {'show': show})) + if vol_msg: + vol = int(vol_msg.data["percent"] * 100) return vol @@ -390,10 +302,6 @@ def __get_volume_level(self, message, default=None): level = self.__bound_level(level) return level - def shutdown(self): - if self.vol_before_mute is not None: - self._unmute_volume() - def create_skill(): return VolumeSkill() diff --git a/hal/__init__.py b/hal/__init__.py new file mode 100644 index 0000000..7f2e780 --- /dev/null +++ b/hal/__init__.py @@ -0,0 +1,24 @@ +# Copyright 2021 Mycroft AI Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""This is a temporary module to emulate the Hardware Abstraction Layer (HAL). + +It is intended to make a simpler transition from the current Volume Skill that +directly interfaces with ALSA, to one that communicates changes to the HAL via +the message bus. + +This module will be deprecated at the earliest possible moment. +""" +from .alsa import get_alsa_mixer +from .hal import HALFactory \ No newline at end of file diff --git a/hal/alsa.py b/hal/alsa.py new file mode 100644 index 0000000..ce750c7 --- /dev/null +++ b/hal/alsa.py @@ -0,0 +1,81 @@ +# Copyright 2021 Mycroft AI Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Provides access to the ALSA audio system. + +DEPRECATION NOTICE: +This will shortly be moved out of the Volume Skill to the Hardware Abstraction +Layer (HAL). The Volume Skill will be emitting messages to the bus, and the +HAL will be responsible for managing the system state. +""" + +from alsaaudio import Mixer, mixers as alsa_mixers + +from mycroft.util import LOG + +from .base import HAL + +class AlsaHAL(HAL): + """Emulate the Hardware Abstraction Layer (HAL) for ALSA. + + This class will be deprecated at the earliest possible moment. + """ + def __init__(self, settings): + super().__init__(settings) + self._mixer = get_alsa_mixer() + + def _get_volume(self, message=None): + """Get the current volume level.""" + volume = min(self._mixer.getvolume()[0], self.max_volume) + LOG.debug('Current volume: {}'.format(volume)) + return volume + + def _set_volume(self, message): + """Set the volume level.""" + target_volume = int(message.data['percent'] * 100) + settable_volume = self.constrain_volume(target_volume) + LOG.debug("Setting volume to: {}".format(settable_volume)) + self._mixer.setvolume(settable_volume) + success_data = {'success': True, 'volume': settable_volume} + self.bus.emit(message.reply('mycroft.volume.updated', + data=success_data)) + return success_data + +def get_alsa_mixer(): + LOG.debug('Finding Alsa Mixer for control...') + mixer = None + try: + # If there are only 1 mixer use that one + mixers = alsa_mixers() + if len(mixers) == 1: + mixer = Mixer(mixers[0]) + elif 'Master' in mixers: + # Try using the default mixer (Master) + mixer = Mixer('Master') + elif 'PCM' in mixers: + # PCM is another common one + mixer = Mixer('PCM') + elif 'Digital' in mixers: + # My mixer is called 'Digital' (JustBoom DAC) + mixer = Mixer('Digital') + else: + # should be equivalent to 'Master' + mixer = Mixer() + except Exception: + # Retry instanciating the mixer with the built-in default + try: + mixer = Mixer() + except Exception as e: + LOG.error('Couldn\'t allocate mixer, {}'.format(repr(e))) + return mixer \ No newline at end of file diff --git a/hal/base.py b/hal/base.py new file mode 100644 index 0000000..857d7a9 --- /dev/null +++ b/hal/base.py @@ -0,0 +1,126 @@ +# Copyright 2021 Mycroft AI Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Provides access to the ALSA audio system. + +DEPRECATION NOTICE: +This will shortly be moved out of the Volume Skill to the Hardware Abstraction +Layer (HAL). The Volume Skill will be emitting messages to the bus, and the +HAL will be responsible for managing the system state. +""" + +from mycroft.messagebus.client import MessageBusClient +from mycroft.messagebus.message import Message +from mycroft.util import LOG, start_message_bus_client + +class HAL(): + """Emulate the Hardware Abstraction Layer (HAL) for audio. + + This class will be deprecated at the earliest possible moment. + + Terminology: + "Level" = Mycroft volume levels, from 0 to 10 + "Volume" = ALSA mixer setting, from 0 to 100 + """ + def __init__(self, settings): + self.default_volume = settings.get('default_volume', 60) + self.min_volume = settings.get('min_volume', 0) + self.max_volume = settings.get('max_volume', 83) + self.volume_step = (self.max_volume - self.min_volume) / 10 + self.vol_before_mute = None + + self.bus = MessageBusClient() + self.register_volume_control_handlers() + start_message_bus_client('AUDIO_HAL', self.bus) + + def register_volume_control_handlers(self): + self.bus.on('mycroft.volume.get', self._get_volume) + self.bus.on('mycroft.volume.set', self._set_volume) + self.bus.on('mycroft.volume.increase', self._increase_volume) + self.bus.on('mycroft.volume.decrease', self._decrease_volume) + self.bus.on('mycroft.volume.mute', self._mute_volume) + self.bus.on('mycroft.volume.unmute', self._unmute_volume) + self.bus.on('recognizer_loop:record_begin', self._duck) + self.bus.on('recognizer_loop:record_end', self._unduck) + + def _get_volume(self, message): + """Get the current volume level.""" + pass + + def _set_volume(self, message): + """Set the volume level.""" + pass + + def _show_volume(self): + """Perform any hardware based display of volume level.""" + pass + + def _set_default_volume(self, message): + self.default_volume = message.data['default_volume'] + + def _decrease_volume(self, message): + """Decrease the volume by 10%.""" + current_volume = self._get_volume() + target_volume = current_volume - self.volume_step + request_data = {'percent': target_volume / 100} + response = self._set_volume(Message('mycroft.volume.set', data=request_data)) + if response['success']: + self.bus.emit(message.reply('mycroft.volume.decrease.response', + data=response)) + + def _increase_volume(self, message): + """Increase the volume by 10%.""" + current_volume = self._get_volume() + target_volume = current_volume + self.volume_step + request_data = {'percent': target_volume / 100} + response = self._set_volume(Message('mycroft.volume.set', data=request_data)) + if response['success']: + self.bus.emit(message.reply('mycroft.volume.increased', data=response)) + + def _mute_volume(self, message): + """Mute the audio output.""" + self.volume_before_mute = self._get_volume() + LOG.debug('Muting. Volume before mute: {}'.format(self.volume_before_mute)) + self._set_volume(Message('mycroft.volume.set', + data={'percentage': 0})) + self.bus.emit(message.reply('mycroft.volume.muted', data={'success': true})) + + def _unmute_volume(self, message): + """Unmute the audio output returning to previous volume level.""" + LOG.debug('Unmuting to volume before mute: {}'.format(self.volume_before_mute)) + request_data = {'percentage': self.volume_before_mute / 100} + response = self.bus.wait_for_response(Message('mycroft.volume.set', + data=request_data)) + if response and response.data['success']: + self.bus.emit(message.reply('mycroft.volume.unmuted', + data=response.data)) + + def _duck(self, message): + """Temporarily duck (significantly lower) audio output.""" + if self.settings.get('ducking', True): + self._mute_volume() + + def _unduck(self, message): + """Restore audio output volume after ducking.""" + pass + + def constrain_volume(self, target_volume): + """Ensure volume is within the min and max values.""" + if target_volume > self.max_volume: + settable_volume = self.max_volume + elif target_volume < self.min_volume: + settable_volume = self.min_volume + else: + settable_volume = target_volume + return settable_volume diff --git a/hal/hal.py b/hal/hal.py new file mode 100644 index 0000000..1ad91c8 --- /dev/null +++ b/hal/hal.py @@ -0,0 +1,32 @@ +# Copyright 2021 Mycroft AI Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from .alsa import AlsaHAL +from .mark1 import Mark1HAL + +class HALFactory: + """Emulate the Hardware Abstraction Layer (HAL) for audio management. + + This class will be deprecated at the earliest possible moment. + """ + CLASSES = { + "ALSA": AlsaHAL, + "Mark_1": Mark1HAL + } + + @staticmethod + def create(hal_type, settings): + hal = HALFactory.CLASSES.get(hal_type) + return hal(settings) diff --git a/hal/mark1.py b/hal/mark1.py new file mode 100644 index 0000000..745af1a --- /dev/null +++ b/hal/mark1.py @@ -0,0 +1,40 @@ +# Copyright 2021 Mycroft AI Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Provides access to the ALSA audio system. + +DEPRECATION NOTICE: +This will shortly be moved out of the Volume Skill to the Hardware Abstraction +Layer (HAL). The Volume Skill will be emitting messages to the bus, and the +HAL will be responsible for managing the system state. +""" + +from mycroft.util import LOG + +from .alsa import AlsaHAL + +class Mark1HAL(AlsaHAL): + """Emulate the Hardware Abstraction Layer (HAL) for the Mark 1. + + This class will be deprecated at the earliest possible moment. + """ + def __init__(self, settings): + super().__init__(settings) + self._enclosure = EnclosureAPI(self.bus, self.__class__.__name__) + + def _show_volume(self, volume): + """Perform any hardware based display of volume level.""" + level = round(volume / 10) # convert % to int(0..10) + self._enclosure.eyes_volume(new_level) + pass