From 5d1df036f8775c651e20933be85603e0e701421a Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Tue, 9 Sep 2025 16:51:44 -0700 Subject: [PATCH] ChatGPT modified code for the option of using OWW If the arguments for OWW are included, microwakeword will be disabled and OWW will be used for detection. Requires a running instance of OWW on the local machine using port 10400. If the OWW arguments are excluded, microwakeword is used for detection. --wake-uri 'tcp://127.0.0.1:10400' \ --wake-word-name 'marvin' --- README.md | 19 +- linux_voice_assistant.egg-info/PKG-INFO | 73 +++++ linux_voice_assistant.egg-info/SOURCES.txt | 19 ++ .../dependency_links.txt | 1 + linux_voice_assistant.egg-info/requires.txt | 11 + linux_voice_assistant.egg-info/top_level.txt | 1 + linux_voice_assistant.egg-info/zip-safe | 1 + linux_voice_assistant/__main__.py | 85 +++++- .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 164 bytes .../__pycache__/__main__.cpython-313.pyc | Bin 0 -> 25249 bytes .../__pycache__/api_server.cpython-313.pyc | Bin 0 -> 7872 bytes .../__pycache__/entity.cpython-313.pyc | Bin 0 -> 7013 bytes .../__pycache__/event_bus.cpython-313.pyc | Bin 0 -> 3383 bytes .../__pycache__/event_led.cpython-313.pyc | Bin 0 -> 12139 bytes .../__pycache__/microwakeword.cpython-313.pyc | Bin 0 -> 12671 bytes .../__pycache__/mpv_player.cpython-313.pyc | Bin 0 -> 4691 bytes .../__pycache__/util.cpython-313.pyc | Bin 0 -> 1403 bytes linux_voice_assistant/event_bus.py | 67 +++++ linux_voice_assistant/event_led.py | 262 ++++++++++++++++++ linux_voice_assistant/openwakeword_client.py | 144 ++++++++++ pyproject.toml | 1 - requirements.txt | 2 + script/setup | 3 + service/linux-voice-assistant.service | 22 ++ 24 files changed, 677 insertions(+), 34 deletions(-) create mode 100644 linux_voice_assistant.egg-info/PKG-INFO create mode 100644 linux_voice_assistant.egg-info/SOURCES.txt create mode 100644 linux_voice_assistant.egg-info/dependency_links.txt create mode 100644 linux_voice_assistant.egg-info/requires.txt create mode 100644 linux_voice_assistant.egg-info/top_level.txt create mode 100644 linux_voice_assistant.egg-info/zip-safe create mode 100644 linux_voice_assistant/__pycache__/__init__.cpython-313.pyc create mode 100644 linux_voice_assistant/__pycache__/__main__.cpython-313.pyc create mode 100644 linux_voice_assistant/__pycache__/api_server.cpython-313.pyc create mode 100644 linux_voice_assistant/__pycache__/entity.cpython-313.pyc create mode 100644 linux_voice_assistant/__pycache__/event_bus.cpython-313.pyc create mode 100644 linux_voice_assistant/__pycache__/event_led.cpython-313.pyc create mode 100644 linux_voice_assistant/__pycache__/microwakeword.cpython-313.pyc create mode 100644 linux_voice_assistant/__pycache__/mpv_player.cpython-313.pyc create mode 100644 linux_voice_assistant/__pycache__/util.cpython-313.pyc create mode 100644 linux_voice_assistant/event_bus.py create mode 100644 linux_voice_assistant/event_led.py create mode 100644 linux_voice_assistant/openwakeword_client.py create mode 100644 service/linux-voice-assistant.service diff --git a/README.md b/README.md index 962f852a..a7e1f333 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Supports announcments, start/continue conversation, and timers. Install system dependencies (`apt-get`): -* `libportaudio2` or `portaudio19-dev` (for `sounddevice`) +* `libportaudio2` (for `sounddevice`) * `build-essential` (for `pymicro-features`) * `libmpv-dev` (for `python-mpv`) @@ -25,25 +25,8 @@ script/setup Use `script/run` or `python3 -m linux_voice_assistant` -You must specify `--name ` with a name that will be available in Home Assistant. - See `--help` for more options. -### Microphone - -Use `--audio-input-device` to change the microphone device. Use `python3 -m sounddevice` to see the available PortAudio devices. - -The microphone device **must** support 16Khz mono audio. - -### Speaker - -Use `--audio-output-device` to change the speaker device. Use `mpv --audio-device=help` to see the available MPV devices. - -## Wake Word - -Change the default wake word with `--wake-model ` where `` is the name of a model in the `wakewords` directory. For example, `--wake-model hey_jarvis` will load `wakewords/hey_jarvis.tflite` by default. - - ## Connecting to Home Assistant 1. In Home Assistant, go to "Settings" -> "Device & services" diff --git a/linux_voice_assistant.egg-info/PKG-INFO b/linux_voice_assistant.egg-info/PKG-INFO new file mode 100644 index 00000000..6e2385d2 --- /dev/null +++ b/linux_voice_assistant.egg-info/PKG-INFO @@ -0,0 +1,73 @@ +Metadata-Version: 2.4 +Name: linux-voice-assistant +Version: 1.0.0 +Summary: Linux voice assistant for Home Assistant using the ESPHome protocol +Author-email: The Home Assistant Authors +License: Apache-2.0 +Project-URL: Source Code, http://github.com/OHF-Voice/linux-voice-assistant +Keywords: home,assistant,voice,esphome,linux +Platform: any +Classifier: Development Status :: 3 - Alpha +Classifier: Intended Audience :: Developers +Classifier: Topic :: Text Processing :: Linguistic +Classifier: License :: OSI Approved :: Apache Software License +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Programming Language :: Python :: 3.13 +Requires-Python: >=3.9.0 +Description-Content-Type: text/markdown +License-File: LICENSE.md +Requires-Dist: aioesphomeapi==37.2.1 +Requires-Dist: sounddevice<1 +Requires-Dist: numpy<3,>=2 +Requires-Dist: pymicro-features==1.0.0 +Provides-Extra: dev +Requires-Dist: black==24.8.0; extra == "dev" +Requires-Dist: flake8==7.2.0; extra == "dev" +Requires-Dist: mypy==1.14.0; extra == "dev" +Requires-Dist: pylint==3.2.7; extra == "dev" +Requires-Dist: pytest==8.3.5; extra == "dev" +Dynamic: license-file + +# Linux Voice Assistant + +Experimental Linux voice assistant for [Home Assistant][homeassistant] that uses the [ESPHome][esphome] protocol. + +Runs on Linux `aarch64` and `x86_64` platforms. Tested with Python 3.13 and Python 3.11. +Supports announcments, start/continue conversation, and timers. + +## Installation + +Install system dependencies (`apt-get`): + +* `libportaudio2` (for `sounddevice`) +* `build-essential` (for `pymicro-features`) +* `libmpv-dev` (for `python-mpv`) + +Clone and install project: + +``` sh +git clone https://github.com/OHF-Voice/linux-voice-assistant.git +cd linux-voice-assistant +script/setup +``` + +## Running + +Use `script/run` or `python3 -m linux_voice_assistant` + +See `--help` for more options. + +## Connecting to Home Assistant + +1. In Home Assistant, go to "Settings" -> "Device & services" +2. Click the "Add integration" button +3. Choose "ESPHome" and then "Set up another instance of ESPHome" +4. Enter the IP address of your voice satellite with port 6053 +5. Click "Submit" + + +[homeassistant]: https://www.home-assistant.io/ +[esphome]: https://esphome.io/ diff --git a/linux_voice_assistant.egg-info/SOURCES.txt b/linux_voice_assistant.egg-info/SOURCES.txt new file mode 100644 index 00000000..8c03767b --- /dev/null +++ b/linux_voice_assistant.egg-info/SOURCES.txt @@ -0,0 +1,19 @@ +LICENSE.md +README.md +pyproject.toml +setup.cfg +linux_voice_assistant/__init__.py +linux_voice_assistant/__main__.py +linux_voice_assistant/api_server.py +linux_voice_assistant/entity.py +linux_voice_assistant/event_bus.py +linux_voice_assistant/event_led.py +linux_voice_assistant/microwakeword.py +linux_voice_assistant/mpv_player.py +linux_voice_assistant/util.py +linux_voice_assistant.egg-info/PKG-INFO +linux_voice_assistant.egg-info/SOURCES.txt +linux_voice_assistant.egg-info/dependency_links.txt +linux_voice_assistant.egg-info/requires.txt +linux_voice_assistant.egg-info/top_level.txt +linux_voice_assistant.egg-info/zip-safe \ No newline at end of file diff --git a/linux_voice_assistant.egg-info/dependency_links.txt b/linux_voice_assistant.egg-info/dependency_links.txt new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/linux_voice_assistant.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/linux_voice_assistant.egg-info/requires.txt b/linux_voice_assistant.egg-info/requires.txt new file mode 100644 index 00000000..768cd6f4 --- /dev/null +++ b/linux_voice_assistant.egg-info/requires.txt @@ -0,0 +1,11 @@ +aioesphomeapi==37.2.1 +sounddevice<1 +numpy<3,>=2 +pymicro-features==1.0.0 + +[dev] +black==24.8.0 +flake8==7.2.0 +mypy==1.14.0 +pylint==3.2.7 +pytest==8.3.5 diff --git a/linux_voice_assistant.egg-info/top_level.txt b/linux_voice_assistant.egg-info/top_level.txt new file mode 100644 index 00000000..1f6217e2 --- /dev/null +++ b/linux_voice_assistant.egg-info/top_level.txt @@ -0,0 +1 @@ +linux_voice_assistant diff --git a/linux_voice_assistant.egg-info/zip-safe b/linux_voice_assistant.egg-info/zip-safe new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/linux_voice_assistant.egg-info/zip-safe @@ -0,0 +1 @@ + diff --git a/linux_voice_assistant/__main__.py b/linux_voice_assistant/__main__.py index 8ff6b7ef..a4c3752f 100644 --- a/linux_voice_assistant/__main__.py +++ b/linux_voice_assistant/__main__.py @@ -42,9 +42,13 @@ from .api_server import APIServer from .entity import ESPHomeEntity, MediaPlayerEntity from .microwakeword import MicroWakeWord +from .openwakeword_client import WyomingWakeClient from .mpv_player import MpvMediaPlayer from .util import call_all, get_mac, is_arm +from .event_bus import EventBus +from .event_led import LedEvent + _LOGGER = logging.getLogger(__name__) _MODULE_DIR = Path(__file__).parent _REPO_DIR = _MODULE_DIR.parent @@ -78,8 +82,12 @@ class ServerState: tts_player: MpvMediaPlayer wakeup_sound: str timer_finished_sound: str + loop: asyncio.AbstractEventLoop + event_bus: EventBus media_player_entity: Optional[MediaPlayerEntity] = None satellite: "Optional[VoiceSatelliteProtocol]" = None + wyoming_wake: Optional[WyomingWakeClient] = None + use_wyoming_wake: bool = False # ----------------------------------------------------------------------------- @@ -110,11 +118,16 @@ def __init__(self, state: ServerState) -> None: self._continue_conversation = False self._timer_finished = False + self.state.event_bus.publish('ready', {}) + _LOGGER.info('System is ready!') + def handle_voice_event( self, event_type: VoiceAssistantEventType, data: Dict[str, str] ) -> None: _LOGGER.debug("Voice event: type=%s, data=%s", event_type.name, data) + self.state.event_bus.publish(f'voice_{event_type.name}', data) + if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START: self._tts_url = data.get("url") self._tts_played = False @@ -157,6 +170,7 @@ def handle_timer_event( self._play_timer_finished() def handle_message(self, msg: message.Message) -> Iterable[message.Message]: + _LOGGER.debug(f'message {msg.__name__}') if isinstance(msg, VoiceAssistantEventResponse): # Pipeline event data: Dict[str, str] = {} @@ -197,14 +211,11 @@ def handle_message(self, msg: message.Message) -> Iterable[message.Message]: | VoiceAssistantFeature.TIMERS ), ) - elif isinstance( - msg, - ( - ListEntitiesRequest, - SubscribeHomeAssistantStatesRequest, - MediaPlayerCommandRequest, - ), - ): + elif isinstance(msg, ( + ListEntitiesRequest, + SubscribeHomeAssistantStatesRequest, + MediaPlayerCommandRequest, + ),): for entity in self.state.entities: yield from entity.handle_message(msg) @@ -245,13 +256,13 @@ def handle_message(self, msg: message.Message) -> Iterable[message.Message]: break def handle_audio(self, audio_chunk: bytes) -> None: - if not self._is_streaming_audio: return self.send_messages([VoiceAssistantAudio(data=audio_chunk)]) def wakeup(self) -> None: + # Why are we stopping the timer? Wouldn't it be better to delay it? if self._timer_finished: # Stop timer instead self._timer_finished = False @@ -264,6 +275,10 @@ def wakeup(self) -> None: self.send_messages( [VoiceAssistantRequest(start=True, wake_word_phrase=wake_word_phrase)] ) + + self.state.event_bus.publish('voice_wakeword', {'wake_word_phrase': wake_word_phrase}) + + self.duck() self._is_streaming_audio = True self.state.tts_player.play(self.state.wakeup_sound) @@ -286,6 +301,8 @@ def play_tts(self) -> None: self._tts_played = True _LOGGER.debug("Playing TTS response: %s", self._tts_url) + self.state.event_bus.publish('voice_play_tts', {}) + self.state.stop_word.is_active = True self.state.tts_player.play(self._tts_url, done_callback=self._tts_finished) @@ -301,6 +318,9 @@ def _tts_finished(self) -> None: self.state.stop_word.is_active = False self.send_messages([VoiceAssistantAnnounceFinished()]) + # Actual time the TTS stops speaking + self.state.event_bus.publish('voice__tts_finished', {}) + if self._continue_conversation: self.send_messages([VoiceAssistantRequest(start=True)]) self._is_streaming_audio = True @@ -328,6 +348,9 @@ def connection_lost(self, exc): def process_audio(state: ServerState): + # debug counters + chunks_sent = 0 + last_log = 0.0 try: while True: @@ -340,11 +363,18 @@ def process_audio(state: ServerState): try: state.satellite.handle_audio(audio_chunk) - - if state.wake_word.is_active and state.wake_word.process_streaming( - audio_chunk - ): - state.satellite.wakeup() + chunks_sent += 1 + if state.use_wyoming_wake and chunks_sent % 100 == 0: + _LOGGER.debug("[OWW] chunks_sent=%d (approx %.1fs of audio)", chunks_sent, chunks_sent*0.064) + + if state.use_wyoming_wake: + if state.wyoming_wake: + state.wyoming_wake.send_audio_chunk(audio_chunk) + else: + if state.wake_word.is_active and state.wake_word.process_streaming( + audio_chunk + ): + state.satellite.wakeup() if state.stop_word.is_active and state.stop_word.process_streaming( audio_chunk @@ -363,6 +393,8 @@ def process_audio(state: ServerState): async def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--name", required=True) + parser.add_argument("--wake-uri", help="Wyoming wake server URI (e.g., tcp://127.0.0.1:10400)") + parser.add_argument("--wake-word-name", help="Wake word name on the Wyoming server (e.g., hal)") parser.add_argument( "--audio-input-device", default="default", @@ -401,6 +433,12 @@ async def main() -> None: logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO) _LOGGER.debug(args) + use_wyoming = bool(args.wake_uri and args.wake_word_name) + wyoming_client = None + if use_wyoming: + _LOGGER.info("Using Wyoming openWakeWord at %s (name=%s)", args.wake_uri, args.wake_word_name) + wyoming_client = WyomingWakeClient(args.wake_uri, args.wake_word_name) + # Load available wake words wake_word_dir = Path(args.wake_word_dir) available_wake_words: Dict[str, AvailableWakeWord] = {} @@ -432,6 +470,8 @@ async def main() -> None: stop_config_path = wake_word_dir / f"{args.stop_model}.json" _LOGGER.debug("Loading stop model: %s", stop_config_path) stop_model = MicroWakeWord.from_config(stop_config_path, libtensorflowlite_c_path) + + loop = asyncio.get_running_loop() state = ServerState( name=args.name, @@ -441,12 +481,28 @@ async def main() -> None: available_wake_words=available_wake_words, wake_word=wake_model, stop_word=stop_model, + event_bus=EventBus(), + loop=loop, music_player=MpvMediaPlayer(device=args.audio_output_device), tts_player=MpvMediaPlayer(device=args.audio_output_device), wakeup_sound=args.wakeup_sound, timer_finished_sound=args.timer_finished_sound, + wyoming_wake=wyoming_client, + use_wyoming_wake=use_wyoming, ) + LedEvent(state) + + # Connect to Wyoming wake server if enabled + if state.use_wyoming_wake and state.wyoming_wake: + def _on_detect(_name, _ts): + _LOGGER.debug("[OWW] detection callback fired name=%s ts=%s", _name, _ts) + if state.satellite is not None: + state.loop.call_soon_threadsafe(lambda: state.satellite.wakeup()) + state.wyoming_wake.connect(_on_detect) + state.wake_word.is_active = False + _LOGGER.debug("[OWW] Local MicroWakeWord disabled; streaming audio to Wyoming") + process_audio_thread = threading.Thread( target=process_audio, args=(state,), daemon=True ) @@ -455,7 +511,6 @@ async def main() -> None: def sd_callback(indata, _frames, _time, _status): state.audio_queue.put_nowait(bytes(indata)) - loop = asyncio.get_running_loop() server = await loop.create_server( lambda: VoiceSatelliteProtocol(state), host=args.host, port=args.port ) diff --git a/linux_voice_assistant/__pycache__/__init__.cpython-313.pyc b/linux_voice_assistant/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9650868f787e2a582ad04fc6518dc2561e95148f GIT binary patch literal 164 zcmey&%ge<81pCeRWq|0%AOZ#$p^VQgK*m&tbOudEzm*I{OhDdekkl<_{fzwFRQ;^P z+|*+IoXot^3f;2&%;Z$v#Ny)2;*!L?5{PI#STr6X8XupTmst`YuUAlci^C>2KczG$ Y)vkyYXeP+cVi4maGb1Bo5i^hl0Dd4UF#rGn literal 0 HcmV?d00001 diff --git a/linux_voice_assistant/__pycache__/__main__.cpython-313.pyc b/linux_voice_assistant/__pycache__/__main__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c2d123659def2856ef64f3b21a04f0079c1a3f30 GIT binary patch literal 25249 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 literal 0 HcmV?d00001 diff --git a/linux_voice_assistant/__pycache__/event_bus.cpython-313.pyc b/linux_voice_assistant/__pycache__/event_bus.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..894e0c903895ce00d7a429ae0163e58536df5f66 GIT binary patch literal 3383 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 literal 0 HcmV?d00001 diff --git a/linux_voice_assistant/__pycache__/microwakeword.cpython-313.pyc b/linux_voice_assistant/__pycache__/microwakeword.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8ef7b51f83e77f20571074e7bc4c1ad6e4831258 GIT binary patch literal 12671 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 L?td7}>6HE-%#vfG literal 0 HcmV?d00001 diff --git a/linux_voice_assistant/__pycache__/mpv_player.cpython-313.pyc b/linux_voice_assistant/__pycache__/mpv_player.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..821a8d38bbb9db42780caf5bd836770510e1b1f4 GIT binary patch literal 4691 zcmb_g-ER}w6~E*0*yC>}P9WczI0WLQNmv3)OSgzZVOL2oizlh5679&b2V-!??j1V< zq)I$hL1L9u6>5->8nrLst&e?byYwHZo22%RST)kNm3Z?u*&?+sJ?D;R?3jT1&@1QO zbI-Y-=l;&eWG@i#5-7hv_q*GldkFaoJ63VliM@wFJRvfnMCN42G)FmT`Dw=#Pk9M> z&S~eAKm|q%)2=Bubx(;@obpf)vX*bOQL+#?%b9%|>>^PjyL*T% zo^{lDp^PKud2r&In$9MrLN0kT1x3q9nH^A1N{17v{69kOe|y&x=z=9n`MHqs4qJQD=Q zvp(aA3tqN@D0}KF_~cI6i!}8ETNiMiqr{K--}n;$b3clFh2822g- z)dNx0(qp%>fHxn-lNc@&tA;nLD@X!j4G*Joh{B+f&TFcY0+cQ$Q+Fxe+lU-wDmlQd z;aAe7)LmscpDQh?Ft4N?=&Q@BRm+YNdN)%XxsoSW?&FWJ#@Pa$!f{Dh)2!8#}*B#|!rjQBks5wx}q^(SzU` zw*&wMqGzCbNM0W4{<*s1eC+#&udVvtsnzL6)9b;q|HN+3AaFyw$Bsi2tbTZL#kno^ zz8pUB>kp8Uc87+s?XC{P0Fdm8{#D;2Uqy_T#puTQZE<|FSedw3p18Opet!?PA_iZg zIU{gDxrSFS<_n+^suxo>z+f=a2*14n26BLa4Qb%kiv>T4%-c|%<*rt_=o$XPhE{9(d7(p!*%sXJbCV7pN9Np4AXL;7xeFNGTed!52W} zR%Wh$^wE`sA*9vCQih_DQ9J@|!!b^@jhH}tkTZytgw+sKhN!9ciVD1^j_y*zLVHmf zLusT1cDN8Opl6|aNNRrK39Nb^c^>=z=IdNbJ)5lf2Ft#|>cG(Y+{zC=i|_VJkK?91t)i5aO=&zJzr?`!s>}~1b9HnEaF70>O89r0a{_WwEgQS0RN+eo!& z-*A~h_6_iv?8_}jKUMBOb@1qH)dTqyLIWYSSAeQHpOU#oSOJUSEGKTY8VC8*A(qw% zClFd}`5YwYWAJ+5^3i9q0(Mt--ZTu1SNOOwm%%S3??IK-Omk#HFbiub_PHTo9zH^Iu)Nq#7-aYulZk~6Sl3~%0SUwa^rLo; zj)6NvhRC;wH6{%@yycL{Ae=}d@xkx$gX9)BMCLJBW5rF9T@YW$JYt2FLo;W-p@%`( z;}$VjvXFN5!SOkW-UT&PRMV15X`V{7q-mHqU~VQ^iEqqn#sEwPSX`&akzxq~hA);c z7@jNlQY!mwqbGoAcvPzoUI}{5jS?$(2e7ITQJ+4E?vqa3?Hq8hsxohKSihh5}vN{$o#jCX~*Y%BbR#k7j5oKC&`N*@1;@q zi<5(w1Nx23kHu_Wjxl2nY{)d2awr&6=f-( zF6EH+E6R^c$y{9$Qp|K3z8_RAuP7ApNk_2i!wOA^qQy~s0>inO&*#kNWnmv(0}FBV zJ4j(nGx)4lq?fSE9;#DOAfYT~Q%bT}q}jz%QPmaYIbjaMOasx__e297~T!EuT8Ey*X5_4wF~7yv?c(v<|19v zhPc_jIlI|amQI(CPS)JW7fEM#4X4$-#2fs^2h{$kLy-1G9w@Iv7~B^*VPfCK3Gdfj zf-t%fsS#*4JNB`89c&Y3IP>6m+*la0V))ccfm+?Ud?th6hM`nGmjiQw|48WL$;FhZ zI8X65Rg-BLV16OGiuViHVX9?1M@@qpFD#p%ot}(ZoPj05pg#g0xCNr4vj==}+!ti*D-!&Y9Q%^={FRKB$>`rnWY^nP@%H}N+q?epmUrZ# R`?c!^$IWp6B&=a8{})zOe)a$W literal 0 HcmV?d00001 diff --git a/linux_voice_assistant/__pycache__/util.cpython-313.pyc b/linux_voice_assistant/__pycache__/util.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..96804281b72b0af402179e49f08f82212a8f3fb0 GIT binary patch literal 1403 zcma(RJ8auV^c_i2-$?!hN~{#Any840#IloEiBY?50S_(WNCnIaK@p<_P0k@$H{4#)0pXfPA?0^asRH6#Ygp%MCsDP)qEZ&l+l;tvMIdn^=GSZ>d@T{Q9 z8Q73$L=CUXtI{;6!mPlZ@=p!nO;RI)Milj9s2>e(@v5N2z8NX|mTmc4*}Cc1+=`d) z@N`NFVUZ=>w)HjJMXCUp$1HGl{bB7VJZ5q(W|;O;;IE5eW*5*MJ* zr|VeZ%Ao$C9H2~Qu>+W8XwJl$ zYn!ECK@pjdpJBveVqG^p9wM78P&FNMvq2YGrfT{cI?pfIuA$rBVty!#V>mB|=(qzd*iRQ9&foq` z-@U%~!7p=r{z2w)due}S=IG2-9OG z^dvcYzxG4zC|&%c@L=r0%HLy0^S2Mv#lvK=_1Tm7SgX`S8(>kaE!Z;8;P(OWr1|KB zq2WRe2dd)e3jL+R5#qCQNSVA@DI8p)KR8eb-(z{*vVD^(0zHQgnPmB9{iK}iNjVXo zf`%V`9~F3o-dRLg&PaOO-~H;*$ffV4r;)_Z@(U40Cus(SZzapq;VaN9A$s@icW~Rv zFpoAL6FwUb6?_f&u7wjm?z328qE729r0dkE;aDOKTld#pWbpyjEC=5w&7rBsd(TacJn~U@^3V7yT5m8qpggk}xb4dLI@n?{G2Iso6NXECvUjiQ8gg{ceXZI%C h*V=x24pTmuD0Bgtzvo`^qZ<>*jcw@_(l>%|{{slHHjw}T literal 0 HcmV?d00001 diff --git a/linux_voice_assistant/event_bus.py b/linux_voice_assistant/event_bus.py new file mode 100644 index 00000000..8ce5ecab --- /dev/null +++ b/linux_voice_assistant/event_bus.py @@ -0,0 +1,67 @@ +import logging +from typing import Any, Callable, Dict, List, Optional + +_LOGGER = logging.getLogger(__name__) + + +class EventBus: + """A simple synchronous publish/subscribe event bus.""" + + def __init__(self): + # A dictionary to hold listeners for specific string topics + self.topics: Dict[str, List[Callable[[Any], None]]] = {} + + def subscribe(self, topic: str, listener: Callable[[Any], None]) -> None: + """ + Subscribes a listener to a topic. + """ + + # _LOGGER.debug(f'EventBus subscribe {topic}') + + if topic not in self.topics: + self.topics[topic] = [] + self.topics[topic].append(listener) + + def publish(self, topic: str, data: [dict, None]) -> None: + """ + Publishes an event to all subscribed listeners. + """ + + # _LOGGER.debug(f'EventBus publish {topic}') + + data['__topic'] = topic + + listeners = self.topics.get(topic, []) + for listener in listeners: + listener(data) + +# Client helpers for subscriptions + +# The decorator to mark methods for subscription. +def subscribe(func: Callable) -> Callable: + """Decorator to mark a method for event bus subscription.""" + func._event_bus_subscribe = True + return func + +class EventHandler: + """ + A base class for components that subscribe to events. + + Subclasses should define event handlers as methods decorated with `@subscribe`. + The method name will automatically be used as the event topic. + """ + + def __init__(self, state: Any): + self.state = state + self._subscribe_all_methods() + _LOGGER.debug(f"EventHandler {self.__class__.__name__} has subscribed to all decorated methods.") + + def _subscribe_all_methods(self): + """Finds and subscribes all methods decorated with @subscribe.""" + for method_name in dir(self): + method = getattr(self, method_name) + + if hasattr(method, '_event_bus_subscribe'): + # The topic is the name of the method itself. + self.state.event_bus.subscribe(method_name, method) + _LOGGER.debug(f"Subscribed method '{method_name}' to topic '{method_name}'") \ No newline at end of file diff --git a/linux_voice_assistant/event_led.py b/linux_voice_assistant/event_led.py new file mode 100644 index 00000000..651a34a2 --- /dev/null +++ b/linux_voice_assistant/event_led.py @@ -0,0 +1,262 @@ +import logging +from typing import Any, Callable + +from .event_bus import EventHandler, subscribe + +_LOGGER = logging.getLogger(__name__) + + + +"""Controls the LEDs on the ReSpeaker 2mic HAT.""" +from math import ceil +from typing import Tuple + +import time +import asyncio +import gpiozero +import spidev + + +NUM_LEDS = 3 +LEDS_GPIO = 12 +RGB_MAP = { + "rgb": [3, 2, 1], + "rbg": [3, 1, 2], + "grb": [2, 3, 1], + "gbr": [2, 1, 3], + "brg": [1, 3, 2], + "bgr": [1, 2, 3], +} + + + +_OFF = (0, 0, 0) +_WHITE = (255, 255, 255) +_RED = (255, 0, 0) +_YELLOW = (255, 255, 0) +_BLUE = (0, 0, 255) +_GREEN = (0, 255, 0) + +# ----------------------------------------------------------------------------- + + +class APA102: + """ + Driver for APA102 LEDS (aka "DotStar"). + (c) Martin Erzberger 2016-2017 + """ + + # Constants + MAX_BRIGHTNESS = 0b11111 # Safeguard: Set to a value appropriate for your setup + LED_START = 0b11100000 # Three "1" bits, followed by 5 brightness bits + + def __init__( + self, + num_led, + global_brightness, + loop: asyncio.AbstractEventLoop, + order="rgb", + bus=0, + device=1, + max_speed_hz=8000000, + ): + self.num_led = num_led # The number of LEDs in the Strip + order = order.lower() + self.rgb = RGB_MAP.get(order, RGB_MAP["rgb"]) + # Limit the brightness to the maximum if it's set higher + if global_brightness > self.MAX_BRIGHTNESS: + self.global_brightness = self.MAX_BRIGHTNESS + else: + self.global_brightness = global_brightness + print("LED brightness:", self.global_brightness) + + self.leds = [self.LED_START, 0, 0, 0] * self.num_led # Pixel buffer + self.spi = spidev.SpiDev() # Init the SPI device + self.spi.open(bus, device) # Open SPI port 0, slave device (CS) 1 + # Up the speed a bit, so that the LEDs are painted faster + if max_speed_hz: + self.spi.max_speed_hz = max_speed_hz + + self.current_task = None + self.loop = loop + + + def clock_start_frame(self): + """Sends a start frame to the LED strip. + + This method clocks out a start frame, telling the receiving LED + that it must update its own color now. + """ + self.spi.xfer2([0] * 4) # Start frame, 32 zero bits + + def clock_end_frame(self): + """Sends an end frame to the LED strip. + + As explained above, dummy data must be sent after the last real colour + information so that all of the data can reach its destination down the line. + The delay is not as bad as with the human example above. + It is only 1/2 bit per LED. This is because the SPI clock line + needs to be inverted. + + Say a bit is ready on the SPI data line. The sender communicates + this by toggling the clock line. The bit is read by the LED + and immediately forwarded to the output data line. When the clock goes + down again on the input side, the LED will toggle the clock up + on the output to tell the next LED that the bit is ready. + + After one LED the clock is inverted, and after two LEDs it is in sync + again, but one cycle behind. Therefore, for every two LEDs, one bit + of delay gets accumulated. For 300 LEDs, 150 additional bits must be fed to + the input of LED one so that the data can reach the last LED. + + Ultimately, we need to send additional numLEDs/2 arbitrary data bits, + in order to trigger numLEDs/2 additional clock changes. This driver + sends zeroes, which has the benefit of getting LED one partially or + fully ready for the next update to the strip. An optimized version + of the driver could omit the "clockStartFrame" method if enough zeroes have + been sent as part of "clockEndFrame". + """ + + self.spi.xfer2([0xFF] * 4) + + # Round up num_led/2 bits (or num_led/16 bytes) + # for _ in range((self.num_led + 15) // 16): + # self.spi.xfer2([0x00]) + + def set_pixel(self, led_num, red, green, blue, bright_percent=100): + """Sets the color of one pixel in the LED stripe. + + The changed pixel is not shown yet on the Stripe, it is only + written to the pixel buffer. Colors are passed individually. + If brightness is not set the global brightness setting is used. + """ + if led_num < 0: + return # Pixel is invisible, so ignore + if led_num >= self.num_led: + return # again, invisible + + # Calculate pixel brightness as a percentage of the + # defined global_brightness. Round up to nearest integer + # as we expect some brightness unless set to 0 + brightness = int(ceil(bright_percent * self.global_brightness / 100.0)) + + # LED startframe is three "1" bits, followed by 5 brightness bits + ledstart = (brightness & 0b00011111) | self.LED_START + + start_index = 4 * led_num + self.leds[start_index] = ledstart + self.leds[start_index + self.rgb[0]] = red + self.leds[start_index + self.rgb[1]] = green + self.leds[start_index + self.rgb[2]] = blue + + def set_pixel_rgb(self, led_num, rgb_color, bright_percent=100): + """Sets the color of one pixel in the LED stripe. + + The changed pixel is not shown yet on the Stripe, it is only + written to the pixel buffer. + Colors are passed combined (3 bytes concatenated) + If brightness is not set the global brightness setting is used. + """ + self.set_pixel( + led_num, + (rgb_color & 0xFF0000) >> 16, + (rgb_color & 0x00FF00) >> 8, + rgb_color & 0x0000FF, + bright_percent or self.global_brightness, + ) + + def rotate(self, positions=1): + """Rotate the LEDs by the specified number of positions. + + Treating the internal LED array as a circular buffer, rotate it by + the specified number of positions. The number could be negative, + which means rotating in the opposite direction. + """ + cutoff = 4 * (positions % self.num_led) + self.leds = self.leds[cutoff:] + self.leds[:cutoff] + + def show(self): + """Sends the content of the pixel buffer to the strip. + + Todo: More than 1024 LEDs requires more than one xfer operation. + """ + self.clock_start_frame() + # xfer2 kills the list, unfortunately. So it must be copied first + # SPI takes up to 4096 Integers. So we are fine for up to 1024 LEDs. + data = list(self.leds) + while data: + self.spi.xfer2(data[:32]) + data = data[32:] + self.clock_end_frame() + + def cleanup(self): + """Release the SPI device; Call this method at the end""" + + self.spi.close() # Close SPI port + + + def run_action(self, action_mentod_name: str, *args: Any) -> None: + if self.current_task and not self.current_task.done(): + self.current_task.cancel() + + self.current_task = asyncio.run_coroutine_threadsafe(getattr(self, action_mentod_name)(*args), self.loop) + + async def color(self, rgb: Tuple[int, int, int], brightness = None) -> None: + for i in range(NUM_LEDS): + self.set_pixel(i, rgb[0], rgb[1], rgb[2], brightness or self.global_brightness) + + self.show() + + async def blink(self, color, count=10000): + for _ in range(count): + await self.color(color) + await asyncio.sleep(0.3) + await self.color(_OFF) + await asyncio.sleep(0.3) + + async def pulse(self, color: Tuple[int, int, int], speed: float = 0.009): + """Asynchronously pulses the LEDs from off to full brightness and back.""" + # Fade in + while(1): + for brightness in range(1, self.global_brightness+1): + await self.color(color, brightness) + await asyncio.sleep(speed) + + # Fade out + for brightness in range(self.global_brightness+1, 0, -1): + await self.color(color, brightness) + await asyncio.sleep(speed) + + + + +class LedEvent(EventHandler): + def __init__(self, state): + super().__init__(state) + self.leds = APA102(num_led=3, global_brightness=31, loop=self.state.loop) + + @subscribe + def ready(self, data: dict): + _LOGGER.debug('ready LED green blink') + self.leds.run_action("blink", _GREEN, 3) + + @subscribe + def voice_wakeword(self, data: dict): + self.leds.run_action("pulse", _BLUE) + + @subscribe + def voice_VOICE_ASSISTANT_STT_VAD_END(self, data: dict): + self.leds.run_action("pulse", _YELLOW) + + @subscribe + def voice_play_tts(self, data: dict): + self.leds.run_action("pulse", _GREEN) + + # This event fires long before the TTS is done speaking + # @subscribe + # def voice_VOICE_ASSISTANT_RUN_END(self, data: dict): + # self.leds.run_action("color", _OFF, 0) + + @subscribe + def voice__tts_finished(self, data: dict): + self.leds.run_action("color", _OFF, 0) diff --git a/linux_voice_assistant/openwakeword_client.py b/linux_voice_assistant/openwakeword_client.py new file mode 100644 index 00000000..59c1c50a --- /dev/null +++ b/linux_voice_assistant/openwakeword_client.py @@ -0,0 +1,144 @@ +import json +import logging +import socket +import threading +import time +from typing import Callable, Optional +from urllib.parse import urlparse + +_LOGGER = logging.getLogger(__name__) + +def _jsonl(obj: dict) -> bytes: + return (json.dumps(obj, separators=(",", ":")) + "\n").encode("utf-8") + +class WyomingWakeClient: + """Minimal client for Wyoming wake detection (openWakeWord) with extra debug.""" + + def __init__(self, uri: str, name: str, rate=16000, width=2, channels=1): + self.uri, self.name = uri, name + self.rate, self.width, self.channels = rate, width, channels + self._sock: Optional[socket.socket] = None + self._reader: Optional[threading.Thread] = None + self._on_detect: Optional[Callable[[str, Optional[int]], None]] = None + self._closed = False + self._chunks = 0 + self._last_log_time = 0.0 + + def connect(self, on_detect: Callable[[str, Optional[int]], None]) -> None: + self._on_detect = on_detect + parsed = urlparse(self.uri) + if parsed.scheme != "tcp": + raise ValueError(f"Only tcp:// URIs are supported (got {self.uri})") + host = parsed.hostname or "127.0.0.1" + port = parsed.port or 10400 + _LOGGER.debug("[OWW] Connecting to %s:%s", host, port) + self._sock = socket.create_connection((host, port)) + self._sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + self._send({ "type": "audio-start", "data": { "rate": self.rate, "width": self.width, "channels": self.channels }}) + self._arm_detect() + self._reader = threading.Thread(target=self._read_loop, daemon=True) + self._reader.start() + _LOGGER.info("[OWW] Connected to Wyoming wake server at %s", self.uri) + + def close(self) -> None: + self._closed = True + try: + self._send({"type": "audio-stop"}) + except Exception: + pass + try: + if self._sock: + self._sock.close() + finally: + self._sock = None + + def _arm_detect(self) -> None: + _LOGGER.debug("[OWW] Arming detect for name=%s", self.name) + self._send({"type": "detect", "data": {"names": [self.name]}}) + + def send_audio_chunk(self, pcm: bytes, timestamp_ms: Optional[int] = None) -> None: + if self._sock is None or self._closed: + return + hdr = { + "type": "audio-chunk", + "data": {"rate": self.rate, "width": self.width, "channels": self.channels}, + "payload_length": len(pcm), + } + if timestamp_ms is not None: + hdr["data"]["timestamp"] = timestamp_ms + try: + self._sock.sendall(_jsonl(hdr)) + self._sock.sendall(pcm) + except Exception: + _LOGGER.exception("[OWW] Failed to send audio chunk") + return + + self._chunks += 1 + if (self._chunks % 1000) == 0: + # ~64ms * 1000 = ~64s of audio + _LOGGER.debug("[OWW] Sent %d chunks (~%.1fs)", self._chunks, self._chunks * 0.064) + + # internals + def _send(self, obj: dict) -> None: + assert self._sock is not None + try: + self._sock.sendall(_jsonl(obj)) + except Exception: + _LOGGER.exception("[OWW] send failed (%s)", obj.get("type")) + + def _cycle_stream(self) -> None: + """Some servers require restarting the audio stream after a detection.""" + _LOGGER.debug("[OWW] Cycling audio stream") + try: + self._send({"type": "audio-stop"}) + except Exception: + _LOGGER.exception("[OWW] audio-stop failed") + try: + self._send({"type": "audio-start", "data": {"rate": self.rate, "width": self.width, "channels": self.channels}}) + except Exception: + _LOGGER.exception("[OWW] audio-start failed") + + def _read_loop(self) -> None: + assert self._sock is not None + f = self._sock.makefile("rb") + _LOGGER.debug("[OWW] Reader loop started") + buf = b"" + decoder = json.JSONDecoder() + while not self._closed: + chunk = f.readline() + if not chunk: + _LOGGER.debug("[OWW] Reader EOF") + break + buf += chunk + try: + # There might be multiple JSON objects in one line; parse until exhausted. + while buf: + s = buf.decode("utf-8") + obj, idx = decoder.raw_decode(s) + buf = s[idx:].lstrip().encode("utf-8") + mtype = obj.get("type") + if mtype and mtype != "detection": + _LOGGER.debug("[OWW] recv %s", mtype) + if mtype == "detection": + data = obj.get("data", {}) or {} + name = data.get("name") or self.name + ts = data.get("timestamp") + _LOGGER.info("[OWW] Detection: name=%s ts=%s (chunks=%d)", name, ts, self._chunks) + if self._on_detect: + try: + self._on_detect(name, ts) + except Exception: + _LOGGER.exception("[OWW] Error in detection callback") + # Some servers need an audio stream restart before re-arming + try: + self._cycle_stream() + except Exception: + _LOGGER.exception("[OWW] Failed to cycle stream") + # Re-arm detect so subsequent detections work + try: + self._arm_detect() + except Exception: + _LOGGER.exception("[OWW] Failed to re-arm detect") + except json.JSONDecodeError: + # Need more bytes; continue accumulating + pass diff --git a/pyproject.toml b/pyproject.toml index 80c6da04..17011ffb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,6 @@ dependencies = [ "sounddevice<1", "numpy>=2,<3", "pymicro-features==1.0.0", - "python-mpv=>1,<2", ] [project.optional-dependencies] diff --git a/requirements.txt b/requirements.txt index be3245c3..5e879ab4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ aioesphomeapi sounddevice +gpiozero +spidev diff --git a/script/setup b/script/setup index 0ff2e95b..b9cb4334 100755 --- a/script/setup +++ b/script/setup @@ -21,6 +21,9 @@ builder.create(_VENV_DIR) pip = [context.env_exe, "-m", "pip"] subprocess.check_call(pip + ["install", "--upgrade", "pip"]) subprocess.check_call(pip + ["install", "--upgrade", "setuptools", "wheel"]) +subprocess.check_call(pip + ["install", "--upgrade", "python-mpv"]) +subprocess.check_call(pip + ["install", "--upgrade", "gpiozero"]) +subprocess.check_call(pip + ["install", "--upgrade", "spidev"]) # Install requirements subprocess.check_call(pip + ["install", "-e", str(_PROGRAM_DIR)]) diff --git a/service/linux-voice-assistant.service b/service/linux-voice-assistant.service new file mode 100644 index 00000000..38860710 --- /dev/null +++ b/service/linux-voice-assistant.service @@ -0,0 +1,22 @@ +[Unit] +Description=Linux Voice Assistant +Requires=wyoming-openwakeword.service +After=sound.target network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=%h/linux-voice-assistant +ExecStart=%h/linux-voice-assistant/script/run \ + --name 'Linux Satellite' \ + --audio-input-device seeed-2mic-voicecard \ + --audio-output-device alsa/hw:2,0 \ + --wake-uri 'tcp://127.0.0.1:10400' \ + --wake-word-name 'marvin' +Restart=always +RestartSec=2 +User=%i +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=multi-user.target