diff --git a/docs/api/index.rst b/docs/api/index.rst index 444b6ece..5aac825c 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -21,6 +21,7 @@ API reference backends core audio + mixer frontends commands ext diff --git a/docs/api/mixer.rst b/docs/api/mixer.rst new file mode 100644 index 00000000..4697a9d5 --- /dev/null +++ b/docs/api/mixer.rst @@ -0,0 +1,22 @@ +.. _mixer-api: + +*************** +Audio mixer API +*************** + +.. module:: mopidy.mixer + :synopsis: The audio mixer API + +.. autoclass:: mopidy.mixer.Mixer + :members: + +.. autoclass:: mopidy.mixer.MixerListener + :members: + + +Mixer implementations +===================== + +- `Mopidy-ALSAMixer `_ + +- Mopidy-SoftwareMixer (see :mod:`mopidy.softwaremixer` in the source code) diff --git a/docs/changelog.rst b/docs/changelog.rst index c8ce1841..ef23bb6a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,44 @@ Changelog This changelog is used to track all major changes to Mopidy. +v0.20.0 (UNRELEASED) +==================== + +**Audio** + +- Removed support for GStreamer mixers. GStreamer 1.x does not support volume + control, so we changed to use software mixing by default in v0.17.0. Now, + we're removing support for all other GStreamer mixers and are reintroducing + mixers as something extensions can provide independently of GStreamer. + (Fixes: :issue:`665`, PR: :issue:`760`) + +- Changed the :confval:`audio/mixer` config value to refer to Mopidy mixer + extensions instead of GStreamer mixers. The default value, ``software``, + still has the same behavior. All other values will either no longer work or + will at the very least require you to install an additional extension. + +- Changed the :confval:`audio/mixer_volume` config value behavior from + affecting GStreamer mixers to affecting Mopidy mixer extensions instead. The + end result should be the same without any changes to this config value. + +- Deprecated the :confval:`audio/mixer_track` config value. This config value + is no longer in use. Mixer extensions that need additional configuration + handle this themselves. + +**Mixers** + +- Added new :class:`mopidy.mixer.Mixer` API which can be implemented by + extensions. + +- Created a bundled extension, :ref:`ext-softwaremixer`, for controlling volume + in software in GStreamer's pipeline. This is Mopidy's default mixer. To use + this mixer, set :confval:`audio/mixer` to ``software``. + +- Created an external extension, `Mopidy-ALSAMixer + `_, for controlling volume with + hardware through ALSA. To use this mixer, install the extension, and set + :confval:`audio/mixer` to ``alsamixer``. + v0.19.0 (UNRELEASED) ==================== diff --git a/docs/conf.py b/docs/conf.py index 533f3fd5..52e84e06 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -35,8 +35,6 @@ class Mock(object): # glib.get_user_config_dir() return str elif (name[0] == name[0].upper() - # gst.interfaces.MIXER_TRACK_* - and not name.startswith('MIXER_TRACK_') # gst.PadTemplate and not name.startswith('PadTemplate') # dbus.String() diff --git a/docs/config.rst b/docs/config.rst index ffef2ffe..f5f6bd19 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -64,23 +64,15 @@ Audio configuration Audio mixer to use. - Expects a GStreamer mixer to use, typical values are: ``software``, - ``autoaudiomixer``, ``alsamixer``, ``pulsemixer``, ``ossmixer``, and - ``oss4mixer``. - The default is ``software``, which does volume control inside Mopidy before the audio is sent to the audio output. This mixer does not affect the volume of any other audio playback on the system. It is the only mixer that will affect the audio volume if you're streaming the audio from Mopidy through Shoutcast. - If you want to use a hardware mixer, try ``autoaudiomixer``. It attempts to - select a sane hardware mixer for you automatically. When Mopidy is started, - it will log what mixer ``autoaudiomixer`` selected, for example:: - - INFO Audio mixer set to "alsamixer" using track "Master" - - Setting the config value to blank turns off volume control. + If you want to use a hardware mixer, you need to install a Mopidy extension + which integrates with your sound subsystem. E.g. for ALSA, install + `Mopidy-ALSAMixer `_. .. confval:: audio/mixer_volume @@ -91,14 +83,6 @@ Audio configuration Setting the config value to blank leaves the audio mixer volume unchanged. For the software mixer blank means 100. -.. confval:: audio/mixer_track - - Audio mixer track to use. - - Name of the mixer track to use. If this is not set we will try to find the - master output track. As an example, using ``alsamixer`` you would typically - set this to ``Master`` or ``PCM``. - .. confval:: audio/output Audio output to use. diff --git a/docs/ext/external.rst b/docs/ext/external.rst index 82b5a9d2..897d25cf 100644 --- a/docs/ext/external.rst +++ b/docs/ext/external.rst @@ -13,6 +13,7 @@ Mopidy also bundles some extensions: - :ref:`ext-stream` - :ref:`ext-http` - :ref:`ext-mpd` +- :ref:`ext-softwaremixer` Mopidy-API-Explorer diff --git a/docs/ext/softwaremixer.rst b/docs/ext/softwaremixer.rst new file mode 100644 index 00000000..22badde8 --- /dev/null +++ b/docs/ext/softwaremixer.rst @@ -0,0 +1,35 @@ +.. _ext-softwaremixer: + +******************** +Mopidy-SoftwareMixer +******************** + +Mopidy-SoftwareMixer is an extension for controlling audio volume in software +through GStreamer. It is the only mixer bundled with Mopidy and is enabled by +default. + +If you use PulseAudio, the software mixer will control the per-application +volume for Mopidy in PulseAudio, and any changes to the per-application volume +done from outside Mopidy will be reflected by the software mixer. + +If you don't use PulseAudio, the mixer will adjust the volume internally in +Mopidy's GStreamer pipeline. + + +Configuration +============= + +Multiple mixers can be installed and enabled at the same time, but only the +mixer pointed to by the :confval:`audio/mixer` config value will actually be +used. + +See :ref:`config` for general help on configuring Mopidy. + +.. literalinclude:: ../../mopidy/stream/ext.conf + :language: ini + +.. confval:: softwaremixer/enabled + + If the software mixer should be enabled or not. Usually you don't want to + change this, but instead change the :confval:`audio/mixer` config value to + decide which mixer is actually used. diff --git a/docs/extensiondev.rst b/docs/extensiondev.rst index 55731a7d..27bf5e03 100644 --- a/docs/extensiondev.rst +++ b/docs/extensiondev.rst @@ -431,9 +431,6 @@ Basically, you just implement your GStreamer element in Python and then make your :meth:`~mopidy.ext.Extension.setup` method register all your custom GStreamer elements. -For examples of custom GStreamer elements implemented in Python, see -:mod:`mopidy.audio.mixers`. - Python conventions ================== diff --git a/docs/index.rst b/docs/index.rst index b117abc0..3d453741 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -51,6 +51,7 @@ Extensions ext/stream ext/http ext/mpd + ext/softwaremixer ext/external diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 08c634e9..ae6d280d 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -10,7 +10,7 @@ import gst # noqa import pykka -from mopidy.audio import mixers, playlists, utils +from mopidy.audio import playlists, utils from mopidy.audio.constants import PlaybackState from mopidy.audio.listener import AudioListener from mopidy.utils import process @@ -18,8 +18,6 @@ from mopidy.utils import process logger = logging.getLogger(__name__) -mixers.register_mixers() - playlists.register_typefinders() playlists.register_elements() @@ -52,20 +50,15 @@ class Audio(pykka.ThreadingActor): state = PlaybackState.STOPPED _target_state = gst.STATE_NULL - def __init__(self, config): + def __init__(self, config, mixer): super(Audio, self).__init__() self._config = config + self._mixer = mixer self._playbin = None self._signal_ids = {} # {(element, event): signal_id} - self._mixer = None - self._mixer_track = None - self._mixer_scale = None - self._software_mixing = False - self._volume_set = None - self._appsrc = None self._appsrc_caps = None self._appsrc_need_data_callback = None @@ -76,8 +69,8 @@ class Audio(pykka.ThreadingActor): try: self._setup_playbin() self._setup_output() - self._setup_visualizer() self._setup_mixer() + self._setup_visualizer() self._setup_message_processor() except gobject.GError as ex: logger.exception(ex) @@ -190,6 +183,23 @@ class Audio(pykka.ThreadingActor): 'Failed to create audio output "%s": %s', output_desc, ex) process.exit_process() + def _setup_mixer(self): + if self._config['audio']['mixer'] != 'software': + return + self._mixer.audio = self.actor_ref.proxy() + self._connect(self._playbin, 'notify::volume', self._on_mixer_change) + self._connect(self._playbin, 'notify::mute', self._on_mixer_change) + + def _on_mixer_change(self, element, gparamspec): + self._mixer.trigger_events_for_changed_values() + + def _teardown_mixer(self): + if self._config['audio']['mixer'] != 'software': + return + self._disconnect(self._playbin, 'notify::volume') + self._disconnect(self._playbin, 'notify::mute') + self._mixer.audio = None + def _setup_visualizer(self): visualizer_element = self._config['audio']['visualizer'] if not visualizer_element: @@ -204,86 +214,6 @@ class Audio(pykka.ThreadingActor): 'Failed to create audio visualizer "%s": %s', visualizer_element, ex) - def _setup_mixer(self): - mixer_desc = self._config['audio']['mixer'] - track_desc = self._config['audio']['mixer_track'] - volume = self._config['audio']['mixer_volume'] - - if mixer_desc is None: - logger.info('Not setting up audio mixer') - return - - if mixer_desc == 'software': - self._software_mixing = True - logger.info('Audio mixer is using software mixing') - if volume is not None: - self.set_volume(volume) - logger.info('Audio mixer volume set to %d', volume) - return - - try: - mixerbin = gst.parse_bin_from_description( - mixer_desc, ghost_unconnected_pads=False) - except gobject.GError as ex: - logger.warning( - 'Failed to create audio mixer "%s": %s', mixer_desc, ex) - return - - # We assume that the bin will contain a single mixer. - mixer = mixerbin.get_by_interface(b'GstMixer') - if not mixer: - logger.warning( - 'Did not find any audio mixers in "%s"', mixer_desc) - return - - if mixerbin.set_state(gst.STATE_READY) != gst.STATE_CHANGE_SUCCESS: - logger.warning( - 'Setting audio mixer "%s" to READY failed', mixer_desc) - return - - track = self._select_mixer_track(mixer, track_desc) - if not track: - logger.warning('Could not find usable audio mixer track') - return - - self._mixer = mixer - self._mixer_track = track - self._mixer_scale = ( - self._mixer_track.min_volume, self._mixer_track.max_volume) - - logger.info( - 'Audio mixer set to "%s" using track "%s"', - str(mixer.get_factory().get_name()).decode('utf-8'), - str(track.label).decode('utf-8')) - - if volume is not None: - self.set_volume(volume) - logger.info('Audio mixer volume set to %d', volume) - - def _select_mixer_track(self, mixer, track_label): - # Ignore tracks without volumes, then look for track with - # label equal to the audio/mixer_track config value, otherwise fallback - # to first usable track hoping the mixer gave them to us in a sensible - # order. - - usable_tracks = [] - for track in mixer.list_tracks(): - if not mixer.get_volume(track): - continue - - if track_label and track.label == track_label: - return track - elif track.flags & (gst.interfaces.MIXER_TRACK_MASTER | - gst.interfaces.MIXER_TRACK_OUTPUT): - usable_tracks.append(track) - - if usable_tracks: - return usable_tracks[0] - - def _teardown_mixer(self): - if self._mixer is not None: - self._mixer.set_state(gst.STATE_NULL) - def _setup_message_processor(self): bus = self._playbin.get_bus() bus.add_signal_watch() @@ -514,108 +444,49 @@ class Audio(pykka.ThreadingActor): def get_volume(self): """ - Get volume level of the installed mixer. + Get volume level of the software mixer. Example values: 0: - Muted. + Minimum volume. 100: - Max volume for given system. - :class:`None`: - No mixer present, so the volume is unknown. + Maximum volume. - :rtype: int in range [0..100] or :class:`None` + :rtype: int in range [0..100] """ - if self._software_mixing: - return int(round(self._playbin.get_property('volume') * 100)) - - if self._mixer is None: - return None - - volumes = self._mixer.get_volume(self._mixer_track) - avg_volume = float(sum(volumes)) / len(volumes) - - internal_scale = (0, 100) - - if self._volume_set is not None: - volume_set_on_mixer_scale = self._rescale( - self._volume_set, old=internal_scale, new=self._mixer_scale) - else: - volume_set_on_mixer_scale = None - - if volume_set_on_mixer_scale == avg_volume: - return self._volume_set - else: - return self._rescale( - avg_volume, old=self._mixer_scale, new=internal_scale) + return int(round(self._playbin.get_property('volume') * 100)) def set_volume(self, volume): """ - Set volume level of the installed mixer. + Set volume level of the software mixer. :param volume: the volume in the range [0..100] :type volume: int :rtype: :class:`True` if successful, else :class:`False` """ - if self._software_mixing: - self._playbin.set_property('volume', volume / 100.0) - return True - - if self._mixer is None: - return False - - self._volume_set = volume - - internal_scale = (0, 100) - - volume = self._rescale( - volume, old=internal_scale, new=self._mixer_scale) - - volumes = (volume,) * self._mixer_track.num_channels - self._mixer.set_volume(self._mixer_track, volumes) - - return self._mixer.get_volume(self._mixer_track) == volumes - - def _rescale(self, value, old=None, new=None): - """Convert value between scales.""" - new_min, new_max = new - old_min, old_max = old - if old_min == old_max: - return old_max - scaling = float(new_max - new_min) / (old_max - old_min) - return int(round(scaling * (value - old_min) + new_min)) + self._playbin.set_property('volume', volume / 100.0) + return True def get_mute(self): """ - Get mute status of the installed mixer. + Get mute status of the software mixer. :rtype: :class:`True` if muted, :class:`False` if unmuted, :class:`None` if no mixer is installed. """ - if self._software_mixing: - return self._playbin.get_property('mute') - - if self._mixer_track is None: - return None - - return bool(self._mixer_track.flags & gst.interfaces.MIXER_TRACK_MUTE) + return self._playbin.get_property('mute') def set_mute(self, mute): """ - Mute or unmute of the installed mixer. + Mute or unmute of the software mixer. - :param mute: Wether to mute the mixer or not. + :param mute: Whether to mute the mixer or not. :type mute: bool :rtype: :class:`True` if successful, else :class:`False` """ - if self._software_mixing: - return self._playbin.set_property('mute', bool(mute)) - - if self._mixer_track is None: - return False - - return self._mixer.set_mute(self._mixer_track, bool(mute)) + self._playbin.set_property('mute', bool(mute)) + return True def set_metadata(self, track): """ diff --git a/mopidy/audio/mixers/__init__.py b/mopidy/audio/mixers/__init__.py deleted file mode 100644 index cf763de3..00000000 --- a/mopidy/audio/mixers/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -from __future__ import unicode_literals - -import gobject - -import pygst -pygst.require('0.10') -import gst # noqa - -from mopidy.audio.mixers.auto import AutoAudioMixer -from mopidy.audio.mixers.fake import FakeMixer - - -def register_mixer(mixer_class): - gobject.type_register(mixer_class) - gst.element_register( - mixer_class, mixer_class.__name__.lower(), gst.RANK_MARGINAL) - - -def register_mixers(): - register_mixer(AutoAudioMixer) - register_mixer(FakeMixer) diff --git a/mopidy/audio/mixers/auto.py b/mopidy/audio/mixers/auto.py deleted file mode 100644 index 01a16e42..00000000 --- a/mopidy/audio/mixers/auto.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Mixer element that automatically selects the real mixer to use. - -Set the :confval:`audio/mixer` config value to ``autoaudiomixer`` to use this -mixer. -""" - -from __future__ import unicode_literals - -import logging - -import pygst -pygst.require('0.10') -import gst # noqa - - -logger = logging.getLogger(__name__) - - -# TODO: we might want to add some ranking to the mixers we know about? -class AutoAudioMixer(gst.Bin): - __gstdetails__ = ( - 'AutoAudioMixer', - 'Mixer', - 'Element automatically selects a mixer.', - 'Mopidy') - - def __init__(self): - gst.Bin.__init__(self) - mixer = self._find_mixer() - if mixer: - self.add(mixer) - logger.debug('AutoAudioMixer chose: %s', mixer.get_name()) - else: - logger.debug('AutoAudioMixer did not find any usable mixers') - - def _find_mixer(self): - registry = gst.registry_get_default() - - factories = registry.get_feature_list(gst.TYPE_ELEMENT_FACTORY) - factories.sort(key=lambda f: (-f.get_rank(), f.get_name())) - - for factory in factories: - # Avoid sink/srcs that implement mixing. - if factory.get_klass() != 'Generic/Audio': - continue - # Avoid anything that doesn't implement mixing. - elif not factory.has_interface('GstMixer'): - continue - - if self._test_mixer(factory): - return factory.create() - - return None - - def _test_mixer(self, factory): - element = factory.create() - if not element: - return False - - try: - result = element.set_state(gst.STATE_READY) - if result != gst.STATE_CHANGE_SUCCESS: - return False - - # Trust that the default device is sane and just check tracks. - return self._test_tracks(element) - finally: - element.set_state(gst.STATE_NULL) - - def _test_tracks(self, element): - # Only allow elements that have a least one output track. - flags = gst.interfaces.MIXER_TRACK_OUTPUT - - for track in element.list_tracks(): - if track.flags & flags: - return True - return False diff --git a/mopidy/audio/mixers/fake.py b/mopidy/audio/mixers/fake.py deleted file mode 100644 index 404f6298..00000000 --- a/mopidy/audio/mixers/fake.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Fake mixer for use in tests. - -Set the :confval:`audio/mixer:` config value to ``fakemixer`` to use this -mixer. -""" - -from __future__ import unicode_literals - -import gobject - -import pygst -pygst.require('0.10') -import gst # noqa - -from mopidy.audio.mixers import utils - - -class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): - __gstdetails__ = ( - 'FakeMixer', - 'Mixer', - 'Fake mixer for use in tests.', - 'Mopidy') - - track_label = gobject.property(type=str, default='Master') - track_initial_volume = gobject.property(type=int, default=0) - track_min_volume = gobject.property(type=int, default=0) - track_max_volume = gobject.property(type=int, default=100) - track_num_channels = gobject.property(type=int, default=2) - track_flags = gobject.property(type=int, default=( - gst.interfaces.MIXER_TRACK_MASTER | gst.interfaces.MIXER_TRACK_OUTPUT)) - - def list_tracks(self): - track = utils.create_track( - self.track_label, - self.track_initial_volume, - self.track_min_volume, - self.track_max_volume, - self.track_num_channels, - self.track_flags) - return [track] - - def get_volume(self, track): - return track.volumes - - def set_volume(self, track, volumes): - track.volumes = volumes - - def set_record(self, track, record): - pass diff --git a/mopidy/audio/mixers/utils.py b/mopidy/audio/mixers/utils.py deleted file mode 100644 index b3b08f19..00000000 --- a/mopidy/audio/mixers/utils.py +++ /dev/null @@ -1,38 +0,0 @@ -from __future__ import unicode_literals - -import gobject - -import pygst -pygst.require('0.10') -import gst # noqa - - -def create_track(label, initial_volume, min_volume, max_volume, - num_channels, flags): - - class Track(gst.interfaces.MixerTrack): - def __init__(self): - super(Track, self).__init__() - self.volumes = (initial_volume,) * self.num_channels - - @gobject.property - def label(self): - return label - - @gobject.property - def min_volume(self): - return min_volume - - @gobject.property - def max_volume(self): - return max_volume - - @gobject.property - def num_channels(self): - return num_channels - - @gobject.property - def flags(self): - return flags - - return Track() diff --git a/mopidy/backend/__init__.py b/mopidy/backend/__init__.py index 68aad778..d6474c43 100644 --- a/mopidy/backend/__init__.py +++ b/mopidy/backend/__init__.py @@ -12,6 +12,11 @@ class Backend(object): :exc:`~mopidy.exceptions.BackendError` with a descriptive error message. This will make Mopidy print the error message and exit so that the user can fix the issue. + + :param config: the entire Mopidy configuration + :type config: dict + :param audio: actor proxy for the audio subsystem + :type audio: :class:`pykka.ActorProxy` for :class:`mopidy.audio.Audio` """ #: Actor proxy to an instance of :class:`mopidy.audio.Audio`. diff --git a/mopidy/commands.py b/mopidy/commands.py index 813d3f99..e43f182e 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -261,13 +261,15 @@ class RootCommand(Command): def run(self, args, config): loop = gobject.MainLoop() + mixer_class = self.get_mixer_class(config, args.registry['mixer']) backend_classes = args.registry['backend'] frontend_classes = args.registry['frontend'] try: - audio = self.start_audio(config) + mixer = self.start_mixer(config, mixer_class) + audio = self.start_audio(config, mixer) backends = self.start_backends(config, backend_classes, audio) - core = self.start_core(audio, backends) + core = self.start_core(mixer, backends) self.start_frontends(config, frontend_classes, core) loop.run() except (exceptions.BackendError, @@ -282,11 +284,47 @@ class RootCommand(Command): self.stop_core() self.stop_backends(backend_classes) self.stop_audio() + self.stop_mixer(mixer_class) process.stop_remaining_actors() - def start_audio(self, config): + def get_mixer_class(self, config, mixer_classes): + logger.debug( + 'Available Mopidy mixers: %s', + ', '.join(m.__name__ for m in mixer_classes) or 'none') + + selected_mixers = [ + m for m in mixer_classes if m.name == config['audio']['mixer']] + if len(selected_mixers) != 1: + logger.error( + 'Did not find unique mixer "%s". Alternatives are: %s', + config['audio']['mixer'], + ', '.join([m.name for m in mixer_classes])) + process.exit_process() + return selected_mixers[0] + + def start_mixer(self, config, mixer_class): + try: + logger.info('Starting Mopidy mixer: %s', mixer_class.__name__) + mixer = mixer_class.start(config=config).proxy() + self.configure_mixer(config, mixer) + return mixer + except exceptions.MixerError as exc: + logger.error( + 'Mixer (%s) initialization error: %s', + mixer_class.__name__, exc.message) + raise + + def configure_mixer(self, config, mixer): + volume = config['audio']['mixer_volume'] + if volume is not None: + mixer.set_volume(volume) + logger.info('Mixer volume set to %d', volume) + else: + logger.debug('Mixer volume left unchanged') + + def start_audio(self, config, mixer): logger.info('Starting Mopidy audio') - return Audio.start(config=config).proxy() + return Audio.start(config=config, mixer=mixer).proxy() def start_backends(self, config, backend_classes, audio): logger.info( @@ -307,9 +345,9 @@ class RootCommand(Command): return backends - def start_core(self, audio, backends): + def start_core(self, mixer, backends): logger.info('Starting Mopidy core') - return Core.start(audio=audio, backends=backends).proxy() + return Core.start(mixer=mixer, backends=backends).proxy() def start_frontends(self, config, frontend_classes, core): logger.info( @@ -343,6 +381,10 @@ class RootCommand(Command): logger.info('Stopping Mopidy audio') process.stop_actors_by_class(Audio) + def stop_mixer(self, mixer_class): + logger.info('Stopping Mopidy mixer') + process.stop_actors_by_class(mixer_class) + class ConfigCommand(Command): help = 'Show currently active configuration.' diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index ecec1f89..3b63a1ab 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -25,7 +25,7 @@ _loglevels_schema = LogLevelConfigSchema('loglevels') _audio_schema = ConfigSchema('audio') _audio_schema['mixer'] = String() -_audio_schema['mixer_track'] = String(optional=True) +_audio_schema['mixer_track'] = Deprecated() _audio_schema['mixer_volume'] = Integer(optional=True, minimum=0, maximum=100) _audio_schema['output'] = String() _audio_schema['visualizer'] = String(optional=True) diff --git a/mopidy/config/default.conf b/mopidy/config/default.conf index 37f69ce1..6a900cf9 100644 --- a/mopidy/config/default.conf +++ b/mopidy/config/default.conf @@ -7,7 +7,6 @@ config_file = [audio] mixer = software -mixer_track = mixer_volume = output = autoaudiosink visualizer = diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index a3dba245..8e4408f7 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -5,7 +5,7 @@ import itertools import pykka -from mopidy import audio, backend +from mopidy import audio, backend, mixer from mopidy.audio import PlaybackState from mopidy.core.library import LibraryController from mopidy.core.listener import CoreListener @@ -15,7 +15,10 @@ from mopidy.core.tracklist import TracklistController from mopidy.utils import versioning -class Core(pykka.ThreadingActor, audio.AudioListener, backend.BackendListener): +class Core( + pykka.ThreadingActor, audio.AudioListener, backend.BackendListener, + mixer.MixerListener): + library = None """The library controller. An instance of :class:`mopidy.core.LibraryController`.""" @@ -32,7 +35,7 @@ class Core(pykka.ThreadingActor, audio.AudioListener, backend.BackendListener): """The tracklist controller. An instance of :class:`mopidy.core.TracklistController`.""" - def __init__(self, audio=None, backends=None): + def __init__(self, mixer=None, backends=None): super(Core, self).__init__() self.backends = Backends(backends) @@ -40,7 +43,7 @@ class Core(pykka.ThreadingActor, audio.AudioListener, backend.BackendListener): self.library = LibraryController(backends=self.backends, core=self) self.playback = PlaybackController( - audio=audio, backends=self.backends, core=self) + mixer=mixer, backends=self.backends, core=self) self.playlists = PlaylistsController( backends=self.backends, core=self) @@ -81,6 +84,14 @@ class Core(pykka.ThreadingActor, audio.AudioListener, backend.BackendListener): # Forward event from backend to frontends CoreListener.send('playlists_loaded') + def volume_changed(self, volume): + # Forward event from mixer to frontends + CoreListener.send('volume_changed', volume=volume) + + def mute_changed(self, mute): + # Forward event from mixer to frontends + CoreListener.send('mute_changed', mute=mute) + class Backends(list): def __init__(self, backends): diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 7f4a31f4..097a9401 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -13,8 +13,8 @@ logger = logging.getLogger(__name__) class PlaybackController(object): pykka_traversable = True - def __init__(self, audio, backends, core): - self.audio = audio + def __init__(self, mixer, backends, core): + self.mixer = mixer self.backends = backends self.core = core @@ -88,41 +88,39 @@ class PlaybackController(object): """Time position in milliseconds.""" def get_volume(self): - if self.audio: - return self.audio.get_volume().get() + if self.mixer: + return self.mixer.get_volume().get() else: # For testing return self._volume def set_volume(self, volume): - if self.audio: - self.audio.set_volume(volume) + if self.mixer: + self.mixer.set_volume(volume) else: # For testing self._volume = volume - self._trigger_volume_changed(volume) - volume = property(get_volume, set_volume) - """Volume as int in range [0..100] or :class:`None`""" + """Volume as int in range [0..100] or :class:`None` if unknown. The volume + scale is linear. + """ def get_mute(self): - if self.audio: - return self.audio.get_mute().get() + if self.mixer: + return self.mixer.get_mute().get() else: # For testing return self._mute def set_mute(self, value): value = bool(value) - if self.audio: - self.audio.set_mute(value) + if self.mixer: + self.mixer.set_mute(value) else: # For testing self._mute = value - self._trigger_mute_changed(value) - mute = property(get_mute, set_mute) """Mute state as a :class:`True` if muted, :class:`False` otherwise""" @@ -351,14 +349,6 @@ class PlaybackController(object): 'playback_state_changed', old_state=old_state, new_state=new_state) - def _trigger_volume_changed(self, volume): - logger.debug('Triggering volume changed event') - listener.CoreListener.send('volume_changed', volume=volume) - - def _trigger_mute_changed(self, mute): - logger.debug('Triggering mute changed event') - listener.CoreListener.send('mute_changed', mute=mute) - def _trigger_seeked(self, time_position): logger.debug('Triggering seeked event') listener.CoreListener.send('seeked', time_position=time_position) diff --git a/mopidy/mixer.py b/mopidy/mixer.py new file mode 100644 index 00000000..76c6e92a --- /dev/null +++ b/mopidy/mixer.py @@ -0,0 +1,146 @@ +from __future__ import unicode_literals + +import logging + +from mopidy import listener + + +logger = logging.getLogger(__name__) + + +class Mixer(object): + """ + Audio mixer API + + If the mixer has problems during initialization it should raise + :exc:`~mopidy.exceptions.MixerError` with a descriptive error message. This + will make Mopidy print the error message and exit so that the user can fix + the issue. + + :param config: the entire Mopidy configuration + :type config: dict + """ + + name = None + """ + Name of the mixer. + + Used when configuring what mixer to use. Should match the + :attr:`~mopidy.ext.Extension.ext_name` of the extension providing the + mixer. + """ + + def get_volume(self): + """ + Get volume level of the mixer on a linear scale from 0 to 100. + + Example values: + + 0: + Minimum volume, usually silent. + 100: + Maximum volume. + :class:`None`: + Volume is unknown. + + *MAY be implemented by subclass.* + + :rtype: int in range [0..100] or :class:`None` + """ + return None + + def set_volume(self, volume): + """ + Set volume level of the mixer. + + *MAY be implemented by subclass.* + + :param volume: Volume in the range [0..100] + :type volume: int + :rtype: :class:`True` if success, :class:`False` if failure + """ + return False + + def trigger_volume_changed(self, volume): + """ + Send ``volume_changed`` event to all mixer listeners. + + This method should be called by subclasses when the volume is changed, + either because of a call to :meth:`set_volume` or because of any + external entity changing the volume. + """ + logger.debug('Mixer event: volume_changed(volume=%d)', volume) + MixerListener.send('volume_changed', volume=volume) + + def get_mute(self): + """ + Get mute state of the mixer. + + *MAY be implemented by subclass.* + + :rtype: :class:`True` if muted, :class:`False` if unmuted, + :class:`None` if unknown. + """ + return None + + def set_mute(self, mute): + """ + Mute or unmute the mixer. + + *MAY be implemented by subclass.* + + :param mute: :class:`True` to mute, :class:`False` to unmute + :type mute: bool + :rtype: :class:`True` if success, :class:`False` if failure + """ + return False + + def trigger_mute_changed(self, mute): + """ + Send ``mute_changed`` event to all mixer listeners. + + This method should be called by subclasses when the mute state is + changed, either because of a call to :meth:`set_mute` or because of + any external entity changing the mute state. + """ + logger.debug('Mixer event: mute_changed(mute=%s)', mute) + MixerListener.send('mute_changed', mute=mute) + + +class MixerListener(listener.Listener): + """ + Marker interface for recipients of events sent by the mixer actor. + + Any Pykka actor that mixes in this class will receive calls to the methods + defined here when the corresponding events happen in the mixer actor. This + interface is used both for looking up what actors to notify of the events, + and for providing default implementations for those listeners that are not + interested in all events. + """ + + @staticmethod + def send(event, **kwargs): + """Helper to allow calling of mixer listener events""" + listener.send_async(MixerListener, event, **kwargs) + + def volume_changed(self, volume): + """ + Called after the volume has changed. + + *MAY* be implemented by actor. + + :param volume: the new volume + :type volume: int in range [0..100] + """ + pass + + def mute_changed(self, mute): + """ + Called after the mute state has changed. + + *MAY* be implemented by actor. + + :param mute: :class:`True` if muted, :class:`False` if not muted + :type mute: bool + """ + pass diff --git a/mopidy/softwaremixer/__init__.py b/mopidy/softwaremixer/__init__.py new file mode 100644 index 00000000..242069eb --- /dev/null +++ b/mopidy/softwaremixer/__init__.py @@ -0,0 +1,25 @@ +from __future__ import unicode_literals + +import os + +import mopidy +from mopidy import config, ext + + +class Extension(ext.Extension): + + dist_name = 'Mopidy-SoftwareMixer' + ext_name = 'softwaremixer' + version = mopidy.__version__ + + def get_default_config(self): + conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') + return config.read(conf_file) + + def get_config_schema(self): + schema = super(Extension, self).get_config_schema() + return schema + + def setup(self, registry): + from .mixer import SoftwareMixer + registry.add('mixer', SoftwareMixer) diff --git a/mopidy/softwaremixer/ext.conf b/mopidy/softwaremixer/ext.conf new file mode 100644 index 00000000..47a98ba7 --- /dev/null +++ b/mopidy/softwaremixer/ext.conf @@ -0,0 +1,2 @@ +[softwaremixer] +enabled = true diff --git a/mopidy/softwaremixer/mixer.py b/mopidy/softwaremixer/mixer.py new file mode 100644 index 00000000..0ebbfeb7 --- /dev/null +++ b/mopidy/softwaremixer/mixer.py @@ -0,0 +1,56 @@ +from __future__ import unicode_literals + +import logging + +import pykka + +from mopidy import mixer + + +logger = logging.getLogger(__name__) + + +class SoftwareMixer(pykka.ThreadingActor, mixer.Mixer): + + name = 'software' + + def __init__(self, config): + super(SoftwareMixer, self).__init__(config) + + self.audio = None + self._last_volume = None + self._last_mute = None + + logger.info('Mixing using GStreamer software mixing') + + def get_volume(self): + if self.audio is None: + return None + return self.audio.get_volume().get() + + def set_volume(self, volume): + if self.audio is None: + return False + self.audio.set_volume(volume) + return True + + def get_mute(self): + if self.audio is None: + return None + return self.audio.get_mute().get() + + def set_mute(self, mute): + if self.audio is None: + return False + self.audio.set_mute(mute) + return True + + def trigger_events_for_changed_values(self): + old_volume, self._last_volume = self._last_volume, self.get_volume() + old_mute, self._last_mute = self._last_mute, self.get_mute() + + if old_volume != self._last_volume: + self.trigger_volume_changed(self._last_volume) + + if old_mute != self._last_mute: + self.trigger_mute_changed(self._last_mute) diff --git a/setup.py b/setup.py index 437fe121..3f69591d 100644 --- a/setup.py +++ b/setup.py @@ -42,6 +42,7 @@ setup( 'http = mopidy.http:Extension', 'local = mopidy.local:Extension', 'mpd = mopidy.mpd:Extension', + 'softwaremixer = mopidy.softwaremixer:Extension', 'stream = mopidy.stream:Extension', ], }, diff --git a/tests/audio/test_actor.py b/tests/audio/test_actor.py index 1df4ff18..869d8ac6 100644 --- a/tests/audio/test_actor.py +++ b/tests/audio/test_actor.py @@ -23,8 +23,7 @@ class AudioTest(unittest.TestCase): def setUp(self): config = { 'audio': { - 'mixer': 'fakemixer track_max_volume=65536', - 'mixer_track': None, + 'mixer': 'foomixer', 'mixer_volume': None, 'output': 'fakesink', 'visualizer': None, @@ -34,7 +33,7 @@ class AudioTest(unittest.TestCase): }, } self.song_uri = path_to_uri(path_to_data_dir('song1.wav')) - self.audio = audio.Audio.start(config=config).proxy() + self.audio = audio.Audio.start(config=config, mixer=None).proxy() def tearDown(self): pykka.ActorRegistry.stop_all() @@ -74,38 +73,11 @@ class AudioTest(unittest.TestCase): self.assertTrue(self.audio.set_volume(value).get()) self.assertEqual(value, self.audio.get_volume().get()) - def test_set_volume_with_mixer_max_below_100(self): - config = { - 'audio': { - 'mixer': 'fakemixer track_max_volume=40', - 'mixer_track': None, - 'mixer_volume': None, - 'output': 'fakesink', - 'visualizer': None, - } - } - self.audio = audio.Audio.start(config=config).proxy() - - for value in range(0, 101): - self.assertTrue(self.audio.set_volume(value).get()) - self.assertEqual(value, self.audio.get_volume().get()) - - def test_set_volume_with_mixer_min_equal_max(self): - config = { - 'audio': { - 'mixer': 'fakemixer track_max_volume=0', - 'mixer_track': None, - 'mixer_volume': None, - 'output': 'fakesink', - 'visualizer': None, - } - } - self.audio = audio.Audio.start(config=config).proxy() - self.assertEqual(0, self.audio.get_volume().get()) - @unittest.SkipTest def test_set_mute(self): - pass # TODO Probably needs a fakemixer with a mixer track + for value in (True, False): + self.assertTrue(self.audio.set_mute(value).get()) + self.assertEqual(value, self.audio.get_mute().get()) @unittest.SkipTest def test_set_state_encapsulation(self): @@ -122,7 +94,7 @@ class AudioTest(unittest.TestCase): class AudioStateTest(unittest.TestCase): def setUp(self): - self.audio = audio.Audio(config=None) + self.audio = audio.Audio(config=None, mixer=None) def test_state_starts_as_stopped(self): self.assertEqual(audio.PlaybackState.STOPPED, self.audio.state) @@ -167,7 +139,7 @@ class AudioStateTest(unittest.TestCase): class AudioBufferingTest(unittest.TestCase): def setUp(self): - self.audio = audio.Audio(config=None) + self.audio = audio.Audio(config=None, mixer=None) self.audio._playbin = mock.Mock(spec=['set_state']) self.buffer_full_message = mock.Mock() diff --git a/tests/core/test_actor.py b/tests/core/test_actor.py index 47ac0168..79d778af 100644 --- a/tests/core/test_actor.py +++ b/tests/core/test_actor.py @@ -20,7 +20,7 @@ class CoreActorTest(unittest.TestCase): self.backend2.uri_schemes.get.return_value = ['dummy2'] self.backend2.actor_ref.actor_class.__name__ = b'B2' - self.core = Core(audio=None, backends=[self.backend1, self.backend2]) + self.core = Core(mixer=None, backends=[self.backend1, self.backend2]) def tearDown(self): pykka.ActorRegistry.stop_all() @@ -37,7 +37,7 @@ class CoreActorTest(unittest.TestCase): self.assertRaisesRegexp( AssertionError, 'Cannot add URI scheme dummy1 for B2, it is already handled by B1', - Core, audio=None, backends=[self.backend1, self.backend2]) + Core, mixer=None, backends=[self.backend1, self.backend2]) def test_version(self): self.assertEqual(self.core.version, versioning.get_version()) diff --git a/tests/core/test_events.py b/tests/core/test_events.py index 87abd9f9..ab7906a8 100644 --- a/tests/core/test_events.py +++ b/tests/core/test_events.py @@ -20,11 +20,23 @@ class BackendEventsTest(unittest.TestCase): def tearDown(self): pykka.ActorRegistry.stop_all() - def test_backends_playlists_loaded_forwards_event_to_frontends(self, send): + def test_forwards_backend_playlists_loaded_event_to_frontends(self, send): self.core.playlists_loaded().get() self.assertEqual(send.call_args[0][0], 'playlists_loaded') + def test_forwards_mixer_volume_changed_event_to_frontends(self, send): + self.core.volume_changed(volume=60).get() + + self.assertEqual(send.call_args[0][0], 'volume_changed') + self.assertEqual(send.call_args[1]['volume'], 60) + + def test_forwards_mixer_mute_changed_event_to_frontends(self, send): + self.core.mute_changed(mute=True).get() + + self.assertEqual(send.call_args[0][0], 'mute_changed') + self.assertEqual(send.call_args[1]['mute'], True) + def test_tracklist_add_sends_tracklist_changed_event(self, send): send.reset_mock() diff --git a/tests/core/test_library.py b/tests/core/test_library.py index 1db22688..9eac3ebd 100644 --- a/tests/core/test_library.py +++ b/tests/core/test_library.py @@ -30,7 +30,7 @@ class CoreLibraryTest(unittest.TestCase): self.backend3.has_library().get.return_value = False self.backend3.has_library_browse().get.return_value = False - self.core = core.Core(audio=None, backends=[ + self.core = core.Core(mixer=None, backends=[ self.backend1, self.backend2, self.backend3]) def test_browse_root_returns_dir_ref_for_each_lib_with_root_dir_name(self): diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 7a97b3d7..ce6c8571 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -36,7 +36,7 @@ class CorePlaybackTest(unittest.TestCase): Track(uri='dummy1:b', length=40000), ] - self.core = core.Core(audio=None, backends=[ + self.core = core.Core(mixer=None, backends=[ self.backend1, self.backend2, self.backend3]) self.core.tracklist.add(self.tracks) @@ -376,17 +376,16 @@ class CorePlaybackTest(unittest.TestCase): # TODO Test on_tracklist_change - # TODO Test volume + def test_volume(self): + self.assertEqual(self.core.playback.volume, None) - @mock.patch( - 'mopidy.core.playback.listener.CoreListener', spec=core.CoreListener) - def test_set_volume_emits_volume_changed_event(self, listener_mock): - self.core.playback.set_volume(10) - listener_mock.reset_mock() + self.core.playback.volume = 30 - self.core.playback.set_volume(20) + self.assertEqual(self.core.playback.volume, 30) - listener_mock.send.assert_called_once_with('volume_changed', volume=20) + self.core.playback.volume = 70 + + self.assertEqual(self.core.playback.volume, 70) def test_mute(self): self.assertEqual(self.core.playback.mute, False) diff --git a/tests/core/test_playlists.py b/tests/core/test_playlists.py index 73b4f486..49f617b5 100644 --- a/tests/core/test_playlists.py +++ b/tests/core/test_playlists.py @@ -34,7 +34,7 @@ class PlaylistsTest(unittest.TestCase): self.pl2b = Playlist(name='B', tracks=[Track(uri='dummy2:b')]) self.sp2.playlists.get.return_value = [self.pl2a, self.pl2b] - self.core = core.Core(audio=None, backends=[ + self.core = core.Core(mixer=None, backends=[ self.backend3, self.backend1, self.backend2]) def test_get_playlists_combines_result_from_backends(self): diff --git a/tests/core/test_tracklist.py b/tests/core/test_tracklist.py index 9f4dc9c0..963a4bb7 100644 --- a/tests/core/test_tracklist.py +++ b/tests/core/test_tracklist.py @@ -21,7 +21,7 @@ class TracklistTest(unittest.TestCase): self.library = mock.Mock(spec=backend.LibraryProvider) self.backend.library = self.library - self.core = core.Core(audio=None, backends=[self.backend]) + self.core = core.Core(mixer=None, backends=[self.backend]) self.tl_tracks = self.core.tracklist.add(self.tracks) def test_add_by_uri_looks_up_uri_in_library(self): diff --git a/tests/local/test_tracklist.py b/tests/local/test_tracklist.py index 7717f1a5..af07a4e6 100644 --- a/tests/local/test_tracklist.py +++ b/tests/local/test_tracklist.py @@ -30,7 +30,7 @@ class LocalTracklistProviderTest(unittest.TestCase): self.audio = audio.DummyAudio.start().proxy() self.backend = actor.LocalBackend.start( config=self.config, audio=self.audio).proxy() - self.core = core.Core(audio=self.audio, backends=[self.backend]) + self.core = core.Core(mixer=None, backends=[self.backend]) self.controller = self.core.tracklist self.playback = self.core.playback diff --git a/tests/test_mixer.py b/tests/test_mixer.py new file mode 100644 index 00000000..53c10292 --- /dev/null +++ b/tests/test_mixer.py @@ -0,0 +1,26 @@ +from __future__ import unicode_literals + +import unittest + +import mock + +from mopidy import mixer + + +class MixerListenerTest(unittest.TestCase): + def setUp(self): + self.listener = mixer.MixerListener() + + def test_on_event_forwards_to_specific_handler(self): + self.listener.volume_changed = mock.Mock() + + self.listener.on_event( + 'volume_changed', volume=60) + + self.listener.volume_changed.assert_called_with(volume=60) + + def test_listener_has_default_impl_for_volume_changed(self): + self.listener.volume_changed(volume=60) + + def test_listener_has_default_impl_for_mute_changed(self): + self.listener.mute_changed(mute=True)