diff --git a/mycroft/skills/audioservice.py b/mycroft/skills/audioservice.py index 47a2cb9e45..0ec301dc8a 100644 --- a/mycroft/skills/audioservice.py +++ b/mycroft/skills/audioservice.py @@ -12,21 +12,19 @@ # See the License for the specific language governing permissions and # limitations under the License. # -import time from os.path import abspath from mycroft.messagebus.message import Message def ensure_uri(s): - """ - Interprete paths as file:// uri's + """Interprete paths as file:// uri's. - Args: - s: string to be checked + Arguments: + s: string to be checked - Returns: - if s is uri, s is returned otherwise file:// is prepended + Returns: + if s is uri, s is returned otherwise file:// is prepended """ if isinstance(s, str): if '://' not in s: @@ -43,33 +41,25 @@ def ensure_uri(s): class AudioService: - """ - AudioService class for interacting with the audio subsystem + """AudioService class for interacting with the audio subsystem - Arguments: - bus: Mycroft messagebus connection + Arguments: + bus: Mycroft messagebus connection """ def __init__(self, bus): self.bus = bus - self.bus.on('mycroft.audio.service.track_info_reply', - self._track_info) - self.info = None - - def _track_info(self, message=None): - """ - Handler for catching returning track info - """ - self.info = message.data def queue(self, tracks=None): - """ Queue up a track to playing playlist. + """Queue up a track to playing playlist. - Args: - tracks: track uri or list of track uri's + Arguments: + tracks: track uri or list of track uri's + Each track can be added as a tuple with (uri, mime) + to give a hint of the mime type to the system """ tracks = tracks or [] - if isinstance(tracks, str): + if isinstance(tracks, (str, tuple)): tracks = [tracks] elif not isinstance(tracks, list): raise ValueError @@ -78,15 +68,15 @@ class AudioService: data={'tracks': tracks})) def play(self, tracks=None, utterance=None, repeat=None): - """ Start playback. + """Start playback. - Args: - tracks: track uri or list of track uri's - Each track can be added as a tuple with (uri, mime) - to give a hint of the mime type to the system - utterance: forward utterance for further processing by the - audio service. - repeat: if the playback should be looped + Arguments: + tracks: track uri or list of track uri's + Each track can be added as a tuple with (uri, mime) + to give a hint of the mime type to the system + utterance: forward utterance for further processing by the + audio service. + repeat: if the playback should be looped """ repeat = repeat or False tracks = tracks or [] @@ -102,31 +92,30 @@ class AudioService: 'repeat': repeat})) def stop(self): - """ Stop the track. """ + """Stop the track.""" self.bus.emit(Message('mycroft.audio.service.stop')) def next(self): - """ Change to next track. """ + """Change to next track.""" self.bus.emit(Message('mycroft.audio.service.next')) def prev(self): - """ Change to previous track. """ + """Change to previous track.""" self.bus.emit(Message('mycroft.audio.service.prev')) def pause(self): - """ Pause playback. """ + """Pause playback.""" self.bus.emit(Message('mycroft.audio.service.pause')) def resume(self): - """ Resume paused playback. """ + """Resume paused playback.""" self.bus.emit(Message('mycroft.audio.service.resume')) def seek(self, seconds=1): - """ - seek X seconds + """Seek X seconds. - Args: - seconds (int): number of seconds to seek, if negative rewind + Arguments: + seconds (int): number of seconds to seek, if negative rewind """ if seconds < 0: self.seek_backward(abs(seconds)) @@ -134,42 +123,37 @@ class AudioService: self.seek_forward(seconds) def seek_forward(self, seconds=1): - """ - skip ahead X seconds + """Skip ahead X seconds. - Args: - seconds (int): number of seconds to skip + Arguments: + seconds (int): number of seconds to skip """ self.bus.emit(Message('mycroft.audio.service.seek_forward', {"seconds": seconds})) def seek_backward(self, seconds=1): - """ - rewind X seconds + """Rewind X seconds - Args: - seconds (int): number of seconds to rewind + Arguments: + seconds (int): number of seconds to rewind """ self.bus.emit(Message('mycroft.audio.service.seek_backward', {"seconds": seconds})) def track_info(self): - """ Request information of current playing track. + """Request information of current playing track. - Returns: - Dict with track info. + Returns: + Dict with track info. """ - self.info = None - self.bus.emit(Message('mycroft.audio.service.track_info')) - wait = 5.0 - while self.info is None and wait >= 0: - time.sleep(0.1) - wait -= 0.1 - - return self.info or {} + info = self.bus.wait_for_response( + Message('mycroft.audio.service.track_info'), + reply_type='mycroft.audio.service.track_info_reply', + timeout=5) + return info.data if info else {} def available_backends(self): - """ Return available audio backends. + """Return available audio backends. Returns: dict with backend names as keys @@ -180,4 +164,5 @@ class AudioService: @property def is_playing(self): + """True if the audioservice is playing, else False.""" return self.track_info() != {} diff --git a/mycroft/skills/common_play_skill.py b/mycroft/skills/common_play_skill.py index 69da901f99..df31518605 100644 --- a/mycroft/skills/common_play_skill.py +++ b/mycroft/skills/common_play_skill.py @@ -52,7 +52,8 @@ class CommonPlaySkill(MycroftSkill, ABC): # with a translatable name in their initialize() method. def bind(self, bus): - """ Overrides the normal bind method. + """Overrides the normal bind method. + Adds handlers for play:query and play:start messages allowing interaction with the playback control skill. @@ -66,6 +67,7 @@ class CommonPlaySkill(MycroftSkill, ABC): self.add_event('play:start', self.__handle_play_start) def __handle_play_query(self, message): + """Query skill if it can start playback from given phrase.""" search_phrase = message.data["phrase"] # First, notify the requestor that we are attempting to handle @@ -94,11 +96,19 @@ class CommonPlaySkill(MycroftSkill, ABC): "searching": False})) def __calc_confidence(self, match, phrase, level): - # "play pandora" - # "play pandora is my girlfriend" - # "play tom waits on pandora" + """Translate confidence level and match to a 0-1 value. - # Assume the more of the words that get consumed, the better the match + "play pandora" + "play pandora is my girlfriend" + "play tom waits on pandora" + + Assume the more of the words that get consumed, the better the match + + Arguments: + match (str): Matching string + phrase (str): original input phrase + level (CPSMatchLevel): match level + """ consumed_pct = len(match.split()) / len(phrase.split()) if consumed_pct > 1.0: consumed_pct = 1.0 / consumed_pct # deal with over/under-matching @@ -123,6 +133,7 @@ class CommonPlaySkill(MycroftSkill, ABC): return 0.0 # should never happen def __handle_play_start(self, message): + """Bus handler for starting playback using the skill.""" if message.data["skill_id"] != self.skill_id: # Not for this skill! return @@ -142,8 +153,7 @@ class CommonPlaySkill(MycroftSkill, ABC): self.CPS_start(phrase, data) def CPS_play(self, *args, **kwargs): - """ - Begin playback of a media file or stream + """Begin playback of a media file or stream Normally this method will be invoked with somthing like: self.CPS_play(url) @@ -160,6 +170,7 @@ class CommonPlaySkill(MycroftSkill, ABC): self.audioservice.play(*args, **kwargs) def stop(self): + """Stop anything playing on the audioservice.""" if self.audioservice.is_playing: self.audioservice.stop() return True @@ -172,11 +183,9 @@ class CommonPlaySkill(MycroftSkill, ABC): # act as a CommonPlay Skill @abstractmethod def CPS_match_query_phrase(self, phrase): - """ - Analyze phrase to see if it is a play-able phrase with this - skill. + """Analyze phrase to see if it is a play-able phrase with this skill. - Args: + Arguments: phrase (str): User phrase uttered after "Play", e.g. "some music" Returns: @@ -204,10 +213,9 @@ class CommonPlaySkill(MycroftSkill, ABC): @abstractmethod def CPS_start(self, phrase, data): - """ - Begin playing whatever is specified in 'phrase' + """Begin playing whatever is specified in 'phrase' - Args: + Arguments: phrase (str): User phrase uttered after "Play", e.g. "some music" data (dict): Callback data specified in match_query_phrase() """ diff --git a/test/unittests/skills/test_audioservice.py b/test/unittests/skills/test_audioservice.py new file mode 100644 index 0000000000..75635374f7 --- /dev/null +++ b/test/unittests/skills/test_audioservice.py @@ -0,0 +1,176 @@ +from unittest import TestCase, mock + +from mycroft.messagebus import Message +from mycroft.skills.audioservice import AudioService + + +class TestAudioServiceControls(TestCase): + def assertLastMessageTypeEqual(self, bus, msg_type): + message = bus.emit.call_args_list[-1][0][0] + self.assertEqual(message.msg_type, msg_type) + + def setUp(self): + self.bus = mock.Mock(name='bus') + self.audioservice = AudioService(self.bus) + + def test_pause(self): + self.audioservice.pause() + self.assertLastMessageTypeEqual(self.bus, + 'mycroft.audio.service.pause') + + def test_resume(self): + self.audioservice.resume() + self.assertLastMessageTypeEqual(self.bus, + 'mycroft.audio.service.resume') + + def test_next(self): + self.audioservice.next() + self.assertLastMessageTypeEqual(self.bus, 'mycroft.audio.service.next') + + def test_prev(self): + self.audioservice.prev() + self.assertLastMessageTypeEqual(self.bus, 'mycroft.audio.service.prev') + + def test_stop(self): + self.audioservice.stop() + self.assertLastMessageTypeEqual(self.bus, 'mycroft.audio.service.stop') + + def test_seek(self): + self.audioservice.seek() + message = self.bus.emit.call_args_list[-1][0][0] + self.assertEqual(message.msg_type, + 'mycroft.audio.service.seek_forward') + self.assertEqual(message.data['seconds'], 1) + self.audioservice.seek(5) + message = self.bus.emit.call_args_list[-1][0][0] + self.assertEqual(message.msg_type, + 'mycroft.audio.service.seek_forward') + self.assertEqual(message.data['seconds'], 5) + self.audioservice.seek(-5) + message = self.bus.emit.call_args_list[-1][0][0] + self.assertEqual(message.msg_type, + 'mycroft.audio.service.seek_backward') + self.assertEqual(message.data['seconds'], 5) + + +class TestAudioServicePlay(TestCase): + def setUp(self): + self.bus = mock.Mock(name='bus') + self.audioservice = AudioService(self.bus) + + def test_proper_uri(self): + self.audioservice.play('file:///hello_nasty.mp3') + message = self.bus.emit.call_args_list[-1][0][0] + self.assertEqual(message.msg_type, 'mycroft.audio.service.play') + self.assertEqual(message.data['tracks'], ['file:///hello_nasty.mp3']) + self.assertEqual(message.data['repeat'], False) + + def test_path(self): + self.audioservice.play('/hello_nasty.mp3') + message = self.bus.emit.call_args_list[-1][0][0] + self.assertEqual(message.msg_type, 'mycroft.audio.service.play') + self.assertEqual(message.data['tracks'], ['file:///hello_nasty.mp3']) + self.assertEqual(message.data['repeat'], False) + + def test_tuple(self): + """Test path together with mimetype.""" + self.audioservice.play(('/hello_nasty.mp3', 'audio/mp3')) + message = self.bus.emit.call_args_list[-1][0][0] + self.assertEqual(message.msg_type, 'mycroft.audio.service.play') + self.assertEqual(message.data['tracks'], + [('file:///hello_nasty.mp3', 'audio/mp3')]) + self.assertEqual(message.data['repeat'], False) + + def test_invalid(self): + """Test play request with invalid type.""" + with self.assertRaises(ValueError): + self.audioservice.play(12) + + def test_extra_arguments(self): + """Test sending along utterance and setting repeat.""" + self.audioservice.play('/hello_nasty.mp3', 'on vlc', True) + message = self.bus.emit.call_args_list[-1][0][0] + self.assertEqual(message.msg_type, 'mycroft.audio.service.play') + self.assertEqual(message.data['tracks'], ['file:///hello_nasty.mp3']) + self.assertEqual(message.data['repeat'], True) + self.assertEqual(message.data['utterance'], 'on vlc') + + +class TestAudioServiceQueue(TestCase): + def setUp(self): + self.bus = mock.Mock(name='bus') + self.audioservice = AudioService(self.bus) + + def test_uri(self): + self.audioservice.queue('file:///hello_nasty.mp3') + message = self.bus.emit.call_args_list[-1][0][0] + self.assertEqual(message.msg_type, 'mycroft.audio.service.queue') + self.assertEqual(message.data['tracks'], ['file:///hello_nasty.mp3']) + + def test_path(self): + self.audioservice.queue('/hello_nasty.mp3') + message = self.bus.emit.call_args_list[-1][0][0] + self.assertEqual(message.msg_type, 'mycroft.audio.service.queue') + self.assertEqual(message.data['tracks'], ['file:///hello_nasty.mp3']) + + def test_tuple(self): + self.audioservice.queue(('/hello_nasty.mp3', 'audio/mp3')) + message = self.bus.emit.call_args_list[-1][0][0] + self.assertEqual(message.msg_type, 'mycroft.audio.service.queue') + self.assertEqual(message.data['tracks'], + [('file:///hello_nasty.mp3', 'audio/mp3')]) + + def test_invalid(self): + with self.assertRaises(ValueError): + self.audioservice.queue(12) + + +class TestAudioServiceMisc(TestCase): + def test_lifecycle(self): + bus = mock.Mock(name='bus') + audioservice = AudioService(bus) + self.assertEqual(audioservice.bus, bus) + + def test_available_backends(self): + bus = mock.Mock(name='bus') + audioservice = AudioService(bus) + + available_backends = { + 'simple': { + 'suported_uris': ['http', 'file'], + 'default': True, + 'remote': False + } + } + bus.wait_for_response.return_value = Message('test_msg', + available_backends) + response = audioservice.available_backends() + self.assertEqual(available_backends, response) + # Check no response behaviour + bus.wait_for_response.return_value = None + response = audioservice.available_backends() + self.assertEqual({}, response) + + def test_track_info(self): + """Test is_playing property.""" + bus = mock.Mock(name='bus') + audioservice = AudioService(bus) + info = {'album': 'Hello Nasty', + 'artist': 'Beastie Boys', + 'name': 'Intergalactic' + } + bus.wait_for_response.return_value = Message('test_msg', info) + self.assertEqual(audioservice.track_info(), info) + bus.wait_for_response.return_value = None + self.assertEqual(audioservice.track_info(), {}) + + def test_is_playing(self): + """Test is_playing property.""" + bus = mock.Mock(name='bus') + audioservice = AudioService(bus) + audioservice.track_info = mock.Mock() + + audioservice.track_info.return_value = {'track': 'one cool song'} + self.assertTrue(audioservice.is_playing) + audioservice.track_info.return_value = {} + self.assertFalse(audioservice.is_playing) diff --git a/test/unittests/skills/test_common_play_skill.py b/test/unittests/skills/test_common_play_skill.py new file mode 100644 index 0000000000..d7e92ff521 --- /dev/null +++ b/test/unittests/skills/test_common_play_skill.py @@ -0,0 +1,140 @@ +from unittest import TestCase, mock + +from mycroft.messagebus import Message +from mycroft.skills.common_play_skill import CommonPlaySkill, CPSMatchLevel +from mycroft.skills.audioservice import AudioService + + +class AnyCallable: + """Class matching any callable. + + Useful for assert_called_with arguments. + """ + def __eq__(self, other): + return callable(other) + + +class TestCommonPlay(TestCase): + def setUp(self): + self.skill = CPSTest() + self.bus = mock.Mock(name='bus') + self.skill.bind(self.bus) + self.audioservice = mock.Mock(name='audioservice') + self.skill.audioservice = self.audioservice + + def test_lifecycle(self): + skill = CPSTest() + bus = mock.Mock(name='bus') + skill.bind(bus) + self.assertTrue(isinstance(skill.audioservice, AudioService)) + bus.on.assert_any_call('play:query', AnyCallable()) + bus.on.assert_any_call('play:start', AnyCallable()) + skill.shutdown() + + def test_handle_start_playback(self): + """Test common play start method.""" + self.skill.audioservice.is_playing = True + start_playback = self.bus.on.call_args_list[-1][0][1] + + phrase = 'Don\'t open until doomsday' + start_playback(Message('play:start', data={'phrase': phrase, + 'skill_id': 'asdf'})) + self.skill.CPS_start.assert_not_called() + + self.bus.emit.reset_mock() + start_playback(Message('play:start', + data={'phrase': phrase, + 'skill_id': self.skill.skill_id})) + self.audioservice.stop.assert_called_once_with() + self.skill.CPS_start.assert_called_once_with(phrase, None) + + def test_cps_play(self): + """Test audioservice play helper.""" + self.skill.play_service_string = 'play on godzilla' + self.skill.CPS_play(['looking_for_freedom.mp3'], + utterance='play on mothra') + self.audioservice.play.assert_called_once_with( + ['looking_for_freedom.mp3'], utterance='play on mothra') + + self.audioservice.play.reset_mock() + # Assert that the utterance is injected + self.skill.CPS_play(['looking_for_freedom.mp3']) + self.audioservice.play.assert_called_once_with( + ['looking_for_freedom.mp3'], utterance='play on godzilla') + + def test_stop(self): + """Test default reaction to stop command.""" + self.audioservice.is_playing = False + self.assertFalse(self.skill.stop()) + + self.audioservice.is_playing = True + self.assertTrue(self.skill.stop()) + + +class TestCPSQuery(TestCase): + def setUp(self): + self.skill = CPSTest() + self.bus = mock.Mock(name='bus') + self.skill.bind(self.bus) + self.audioservice = mock.Mock(name='audioservice') + self.skill.audioservice = self.audioservice + self.query_phrase = self.bus.on.call_args_list[-2][0][1] + + def test_handle_play_query_no_match(self): + """Test common play match when no match is found.""" + + # Check Not matching queries + self.skill.CPS_match_query_phrase.return_value = None + self.query_phrase(Message('play:query', + data={'phrase': 'Monster mash'})) + + # Check that the skill replied that it was searching + extension = self.bus.emit.call_args_list[-2][0][0] + self.assertEqual(extension.data['phrase'], 'Monster mash') + self.assertEqual(extension.data['skill_id'], self.skill.skill_id) + self.assertEqual(extension.data['searching'], True) + + # Assert that the skill reported that it couldn't find the phrase + response = self.bus.emit.call_args_list[-1][0][0] + self.assertEqual(response.data['phrase'], 'Monster mash') + self.assertEqual(response.data['skill_id'], self.skill.skill_id) + self.assertEqual(response.data['searching'], False) + + def test_play_query_match(self): + """Test common play match when a match is found.""" + phrase = 'Don\'t open until doomsday' + self.skill.CPS_match_query_phrase.return_value = (phrase, + CPSMatchLevel.TITLE) + self.query_phrase(Message('play:query', + data={'phrase': phrase})) + # Assert that the skill reported the correct confidence + response = self.bus.emit.call_args_list[-1][0][0] + self.assertEqual(response.data['phrase'], phrase) + self.assertEqual(response.data['skill_id'], self.skill.skill_id) + self.assertAlmostEqual(response.data['conf'], 0.85) + + # Partial phrase match + self.skill.CPS_match_query_phrase.return_value = ('until doomsday', + CPSMatchLevel.TITLE) + self. query_phrase(Message('play:query', + data={'phrase': phrase})) + # Assert that the skill reported the correct confidence + response = self.bus.emit.call_args_list[-1][0][0] + self.assertEqual(response.data['phrase'], phrase) + self.assertEqual(response.data['skill_id'], self.skill.skill_id) + self.assertAlmostEqual(response.data['conf'], 0.825) + + +class CPSTest(CommonPlaySkill): + """Simple skill for testing the CommonPlaySkill""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.CPS_match_query_phrase = mock.Mock(name='match_phrase') + self.CPS_start = mock.Mock(name='start_playback') + self.skill_id = 'CPSTest' + + def CPS_match_query_phrase(self, phrase): + pass + + def CPS_start(self, data): + pass