commit
c6d810a049
@ -21,6 +21,7 @@ API reference
|
||||
backends
|
||||
core
|
||||
audio
|
||||
mixer
|
||||
frontends
|
||||
commands
|
||||
ext
|
||||
|
||||
22
docs/api/mixer.rst
Normal file
22
docs/api/mixer.rst
Normal 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)
|
||||
@ -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)
|
||||
====================
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -13,6 +13,7 @@ Mopidy also bundles some extensions:
|
||||
- :ref:`ext-stream`
|
||||
- :ref:`ext-http`
|
||||
- :ref:`ext-mpd`
|
||||
- :ref:`ext-softwaremixer`
|
||||
|
||||
|
||||
Mopidy-API-Explorer
|
||||
|
||||
35
docs/ext/softwaremixer.rst
Normal file
35
docs/ext/softwaremixer.rst
Normal 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.
|
||||
@ -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
|
||||
==================
|
||||
|
||||
@ -51,6 +51,7 @@ Extensions
|
||||
ext/stream
|
||||
ext/http
|
||||
ext/mpd
|
||||
ext/softwaremixer
|
||||
ext/external
|
||||
|
||||
|
||||
|
||||
@ -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):
|
||||
"""
|
||||
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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()
|
||||
@ -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`.
|
||||
|
||||
@ -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.'
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -7,7 +7,6 @@ config_file =
|
||||
|
||||
[audio]
|
||||
mixer = software
|
||||
mixer_track =
|
||||
mixer_volume =
|
||||
output = autoaudiosink
|
||||
visualizer =
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
146
mopidy/mixer.py
Normal 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
|
||||
25
mopidy/softwaremixer/__init__.py
Normal file
25
mopidy/softwaremixer/__init__.py
Normal 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)
|
||||
2
mopidy/softwaremixer/ext.conf
Normal file
2
mopidy/softwaremixer/ext.conf
Normal file
@ -0,0 +1,2 @@
|
||||
[softwaremixer]
|
||||
enabled = true
|
||||
56
mopidy/softwaremixer/mixer.py
Normal file
56
mopidy/softwaremixer/mixer.py
Normal 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)
|
||||
1
setup.py
1
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',
|
||||
],
|
||||
},
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
26
tests/test_mixer.py
Normal 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)
|
||||
Loading…
Reference in New Issue
Block a user