Merge branch 'develop' into feature/spotify-track-proxy
This commit is contained in:
commit
e01e5ff576
@ -1,18 +0,0 @@
|
||||
.. _output-api:
|
||||
|
||||
**********
|
||||
Output API
|
||||
**********
|
||||
|
||||
Outputs are used by :mod:`mopidy.gstreamer` to output audio in some way.
|
||||
|
||||
.. autoclass:: mopidy.outputs.BaseOutput
|
||||
:members:
|
||||
|
||||
|
||||
Output implementations
|
||||
======================
|
||||
|
||||
* :class:`mopidy.outputs.custom.CustomOutput`
|
||||
* :class:`mopidy.outputs.local.LocalOutput`
|
||||
* :class:`mopidy.outputs.shoutcast.ShoutcastOutput`
|
||||
@ -18,7 +18,7 @@ v0.8 (in development)
|
||||
Track position and CPID was intermixed, so it would cause a crash if a CPID
|
||||
matching the track position didn't exist. (Fixes: :issue:`162`)
|
||||
|
||||
- Added :option:`--list-deps` option to :cmd:`mopidy` command that lists
|
||||
- Added :option:`--list-deps` option to the `mopidy` command that lists
|
||||
required and optional dependencies, their current versions, and some other
|
||||
information useful for debugging. (Fixes: :issue:`74`)
|
||||
|
||||
@ -42,6 +42,32 @@ v0.8 (in development)
|
||||
protocol support for volume has also been updated to return -1 when we have
|
||||
no mixer set.
|
||||
|
||||
- Removed the Denon hardware mixer, as it is not maintained.
|
||||
|
||||
- Updated the NAD hardware mixer to work in the new GStreamer based mixing
|
||||
regime. Settings are now passed as GStreamer element properties. In practice
|
||||
that means that the following old-style config:
|
||||
|
||||
MIXER = u'mopidy.mixers.nad.NadMixer'
|
||||
MIXER_EXT_PORT = u'/dev/ttyUSB0'
|
||||
MIXER_EXT_SOURCE = u'Aux'
|
||||
MIXER_EXT_SPEAKERS_A = u'On'
|
||||
MIXER_EXT_SPEAKERS_B = u'Off'
|
||||
|
||||
Now is reduced to simply:
|
||||
|
||||
MIXER = u'nadmixer port=/dev/ttyUSB0 source=aux speakers-a=on speakers-b=off'
|
||||
|
||||
The ``port`` property defaults to ``/dev/ttyUSB0``, and the rest of the
|
||||
properties may be left out if you don't want the mixer to adjust the settings
|
||||
on your NAD amplifier when Mopidy is started.
|
||||
|
||||
- Fixed :issue:`150` which caused some clients to block Mopidy completely. Bug
|
||||
was caused by some clients sending ``close`` and then shutting down the
|
||||
connection right away. This trigged a situation in which the connection
|
||||
cleanup code would wait for an response that would never come inside the
|
||||
event loop, blocking everything else.
|
||||
|
||||
|
||||
v0.7.3 (2012-08-11)
|
||||
===================
|
||||
|
||||
@ -22,17 +22,17 @@ class Mock(object):
|
||||
def __call__(self, *args, **kwargs):
|
||||
return Mock()
|
||||
|
||||
def __or__(self, other):
|
||||
return Mock()
|
||||
|
||||
@classmethod
|
||||
def __getattr__(self, name):
|
||||
if name in ('__file__', '__path__'):
|
||||
return '/dev/null'
|
||||
elif name[0] == name[0].upper():
|
||||
return type(name, (), {})
|
||||
else:
|
||||
return Mock()
|
||||
|
||||
MOCK_MODULES = [
|
||||
'alsaaudio',
|
||||
'dbus',
|
||||
'dbus.mainloop',
|
||||
'dbus.mainloop.glib',
|
||||
|
||||
@ -125,6 +125,47 @@ statistics and uses pylint to check for errors and possible improvements in our
|
||||
code. So, if you're out of work, the code coverage and pylint data at the CI
|
||||
server should give you a place to start.
|
||||
|
||||
Protocol debugging
|
||||
==================
|
||||
|
||||
Since the main interface provided to Mopidy is through the MPD protocol, it is
|
||||
crucial that we try and stay in sync with protocol developments. In an attempt
|
||||
to make it easier to debug differences Mopidy and MPD protocol handling we have
|
||||
created ``tools/debug-proxy.py``.
|
||||
|
||||
This tool is proxy that sits in front of two MPD protocol aware servers and
|
||||
sends all requests to both, returning the primary response to the client and
|
||||
then printing any diff in the two responses.
|
||||
|
||||
Note that this tool depends on ``gevent`` unlike the rest of Mopidy at the time
|
||||
of writing. See ``--help`` for available options. Sample session::
|
||||
|
||||
[127.0.0.1]:59714
|
||||
listallinfo
|
||||
--- Reference response
|
||||
+++ Actual response
|
||||
@@ -1,16 +1,1 @@
|
||||
-file: uri1
|
||||
-Time: 4
|
||||
-Artist: artist1
|
||||
-Title: track1
|
||||
-Album: album1
|
||||
-file: uri2
|
||||
-Time: 4
|
||||
-Artist: artist2
|
||||
-Title: track2
|
||||
-Album: album2
|
||||
-file: uri3
|
||||
-Time: 4
|
||||
-Artist: artist3
|
||||
-Title: track3
|
||||
-Album: album3
|
||||
-OK
|
||||
+ACK [2@0] {listallinfo} incorrect arguments
|
||||
|
||||
To ensure that Mopidy and MPD have comparable state it is suggested you setup
|
||||
both to use ``tests/data/library_tag_cache`` for their tag cache and
|
||||
``tests/data`` for music/playlist folders.
|
||||
|
||||
Writing documentation
|
||||
=====================
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
************************************************
|
||||
:mod:`mopidy.outputs` -- GStreamer audio outputs
|
||||
************************************************
|
||||
|
||||
The following GStreamer audio outputs implements the :ref:`output-api`.
|
||||
|
||||
.. autoclass:: mopidy.outputs.custom.CustomOutput
|
||||
|
||||
.. autoclass:: mopidy.outputs.local.LocalOutput
|
||||
|
||||
.. autoclass:: mopidy.outputs.shoutcast.ShoutcastOutput
|
||||
@ -371,8 +371,9 @@ class MprisObject(dbus.service.Object):
|
||||
|
||||
def get_Volume(self):
|
||||
volume = self.backend.playback.volume.get()
|
||||
if volume is not None:
|
||||
return volume / 100.0
|
||||
if volume is None:
|
||||
return 0
|
||||
return volume / 100.0
|
||||
|
||||
def set_Volume(self, value):
|
||||
if not self.get_CanControl():
|
||||
|
||||
@ -133,9 +133,7 @@ class GStreamer(ThreadingActor):
|
||||
|
||||
def _on_message(self, bus, message):
|
||||
if message.type == gst.MESSAGE_EOS:
|
||||
logger.debug(u'GStreamer signalled end-of-stream. '
|
||||
'Telling backend ...')
|
||||
self._get_backend().playback.on_end_of_track()
|
||||
self._notify_backend_of_eos()
|
||||
elif message.type == gst.MESSAGE_ERROR:
|
||||
error, debug = message.parse_error()
|
||||
logger.error(u'%s %s', error, debug)
|
||||
@ -144,10 +142,14 @@ class GStreamer(ThreadingActor):
|
||||
error, debug = message.parse_warning()
|
||||
logger.warning(u'%s %s', error, debug)
|
||||
|
||||
def _get_backend(self):
|
||||
def _notify_backend_of_eos(self):
|
||||
backend_refs = ActorRegistry.get_by_class(Backend)
|
||||
assert len(backend_refs) == 1, 'Expected exactly one running backend.'
|
||||
return backend_refs[0].proxy()
|
||||
assert len(backend_refs) <= 1, 'Expected at most one running backend.'
|
||||
if backend_refs:
|
||||
logger.debug(u'Notifying backend of end-of-stream.')
|
||||
backend_refs[0].proxy().playback.on_end_of_track()
|
||||
else:
|
||||
logger.debug(u'No backend to notify of end-of-stream found.')
|
||||
|
||||
def set_uri(self, uri):
|
||||
"""
|
||||
|
||||
@ -1,2 +1,43 @@
|
||||
import pygst
|
||||
pygst.require('0.10')
|
||||
import gst
|
||||
import gobject
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
# Import all mixers so that they are registered with GStreamer.
|
||||
#
|
||||
# Keep these imports at the bottom of the file to avoid cyclic import problems
|
||||
# when mixers use the above code.
|
||||
from mopidy.mixers.auto import AutoAudioMixer
|
||||
from mopidy.mixers.fake import FakeMixer
|
||||
from mopidy.mixers.nad import NadMixer
|
||||
|
||||
@ -3,35 +3,7 @@ pygst.require('0.10')
|
||||
import gobject
|
||||
import gst
|
||||
|
||||
|
||||
def create_fake_track(label, intial_volume, min_volume, max_volume,
|
||||
num_channels, flags):
|
||||
class Track(gst.interfaces.MixerTrack):
|
||||
def __init__(self):
|
||||
super(Track, self).__init__()
|
||||
self.volumes = (intial_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()
|
||||
from mopidy.mixers import create_track
|
||||
|
||||
|
||||
class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer):
|
||||
@ -41,15 +13,10 @@ class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer):
|
||||
'Thomas Adamcik')
|
||||
|
||||
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))
|
||||
@ -58,12 +25,13 @@ class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer):
|
||||
gst.Element.__init__(self)
|
||||
|
||||
def list_tracks(self):
|
||||
track = create_fake_track(self.track_label,
|
||||
self.track_initial_volume,
|
||||
self.track_min_volume,
|
||||
self.track_max_volume,
|
||||
self.track_num_channels,
|
||||
self.track_flags)
|
||||
track = 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):
|
||||
|
||||
236
mopidy/mixers/nad.py
Normal file
236
mopidy/mixers/nad.py
Normal file
@ -0,0 +1,236 @@
|
||||
import logging
|
||||
|
||||
import pygst
|
||||
pygst.require('0.10')
|
||||
import gobject
|
||||
import gst
|
||||
|
||||
try:
|
||||
import serial
|
||||
except ImportError:
|
||||
serial = None
|
||||
|
||||
from pykka.actor import ThreadingActor
|
||||
|
||||
from mopidy.mixers import create_track
|
||||
|
||||
|
||||
logger = logging.getLogger('mopidy.mixers.nad')
|
||||
|
||||
|
||||
class NadMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer):
|
||||
__gstdetails__ = ('NadMixer',
|
||||
'Mixer',
|
||||
'Mixer to control NAD amplifiers using a serial link',
|
||||
'Stein Magnus Jodal')
|
||||
|
||||
port = gobject.property(type=str, default='/dev/ttyUSB0')
|
||||
source = gobject.property(type=str)
|
||||
speakers_a = gobject.property(type=str)
|
||||
speakers_b = gobject.property(type=str)
|
||||
|
||||
def __init__(self):
|
||||
gst.Element.__init__(self)
|
||||
self._volume_cache = 0
|
||||
self._nad_talker = None
|
||||
|
||||
def list_tracks(self):
|
||||
track = create_track(
|
||||
label='Master',
|
||||
initial_volume=0,
|
||||
min_volume=0,
|
||||
max_volume=100,
|
||||
num_channels=1,
|
||||
flags=(gst.interfaces.MIXER_TRACK_MASTER |
|
||||
gst.interfaces.MIXER_TRACK_OUTPUT))
|
||||
return [track]
|
||||
|
||||
def get_volume(self, track):
|
||||
return [self._volume_cache]
|
||||
|
||||
def set_volume(self, track, volumes):
|
||||
if len(volumes):
|
||||
volume = volumes[0]
|
||||
self._volume_cache = volume
|
||||
self._nad_talker.set_volume(volume)
|
||||
|
||||
def set_mute(self, track, mute):
|
||||
self._nad_talker.mute(mute)
|
||||
|
||||
def do_change_state(self, transition):
|
||||
if transition == gst.STATE_CHANGE_NULL_TO_READY:
|
||||
if serial is None:
|
||||
logger.warning(u'nadmixer dependency python-serial not found')
|
||||
return gst.STATE_CHANGE_FAILURE
|
||||
self._start_nad_talker()
|
||||
return gst.STATE_CHANGE_SUCCESS
|
||||
|
||||
def _start_nad_talker(self):
|
||||
self._nad_talker = NadTalker.start(
|
||||
port=self.port,
|
||||
source=self.source or None,
|
||||
speakers_a=self.speakers_a or None,
|
||||
speakers_b=self.speakers_b or None
|
||||
).proxy()
|
||||
|
||||
|
||||
gobject.type_register(NadMixer)
|
||||
gst.element_register(NadMixer, 'nadmixer', gst.RANK_MARGINAL)
|
||||
|
||||
|
||||
class NadTalker(ThreadingActor):
|
||||
"""
|
||||
Independent thread which does the communication with the NAD amplifier
|
||||
|
||||
Since the communication is done in an independent thread, Mopidy won't
|
||||
block other requests while doing rather time consuming work like
|
||||
calibrating the NAD amplifier's volume.
|
||||
"""
|
||||
|
||||
# Serial link settings
|
||||
BAUDRATE = 115200
|
||||
BYTESIZE = 8
|
||||
PARITY = 'N'
|
||||
STOPBITS = 1
|
||||
|
||||
# Timeout in seconds used for read/write operations.
|
||||
# If you set the timeout too low, the reads will never get complete
|
||||
# confirmations and calibration will decrease volume forever. If you set
|
||||
# the timeout too high, stuff takes more time. 0.2s seems like a good value
|
||||
# for NAD C 355BEE.
|
||||
TIMEOUT = 0.2
|
||||
|
||||
# Number of volume levels the amplifier supports. 40 for NAD C 355BEE.
|
||||
VOLUME_LEVELS = 40
|
||||
|
||||
def __init__(self, port, source, speakers_a, speakers_b):
|
||||
super(NadTalker, self).__init__()
|
||||
|
||||
self.port = port
|
||||
self.source = source
|
||||
self.speakers_a = speakers_a
|
||||
self.speakers_b = speakers_b
|
||||
|
||||
# Volume in range 0..VOLUME_LEVELS. :class:`None` before calibration.
|
||||
self._nad_volume = None
|
||||
|
||||
self._device = None
|
||||
|
||||
def on_start(self):
|
||||
self._open_connection()
|
||||
self._set_device_to_known_state()
|
||||
|
||||
def _open_connection(self):
|
||||
logger.info(u'NAD amplifier: Connecting through "%s"',
|
||||
self.port)
|
||||
self._device = serial.Serial(
|
||||
port=self.port,
|
||||
baudrate=self.BAUDRATE,
|
||||
bytesize=self.BYTESIZE,
|
||||
parity=self.PARITY,
|
||||
stopbits=self.STOPBITS,
|
||||
timeout=self.TIMEOUT)
|
||||
self._get_device_model()
|
||||
|
||||
def _set_device_to_known_state(self):
|
||||
self._power_device_on()
|
||||
self._select_speakers()
|
||||
self._select_input_source()
|
||||
self.mute(False)
|
||||
self._calibrate_volume()
|
||||
|
||||
def _get_device_model(self):
|
||||
model = self._ask_device('Main.Model')
|
||||
logger.info(u'NAD amplifier: Connected to model "%s"', model)
|
||||
return model
|
||||
|
||||
def _power_device_on(self):
|
||||
self._check_and_set('Main.Power', 'On')
|
||||
|
||||
def _select_speakers(self):
|
||||
if self.speakers_a is not None:
|
||||
self._check_and_set('Main.SpeakerA', self.speakers_a.title())
|
||||
if self.speakers_b is not None:
|
||||
self._check_and_set('Main.SpeakerB', self.speakers_b.title())
|
||||
|
||||
def _select_input_source(self):
|
||||
if self.source is not None:
|
||||
self._check_and_set('Main.Source', self.source.title())
|
||||
|
||||
def mute(self, mute):
|
||||
if mute:
|
||||
self._check_and_set('Main.Mute', 'On')
|
||||
else:
|
||||
self._check_and_set('Main.Mute', 'Off')
|
||||
|
||||
def _calibrate_volume(self):
|
||||
# The NAD C 355BEE amplifier has 40 different volume levels. We have no
|
||||
# way of asking on which level we are. Thus, we must calibrate the
|
||||
# mixer by decreasing the volume 39 times.
|
||||
logger.info(u'NAD amplifier: Calibrating by setting volume to 0')
|
||||
self._nad_volume = self.VOLUME_LEVELS
|
||||
self.set_volume(0)
|
||||
logger.info(u'NAD amplifier: Done calibrating')
|
||||
|
||||
def set_volume(self, volume):
|
||||
# Increase or decrease the amplifier volume until it matches the given
|
||||
# target volume.
|
||||
logger.debug(u'Setting volume to %d' % volume)
|
||||
target_nad_volume = int(round(volume * self.VOLUME_LEVELS / 100.0))
|
||||
if self._nad_volume is None:
|
||||
return # Calibration needed
|
||||
while target_nad_volume > self._nad_volume:
|
||||
if self._increase_volume():
|
||||
self._nad_volume += 1
|
||||
while target_nad_volume < self._nad_volume:
|
||||
if self._decrease_volume():
|
||||
self._nad_volume -= 1
|
||||
|
||||
def _increase_volume(self):
|
||||
# Increase volume. Returns :class:`True` if confirmed by device.
|
||||
self._write('Main.Volume+')
|
||||
return self._readline() == 'Main.Volume+'
|
||||
|
||||
def _decrease_volume(self):
|
||||
# Decrease volume. Returns :class:`True` if confirmed by device.
|
||||
self._write('Main.Volume-')
|
||||
return self._readline() == 'Main.Volume-'
|
||||
|
||||
def _check_and_set(self, key, value):
|
||||
for attempt in range(1, 4):
|
||||
if self._ask_device(key) == value:
|
||||
return
|
||||
logger.info(u'NAD amplifier: Setting "%s" to "%s" (attempt %d/3)',
|
||||
key, value, attempt)
|
||||
self._command_device(key, value)
|
||||
if self._ask_device(key) != value:
|
||||
logger.info(u'NAD amplifier: Gave up on setting "%s" to "%s"',
|
||||
key, value)
|
||||
|
||||
def _ask_device(self, key):
|
||||
self._write('%s?' % key)
|
||||
return self._readline().replace('%s=' % key, '')
|
||||
|
||||
def _command_device(self, key, value):
|
||||
if type(value) == unicode:
|
||||
value = value.encode('utf-8')
|
||||
self._write('%s=%s' % (key, value))
|
||||
self._readline()
|
||||
|
||||
def _write(self, data):
|
||||
# Write data to device. Prepends and appends a newline to the data, as
|
||||
# recommended by the NAD documentation.
|
||||
if not self._device.isOpen():
|
||||
self._device.open()
|
||||
self._device.write('\n%s\n' % data)
|
||||
logger.debug('Write: %s', data)
|
||||
|
||||
def _readline(self):
|
||||
# Read line from device. The result is stripped for leading and
|
||||
# trailing whitespace.
|
||||
if not self._device.isOpen():
|
||||
self._device.open()
|
||||
result = self._device.readline().strip()
|
||||
if result:
|
||||
logger.debug('Read: %s', result)
|
||||
return result
|
||||
@ -150,7 +150,7 @@ class Connection(object):
|
||||
logger.log(level, reason)
|
||||
|
||||
try:
|
||||
self.actor_ref.stop()
|
||||
self.actor_ref.stop(block=False)
|
||||
except ActorDeadError:
|
||||
pass
|
||||
|
||||
|
||||
@ -205,6 +205,10 @@ class PlayerInterfaceTest(unittest.TestCase):
|
||||
self.assertEquals(result['xesam:trackNumber'], 7)
|
||||
|
||||
def test_get_volume_should_return_volume_between_zero_and_one(self):
|
||||
self.backend.playback.volume = None
|
||||
result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume')
|
||||
self.assertEquals(result, 0)
|
||||
|
||||
self.backend.playback.volume = 0
|
||||
result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume')
|
||||
self.assertEquals(result, 0)
|
||||
|
||||
@ -23,9 +23,6 @@ class GStreamerTest(unittest.TestCase):
|
||||
self.gstreamer.prepare_change()
|
||||
self.gstreamer.set_uri(uri)
|
||||
|
||||
def tearDown(self):
|
||||
settings.runtime.clear()
|
||||
|
||||
def test_start_playback_existing_file(self):
|
||||
self.prepare_uri(self.song_uri)
|
||||
self.assertTrue(self.gstreamer.start_playback())
|
||||
|
||||
@ -91,7 +91,7 @@ class ConnectionTest(unittest.TestCase):
|
||||
self.mock.sock = Mock(spec=socket.SocketType)
|
||||
|
||||
network.Connection.stop(self.mock, sentinel.reason)
|
||||
self.mock.actor_ref.stop.assert_called_once_with()
|
||||
self.mock.actor_ref.stop.assert_called_once_with(block=False)
|
||||
|
||||
def test_stop_handles_actor_already_being_stopped(self):
|
||||
self.mock.stopping = False
|
||||
@ -100,7 +100,7 @@ class ConnectionTest(unittest.TestCase):
|
||||
self.mock.sock = Mock(spec=socket.SocketType)
|
||||
|
||||
network.Connection.stop(self.mock, sentinel.reason)
|
||||
self.mock.actor_ref.stop.assert_called_once_with()
|
||||
self.mock.actor_ref.stop.assert_called_once_with(block=False)
|
||||
|
||||
def test_stop_sets_stopping_to_true(self):
|
||||
self.mock.stopping = False
|
||||
|
||||
Loading…
Reference in New Issue
Block a user