diff --git a/docs/changelog.rst b/docs/changelog.rst index ca36454e02..62b2916d22 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -57,6 +57,9 @@ v0.20.0 (UNRELEASED) - Add debug logging of unknown sections. (Fixes: :issue:`694`, PR: :issue:`1002`) +- Add support for configuring :confval:`audio/mixer` to ``none``. (Fixes: + :issue:`936`) + **Logging** - Add custom log level ``TRACE`` (numerical level 5), which can be used by @@ -114,6 +117,9 @@ v0.20.0 (UNRELEASED) - Switch the ``list`` command over to using :meth:`mopidy.core.LibraryController.get_distinct`. (Fixes: :issue:`913`) +- Add support for ``toggleoutput`` command. ``mixrampdb`` and ``mixrampdelay`` + have also been added but have not been implemented yet. + **HTTP frontend** - Prevent race condition in webservice broadcast from breaking the server. diff --git a/docs/config.rst b/docs/config.rst index 69945ab898..46b156359a 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -70,6 +70,8 @@ Audio configuration will affect the audio volume if you're streaming the audio from Mopidy through Shoutcast. + If you want to disable audio mixing set the value to ``none``. + If you want to use a hardware mixer, you need to install a Mopidy extension which integrates with your sound subsystem. E.g. for ALSA, install `Mopidy-ALSAMixer `_. diff --git a/mopidy/commands.py b/mopidy/commands.py index d9b4ce0ebb..5df8dd5aa6 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -276,7 +276,9 @@ def run(self, args, config): exit_status_code = 0 try: - mixer = self.start_mixer(config, mixer_class) + mixer = None + if mixer_class is not None: + mixer = self.start_mixer(config, mixer_class) audio = self.start_audio(config, mixer) backends = self.start_backends(config, backend_classes, audio) core = self.start_core(mixer, backends, audio) @@ -297,7 +299,8 @@ def run(self, args, config): self.stop_core() self.stop_backends(backend_classes) self.stop_audio() - self.stop_mixer(mixer_class) + if mixer_class is not None: + self.stop_mixer(mixer_class) process.stop_remaining_actors() return exit_status_code @@ -306,13 +309,18 @@ def get_mixer_class(self, config, mixer_classes): 'Available Mopidy mixers: %s', ', '.join(m.__name__ for m in mixer_classes) or 'none') + if config['audio']['mixer'] == 'none': + logger.debug('Mixer disabled') + return 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])) + ', '.join([m.name for m in mixer_classes]) + ', none' or + 'none') process.exit_process() return selected_mixers[0] diff --git a/mopidy/core/mixer.py b/mopidy/core/mixer.py index 4d77f8bc36..1f5ada9e9c 100644 --- a/mopidy/core/mixer.py +++ b/mopidy/core/mixer.py @@ -11,8 +11,6 @@ class MixerController(object): def __init__(self, mixer): self._mixer = mixer - self._volume = None - self._mute = False def get_volume(self): """Get the volume. @@ -27,12 +25,15 @@ def get_volume(self): def set_volume(self, volume): """Set the volume. - The volume is defined as an integer in range [0..100]. + The volume is defined as an integer in range [0..100] or :class:`None` + if the mixer is disabled. The volume scale is linear. """ - if self._mixer is not None: - self._mixer.set_volume(volume) + if self._mixer is None: + return False + else: + return self._mixer.set_volume(volume).get() def get_mute(self): """Get mute state. @@ -40,13 +41,19 @@ def get_mute(self): :class:`True` if muted, :class:`False` unmuted, :class:`None` if unknown. """ - if self._mixer is not None: + if self._mixer is None: + return False + else: return self._mixer.get_mute().get() def set_mute(self, mute): """Set mute state. :class:`True` to mute, :class:`False` to unmute. + + Returns :class:`True` if call is successful, otherwise :class:`False`. """ - if self._mixer is not None: - self._mixer.set_mute(bool(mute)) + if self._mixer is None: + return False + else: + return self._mixer.set_mute(bool(mute)).get() diff --git a/mopidy/mpd/protocol/audio_output.py b/mopidy/mpd/protocol/audio_output.py index 0152f852e7..6ffedcf105 100644 --- a/mopidy/mpd/protocol/audio_output.py +++ b/mopidy/mpd/protocol/audio_output.py @@ -13,7 +13,9 @@ def disableoutput(context, outputid): Turns an output off. """ if outputid == 0: - context.core.mixer.set_mute(False) + success = context.core.mixer.set_mute(False).get() + if success is False: + raise exceptions.MpdSystemError('problems disabling output') else: raise exceptions.MpdNoExistError('No such audio output') @@ -28,13 +30,14 @@ def enableoutput(context, outputid): Turns an output on. """ if outputid == 0: - context.core.mixer.set_mute(True) + success = context.core.mixer.set_mute(True).get() + if success is False: + raise exceptions.MpdSystemError('problems enabling output') else: raise exceptions.MpdNoExistError('No such audio output') -# TODO: implement and test -# @protocol.commands.add('toggleoutput', outputid=protocol.UINT) +@protocol.commands.add('toggleoutput', outputid=protocol.UINT) def toggleoutput(context, outputid): """ *musicpd.org, audio output section:* @@ -43,7 +46,13 @@ def toggleoutput(context, outputid): Turns an output on or off, depending on the current state. """ - pass + if outputid == 0: + mute_status = context.core.mixer.get_mute().get() + success = context.core.mixer.set_mute(not mute_status) + if success is False: + raise exceptions.MpdSystemError('problems toggling output') + else: + raise exceptions.MpdNoExistError('No such audio output') @protocol.commands.add('outputs') diff --git a/mopidy/mpd/protocol/playback.py b/mopidy/mpd/protocol/playback.py index f7856a0345..4cf8b2e895 100644 --- a/mopidy/mpd/protocol/playback.py +++ b/mopidy/mpd/protocol/playback.py @@ -32,8 +32,7 @@ def crossfade(context, seconds): raise exceptions.MpdNotImplemented # TODO -# TODO: add at least reflection tests before adding NotImplemented version -# @protocol.commands.add('mixrampdb') +@protocol.commands.add('mixrampdb') def mixrampdb(context, decibels): """ *musicpd.org, playback section:* @@ -46,11 +45,10 @@ def mixrampdb(context, decibels): volume so use negative values, I prefer -17dB. In the absence of mixramp tags crossfading will be used. See http://sourceforge.net/projects/mixramp """ - pass + raise exceptions.MpdNotImplemented # TODO -# TODO: add at least reflection tests before adding NotImplemented version -# @protocol.commands.add('mixrampdelay', seconds=protocol.UINT) +@protocol.commands.add('mixrampdelay', seconds=protocol.UINT) def mixrampdelay(context, seconds): """ *musicpd.org, playback section:* @@ -61,7 +59,7 @@ def mixrampdelay(context, seconds): value of "nan" disables MixRamp overlapping and falls back to crossfading. """ - pass + raise exceptions.MpdNotImplemented # TODO @protocol.commands.add('next') @@ -397,7 +395,10 @@ def setvol(context, volume): - issues ``setvol 50`` without quotes around the argument. """ # NOTE: we use INT as clients can pass in +N etc. - context.core.mixer.set_volume(min(max(0, volume), 100)) + value = min(max(0, volume), 100) + success = context.core.mixer.set_volume(value).get() + if success is False: + raise exceptions.MpdSystemError('problems setting volume') @protocol.commands.add('single', state=protocol.BOOL) diff --git a/tests/core/test_listener.py b/tests/core/test_listener.py index 6400376968..1338ec5e71 100644 --- a/tests/core/test_listener.py +++ b/tests/core/test_listener.py @@ -57,3 +57,6 @@ def test_listener_has_default_impl_for_mute_changed(self): def test_listener_has_default_impl_for_seeked(self): self.listener.seeked(0) + + def test_listener_has_default_impl_for_current_metadata_changed(self): + self.listener.current_metadata_changed() diff --git a/tests/core/test_mixer.py b/tests/core/test_mixer.py index 80e6f7ef95..6485f3e8b5 100644 --- a/tests/core/test_mixer.py +++ b/tests/core/test_mixer.py @@ -4,7 +4,10 @@ import mock +import pykka + from mopidy import core, mixer +from tests import dummy_mixer class CoreMixerTest(unittest.TestCase): @@ -33,3 +36,55 @@ def test_set_mute(self): self.core.mixer.set_mute(True) self.mixer.set_mute.assert_called_once_with(True) + + +class CoreNoneMixerTest(unittest.TestCase): + def setUp(self): # noqa: N802 + self.core = core.Core(mixer=None, backends=[]) + + def test_get_volume_return_none(self): + self.assertEqual(self.core.mixer.get_volume(), None) + + def test_set_volume_return_false(self): + self.assertEqual(self.core.mixer.set_volume(30), False) + + def test_get_set_mute_return_proper_state(self): + self.assertEqual(self.core.mixer.set_mute(False), False) + self.assertEqual(self.core.mixer.get_mute(), False) + self.assertEqual(self.core.mixer.set_mute(True), False) + self.assertEqual(self.core.mixer.get_mute(), False) + + +@mock.patch.object(mixer.MixerListener, 'send') +class CoreMixerListenerTest(unittest.TestCase): + def setUp(self): # noqa: N802 + self.mixer = dummy_mixer.create_proxy() + self.core = core.Core(mixer=self.mixer, backends=[]) + + def tearDown(self): # noqa: N802 + pykka.ActorRegistry.stop_all() + + def test_forwards_mixer_volume_changed_event_to_frontends(self, send): + self.assertEqual(self.core.mixer.set_volume(volume=60), True) + 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.mixer.set_mute(mute=True) + + self.assertEqual(send.call_args[0][0], 'mute_changed') + self.assertEqual(send.call_args[1]['mute'], True) + + +@mock.patch.object(mixer.MixerListener, 'send') +class CoreNoneMixerListenerTest(unittest.TestCase): + def setUp(self): # noqa: N802 + self.core = core.Core(mixer=None, backends=[]) + + def test_forwards_mixer_volume_changed_event_to_frontends(self, send): + self.assertEqual(self.core.mixer.set_volume(volume=60), False) + self.assertEqual(send.call_count, 0) + + def test_forwards_mixer_mute_changed_event_to_frontends(self, send): + self.core.mixer.set_mute(mute=True) + self.assertEqual(send.call_count, 0) diff --git a/tests/dummy_mixer.py b/tests/dummy_mixer.py index f7d90b17dd..6defddba2b 100644 --- a/tests/dummy_mixer.py +++ b/tests/dummy_mixer.py @@ -21,9 +21,13 @@ def get_volume(self): def set_volume(self, volume): self._volume = volume + self.trigger_volume_changed(volume=volume) + return True def get_mute(self): return self._mute def set_mute(self, mute): self._mute = mute + self.trigger_mute_changed(mute=mute) + return True diff --git a/tests/mpd/protocol/__init__.py b/tests/mpd/protocol/__init__.py index b07a5ba3c7..88e3567b29 100644 --- a/tests/mpd/protocol/__init__.py +++ b/tests/mpd/protocol/__init__.py @@ -25,6 +25,8 @@ def queue_send(self, data): class BaseTestCase(unittest.TestCase): + enable_mixer = True + def get_config(self): return { 'mpd': { @@ -33,7 +35,10 @@ def get_config(self): } def setUp(self): # noqa: N802 - self.mixer = dummy_mixer.create_proxy() + if self.enable_mixer: + self.mixer = dummy_mixer.create_proxy() + else: + self.mixer = None self.backend = dummy_backend.create_proxy() self.core = core.Core.start( mixer=self.mixer, backends=[self.backend]).proxy() diff --git a/tests/mpd/protocol/test_audio_output.py b/tests/mpd/protocol/test_audio_output.py index a86f24f04e..322bf18153 100644 --- a/tests/mpd/protocol/test_audio_output.py +++ b/tests/mpd/protocol/test_audio_output.py @@ -4,6 +4,7 @@ class AudioOutputHandlerTest(protocol.BaseTestCase): + def test_enableoutput(self): self.core.mixer.set_mute(False) @@ -50,3 +51,95 @@ def test_outputs_when_muted(self): self.assertInResponse('outputname: Mute') self.assertInResponse('outputenabled: 1') self.assertInResponse('OK') + + def test_outputs_toggleoutput(self): + self.core.mixer.set_mute(False) + + self.send_request('toggleoutput "0"') + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 1') + self.assertInResponse('OK') + + self.send_request('toggleoutput "0"') + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 0') + self.assertInResponse('OK') + + self.send_request('toggleoutput "0"') + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 1') + self.assertInResponse('OK') + + def test_outputs_toggleoutput_unknown_outputid(self): + self.send_request('toggleoutput "7"') + + self.assertInResponse( + 'ACK [50@0] {toggleoutput} No such audio output') + + +class AudioOutputHandlerNoneMixerTest(protocol.BaseTestCase): + enable_mixer = False + + def test_enableoutput(self): + self.core.mixer.set_mute(False) + + self.send_request('enableoutput "0"') + self.assertInResponse( + 'ACK [52@0] {enableoutput} problems enabling output') + self.assertEqual(self.core.mixer.get_mute().get(), False) + + def test_disableoutput(self): + self.core.mixer.set_mute(True) + + self.send_request('disableoutput "0"') + self.assertInResponse( + 'ACK [52@0] {disableoutput} problems disabling output') + self.assertEqual(self.core.mixer.get_mute().get(), False) + + def test_outputs_when_unmuted(self): + self.core.mixer.set_mute(False) + + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 0') + self.assertInResponse('OK') + + def test_outputs_when_muted(self): + self.core.mixer.set_mute(True) + + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 0') + self.assertInResponse('OK') + + def test_outputs_toggleoutput(self): + self.core.mixer.set_mute(False) + + self.send_request('toggleoutput "0"') + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 0') + self.assertInResponse('OK') + + self.send_request('toggleoutput "0"') + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 0') + self.assertInResponse('OK') diff --git a/tests/mpd/protocol/test_idle.py b/tests/mpd/protocol/test_idle.py index 0bd16992b9..e3c6ad389c 100644 --- a/tests/mpd/protocol/test_idle.py +++ b/tests/mpd/protocol/test_idle.py @@ -50,6 +50,12 @@ def test_idle_player(self): self.assertNoEvents() self.assertNoResponse() + def test_idle_output(self): + self.send_request('idle output') + self.assertEqualSubscriptions(['output']) + self.assertNoEvents() + self.assertNoResponse() + def test_idle_player_playlist(self): self.send_request('idle player playlist') self.assertEqualSubscriptions(['player', 'playlist']) @@ -102,6 +108,22 @@ def test_idle_player_then_event_player(self): self.assertOnceInResponse('changed: player') self.assertOnceInResponse('OK') + def test_idle_then_output(self): + self.send_request('idle') + self.idle_event('output') + self.assertNoSubscriptions() + self.assertNoEvents() + self.assertOnceInResponse('changed: output') + self.assertOnceInResponse('OK') + + def test_idle_output_then_event_output(self): + self.send_request('idle output') + self.idle_event('output') + self.assertNoSubscriptions() + self.assertNoEvents() + self.assertOnceInResponse('changed: output') + self.assertOnceInResponse('OK') + def test_idle_player_then_noidle(self): self.send_request('idle player') self.send_request('noidle') @@ -206,3 +228,11 @@ def test_player_then_playlist_then_idle_playlist(self): self.assertNotInResponse('changed: player') self.assertOnceInResponse('changed: playlist') self.assertOnceInResponse('OK') + + def test_output_then_idle_toggleoutput(self): + self.idle_event('output') + self.send_request('idle output') + self.assertNoEvents() + self.assertNoSubscriptions() + self.assertOnceInResponse('changed: output') + self.assertOnceInResponse('OK') diff --git a/tests/mpd/protocol/test_playback.py b/tests/mpd/protocol/test_playback.py index ea9c59ce07..4f3d6d7a8d 100644 --- a/tests/mpd/protocol/test_playback.py +++ b/tests/mpd/protocol/test_playback.py @@ -150,6 +150,14 @@ def test_replay_gain_status_default(self): self.assertInResponse('OK') self.assertInResponse('off') + def test_mixrampdb(self): + self.send_request('mixrampdb "10"') + self.assertInResponse('ACK [0@0] {mixrampdb} Not implemented') + + def test_mixrampdelay(self): + self.send_request('mixrampdelay "10"') + self.assertInResponse('ACK [0@0] {mixrampdelay} Not implemented') + @unittest.SkipTest def test_replay_gain_status_off(self): pass @@ -463,3 +471,11 @@ def test_stop(self): self.send_request('stop') self.assertEqual(STOPPED, self.core.playback.state.get()) self.assertInResponse('OK') + + +class PlaybackOptionsHandlerNoneMixerTest(protocol.BaseTestCase): + enable_mixer = False + + def test_setvol_max_error(self): + self.send_request('setvol "100"') + self.assertInResponse('ACK [52@0] {setvol} problems setting volume') diff --git a/tests/mpd/test_exceptions.py b/tests/mpd/test_exceptions.py index d055ef7e90..7bb640967c 100644 --- a/tests/mpd/test_exceptions.py +++ b/tests/mpd/test_exceptions.py @@ -3,8 +3,8 @@ import unittest from mopidy.mpd.exceptions import ( - MpdAckError, MpdNoCommand, MpdNotImplemented, MpdPermissionError, - MpdSystemError, MpdUnknownCommand) + MpdAckError, MpdNoCommand, MpdNoExistError, MpdNotImplemented, + MpdPermissionError, MpdSystemError, MpdUnknownCommand) class MpdExceptionsTest(unittest.TestCase): @@ -61,3 +61,11 @@ def test_mpd_permission_error(self): self.assertEqual( e.get_mpd_ack(), 'ACK [4@0] {foo} you don\'t have permission for "foo"') + + def test_mpd_noexist_error(self): + try: + raise MpdNoExistError(command='foo') + except MpdNoExistError as e: + self.assertEqual( + e.get_mpd_ack(), + 'ACK [50@0] {foo} ')