diff --git a/pyproject.toml b/pyproject.toml index 46368be..5b65c8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "stark-place" -version = "1.0.2" +version = "1.1.0" description = "S.T.A.R.K. Platform Library And Community Extensions" authors = ["Mark Parker "] license = "CC BY-NC-SA 4.0" diff --git a/stark_place/examples/context.py b/stark_place/examples/context.py index f90317a..3eb02f7 100644 --- a/stark_place/examples/context.py +++ b/stark_place/examples/context.py @@ -6,11 +6,11 @@ from stark.interfaces.silero import SileroSpeechSynthesizer -VOSK_MODEL_URL = "YOUR_CHOSEN_VOSK_MODEL_URL" -SILERO_MODEL_URL = "YOUR_CHOSEN_SILERO_MODEL_URL" +vosk_model_ur = 'YOUR_CHOSEN_VOSK_MODEL_URL' +silero_model_ur = 'YOUR_CHOSEN_SILERO_MODEL_URL' -recognizer = VoskSpeechRecognizer(model_url=VOSK_MODEL_URL) -synthesizer = SileroSpeechSynthesizer(model_url=SILERO_MODEL_URL) +recognizer = VoskSpeechRecognizer(model_url=vosk_model_ur) +synthesizer = SileroSpeechSynthesizer(model_url=silero_model_ur) manager = CommandsManager() diff --git a/stark_place/examples/custom_run.py b/stark_place/examples/custom_run.py index d14e197..0238f02 100644 --- a/stark_place/examples/custom_run.py +++ b/stark_place/examples/custom_run.py @@ -7,11 +7,11 @@ from stark.general.blockage_detector import BlockageDetector -VOSK_MODEL_URL = "YOUR_CHOSEN_VOSK_MODEL_URL" -SILERO_MODEL_URL = "YOUR_CHOSEN_SILERO_MODEL_URL" +vosk_model_ur = 'YOUR_CHOSEN_VOSK_MODEL_URL' +silero_model_ur = 'YOUR_CHOSEN_SILERO_MODEL_URL' -recognizer = VoskSpeechRecognizer(model_url=VOSK_MODEL_URL) -synthesizer = SileroSpeechSynthesizer(model_url=SILERO_MODEL_URL) +recognizer = VoskSpeechRecognizer(model_url=vosk_model_ur) +synthesizer = SileroSpeechSynthesizer(model_url=silero_model_ur) manager = CommandsManager() diff --git a/stark_place/examples/hello_world.py b/stark_place/examples/hello_world.py index 2de0558..e8e276c 100644 --- a/stark_place/examples/hello_world.py +++ b/stark_place/examples/hello_world.py @@ -4,11 +4,11 @@ from stark.interfaces.silero import SileroSpeechSynthesizer -VOSK_MODEL_URL = "YOUR_CHOSEN_VOSK_MODEL_URL" -SILERO_MODEL_URL = "YOUR_CHOSEN_SILERO_MODEL_URL" +vosk_model_ur = 'YOUR_CHOSEN_VOSK_MODEL_URL' +silero_model_ur = 'YOUR_CHOSEN_SILERO_MODEL_URL' -recognizer = VoskSpeechRecognizer(model_url=VOSK_MODEL_URL) -synthesizer = SileroSpeechSynthesizer(model_url=SILERO_MODEL_URL) +recognizer = VoskSpeechRecognizer(model_url=vosk_model_ur) +synthesizer = SileroSpeechSynthesizer(model_url=silero_model_ur) manager = CommandsManager() diff --git a/stark_place/examples/keyboard_trigger.py b/stark_place/examples/keyboard_trigger.py index c07a4cf..234da6c 100644 --- a/stark_place/examples/keyboard_trigger.py +++ b/stark_place/examples/keyboard_trigger.py @@ -1,3 +1,8 @@ +''' +Requirements except stark: + - pynput +''' + import asyncer from stark import CommandsContext, CommandsManager, Response from stark.general.blockage_detector import BlockageDetector @@ -7,13 +12,14 @@ from stark.voice_assistant import VoiceAssistant, Mode from stark_place.triggers import keyboard_key # import the trigger +from stark_place.notifications import sound -VOSK_MODEL_URL = "YOUR_CHOSEN_VOSK_MODEL_URL" -SILERO_MODEL_URL = "YOUR_CHOSEN_SILERO_MODEL_URL" +vosk_model_ur = 'YOUR_CHOSEN_VOSK_MODEL_URL' +silero_model_ur = 'YOUR_CHOSEN_SILERO_MODEL_URL' -recognizer = VoskSpeechRecognizer(model_url=VOSK_MODEL_URL) -synthesizer = SileroSpeechSynthesizer(model_url=SILERO_MODEL_URL) +recognizer = VoskSpeechRecognizer(model_url=vosk_model_ur) +synthesizer = SileroSpeechSynthesizer(model_url=silero_model_ur) manager = CommandsManager() @@ -42,7 +48,11 @@ async def run( voice_assistant.mode = Mode.external() # stop listening after first response - main_task_group.soonify(keyboard_key.start)(main_task_group.soonify(speech_recognizer.start_listening)) # add trigger to the main loop + def on_hotkey(): + sound.play() # optional: play a sound when the hotkey is pressed, check the realisation in stark_place/notifications/sound.py + main_task_group.soonify(speech_recognizer.start_listening) + + main_task_group.soonify(keyboard_key.start)(on_hotkey) # add trigger to the main loop # main_task_group.soonify(speech_recognizer.start_listening)() # don't start listening until the hotkey is pressed main_task_group.soonify(context.handle_responses)() diff --git a/stark_place/examples/porcupine_wakeword_trigger.py b/stark_place/examples/porcupine_wakeword_trigger.py new file mode 100644 index 0000000..5d1dff6 --- /dev/null +++ b/stark_place/examples/porcupine_wakeword_trigger.py @@ -0,0 +1,92 @@ +''' +Requirements except stark: + - pvporcupine +''' + +import asyncer +from stark import CommandsContext, CommandsManager, Response +from stark.general.blockage_detector import BlockageDetector +from stark.interfaces.protocols import SpeechRecognizer, SpeechSynthesizer +from stark.interfaces.vosk import VoskSpeechRecognizer +from stark.interfaces.silero import SileroSpeechSynthesizer +from stark.voice_assistant import VoiceAssistant, Mode + +from stark_place.triggers import porcupine # import the trigger +from stark_place.notifications import sound + + +vosk_model_ur = 'YOUR_CHOSEN_VOSK_MODEL_URL' +silero_model_ur = 'YOUR_CHOSEN_SILERO_MODEL_URL' + +# register at https://console.picovoice.ai and copy the access key +# train and download a model for the your platform at https://console.picovoice.ai/ppn +# don't rename either the model file or the keyword files +access_key = 'YOUR_ACCESS_KEY' +keyword_paths = ['YOUR_KEYWORD_PATH.ppn',] +model_path = 'YOUR_MODEL_PATH.pv' + +recognizer = VoskSpeechRecognizer(model_url = vosk_model_ur) +synthesizer = SileroSpeechSynthesizer(model_url = silero_model_ur) + +manager = CommandsManager() + +@manager.new('hello') +async def hello_command() -> Response: + text = voice = 'Hello, world!' + return Response(text=text, voice=voice) + +async def run( + manager: CommandsManager, + speech_recognizer: SpeechRecognizer, + speech_synthesizer: SpeechSynthesizer +): + async with asyncer.create_task_group() as main_task_group: + context = CommandsContext( + task_group = main_task_group, + commands_manager = manager + ) + voice_assistant = VoiceAssistant( + speech_recognizer = speech_recognizer, + speech_synthesizer = speech_synthesizer, + commands_context = context + ) + speech_recognizer.delegate = voice_assistant + context.delegate = voice_assistant + + voice_assistant.mode = Mode.external() # stop listening after first response + + # trigger-listener setup + + def add_porcupine_listener(): + # soonify returns immediately, but the wrapped function is added to the task group (main loop) + # porcuping and speech recognizer use the same microphone device, so they can't run at the same time + # porcupine.start runs until the first wake-word is detected and needs to be restarted after the speech recognizer is stopped + def on_wake_word(): + sound.play() # optional: play a sound when the wake-word is detected, check the realisation in stark_place/notifications/sound.py + main_task_group.soonify(start_speech_recognizer)() + + main_task_group.soonify(porcupine.start)( + access_key = access_key, + keyword_paths = keyword_paths, + model_path = model_path, + callback = on_wake_word + ) + + async def start_speech_recognizer(): + await speech_recognizer.start_listening() # awaits until the speech recognizer is stopped + add_porcupine_listener() # start listening for the wake-word after the speech recognizer is stopped + + add_porcupine_listener() # start listening for the wake-word + + # other tasks + + main_task_group.soonify(context.handle_responses)() + + detector = BlockageDetector() + main_task_group.soonify(detector.monitor)() + +async def main(): + await run(manager, recognizer, synthesizer) + +if __name__ == '__main__': + asyncer.runnify(main)() # or anyio.run(main), same thing diff --git a/stark_place/notifications/sound.py b/stark_place/notifications/sound.py new file mode 100644 index 0000000..7674007 --- /dev/null +++ b/stark_place/notifications/sound.py @@ -0,0 +1,15 @@ +import os + + +def play(): + ''' + play all sounds in macos using shell: sh`for s in /System/Library/Sounds/*; do echo "$s" && afplay "$s"; done` + for linux: check the `/usr/share/sounds/` directory and use `aplay` instead of `afplay` + + as an alternative, you can use the cross-platform system-sounds pypi library to list and play sounds + pypi.org/project/system-sounds + or github.com/MarkParker5/system-sounds + + or use the a SpeechSynthesizer to say something like "Yes, sir?" + ''' + os.system('afplay /System/Library/Sounds/Blow.aiff &') # play the sound in the background (non-blocking, immediate return) (macos) diff --git a/stark_place/triggers/keyboard_key.py b/stark_place/triggers/keyboard_key.py index 58b8817..2f75c77 100644 --- a/stark_place/triggers/keyboard_key.py +++ b/stark_place/triggers/keyboard_key.py @@ -1,8 +1,6 @@ from typing import Callable, Any -import os -from pynput.keyboard import Key, KeyCode, Listener, HotKey +from pynput.keyboard import Key, KeyCode, Listener import anyio -from asyncer import asyncify from threading import Event @@ -19,22 +17,10 @@ def on_release(key: Key): listener.start() # start listening in a separate thread while True: # this loop runs in the main thread - await anyio.sleep(0.1) # release the thread for other tasks - if hotkey_event.is_set(): - ''' - optional: play a sound to indicate that the hotkey was pressed - - play all sounds in macos using shell: sh`for s in /System/Library/Sounds/*; do echo "$s" && afplay "$s"; done` - for linux: check the `/usr/share/sounds/` directory and use `aplay` instead of `afplay` - - as an alternative, you can use the system-sounds pypi library to list and play sounds - pypi.org/project/system-sounds - or github.com/MarkParker5/system-sounds - - or use the a SpeechSynthesizer to say something like "Yes, sir?" - ''' - os.system('afplay /System/Library/Sounds/Blow.aiff &') # play the sound in the background (macos) - callback() - hotkey_event.clear() + if not hotkey_event.is_set(): + continue + + callback() + hotkey_event.clear() diff --git a/stark_place/triggers/porcupine.py b/stark_place/triggers/porcupine.py new file mode 100644 index 0000000..ff63e03 --- /dev/null +++ b/stark_place/triggers/porcupine.py @@ -0,0 +1,43 @@ +from typing import Any, Callable +import anyio +import pvporcupine +from pvrecorder import PvRecorder + + +async def start( + access_key: str, + keyword_paths: list[str], + model_path: str, + callback: Callable[[], Any | None] +): + # register at https://console.picovoice.ai and copy the access key + # train and download a model for the your platform at https://console.picovoice.ai/ppn + # don't rename either the model file or the keyword files + porcupine = pvporcupine.create( + access_key = access_key, + keyword_paths = keyword_paths, + model_path = model_path + ) + + recorder = PvRecorder( + frame_length = porcupine.frame_length, + device_index = 0 + ) + recorder.start() + + try: + while True: # this loop runs in the main thread + await anyio.sleep(0.01) # release the thread for other tasks + + pcm = recorder.read() + keyword_index = porcupine.process(pcm) + + if keyword_index == -1: + continue + + callback() + break # stop listening after the first keyword is detected to release the microphone device + finally: + # stop the recorder, delete the porcupine instances, and release the microphone device + porcupine.delete() + recorder.delete()