Merge pull request #760 from jodal/feature/mixers

New mixer API
This commit is contained in:
Thomas Adamcik 2014-07-16 22:32:43 +02:00
commit c6d810a049
34 changed files with 508 additions and 460 deletions

View File

@ -21,6 +21,7 @@ API reference
backends
core
audio
mixer
frontends
commands
ext

22
docs/api/mixer.rst Normal file
View File

@ -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 <https://github.com/mopidy/mopidy-alsamixer>`_
- Mopidy-SoftwareMixer (see :mod:`mopidy.softwaremixer` in the source code)

View File

@ -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
<https://github.com/mopidy/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)
====================

View File

@ -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()

View File

@ -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 <https://github.com/mopidy/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.

View File

@ -13,6 +13,7 @@ Mopidy also bundles some extensions:
- :ref:`ext-stream`
- :ref:`ext-http`
- :ref:`ext-mpd`
- :ref:`ext-softwaremixer`
Mopidy-API-Explorer

View File

@ -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.

View File

@ -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
==================

View File

@ -51,6 +51,7 @@ Extensions
ext/stream
ext/http
ext/mpd
ext/softwaremixer
ext/external

View File

@ -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):
"""

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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`.

View File

@ -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.'

View File

@ -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)

View File

@ -7,7 +7,6 @@ config_file =
[audio]
mixer = software
mixer_track =
mixer_volume =
output = autoaudiosink
visualizer =

View File

@ -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):

View File

@ -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)

146
mopidy/mixer.py Normal file
View File

@ -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

View File

@ -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)

View File

@ -0,0 +1,2 @@
[softwaremixer]
enabled = true

View File

@ -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)

View File

@ -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',
],
},

View File

@ -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()

View File

@ -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())

View File

@ -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()

View File

@ -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):

View File

@ -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)

View File

@ -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):

View File

@ -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):

View File

@ -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

26
tests/test_mixer.py Normal file
View File

@ -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)