diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 801b48f649a..db3a2c01682 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -32,6 +32,7 @@ jobs: pip install ./test/end2end/skill-ovos-schedule pip install ./test/end2end/skill-new-stop pip install ./test/end2end/skill-old-stop + pip install ./test/end2end/skill-fake-fm - name: Install core repo run: | pip install -e .[mycroft,deprecated] diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index d5955a77101..4f01dab4407 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -59,6 +59,7 @@ jobs: pip install ./test/end2end/skill-ovos-schedule pip install ./test/end2end/skill-new-stop pip install ./test/end2end/skill-old-stop + pip install ./test/end2end/skill-fake-fm - name: Install core repo run: | pip install -e .[mycroft,deprecated] diff --git a/ovos_core/intent_services/__init__.py b/ovos_core/intent_services/__init__.py index 4faec8fe92a..0d9b690e165 100644 --- a/ovos_core/intent_services/__init__.py +++ b/ovos_core/intent_services/__init__.py @@ -49,9 +49,11 @@ class IntentService: querying the intent service. """ - def __init__(self, bus): + def __init__(self, bus, config=None): self.bus = bus - config = Configuration() + self.config = config or Configuration().get("intents", {}) + if "padatious" not in self.config: + self.config["padatious"] = Configuration().get("padatious", {}) # Dictionary for translating a skill id to a name self.skill_names = {} @@ -60,18 +62,18 @@ def __init__(self, bus): self.adapt_service = AdaptService() try: from ovos_core.intent_services.padatious_service import PadatiousService - self.padatious_service = PadatiousService(bus, config['padatious']) + self.padatious_service = PadatiousService(bus, self.config["padatious"]) except ImportError: LOG.error(f'Failed to create padatious intent handlers, padatious not installed') self.padatious_service = None - self.padacioso_service = PadaciosoService(bus, config['padatious']) + self.padacioso_service = PadaciosoService(bus, self.config["padatious"]) self.fallback = FallbackService(bus) self.converse = ConverseService(bus) self.common_qa = CommonQAService(bus) self.stop = StopService(bus) self.ocp = None - self.utterance_plugins = UtteranceTransformersService(bus, config=config) - self.metadata_plugins = MetadataTransformersService(bus, config=config) + self.utterance_plugins = UtteranceTransformersService(bus) + self.metadata_plugins = MetadataTransformersService(bus) self._load_ocp_pipeline() # TODO - enable by default once stable @@ -110,8 +112,7 @@ def __init__(self, bus): def _load_ocp_pipeline(self): """EXPERIMENTAL: this feature is not yet ready for end users""" - audio_enabled = Configuration().get("enable_old_audioservice", True) - if not audio_enabled: + if self.config.get("experimental_ocp_pipeline", False): LOG.warning("EXPERIMENTAL: the OCP pipeline is enabled!") try: from ovos_core.intent_services.ocp_service import OCPPipelineMatcher diff --git a/ovos_core/intent_services/ocp_service.py b/ovos_core/intent_services/ocp_service.py index f8c78222fa8..d1bc068a0cc 100644 --- a/ovos_core/intent_services/ocp_service.py +++ b/ovos_core/intent_services/ocp_service.py @@ -4,14 +4,16 @@ from threading import RLock from typing import List, Tuple, Optional +from ovos_classifiers.skovos.classifier import SklearnOVOSClassifier +from ovos_classifiers.skovos.features import ClassifierProbaVectorizer, KeywordFeaturesVectorizer from padacioso import IntentContainer from sklearn.pipeline import FeatureUnion import ovos_core.intent_services -from ovos_bus_client.apis.ocp import OCPInterface, OCPQuery +from ovos_bus_client.apis.ocp import OCPInterface, OCPQuery, ClassicAudioServiceInterface from ovos_bus_client.message import Message -from ovos_classifiers.skovos.classifier import SklearnOVOSClassifier -from ovos_classifiers.skovos.features import ClassifierProbaVectorizer, KeywordFeaturesVectorizer +from ovos_config import Configuration +from ovos_plugin_manager.ocp import load_stream_extractors from ovos_utils import classproperty from ovos_utils.log import LOG from ovos_utils.messagebus import FakeBus @@ -104,6 +106,7 @@ def __init__(self, bus=None, config=None): resources_dir=f"{dirname(__file__)}") self.ocp_api = OCPInterface(self.bus) + self.legacy_api = ClassicAudioServiceInterface(self.bus) self.config = config or {} self.search_lock = RLock() @@ -171,6 +174,12 @@ def register_ocp_api_events(self): self.bus.on('ovos.common_play.register_keyword', self.handle_skill_keyword_register) self.bus.on('ovos.common_play.deregister_keyword', self.handle_skill_keyword_deregister) self.bus.on('ovos.common_play.announce', self.handle_skill_register) + + self.bus.on("mycroft.audio.playing_track", self._handle_legacy_audio_start) + self.bus.on("mycroft.audio.queue_end", self._handle_legacy_audio_end) + self.bus.on("mycroft.audio.service.pause", self._handle_legacy_audio_pause) + self.bus.on("mycroft.audio.service.resume", self._handle_legacy_audio_resume) + self.bus.on("mycroft.audio.service.stop", self._handle_legacy_audio_stop) self.bus.emit(Message("ovos.common_play.status")) # sync on launch def register_ocp_intents(self): @@ -526,7 +535,10 @@ def handle_play_intent(self, message: Message): 'origin': OCP_ID})) # ovos-PHAL-plugin-mk1 will display music icon in response to play message - self.ocp_api.play(results, query) + if self.use_legacy_audio: + self.legacy_play(results, query) + else: + self.ocp_api.play(results, query) def handle_open_intent(self, message: Message): LOG.info("Requesting OCP homescreen") @@ -539,49 +551,54 @@ def handle_like_intent(self, message: Message): self.bus.emit(message.forward("ovos.common_play.like")) def handle_stop_intent(self, message: Message): - LOG.info("Requesting OCP to go to stop") - self.ocp_api.stop() + if self.use_legacy_audio: + LOG.info("Requesting Legacy AudioService to stop") + self.legacy_api.stop() + else: + LOG.info("Requesting OCP to stop") + self.ocp_api.stop() def handle_next_intent(self, message: Message): - LOG.info("Requesting OCP to go to next track") - self.ocp_api.next() + if self.use_legacy_audio: + LOG.info("Requesting Legacy AudioService to go to next track") + self.legacy_api.next() + else: + LOG.info("Requesting OCP to go to next track") + self.ocp_api.next() def handle_prev_intent(self, message: Message): - LOG.info("Requesting OCP to go to prev track") - self.ocp_api.prev() + if self.use_legacy_audio: + LOG.info("Requesting Legacy AudioService to go to prev track") + self.legacy_api.prev() + else: + LOG.info("Requesting OCP to go to prev track") + self.ocp_api.prev() def handle_pause_intent(self, message: Message): - LOG.info("Requesting OCP to go to pause") - self.ocp_api.pause() + if self.use_legacy_audio: + LOG.info("Requesting Legacy AudioService to pause") + self.legacy_api.pause() + else: + LOG.info("Requesting OCP to go to pause") + self.ocp_api.pause() def handle_resume_intent(self, message: Message): - LOG.info("Requesting OCP to go to resume") - self.ocp_api.resume() + if self.use_legacy_audio: + LOG.info("Requesting Legacy AudioService to resume") + self.legacy_api.resume() + else: + LOG.info("Requesting OCP to go to resume") + self.ocp_api.resume() def handle_search_error_intent(self, message: Message): self.bus.emit(message.forward("mycroft.audio.play_sound", {"uri": "snd/error.mp3"})) - LOG.info("Requesting OCP to stop") - self.ocp_api.stop() - - def _do_play(self, phrase: str, results, media_type=MediaType.GENERIC): - self.bus.emit(Message('ovos.common_play.reset')) - LOG.debug(f"Playing {len(results)} results for: {phrase}") - if not results: - self.speak_dialog("cant.play", - data={"phrase": phrase, - "media_type": media_type}) + if self.use_legacy_audio: + LOG.info("Requesting Legacy AudioService to stop") + self.legacy_api.stop() else: - best = self.select_best(results) - results = [r for r in results if r.uri != best.uri] - results.insert(0, best) - self.bus.emit(Message('add_context', - {'context': "Playing", - 'word': "", - 'origin': OCP_ID})) - - # ovos-PHAL-plugin-mk1 will display music icon in response to play message - self.ocp_api.play(results, phrase) + LOG.info("Requesting OCP to stop") + self.ocp_api.stop() # NLP @staticmethod @@ -751,11 +768,12 @@ def filter_results(self, results: list, phrase: str, lang: str, video_only = True # check if user said "play XXX audio only" - if audio_only: + if audio_only or self.use_legacy_audio: l1 = len(results) # TODO - also check inside playlists results = [r for r in results - if isinstance(r, Playlist) or r.playback == PlaybackType.AUDIO] + if (isinstance(r, Playlist) and not self.use_legacy_audio) + or r.playback == PlaybackType.AUDIO] LOG.debug(f"filtered {l1 - len(results)} non-audio results") # check if user said "play XXX video only" @@ -853,3 +871,51 @@ def select_best(self, results: list) -> MediaEntry: LOG.info(f"OVOSCommonPlay selected: {selected.skill_id} - {selected.match_confidence}") LOG.debug(str(selected)) return selected + + ################## + # Legacy Audio subsystem API + @property + def use_legacy_audio(self): + """when neither ovos-media nor old OCP are available""" + if self.config.get("legacy"): + # explicitly set in pipeline config + return True + cfg = Configuration() + return cfg.get("disable_ocp") and cfg.get("enable_old_audioservice") + + def legacy_play(self, results: List[MediaEntry], phrase=""): + xtract = load_stream_extractors() + # for legacy audio service we need to do stream extraction here + # we also need to filter video results + results = [xtract.extract_stream(r.uri, video=False)["uri"] + for r in results + if r.playback == PlaybackType.AUDIO + or r.media_type in OCPQuery.cast2audio] + self.player_state = PlayerState.PLAYING + self.media_state = MediaState.LOADING_MEDIA + self.legacy_api.play(results, utterance=phrase) + + def _handle_legacy_audio_stop(self, message: Message): + if self.use_legacy_audio: + self.player_state = PlayerState.STOPPED + self.media_state = MediaState.NO_MEDIA + + def _handle_legacy_audio_pause(self, message: Message): + if self.use_legacy_audio and self.player_state == PlayerState.PLAYING: + self.player_state = PlayerState.PAUSED + self.media_state = MediaState.LOADED_MEDIA + + def _handle_legacy_audio_resume(self, message: Message): + if self.use_legacy_audio and self.player_state == PlayerState.PAUSED: + self.player_state = PlayerState.PLAYING + self.media_state = MediaState.LOADED_MEDIA + + def _handle_legacy_audio_start(self, message: Message): + if self.use_legacy_audio: + self.player_state = PlayerState.PLAYING + self.media_state = MediaState.LOADED_MEDIA + + def _handle_legacy_audio_end(self, message: Message): + if self.use_legacy_audio: + self.player_state = PlayerState.STOPPED + self.media_state = MediaState.END_OF_MEDIA diff --git a/ovos_core/transformers.py b/ovos_core/transformers.py index 168a1417cd6..f287aabcfe1 100644 --- a/ovos_core/transformers.py +++ b/ovos_core/transformers.py @@ -1,7 +1,6 @@ from typing import Optional, List - +from ovos_config import Configuration from ovos_plugin_manager.metadata_transformers import find_metadata_transformer_plugins -from ovos_plugin_manager.templates.transformers import UtteranceTransformer from ovos_plugin_manager.text_transformers import find_utterance_transformer_plugins from ovos_utils.json_helper import merge_dict @@ -11,7 +10,7 @@ class UtteranceTransformersService: def __init__(self, bus, config=None): - self.config_core = config or {} + self.config_core = config or Configuration() self.loaded_plugins = {} self.has_loaded = False self.bus = bus @@ -68,7 +67,7 @@ def transform(self, utterances: List[str], context: Optional[dict] = None): class MetadataTransformersService: def __init__(self, bus, config=None): - self.config_core = config or {} + self.config_core = config or Configuration() self.loaded_plugins = {} self.has_loaded = False self.bus = bus diff --git a/requirements/tests.txt b/requirements/tests.txt index 9d56ec15ee6..e1715648692 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -20,4 +20,5 @@ ovos-gui~=0.0, >=0.0.2 ovos-messagebus~=0.0 # Support OCP tests -ovos_bus_client>=0.0.9a15 \ No newline at end of file +ovos_bus_client>=0.0.9a15 +ovos-utils>=0.1.0a16 \ No newline at end of file diff --git a/test/end2end/minicroft.py b/test/end2end/minicroft.py index 237227f7c73..863bdb41eb9 100644 --- a/test/end2end/minicroft.py +++ b/test/end2end/minicroft.py @@ -12,20 +12,20 @@ class MiniCroft(SkillManager): - def __init__(self, skill_ids, *args, **kwargs): + def __init__(self, skill_ids, ocp=False, *args, **kwargs): bus = FakeBus() super().__init__(bus, *args, **kwargs) self.skill_ids = skill_ids - self.intent_service = self._register_intent_services() + self.intent_service = self._register_intent_services(ocp=ocp) self.scheduler = EventScheduler(bus, schedule_file="/tmp/schetest.json") - def _register_intent_services(self): + def _register_intent_services(self, ocp=False): """Start up the all intent services and connect them as needed. Args: bus: messagebus client to register the services on """ - service = IntentService(self.bus) + service = IntentService(self.bus, config={"experimental_ocp_pipeline": ocp}) # Register handler to trigger fallback system self.bus.on( 'mycroft.skills.fallback', @@ -61,11 +61,11 @@ def stop(self): SessionManager.default_session = SessionManager.sessions["default"] = Session("default") -def get_minicroft(skill_id): +def get_minicroft(skill_id, ocp=False): if isinstance(skill_id, str): skill_id = [skill_id] assert isinstance(skill_id, list) - croft1 = MiniCroft(skill_id) + croft1 = MiniCroft(skill_id, ocp=ocp) croft1.start() while croft1.status.state != ProcessState.READY: sleep(0.2) diff --git a/test/end2end/session/test_ocp.py b/test/end2end/session/test_ocp.py new file mode 100644 index 00000000000..58d0f663739 --- /dev/null +++ b/test/end2end/session/test_ocp.py @@ -0,0 +1,966 @@ +import time +from time import sleep +from unittest import TestCase + +from ovos_bus_client.message import Message +from ovos_bus_client.session import Session +from ovos_utils.ocp import PlayerState, MediaState +from ..minicroft import get_minicroft + + +class TestOCPPipeline(TestCase): + + def setUp(self): + self.skill_id = "skill-fake-fm.openvoiceos" + self.core = get_minicroft(self.skill_id, ocp=True) + + def tearDown(self) -> None: + self.core.stop() + + def test_no_match(self): + self.assertIsNotNone(self.core.intent_service.ocp) + self.assertFalse(self.core.intent_service.ocp.use_legacy_audio) + messages = [] + + def new_msg(msg): + nonlocal messages + m = Message.deserialize(msg) + if m.msg_type in ["ovos.skills.settings_changed", "gui.status.request"]: + return # skip these, only happen in 1st run + messages.append(m) + print(len(messages), msg) + + def wait_for_n_messages(n): + nonlocal messages + t = time.time() + while len(messages) < n: + sleep(0.1) + if time.time() - t > 10: + raise RuntimeError("did not get the number of expected messages under 10 seconds") + + self.core.bus.on("message", new_msg) + + sess = Session("test-session", + pipeline=[ + "converse", + "ocp_high" + ]) + utt = Message("recognizer_loop:utterance", + {"utterances": ["play unknown thing"]}, + {"session": sess.serialize(), # explicit + }) + self.core.bus.emit(utt) + + # confirm all expected messages are sent + expected_messages = [ + "recognizer_loop:utterance", + "ovos.common_play.status", + "intent.service.skills.activate", + "intent.service.skills.activated", + "ovos.common_play.activate", + "enclosure.active_skill", + "speak", + "ocp:play", + "ovos.common_play.search.start", + "enclosure.mouth.think", + "ovos.common_play.search.stop", # any ongoing previous search + "ovos.common_play.query", + # skill searching (generic) + "ovos.common_play.skill.search_start", + "ovos.common_play.query.response", + "ovos.common_play.query.response", + "ovos.common_play.query.response", + "ovos.common_play.query.response", + "ovos.common_play.query.response", + "ovos.common_play.skill.search_end", + "ovos.common_play.search.end", + # no good results + "ovos.common_play.reset", + "enclosure.active_skill", + "speak" # error + ] + wait_for_n_messages(len(expected_messages)) + + self.assertEqual(len(expected_messages), len(messages)) + + for idx, m in enumerate(messages): + self.assertEqual(m.msg_type, expected_messages[idx]) + + def test_radio_media_match(self): + self.assertIsNotNone(self.core.intent_service.ocp) + self.assertFalse(self.core.intent_service.ocp.use_legacy_audio) + messages = [] + + def new_msg(msg): + nonlocal messages + m = Message.deserialize(msg) + if m.msg_type in ["ovos.skills.settings_changed", "gui.status.request"]: + return # skip these, only happen in 1st run + messages.append(m) + print(len(messages), msg) + + def wait_for_n_messages(n): + nonlocal messages + t = time.time() + while len(messages) < n: + sleep(0.1) + if time.time() - t > 10: + raise RuntimeError("did not get the number of expected messages under 10 seconds") + + self.core.bus.on("message", new_msg) + + sess = Session("test-session", + pipeline=[ + "converse", + "ocp_high" + ]) + utt = Message("recognizer_loop:utterance", + {"utterances": ["play some radio station"]}, + {"session": sess.serialize(), # explicit + }) + self.core.bus.emit(utt) + + # confirm all expected messages are sent + expected_messages = [ + "recognizer_loop:utterance", + "ovos.common_play.status", + "intent.service.skills.activate", + "intent.service.skills.activated", + "ovos.common_play.activate", + "enclosure.active_skill", + "speak", + "ocp:play", + "ovos.common_play.search.start", + "enclosure.mouth.think", + "ovos.common_play.search.stop", # any ongoing previous search + "ovos.common_play.query", # media type radio + # skill searching (radio) + "ovos.common_play.skill.search_start", + "ovos.common_play.query.response", + "ovos.common_play.query.response", + "ovos.common_play.query.response", + "ovos.common_play.query.response", + "ovos.common_play.query.response", + "ovos.common_play.skill.search_end", + "ovos.common_play.search.end", + # good results because of radio media type + "ovos.common_play.reset", + "add_context", # NowPlaying context + "ovos.common_play.play" # OCP api + ] + wait_for_n_messages(len(expected_messages)) + + self.assertEqual(len(expected_messages), len(messages)) + + for idx, m in enumerate(messages): + self.assertEqual(m.msg_type, expected_messages[idx]) + + def test_unk_media_match(self): + self.assertIsNotNone(self.core.intent_service.ocp) + self.assertFalse(self.core.intent_service.ocp.use_legacy_audio) + messages = [] + + def new_msg(msg): + nonlocal messages + m = Message.deserialize(msg) + if m.msg_type in ["ovos.skills.settings_changed", "gui.status.request"]: + return # skip these, only happen in 1st run + messages.append(m) + print(len(messages), msg) + + def wait_for_n_messages(n): + nonlocal messages + t = time.time() + while len(messages) < n: + sleep(0.1) + if time.time() - t > 10: + raise RuntimeError("did not get the number of expected messages under 10 seconds") + + self.core.bus.on("message", new_msg) + + sess = Session("test-session", + pipeline=[ + "converse", + "ocp_high" + ]) + utt = Message("recognizer_loop:utterance", + {"utterances": ["play the alien movie"]}, + {"session": sess.serialize(), # explicit + }) + self.core.bus.emit(utt) + + # confirm all expected messages are sent + expected_messages = [ + "recognizer_loop:utterance", + "ovos.common_play.status", + "intent.service.skills.activate", + "intent.service.skills.activated", + "ovos.common_play.activate", + "enclosure.active_skill", + "speak", + "ocp:play", + "ovos.common_play.search.start", + "enclosure.mouth.think", + "ovos.common_play.search.stop", # any ongoing previous search + "ovos.common_play.query", # movie media type search + # no skills want to search + "ovos.common_play.query", # generic media type fallback + # skill searching (generic) + "ovos.common_play.skill.search_start", + "ovos.common_play.query.response", + "ovos.common_play.query.response", + "ovos.common_play.query.response", + "ovos.common_play.query.response", + "ovos.common_play.query.response", + "ovos.common_play.skill.search_end", + "ovos.common_play.search.end", + # no good results + "ovos.common_play.reset", + "enclosure.active_skill", + "speak" # error + ] + wait_for_n_messages(len(expected_messages)) + + self.assertEqual(len(expected_messages), len(messages)) + + for idx, m in enumerate(messages): + self.assertEqual(m.msg_type, expected_messages[idx]) + + def test_skill_name_match(self): + self.assertFalse(self.core.intent_service.ocp.use_legacy_audio) + self.assertIsNotNone(self.core.intent_service.ocp) + messages = [] + + def new_msg(msg): + nonlocal messages + m = Message.deserialize(msg) + if m.msg_type in ["ovos.skills.settings_changed", "gui.status.request"]: + return # skip these, only happen in 1st run + messages.append(m) + print(len(messages), msg) + + def wait_for_n_messages(n): + nonlocal messages + t = time.time() + while len(messages) < n: + sleep(0.1) + if time.time() - t > 10: + raise RuntimeError("did not get the number of expected messages under 10 seconds") + + self.core.bus.on("message", new_msg) + + sess = Session("test-session", + pipeline=[ + "converse", + "ocp_high" + ]) + utt = Message("recognizer_loop:utterance", + {"utterances": ["play Fake FM"]}, # auto derived from skill class name in this case + {"session": sess.serialize(), + }) + self.core.bus.emit(utt) + + # confirm all expected messages are sent + expected_messages = [ + "recognizer_loop:utterance", + "ovos.common_play.status", + "intent.service.skills.activate", + "intent.service.skills.activated", + "ovos.common_play.activate", + "enclosure.active_skill", + "speak", + "ocp:play", + "ovos.common_play.search.start", + "enclosure.mouth.think", + "ovos.common_play.search.stop", # any ongoing previous search + f"ovos.common_play.query.{self.skill_id}", # explicitly search skill + # skill searching (explicit) + "ovos.common_play.skill.search_start", + "ovos.common_play.query.response", + "ovos.common_play.query.response", + "ovos.common_play.query.response", + "ovos.common_play.query.response", + "ovos.common_play.query.response", + "ovos.common_play.skill.search_end", + "ovos.common_play.search.end", + # good results + "ovos.common_play.reset", + "add_context", # NowPlaying context + "ovos.common_play.play" # OCP api + ] + wait_for_n_messages(len(expected_messages)) + + self.assertEqual(len(expected_messages), len(messages)) + + for idx, m in enumerate(messages): + self.assertEqual(m.msg_type, expected_messages[idx]) + + def test_legacy_match(self): + self.assertIsNotNone(self.core.intent_service.ocp) + self.core.intent_service.ocp.config = {"legacy": True} + self.core.intent_service.ocp.player_state = PlayerState.STOPPED + self.core.intent_service.ocp.media_state = MediaState.NO_MEDIA + self.assertTrue(self.core.intent_service.ocp.use_legacy_audio) + messages = [] + + def new_msg(msg): + nonlocal messages + m = Message.deserialize(msg) + if m.msg_type in ["ovos.skills.settings_changed", "gui.status.request"]: + return # skip these, only happen in 1st run + messages.append(m) + print(len(messages), msg) + + def wait_for_n_messages(n): + nonlocal messages + t = time.time() + while len(messages) < n: + sleep(0.1) + if time.time() - t > 10: + raise RuntimeError("did not get the number of expected messages under 10 seconds") + + self.core.bus.on("message", new_msg) + + sess = Session("test-session", + pipeline=[ + "converse", + "ocp_high" + ]) + utt = Message("recognizer_loop:utterance", + {"utterances": ["play some radio station"]}, + {"session": sess.serialize(), # explicit + }) + self.core.bus.emit(utt) + + # confirm all expected messages are sent + expected_messages = [ + "recognizer_loop:utterance", + "ovos.common_play.status", + "intent.service.skills.activate", + "intent.service.skills.activated", + "ovos.common_play.activate", + "enclosure.active_skill", + "speak", + "ocp:play", + "ovos.common_play.search.start", + "enclosure.mouth.think", + "ovos.common_play.search.stop", # any ongoing previous search + "ovos.common_play.query", # media type radio + # skill searching (radio) + "ovos.common_play.skill.search_start", + "ovos.common_play.query.response", + "ovos.common_play.query.response", + "ovos.common_play.query.response", + "ovos.common_play.query.response", + "ovos.common_play.query.response", + "ovos.common_play.skill.search_end", + "ovos.common_play.search.end", + # good results because of radio media type + "ovos.common_play.reset", + "add_context", # NowPlaying context + 'mycroft.audio.service.play' # LEGACY api + ] + wait_for_n_messages(len(expected_messages)) + + self.assertEqual(len(expected_messages), len(messages)) + + for idx, m in enumerate(messages): + self.assertEqual(m.msg_type, expected_messages[idx]) + + self.assertEqual(self.core.intent_service.ocp.player_state, PlayerState.PLAYING) + self.assertEqual(self.core.intent_service.ocp.media_state, MediaState.LOADING_MEDIA) + + def test_legacy_pause(self): + self.assertIsNotNone(self.core.intent_service.ocp) + self.core.intent_service.ocp.config = {"legacy": True} + self.core.intent_service.ocp.player_state = PlayerState.PLAYING + self.core.intent_service.ocp.media_state = MediaState.LOADED_MEDIA + self.assertTrue(self.core.intent_service.ocp.use_legacy_audio) + messages = [] + + def new_msg(msg): + nonlocal messages + m = Message.deserialize(msg) + if m.msg_type in ["ovos.skills.settings_changed", "gui.status.request"]: + return # skip these, only happen in 1st run + messages.append(m) + print(len(messages), msg) + + def wait_for_n_messages(n): + nonlocal messages + t = time.time() + while len(messages) < n: + sleep(0.1) + if time.time() - t > 10: + raise RuntimeError("did not get the number of expected messages under 10 seconds") + + self.core.bus.on("message", new_msg) + + sess = Session("test-session", + pipeline=[ + "converse", + "ocp_high" + ]) + utt = Message("recognizer_loop:utterance", + {"utterances": ["pause"]}, + {"session": sess.serialize(), # explicit + }) + self.core.bus.emit(utt) + + # confirm all expected messages are sent + expected_messages = [ + "recognizer_loop:utterance", + "ovos.common_play.status", + "intent.service.skills.activate", + "intent.service.skills.activated", + "ovos.common_play.activate", + "ocp:pause", + 'mycroft.audio.service.pause' # LEGACY api + ] + wait_for_n_messages(len(expected_messages)) + + self.assertEqual(len(expected_messages), len(messages)) + + for idx, m in enumerate(messages): + self.assertEqual(m.msg_type, expected_messages[idx]) + + self.assertEqual(self.core.intent_service.ocp.player_state, PlayerState.PAUSED) + + def test_legacy_resume(self): + self.assertIsNotNone(self.core.intent_service.ocp) + self.core.intent_service.ocp.config = {"legacy": True} + self.core.intent_service.ocp.player_state = PlayerState.PAUSED + self.core.intent_service.ocp.media_state = MediaState.LOADED_MEDIA + self.assertTrue(self.core.intent_service.ocp.use_legacy_audio) + messages = [] + + def new_msg(msg): + nonlocal messages + m = Message.deserialize(msg) + if m.msg_type in ["ovos.skills.settings_changed", "gui.status.request"]: + return # skip these, only happen in 1st run + messages.append(m) + print(len(messages), msg) + + def wait_for_n_messages(n): + nonlocal messages + t = time.time() + while len(messages) < n: + sleep(0.1) + if time.time() - t > 10: + raise RuntimeError("did not get the number of expected messages under 10 seconds") + + self.core.bus.on("message", new_msg) + + sess = Session("test-session", + pipeline=[ + "converse", + "ocp_high" + ]) + utt = Message("recognizer_loop:utterance", + {"utterances": ["resume"]}, + {"session": sess.serialize(), # explicit + }) + self.core.bus.emit(utt) + + # confirm all expected messages are sent + expected_messages = [ + "recognizer_loop:utterance", + "ovos.common_play.status", + "intent.service.skills.activate", + "intent.service.skills.activated", + "ovos.common_play.activate", + "ocp:resume", + 'mycroft.audio.service.resume' # LEGACY api + ] + wait_for_n_messages(len(expected_messages)) + + self.assertEqual(len(expected_messages), len(messages)) + + for idx, m in enumerate(messages): + self.assertEqual(m.msg_type, expected_messages[idx]) + + self.assertEqual(self.core.intent_service.ocp.player_state, PlayerState.PLAYING) + + def test_legacy_stop(self): + self.assertIsNotNone(self.core.intent_service.ocp) + self.core.intent_service.ocp.config = {"legacy": True} + self.core.intent_service.ocp.player_state = PlayerState.PLAYING + self.core.intent_service.ocp.media_state = MediaState.LOADED_MEDIA + self.assertTrue(self.core.intent_service.ocp.use_legacy_audio) + messages = [] + + def new_msg(msg): + nonlocal messages + m = Message.deserialize(msg) + if m.msg_type in ["ovos.skills.settings_changed", "gui.status.request"]: + return # skip these, only happen in 1st run + messages.append(m) + print(len(messages), msg) + + def wait_for_n_messages(n): + nonlocal messages + t = time.time() + while len(messages) < n: + sleep(0.1) + if time.time() - t > 10: + raise RuntimeError("did not get the number of expected messages under 10 seconds") + + self.core.bus.on("message", new_msg) + + sess = Session("test-session", + pipeline=[ + "converse", + "ocp_high" + ]) + utt = Message("recognizer_loop:utterance", + {"utterances": ["stop"]}, + {"session": sess.serialize(), # explicit + }) + self.core.bus.emit(utt) + + # confirm all expected messages are sent + expected_messages = [ + "recognizer_loop:utterance", + "ovos.common_play.status", + "intent.service.skills.activate", + "intent.service.skills.activated", + "ovos.common_play.activate", + "ocp:media_stop", + 'mycroft.audio.service.stop' # LEGACY api + ] + wait_for_n_messages(len(expected_messages)) + + self.assertEqual(len(expected_messages), len(messages)) + + for idx, m in enumerate(messages): + self.assertEqual(m.msg_type, expected_messages[idx]) + + self.assertEqual(self.core.intent_service.ocp.player_state, PlayerState.STOPPED) + + def test_legacy_next(self): + self.assertIsNotNone(self.core.intent_service.ocp) + self.core.intent_service.ocp.config = {"legacy": True} + self.core.intent_service.ocp.player_state = PlayerState.PLAYING + self.core.intent_service.ocp.media_state = MediaState.LOADED_MEDIA + self.assertTrue(self.core.intent_service.ocp.use_legacy_audio) + messages = [] + + def new_msg(msg): + nonlocal messages + m = Message.deserialize(msg) + if m.msg_type in ["ovos.skills.settings_changed", "gui.status.request"]: + return # skip these, only happen in 1st run + messages.append(m) + print(len(messages), msg) + + def wait_for_n_messages(n): + nonlocal messages + t = time.time() + while len(messages) < n: + sleep(0.1) + if time.time() - t > 10: + raise RuntimeError("did not get the number of expected messages under 10 seconds") + + self.core.bus.on("message", new_msg) + + sess = Session("test-session", + pipeline=[ + "converse", + "ocp_high" + ]) + utt = Message("recognizer_loop:utterance", + {"utterances": ["next"]}, + {"session": sess.serialize(), # explicit + }) + self.core.bus.emit(utt) + + # confirm all expected messages are sent + expected_messages = [ + "recognizer_loop:utterance", + "ovos.common_play.status", + "intent.service.skills.activate", + "intent.service.skills.activated", + "ovos.common_play.activate", + "ocp:next", + 'mycroft.audio.service.next' # LEGACY api + ] + wait_for_n_messages(len(expected_messages)) + + self.assertEqual(len(expected_messages), len(messages)) + + for idx, m in enumerate(messages): + self.assertEqual(m.msg_type, expected_messages[idx]) + + def test_legacy_prev(self): + self.assertIsNotNone(self.core.intent_service.ocp) + self.core.intent_service.ocp.config = {"legacy": True} + self.core.intent_service.ocp.player_state = PlayerState.PLAYING + self.core.intent_service.ocp.media_state = MediaState.LOADED_MEDIA + self.assertTrue(self.core.intent_service.ocp.use_legacy_audio) + messages = [] + + def new_msg(msg): + nonlocal messages + m = Message.deserialize(msg) + if m.msg_type in ["ovos.skills.settings_changed", "gui.status.request"]: + return # skip these, only happen in 1st run + messages.append(m) + print(len(messages), msg) + + def wait_for_n_messages(n): + nonlocal messages + t = time.time() + while len(messages) < n: + sleep(0.1) + if time.time() - t > 10: + raise RuntimeError("did not get the number of expected messages under 10 seconds") + + self.core.bus.on("message", new_msg) + + sess = Session("test-session", + pipeline=[ + "converse", + "ocp_high" + ]) + utt = Message("recognizer_loop:utterance", + {"utterances": ["previous"]}, + {"session": sess.serialize(), # explicit + }) + self.core.bus.emit(utt) + + # confirm all expected messages are sent + expected_messages = [ + "recognizer_loop:utterance", + "ovos.common_play.status", + "intent.service.skills.activate", + "intent.service.skills.activated", + "ovos.common_play.activate", + "ocp:prev", + 'mycroft.audio.service.prev' # LEGACY api + ] + wait_for_n_messages(len(expected_messages)) + + self.assertEqual(len(expected_messages), len(messages)) + + for idx, m in enumerate(messages): + self.assertEqual(m.msg_type, expected_messages[idx]) + + def test_pause(self): + self.assertIsNotNone(self.core.intent_service.ocp) + self.core.intent_service.ocp.player_state = PlayerState.PLAYING + self.core.intent_service.ocp.media_state = MediaState.LOADED_MEDIA + self.assertFalse(self.core.intent_service.ocp.use_legacy_audio) + messages = [] + + def new_msg(msg): + nonlocal messages + m = Message.deserialize(msg) + if m.msg_type in ["ovos.skills.settings_changed", "gui.status.request"]: + return # skip these, only happen in 1st run + messages.append(m) + print(len(messages), msg) + + def wait_for_n_messages(n): + nonlocal messages + t = time.time() + while len(messages) < n: + sleep(0.1) + if time.time() - t > 10: + raise RuntimeError("did not get the number of expected messages under 10 seconds") + + self.core.bus.on("message", new_msg) + + sess = Session("test-session", + pipeline=[ + "converse", + "ocp_high" + ]) + utt = Message("recognizer_loop:utterance", + {"utterances": ["pause"]}, + {"session": sess.serialize(), # explicit + }) + self.core.bus.emit(utt) + + # confirm all expected messages are sent + expected_messages = [ + "recognizer_loop:utterance", + "ovos.common_play.status", + "intent.service.skills.activate", + "intent.service.skills.activated", + "ovos.common_play.activate", + "ocp:pause", + 'ovos.common_play.pause' + ] + wait_for_n_messages(len(expected_messages)) + + self.assertEqual(len(expected_messages), len(messages)) + + for idx, m in enumerate(messages): + self.assertEqual(m.msg_type, expected_messages[idx]) + + def test_resume(self): + self.assertIsNotNone(self.core.intent_service.ocp) + self.core.intent_service.ocp.player_state = PlayerState.PAUSED + self.core.intent_service.ocp.media_state = MediaState.LOADED_MEDIA + self.assertFalse(self.core.intent_service.ocp.use_legacy_audio) + messages = [] + + def new_msg(msg): + nonlocal messages + m = Message.deserialize(msg) + if m.msg_type in ["ovos.skills.settings_changed", "gui.status.request"]: + return # skip these, only happen in 1st run + messages.append(m) + print(len(messages), msg) + + def wait_for_n_messages(n): + nonlocal messages + t = time.time() + while len(messages) < n: + sleep(0.1) + if time.time() - t > 10: + raise RuntimeError("did not get the number of expected messages under 10 seconds") + + self.core.bus.on("message", new_msg) + + sess = Session("test-session", + pipeline=[ + "converse", + "ocp_high" + ]) + utt = Message("recognizer_loop:utterance", + {"utterances": ["resume"]}, + {"session": sess.serialize(), # explicit + }) + self.core.bus.emit(utt) + + # confirm all expected messages are sent + expected_messages = [ + "recognizer_loop:utterance", + "ovos.common_play.status", + "intent.service.skills.activate", + "intent.service.skills.activated", + "ovos.common_play.activate", + "ocp:resume", + 'ovos.common_play.resume' + ] + wait_for_n_messages(len(expected_messages)) + + self.assertEqual(len(expected_messages), len(messages)) + + for idx, m in enumerate(messages): + self.assertEqual(m.msg_type, expected_messages[idx]) + + def test_stop(self): + self.assertIsNotNone(self.core.intent_service.ocp) + self.core.intent_service.ocp.player_state = PlayerState.PLAYING + self.core.intent_service.ocp.media_state = MediaState.LOADED_MEDIA + self.assertFalse(self.core.intent_service.ocp.use_legacy_audio) + messages = [] + + def new_msg(msg): + nonlocal messages + m = Message.deserialize(msg) + if m.msg_type in ["ovos.skills.settings_changed", "gui.status.request"]: + return # skip these, only happen in 1st run + messages.append(m) + print(len(messages), msg) + + def wait_for_n_messages(n): + nonlocal messages + t = time.time() + while len(messages) < n: + sleep(0.1) + if time.time() - t > 10: + raise RuntimeError("did not get the number of expected messages under 10 seconds") + + self.core.bus.on("message", new_msg) + + sess = Session("test-session", + pipeline=[ + "converse", + "ocp_high" + ]) + utt = Message("recognizer_loop:utterance", + {"utterances": ["stop"]}, + {"session": sess.serialize(), # explicit + }) + self.core.bus.emit(utt) + + # confirm all expected messages are sent + expected_messages = [ + "recognizer_loop:utterance", + "ovos.common_play.status", + "intent.service.skills.activate", + "intent.service.skills.activated", + "ovos.common_play.activate", + "ocp:media_stop", + 'ovos.common_play.stop', + "ovos.common_play.stop.response" + ] + wait_for_n_messages(len(expected_messages)) + + self.assertEqual(len(expected_messages), len(messages)) + + for idx, m in enumerate(messages): + self.assertEqual(m.msg_type, expected_messages[idx]) + + def test_next(self): + self.assertIsNotNone(self.core.intent_service.ocp) + self.core.intent_service.ocp.player_state = PlayerState.PLAYING + self.core.intent_service.ocp.media_state = MediaState.LOADED_MEDIA + self.assertFalse(self.core.intent_service.ocp.use_legacy_audio) + messages = [] + + def new_msg(msg): + nonlocal messages + m = Message.deserialize(msg) + if m.msg_type in ["ovos.skills.settings_changed", "gui.status.request"]: + return # skip these, only happen in 1st run + messages.append(m) + print(len(messages), msg) + + def wait_for_n_messages(n): + nonlocal messages + t = time.time() + while len(messages) < n: + sleep(0.1) + if time.time() - t > 10: + raise RuntimeError("did not get the number of expected messages under 10 seconds") + + self.core.bus.on("message", new_msg) + + sess = Session("test-session", + pipeline=[ + "converse", + "ocp_high" + ]) + utt = Message("recognizer_loop:utterance", + {"utterances": ["next"]}, + {"session": sess.serialize(), # explicit + }) + self.core.bus.emit(utt) + + # confirm all expected messages are sent + expected_messages = [ + "recognizer_loop:utterance", + "ovos.common_play.status", + "intent.service.skills.activate", + "intent.service.skills.activated", + "ovos.common_play.activate", + "ocp:next", + 'ovos.common_play.next' + ] + wait_for_n_messages(len(expected_messages)) + + self.assertEqual(len(expected_messages), len(messages)) + + for idx, m in enumerate(messages): + self.assertEqual(m.msg_type, expected_messages[idx]) + + def test_prev(self): + self.assertIsNotNone(self.core.intent_service.ocp) + self.core.intent_service.ocp.player_state = PlayerState.PLAYING + self.core.intent_service.ocp.media_state = MediaState.LOADED_MEDIA + self.assertFalse(self.core.intent_service.ocp.use_legacy_audio) + messages = [] + + def new_msg(msg): + nonlocal messages + m = Message.deserialize(msg) + if m.msg_type in ["ovos.skills.settings_changed", "gui.status.request"]: + return # skip these, only happen in 1st run + messages.append(m) + print(len(messages), msg) + + def wait_for_n_messages(n): + nonlocal messages + t = time.time() + while len(messages) < n: + sleep(0.1) + if time.time() - t > 10: + raise RuntimeError("did not get the number of expected messages under 10 seconds") + + self.core.bus.on("message", new_msg) + + sess = Session("test-session", + pipeline=[ + "converse", + "ocp_high" + ]) + utt = Message("recognizer_loop:utterance", + {"utterances": ["previous"]}, + {"session": sess.serialize(), # explicit + }) + self.core.bus.emit(utt) + + # confirm all expected messages are sent + expected_messages = [ + "recognizer_loop:utterance", + "ovos.common_play.status", + "intent.service.skills.activate", + "intent.service.skills.activated", + "ovos.common_play.activate", + "ocp:prev", + 'ovos.common_play.previous' + ] + wait_for_n_messages(len(expected_messages)) + + self.assertEqual(len(expected_messages), len(messages)) + + for idx, m in enumerate(messages): + self.assertEqual(m.msg_type, expected_messages[idx]) + + def test_status_matches_not_playing(self): + self.assertIsNotNone(self.core.intent_service.ocp) + self.core.intent_service.ocp.player_state = PlayerState.STOPPED + self.core.intent_service.ocp.media_state = MediaState.NO_MEDIA + + def new_msg(msg): + nonlocal messages + m = Message.deserialize(msg) + if m.msg_type in ["ovos.skills.settings_changed", "gui.status.request"]: + return # skip these, only happen in 1st run + messages.append(m) + print(len(messages), msg) + + def wait_for_n_messages(n): + nonlocal messages + t = time.time() + while len(messages) < n: + sleep(0.1) + if time.time() - t > 10: + raise RuntimeError("did not get the number of expected messages under 10 seconds") + + self.core.bus.on("message", new_msg) + + sess = Session("test-session", + pipeline=[ + "converse", + "ocp_high" + ]) + + # wont match unless PlayerState.Playing + for t in ["pause", "resume", "stop", "next", "previous"]: + messages = [] + + utt = Message("recognizer_loop:utterance", + {"utterances": [t]}, + {"session": sess.serialize(), # explicit + }) + self.core.bus.emit(utt) + + # confirm all expected messages are sent + expected_messages = [ + "recognizer_loop:utterance", + "ovos.common_play.status", + "mycroft.audio.play_sound", + "complete_intent_failure" + ] + wait_for_n_messages(len(expected_messages)) + + self.assertEqual(len(expected_messages), len(messages)) + + for idx, m in enumerate(messages): + self.assertEqual(m.msg_type, expected_messages[idx]) diff --git a/test/end2end/skill-fake-fm/__init__.py b/test/end2end/skill-fake-fm/__init__.py new file mode 100644 index 00000000000..880b9205941 --- /dev/null +++ b/test/end2end/skill-fake-fm/__init__.py @@ -0,0 +1,39 @@ +from os.path import join, dirname + +from ovos_utils.ocp import MediaType, PlaybackType +from ovos_workshop.decorators.ocp import ocp_search +from ovos_workshop.skills.common_play import OVOSCommonPlaybackSkill + + +class FakeFMSkill(OVOSCommonPlaybackSkill): + + def __init__(self, *args, **kwargs): + super().__init__(supported_media = [MediaType.RADIO, + MediaType.GENERIC], + skill_icon=join(dirname(__file__), "ui", "fakefm.png"), + *args, **kwargs) + + @ocp_search() + def search_fakefm(self, phrase, media_type): + score = 50 + if "fake" in phrase: + score += 35 + if media_type == MediaType.RADIO: + score += 20 + else: + score -= 30 + + for i in range(5): + score = score + i + yield { + "match_confidence": score, + "media_type": MediaType.RADIO, + "uri": f"https://fake_{i}.mp3", + "playback": PlaybackType.AUDIO, + "image": f"https://fake_{i}.png", + "bg_image": f"https://fake_{i}.png", + "skill_icon": f"https://fakefm.png", + "title": f"fake station {i}", + "author": "FakeFM", + "length": 0 + } \ No newline at end of file diff --git a/test/end2end/skill-fake-fm/setup.py b/test/end2end/skill-fake-fm/setup.py new file mode 100755 index 00000000000..e8e602d24b7 --- /dev/null +++ b/test/end2end/skill-fake-fm/setup.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +from os import walk, path + +from setuptools import setup + +URL = "https://github.com/OpenVoiceOS/skill-fake-fm" +SKILL_CLAZZ = "FakeFMSkill" # needs to match __init__.py class name + +# below derived from github url to ensure standard skill_id +SKILL_AUTHOR, SKILL_NAME = URL.split(".com/")[-1].split("/") +SKILL_PKG = SKILL_NAME.lower().replace('-', '_') +PLUGIN_ENTRY_POINT = f'{SKILL_NAME.lower()}.{SKILL_AUTHOR.lower()}={SKILL_PKG}:{SKILL_CLAZZ}' + + +# skill_id=package_name:SkillClass + + +def find_resource_files(): + resource_base_dirs = ("locale", "ui", "vocab", "dialog", "regex", "skill") + base_dir = path.dirname(__file__) + package_data = ["*.json"] + for res in resource_base_dirs: + if path.isdir(path.join(base_dir, res)): + for (directory, _, files) in walk(path.join(base_dir, res)): + if files: + package_data.append( + path.join(directory.replace(base_dir, "").lstrip('/'), + '*')) + return package_data + + +setup( + name="skill-fake-fm", + version="0.0.0", + long_description="test", + description='OVOS test plugin', + author_email='jarbasai@mailfence.com', + license='Apache-2.0', + package_dir={SKILL_PKG: ""}, + package_data={SKILL_PKG: find_resource_files()}, + packages=[SKILL_PKG], + include_package_data=True, + install_requires=["ovos-workshop>=0.0.16a8"], + keywords='ovos skill plugin', + entry_points={'ovos.plugin.skill': PLUGIN_ENTRY_POINT} +)