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 00000000..9650868f Binary files /dev/null and b/linux_voice_assistant/__pycache__/__init__.cpython-313.pyc differ 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 00000000..c2d12365 Binary files /dev/null and b/linux_voice_assistant/__pycache__/__main__.cpython-313.pyc differ diff --git a/linux_voice_assistant/__pycache__/api_server.cpython-313.pyc b/linux_voice_assistant/__pycache__/api_server.cpython-313.pyc new file mode 100644 index 00000000..349941e1 Binary files /dev/null and b/linux_voice_assistant/__pycache__/api_server.cpython-313.pyc differ diff --git a/linux_voice_assistant/__pycache__/entity.cpython-313.pyc b/linux_voice_assistant/__pycache__/entity.cpython-313.pyc new file mode 100644 index 00000000..dc74b9e0 Binary files /dev/null and b/linux_voice_assistant/__pycache__/entity.cpython-313.pyc differ 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 00000000..894e0c90 Binary files /dev/null and b/linux_voice_assistant/__pycache__/event_bus.cpython-313.pyc differ diff --git a/linux_voice_assistant/__pycache__/event_led.cpython-313.pyc b/linux_voice_assistant/__pycache__/event_led.cpython-313.pyc new file mode 100644 index 00000000..ae4d2906 Binary files /dev/null and b/linux_voice_assistant/__pycache__/event_led.cpython-313.pyc differ 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 00000000..8ef7b51f Binary files /dev/null and b/linux_voice_assistant/__pycache__/microwakeword.cpython-313.pyc differ 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 00000000..821a8d38 Binary files /dev/null and b/linux_voice_assistant/__pycache__/mpv_player.cpython-313.pyc differ 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 00000000..96804281 Binary files /dev/null and b/linux_voice_assistant/__pycache__/util.cpython-313.pyc differ 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