Merge branch 'develop' into feature/spotify-track-proxy

This commit is contained in:
Thomas Adamcik 2012-09-09 23:55:28 +02:00
commit e01e5ff576
14 changed files with 374 additions and 87 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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