commit
c6d810a049
@ -21,6 +21,7 @@ API reference
|
|||||||
backends
|
backends
|
||||||
core
|
core
|
||||||
audio
|
audio
|
||||||
|
mixer
|
||||||
frontends
|
frontends
|
||||||
commands
|
commands
|
||||||
ext
|
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.
|
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)
|
v0.19.0 (UNRELEASED)
|
||||||
====================
|
====================
|
||||||
|
|||||||
@ -35,8 +35,6 @@ class Mock(object):
|
|||||||
# glib.get_user_config_dir()
|
# glib.get_user_config_dir()
|
||||||
return str
|
return str
|
||||||
elif (name[0] == name[0].upper()
|
elif (name[0] == name[0].upper()
|
||||||
# gst.interfaces.MIXER_TRACK_*
|
|
||||||
and not name.startswith('MIXER_TRACK_')
|
|
||||||
# gst.PadTemplate
|
# gst.PadTemplate
|
||||||
and not name.startswith('PadTemplate')
|
and not name.startswith('PadTemplate')
|
||||||
# dbus.String()
|
# dbus.String()
|
||||||
|
|||||||
@ -64,23 +64,15 @@ Audio configuration
|
|||||||
|
|
||||||
Audio mixer to use.
|
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 default is ``software``, which does volume control inside Mopidy before
|
||||||
the audio is sent to the audio output. This mixer does not affect the
|
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
|
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
|
will affect the audio volume if you're streaming the audio from Mopidy
|
||||||
through Shoutcast.
|
through Shoutcast.
|
||||||
|
|
||||||
If you want to use a hardware mixer, try ``autoaudiomixer``. It attempts to
|
If you want to use a hardware mixer, you need to install a Mopidy extension
|
||||||
select a sane hardware mixer for you automatically. When Mopidy is started,
|
which integrates with your sound subsystem. E.g. for ALSA, install
|
||||||
it will log what mixer ``autoaudiomixer`` selected, for example::
|
`Mopidy-ALSAMixer <https://github.com/mopidy/mopidy-alsamixer>`_.
|
||||||
|
|
||||||
INFO Audio mixer set to "alsamixer" using track "Master"
|
|
||||||
|
|
||||||
Setting the config value to blank turns off volume control.
|
|
||||||
|
|
||||||
.. confval:: audio/mixer_volume
|
.. confval:: audio/mixer_volume
|
||||||
|
|
||||||
@ -91,14 +83,6 @@ Audio configuration
|
|||||||
Setting the config value to blank leaves the audio mixer volume unchanged.
|
Setting the config value to blank leaves the audio mixer volume unchanged.
|
||||||
For the software mixer blank means 100.
|
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
|
.. confval:: audio/output
|
||||||
|
|
||||||
Audio output to use.
|
Audio output to use.
|
||||||
|
|||||||
@ -13,6 +13,7 @@ Mopidy also bundles some extensions:
|
|||||||
- :ref:`ext-stream`
|
- :ref:`ext-stream`
|
||||||
- :ref:`ext-http`
|
- :ref:`ext-http`
|
||||||
- :ref:`ext-mpd`
|
- :ref:`ext-mpd`
|
||||||
|
- :ref:`ext-softwaremixer`
|
||||||
|
|
||||||
|
|
||||||
Mopidy-API-Explorer
|
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
|
your :meth:`~mopidy.ext.Extension.setup` method register all your custom
|
||||||
GStreamer elements.
|
GStreamer elements.
|
||||||
|
|
||||||
For examples of custom GStreamer elements implemented in Python, see
|
|
||||||
:mod:`mopidy.audio.mixers`.
|
|
||||||
|
|
||||||
|
|
||||||
Python conventions
|
Python conventions
|
||||||
==================
|
==================
|
||||||
|
|||||||
@ -51,6 +51,7 @@ Extensions
|
|||||||
ext/stream
|
ext/stream
|
||||||
ext/http
|
ext/http
|
||||||
ext/mpd
|
ext/mpd
|
||||||
|
ext/softwaremixer
|
||||||
ext/external
|
ext/external
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import gst # noqa
|
|||||||
|
|
||||||
import pykka
|
import pykka
|
||||||
|
|
||||||
from mopidy.audio import mixers, playlists, utils
|
from mopidy.audio import playlists, utils
|
||||||
from mopidy.audio.constants import PlaybackState
|
from mopidy.audio.constants import PlaybackState
|
||||||
from mopidy.audio.listener import AudioListener
|
from mopidy.audio.listener import AudioListener
|
||||||
from mopidy.utils import process
|
from mopidy.utils import process
|
||||||
@ -18,8 +18,6 @@ from mopidy.utils import process
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
mixers.register_mixers()
|
|
||||||
|
|
||||||
playlists.register_typefinders()
|
playlists.register_typefinders()
|
||||||
playlists.register_elements()
|
playlists.register_elements()
|
||||||
|
|
||||||
@ -52,20 +50,15 @@ class Audio(pykka.ThreadingActor):
|
|||||||
state = PlaybackState.STOPPED
|
state = PlaybackState.STOPPED
|
||||||
_target_state = gst.STATE_NULL
|
_target_state = gst.STATE_NULL
|
||||||
|
|
||||||
def __init__(self, config):
|
def __init__(self, config, mixer):
|
||||||
super(Audio, self).__init__()
|
super(Audio, self).__init__()
|
||||||
|
|
||||||
self._config = config
|
self._config = config
|
||||||
|
self._mixer = mixer
|
||||||
|
|
||||||
self._playbin = None
|
self._playbin = None
|
||||||
self._signal_ids = {} # {(element, event): signal_id}
|
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 = None
|
||||||
self._appsrc_caps = None
|
self._appsrc_caps = None
|
||||||
self._appsrc_need_data_callback = None
|
self._appsrc_need_data_callback = None
|
||||||
@ -76,8 +69,8 @@ class Audio(pykka.ThreadingActor):
|
|||||||
try:
|
try:
|
||||||
self._setup_playbin()
|
self._setup_playbin()
|
||||||
self._setup_output()
|
self._setup_output()
|
||||||
self._setup_visualizer()
|
|
||||||
self._setup_mixer()
|
self._setup_mixer()
|
||||||
|
self._setup_visualizer()
|
||||||
self._setup_message_processor()
|
self._setup_message_processor()
|
||||||
except gobject.GError as ex:
|
except gobject.GError as ex:
|
||||||
logger.exception(ex)
|
logger.exception(ex)
|
||||||
@ -190,6 +183,23 @@ class Audio(pykka.ThreadingActor):
|
|||||||
'Failed to create audio output "%s": %s', output_desc, ex)
|
'Failed to create audio output "%s": %s', output_desc, ex)
|
||||||
process.exit_process()
|
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):
|
def _setup_visualizer(self):
|
||||||
visualizer_element = self._config['audio']['visualizer']
|
visualizer_element = self._config['audio']['visualizer']
|
||||||
if not visualizer_element:
|
if not visualizer_element:
|
||||||
@ -204,86 +214,6 @@ class Audio(pykka.ThreadingActor):
|
|||||||
'Failed to create audio visualizer "%s": %s',
|
'Failed to create audio visualizer "%s": %s',
|
||||||
visualizer_element, ex)
|
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):
|
def _setup_message_processor(self):
|
||||||
bus = self._playbin.get_bus()
|
bus = self._playbin.get_bus()
|
||||||
bus.add_signal_watch()
|
bus.add_signal_watch()
|
||||||
@ -514,108 +444,49 @@ class Audio(pykka.ThreadingActor):
|
|||||||
|
|
||||||
def get_volume(self):
|
def get_volume(self):
|
||||||
"""
|
"""
|
||||||
Get volume level of the installed mixer.
|
Get volume level of the software mixer.
|
||||||
|
|
||||||
Example values:
|
Example values:
|
||||||
|
|
||||||
0:
|
0:
|
||||||
Muted.
|
Minimum volume.
|
||||||
100:
|
100:
|
||||||
Max volume for given system.
|
Maximum volume.
|
||||||
:class:`None`:
|
|
||||||
No mixer present, so the volume is unknown.
|
|
||||||
|
|
||||||
: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))
|
||||||
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)
|
|
||||||
|
|
||||||
def set_volume(self, volume):
|
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]
|
:param volume: the volume in the range [0..100]
|
||||||
:type volume: int
|
:type volume: int
|
||||||
:rtype: :class:`True` if successful, else :class:`False`
|
:rtype: :class:`True` if successful, else :class:`False`
|
||||||
"""
|
"""
|
||||||
if self._software_mixing:
|
self._playbin.set_property('volume', volume / 100.0)
|
||||||
self._playbin.set_property('volume', volume / 100.0)
|
return True
|
||||||
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))
|
|
||||||
|
|
||||||
def get_mute(self):
|
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,
|
:rtype: :class:`True` if muted, :class:`False` if unmuted,
|
||||||
:class:`None` if no mixer is installed.
|
:class:`None` if no mixer is installed.
|
||||||
"""
|
"""
|
||||||
if self._software_mixing:
|
return self._playbin.get_property('mute')
|
||||||
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)
|
|
||||||
|
|
||||||
def set_mute(self, 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
|
:type mute: bool
|
||||||
:rtype: :class:`True` if successful, else :class:`False`
|
:rtype: :class:`True` if successful, else :class:`False`
|
||||||
"""
|
"""
|
||||||
if self._software_mixing:
|
self._playbin.set_property('mute', bool(mute))
|
||||||
return self._playbin.set_property('mute', bool(mute))
|
return True
|
||||||
|
|
||||||
if self._mixer_track is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return self._mixer.set_mute(self._mixer_track, bool(mute))
|
|
||||||
|
|
||||||
def set_metadata(self, track):
|
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.
|
:exc:`~mopidy.exceptions.BackendError` with a descriptive error message.
|
||||||
This will make Mopidy print the error message and exit so that the user can
|
This will make Mopidy print the error message and exit so that the user can
|
||||||
fix the issue.
|
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`.
|
#: Actor proxy to an instance of :class:`mopidy.audio.Audio`.
|
||||||
|
|||||||
@ -261,13 +261,15 @@ class RootCommand(Command):
|
|||||||
def run(self, args, config):
|
def run(self, args, config):
|
||||||
loop = gobject.MainLoop()
|
loop = gobject.MainLoop()
|
||||||
|
|
||||||
|
mixer_class = self.get_mixer_class(config, args.registry['mixer'])
|
||||||
backend_classes = args.registry['backend']
|
backend_classes = args.registry['backend']
|
||||||
frontend_classes = args.registry['frontend']
|
frontend_classes = args.registry['frontend']
|
||||||
|
|
||||||
try:
|
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)
|
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)
|
self.start_frontends(config, frontend_classes, core)
|
||||||
loop.run()
|
loop.run()
|
||||||
except (exceptions.BackendError,
|
except (exceptions.BackendError,
|
||||||
@ -282,11 +284,47 @@ class RootCommand(Command):
|
|||||||
self.stop_core()
|
self.stop_core()
|
||||||
self.stop_backends(backend_classes)
|
self.stop_backends(backend_classes)
|
||||||
self.stop_audio()
|
self.stop_audio()
|
||||||
|
self.stop_mixer(mixer_class)
|
||||||
process.stop_remaining_actors()
|
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')
|
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):
|
def start_backends(self, config, backend_classes, audio):
|
||||||
logger.info(
|
logger.info(
|
||||||
@ -307,9 +345,9 @@ class RootCommand(Command):
|
|||||||
|
|
||||||
return backends
|
return backends
|
||||||
|
|
||||||
def start_core(self, audio, backends):
|
def start_core(self, mixer, backends):
|
||||||
logger.info('Starting Mopidy core')
|
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):
|
def start_frontends(self, config, frontend_classes, core):
|
||||||
logger.info(
|
logger.info(
|
||||||
@ -343,6 +381,10 @@ class RootCommand(Command):
|
|||||||
logger.info('Stopping Mopidy audio')
|
logger.info('Stopping Mopidy audio')
|
||||||
process.stop_actors_by_class(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):
|
class ConfigCommand(Command):
|
||||||
help = 'Show currently active configuration.'
|
help = 'Show currently active configuration.'
|
||||||
|
|||||||
@ -25,7 +25,7 @@ _loglevels_schema = LogLevelConfigSchema('loglevels')
|
|||||||
|
|
||||||
_audio_schema = ConfigSchema('audio')
|
_audio_schema = ConfigSchema('audio')
|
||||||
_audio_schema['mixer'] = String()
|
_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['mixer_volume'] = Integer(optional=True, minimum=0, maximum=100)
|
||||||
_audio_schema['output'] = String()
|
_audio_schema['output'] = String()
|
||||||
_audio_schema['visualizer'] = String(optional=True)
|
_audio_schema['visualizer'] = String(optional=True)
|
||||||
|
|||||||
@ -7,7 +7,6 @@ config_file =
|
|||||||
|
|
||||||
[audio]
|
[audio]
|
||||||
mixer = software
|
mixer = software
|
||||||
mixer_track =
|
|
||||||
mixer_volume =
|
mixer_volume =
|
||||||
output = autoaudiosink
|
output = autoaudiosink
|
||||||
visualizer =
|
visualizer =
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import itertools
|
|||||||
|
|
||||||
import pykka
|
import pykka
|
||||||
|
|
||||||
from mopidy import audio, backend
|
from mopidy import audio, backend, mixer
|
||||||
from mopidy.audio import PlaybackState
|
from mopidy.audio import PlaybackState
|
||||||
from mopidy.core.library import LibraryController
|
from mopidy.core.library import LibraryController
|
||||||
from mopidy.core.listener import CoreListener
|
from mopidy.core.listener import CoreListener
|
||||||
@ -15,7 +15,10 @@ from mopidy.core.tracklist import TracklistController
|
|||||||
from mopidy.utils import versioning
|
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
|
library = None
|
||||||
"""The library controller. An instance of
|
"""The library controller. An instance of
|
||||||
:class:`mopidy.core.LibraryController`."""
|
:class:`mopidy.core.LibraryController`."""
|
||||||
@ -32,7 +35,7 @@ class Core(pykka.ThreadingActor, audio.AudioListener, backend.BackendListener):
|
|||||||
"""The tracklist controller. An instance of
|
"""The tracklist controller. An instance of
|
||||||
:class:`mopidy.core.TracklistController`."""
|
:class:`mopidy.core.TracklistController`."""
|
||||||
|
|
||||||
def __init__(self, audio=None, backends=None):
|
def __init__(self, mixer=None, backends=None):
|
||||||
super(Core, self).__init__()
|
super(Core, self).__init__()
|
||||||
|
|
||||||
self.backends = Backends(backends)
|
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.library = LibraryController(backends=self.backends, core=self)
|
||||||
|
|
||||||
self.playback = PlaybackController(
|
self.playback = PlaybackController(
|
||||||
audio=audio, backends=self.backends, core=self)
|
mixer=mixer, backends=self.backends, core=self)
|
||||||
|
|
||||||
self.playlists = PlaylistsController(
|
self.playlists = PlaylistsController(
|
||||||
backends=self.backends, core=self)
|
backends=self.backends, core=self)
|
||||||
@ -81,6 +84,14 @@ class Core(pykka.ThreadingActor, audio.AudioListener, backend.BackendListener):
|
|||||||
# Forward event from backend to frontends
|
# Forward event from backend to frontends
|
||||||
CoreListener.send('playlists_loaded')
|
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):
|
class Backends(list):
|
||||||
def __init__(self, backends):
|
def __init__(self, backends):
|
||||||
|
|||||||
@ -13,8 +13,8 @@ logger = logging.getLogger(__name__)
|
|||||||
class PlaybackController(object):
|
class PlaybackController(object):
|
||||||
pykka_traversable = True
|
pykka_traversable = True
|
||||||
|
|
||||||
def __init__(self, audio, backends, core):
|
def __init__(self, mixer, backends, core):
|
||||||
self.audio = audio
|
self.mixer = mixer
|
||||||
self.backends = backends
|
self.backends = backends
|
||||||
self.core = core
|
self.core = core
|
||||||
|
|
||||||
@ -88,41 +88,39 @@ class PlaybackController(object):
|
|||||||
"""Time position in milliseconds."""
|
"""Time position in milliseconds."""
|
||||||
|
|
||||||
def get_volume(self):
|
def get_volume(self):
|
||||||
if self.audio:
|
if self.mixer:
|
||||||
return self.audio.get_volume().get()
|
return self.mixer.get_volume().get()
|
||||||
else:
|
else:
|
||||||
# For testing
|
# For testing
|
||||||
return self._volume
|
return self._volume
|
||||||
|
|
||||||
def set_volume(self, volume):
|
def set_volume(self, volume):
|
||||||
if self.audio:
|
if self.mixer:
|
||||||
self.audio.set_volume(volume)
|
self.mixer.set_volume(volume)
|
||||||
else:
|
else:
|
||||||
# For testing
|
# For testing
|
||||||
self._volume = volume
|
self._volume = volume
|
||||||
|
|
||||||
self._trigger_volume_changed(volume)
|
|
||||||
|
|
||||||
volume = property(get_volume, set_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):
|
def get_mute(self):
|
||||||
if self.audio:
|
if self.mixer:
|
||||||
return self.audio.get_mute().get()
|
return self.mixer.get_mute().get()
|
||||||
else:
|
else:
|
||||||
# For testing
|
# For testing
|
||||||
return self._mute
|
return self._mute
|
||||||
|
|
||||||
def set_mute(self, value):
|
def set_mute(self, value):
|
||||||
value = bool(value)
|
value = bool(value)
|
||||||
if self.audio:
|
if self.mixer:
|
||||||
self.audio.set_mute(value)
|
self.mixer.set_mute(value)
|
||||||
else:
|
else:
|
||||||
# For testing
|
# For testing
|
||||||
self._mute = value
|
self._mute = value
|
||||||
|
|
||||||
self._trigger_mute_changed(value)
|
|
||||||
|
|
||||||
mute = property(get_mute, set_mute)
|
mute = property(get_mute, set_mute)
|
||||||
"""Mute state as a :class:`True` if muted, :class:`False` otherwise"""
|
"""Mute state as a :class:`True` if muted, :class:`False` otherwise"""
|
||||||
|
|
||||||
@ -351,14 +349,6 @@ class PlaybackController(object):
|
|||||||
'playback_state_changed',
|
'playback_state_changed',
|
||||||
old_state=old_state, new_state=new_state)
|
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):
|
def _trigger_seeked(self, time_position):
|
||||||
logger.debug('Triggering seeked event')
|
logger.debug('Triggering seeked event')
|
||||||
listener.CoreListener.send('seeked', time_position=time_position)
|
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',
|
'http = mopidy.http:Extension',
|
||||||
'local = mopidy.local:Extension',
|
'local = mopidy.local:Extension',
|
||||||
'mpd = mopidy.mpd:Extension',
|
'mpd = mopidy.mpd:Extension',
|
||||||
|
'softwaremixer = mopidy.softwaremixer:Extension',
|
||||||
'stream = mopidy.stream:Extension',
|
'stream = mopidy.stream:Extension',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -23,8 +23,7 @@ class AudioTest(unittest.TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
config = {
|
config = {
|
||||||
'audio': {
|
'audio': {
|
||||||
'mixer': 'fakemixer track_max_volume=65536',
|
'mixer': 'foomixer',
|
||||||
'mixer_track': None,
|
|
||||||
'mixer_volume': None,
|
'mixer_volume': None,
|
||||||
'output': 'fakesink',
|
'output': 'fakesink',
|
||||||
'visualizer': None,
|
'visualizer': None,
|
||||||
@ -34,7 +33,7 @@ class AudioTest(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
self.song_uri = path_to_uri(path_to_data_dir('song1.wav'))
|
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):
|
def tearDown(self):
|
||||||
pykka.ActorRegistry.stop_all()
|
pykka.ActorRegistry.stop_all()
|
||||||
@ -74,38 +73,11 @@ class AudioTest(unittest.TestCase):
|
|||||||
self.assertTrue(self.audio.set_volume(value).get())
|
self.assertTrue(self.audio.set_volume(value).get())
|
||||||
self.assertEqual(value, self.audio.get_volume().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
|
@unittest.SkipTest
|
||||||
def test_set_mute(self):
|
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
|
@unittest.SkipTest
|
||||||
def test_set_state_encapsulation(self):
|
def test_set_state_encapsulation(self):
|
||||||
@ -122,7 +94,7 @@ class AudioTest(unittest.TestCase):
|
|||||||
|
|
||||||
class AudioStateTest(unittest.TestCase):
|
class AudioStateTest(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.audio = audio.Audio(config=None)
|
self.audio = audio.Audio(config=None, mixer=None)
|
||||||
|
|
||||||
def test_state_starts_as_stopped(self):
|
def test_state_starts_as_stopped(self):
|
||||||
self.assertEqual(audio.PlaybackState.STOPPED, self.audio.state)
|
self.assertEqual(audio.PlaybackState.STOPPED, self.audio.state)
|
||||||
@ -167,7 +139,7 @@ class AudioStateTest(unittest.TestCase):
|
|||||||
|
|
||||||
class AudioBufferingTest(unittest.TestCase):
|
class AudioBufferingTest(unittest.TestCase):
|
||||||
def setUp(self):
|
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.audio._playbin = mock.Mock(spec=['set_state'])
|
||||||
|
|
||||||
self.buffer_full_message = mock.Mock()
|
self.buffer_full_message = mock.Mock()
|
||||||
|
|||||||
@ -20,7 +20,7 @@ class CoreActorTest(unittest.TestCase):
|
|||||||
self.backend2.uri_schemes.get.return_value = ['dummy2']
|
self.backend2.uri_schemes.get.return_value = ['dummy2']
|
||||||
self.backend2.actor_ref.actor_class.__name__ = b'B2'
|
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):
|
def tearDown(self):
|
||||||
pykka.ActorRegistry.stop_all()
|
pykka.ActorRegistry.stop_all()
|
||||||
@ -37,7 +37,7 @@ class CoreActorTest(unittest.TestCase):
|
|||||||
self.assertRaisesRegexp(
|
self.assertRaisesRegexp(
|
||||||
AssertionError,
|
AssertionError,
|
||||||
'Cannot add URI scheme dummy1 for B2, it is already handled by B1',
|
'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):
|
def test_version(self):
|
||||||
self.assertEqual(self.core.version, versioning.get_version())
|
self.assertEqual(self.core.version, versioning.get_version())
|
||||||
|
|||||||
@ -20,11 +20,23 @@ class BackendEventsTest(unittest.TestCase):
|
|||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
pykka.ActorRegistry.stop_all()
|
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.core.playlists_loaded().get()
|
||||||
|
|
||||||
self.assertEqual(send.call_args[0][0], 'playlists_loaded')
|
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):
|
def test_tracklist_add_sends_tracklist_changed_event(self, send):
|
||||||
send.reset_mock()
|
send.reset_mock()
|
||||||
|
|
||||||
|
|||||||
@ -30,7 +30,7 @@ class CoreLibraryTest(unittest.TestCase):
|
|||||||
self.backend3.has_library().get.return_value = False
|
self.backend3.has_library().get.return_value = False
|
||||||
self.backend3.has_library_browse().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])
|
self.backend1, self.backend2, self.backend3])
|
||||||
|
|
||||||
def test_browse_root_returns_dir_ref_for_each_lib_with_root_dir_name(self):
|
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),
|
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.backend1, self.backend2, self.backend3])
|
||||||
self.core.tracklist.add(self.tracks)
|
self.core.tracklist.add(self.tracks)
|
||||||
|
|
||||||
@ -376,17 +376,16 @@ class CorePlaybackTest(unittest.TestCase):
|
|||||||
|
|
||||||
# TODO Test on_tracklist_change
|
# TODO Test on_tracklist_change
|
||||||
|
|
||||||
# TODO Test volume
|
def test_volume(self):
|
||||||
|
self.assertEqual(self.core.playback.volume, None)
|
||||||
|
|
||||||
@mock.patch(
|
self.core.playback.volume = 30
|
||||||
'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.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):
|
def test_mute(self):
|
||||||
self.assertEqual(self.core.playback.mute, False)
|
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.pl2b = Playlist(name='B', tracks=[Track(uri='dummy2:b')])
|
||||||
self.sp2.playlists.get.return_value = [self.pl2a, self.pl2b]
|
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])
|
self.backend3, self.backend1, self.backend2])
|
||||||
|
|
||||||
def test_get_playlists_combines_result_from_backends(self):
|
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.library = mock.Mock(spec=backend.LibraryProvider)
|
||||||
self.backend.library = self.library
|
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)
|
self.tl_tracks = self.core.tracklist.add(self.tracks)
|
||||||
|
|
||||||
def test_add_by_uri_looks_up_uri_in_library(self):
|
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.audio = audio.DummyAudio.start().proxy()
|
||||||
self.backend = actor.LocalBackend.start(
|
self.backend = actor.LocalBackend.start(
|
||||||
config=self.config, audio=self.audio).proxy()
|
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.controller = self.core.tracklist
|
||||||
self.playback = self.core.playback
|
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