diff options
-rw-r--r-- | .run/hestia.run.xml | 2 | ||||
-rw-r--r-- | __main__.py | 42 | ||||
-rw-r--r-- | config.yaml | 8 | ||||
-rw-r--r-- | modules/config.py | 37 | ||||
-rw-r--r-- | modules/input_handlers/__init__.py | 0 | ||||
-rw-r--r-- | modules/input_handlers/input_handler.py | 11 | ||||
-rw-r--r-- | modules/input_handlers/pipewire_record.py | 72 | ||||
-rw-r--r-- | modules/input_handlers/stdin_input.py | 16 | ||||
-rw-r--r-- | modules/intents/process.py | 6 | ||||
-rw-r--r-- | modules/intents/sway.py | 8 | ||||
-rw-r--r-- | modules/responses/__init__.py | 0 | ||||
-rw-r--r-- | modules/responses/libnotify.py | 15 | ||||
-rw-r--r-- | modules/responses/response_handler.py | 6 | ||||
-rw-r--r-- | modules/responses/response_manager.py | 32 | ||||
-rw-r--r-- | modules/responses/responses.py | 29 | ||||
-rwxr-xr-x | record.sh | 15 | ||||
-rw-r--r-- | requirements.txt | 1 | ||||
-rw-r--r-- | resources/hestia_images/hestia.jpg | bin | 0 -> 43277 bytes | |||
-rw-r--r-- | responses/process_HesExecuteProcess.yaml | 4 | ||||
-rw-r--r-- | sentences/_common.yaml | 3 | ||||
-rw-r--r-- | sentences/sway_HesLock.yaml | 8 |
21 files changed, 286 insertions, 29 deletions
diff --git a/.run/hestia.run.xml b/.run/hestia.run.xml index 6f4bfc5..1409f86 100644 --- a/.run/hestia.run.xml +++ b/.run/hestia.run.xml @@ -15,7 +15,7 @@ <option name="ADD_SOURCE_ROOTS" value="true" /> <EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" /> <option name="SCRIPT_NAME" value="hestia" /> - <option name="PARAMETERS" value="hestia/sentences" /> + <option name="PARAMETERS" value="" /> <option name="SHOW_COMMAND_LINE" value="false" /> <option name="EMULATE_TERMINAL" value="false" /> <option name="MODULE_MODE" value="true" /> diff --git a/__main__.py b/__main__.py index 8818205..7d6f84b 100644 --- a/__main__.py +++ b/__main__.py @@ -1,13 +1,18 @@ import os -import sys import yaml from pathlib import Path + +from .modules.config import Config from .modules.config import load_config from .modules.hassil.recognize import recognize from .modules.hassil.util import merge_dict from .modules.hassil.intents import Intents, TextSlotList +from .modules.responses.response_manager import ResponseManager +from .modules.input_handlers.stdin_input import StdinInput +from .modules.input_handlers.pipewire_record import PipeWireRecord + from .modules.intents import * @@ -15,8 +20,13 @@ def main(): config = load_config() input_dict = {"intents": {}} - yaml_path = Path(config.intents_dir) - yaml_file_paths = yaml_path.glob("*.yaml") + intent_yaml_path = Path(config.intents_dir) + intent_yaml_file_paths = intent_yaml_path.glob("*.yaml") + for yaml_file_path in intent_yaml_file_paths: + with open(yaml_file_path, "r", encoding="utf-8") as yaml_file: + merge_dict(input_dict, yaml.safe_load(yaml_file)) + + intents = Intents.from_dict(input_dict) processes = [] for file in os.listdir(config.applications_dir): @@ -31,31 +41,27 @@ def main(): "process": TextSlotList.from_strings(processes) } - for yaml_file_path in yaml_file_paths: - with open(yaml_file_path, "r", encoding="utf-8") as yaml_file: - merge_dict(input_dict, yaml.safe_load(yaml_file)) - - intents = Intents.from_dict(input_dict) + input_handler = PipeWireRecord() if config.input_mode == Config.INPUT_PW else StdinInput() + response_manager = ResponseManager(config) try: - for line in sys.stdin: - line = line.strip() - if not line: - continue - - result = recognize(line, intents, slot_lists=slot_lists) + for input_text in input_handler.get_input(): + result = recognize(input_text, intents, slot_lists=slot_lists) if result is not None: result_dict = { "intent": result.intent.name, **{e.name: e.value for e in result.entities_list}, } print(result_dict) + + response_manager.respond(result.response, result.intent.name) + handler = getattr(globals()[result_dict["domain"]], result_dict["intent"]) - handler(result_dict) + handler(result_dict, config) else: - print("<no match>") - except KeyboardInterrupt: - pass + print("<no intent match>") + finally: + input_handler.cleanup() if __name__ == '__main__': main()
\ No newline at end of file diff --git a/config.yaml b/config.yaml index bf1b7be..ac3069c 100644 --- a/config.yaml +++ b/config.yaml @@ -1,2 +1,8 @@ intents_dir: sentences -applications_dir: /usr/bin
\ No newline at end of file +responses_dir: responses +resources_dir: resources +applications_dir: /usr/bin + +input_mode: pw-record # other option: stdin + +lock: swaylock
\ No newline at end of file diff --git a/modules/config.py b/modules/config.py index 602ec8d..c5e8d1d 100644 --- a/modules/config.py +++ b/modules/config.py @@ -1,20 +1,48 @@ +import sys + import __main__ import os.path import yaml class Config: + INPUT_PW = "pw-record" + INPUT_STDIN = "stdin" + INPUT_MODES = [INPUT_PW, INPUT_STDIN] + def __init__(self): + self.input_mode = "" + self.intents_dir = "" + self.responses_dir = "" + self.resources_dir = "" self.applications_dir = "" - def update(self, **entries): + self.lock = "" + + self.hestia_images = "" + + def update(self, **entries) -> None: self.__dict__.update(entries) - if not self.intents_dir.startswith("/"): - self.intents_dir = os.path.join(os.path.dirname(__main__.__file__), self.intents_dir) + self.intents_dir = Config.__convert_to_absolute_path(self.intents_dir) + self.responses_dir = Config.__convert_to_absolute_path(self.responses_dir) + self.resources_dir = Config.__convert_to_absolute_path(self.resources_dir) + + self.hestia_images = os.path.join(self.resources_dir, "hestia_images") + + @staticmethod + def __convert_to_absolute_path(path: str) -> str: + if not path.startswith("/"): + path = os.path.join(os.path.dirname(__main__.__file__), path) + + return path + + def validate(self) -> None: + if self.input_mode not in self.INPUT_MODES: + sys.exit(f"Invalid input_mode '{self.input_mode}', valid options: {', '.join(self.INPUT_MODES)}") -def load_config(): +def load_config() -> Config: config = Config() with open(os.path.join(os.path.dirname(__main__.__file__), "config.yaml")) as stream: config.update(**yaml.safe_load(stream)) @@ -24,4 +52,5 @@ def load_config(): with open(user_config) as stream: config.update(**yaml.safe_load(stream)) + config.validate() return config
\ No newline at end of file diff --git a/modules/input_handlers/__init__.py b/modules/input_handlers/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/modules/input_handlers/__init__.py diff --git a/modules/input_handlers/input_handler.py b/modules/input_handlers/input_handler.py new file mode 100644 index 0000000..3db042c --- /dev/null +++ b/modules/input_handlers/input_handler.py @@ -0,0 +1,11 @@ +from abc import ABC, abstractmethod + +class InputHandler(ABC): + + @abstractmethod + def get_input(self) -> str: + pass + + @abstractmethod + def cleanup(self) -> None: + pass
\ No newline at end of file diff --git a/modules/input_handlers/pipewire_record.py b/modules/input_handlers/pipewire_record.py new file mode 100644 index 0000000..0a767e1 --- /dev/null +++ b/modules/input_handlers/pipewire_record.py @@ -0,0 +1,72 @@ +import subprocess +import os.path +import signal +from time import sleep + +import whisper + +from .input_handler import InputHandler + +FIFO_PATH = "/tmp/hestia-listening" +RECORD_PATH = "/tmp/hestia-record.mp3" + +class PipeWireRecord(InputHandler): + def cleanup(self) -> None: + if os.path.exists(FIFO_PATH): + os.remove(FIFO_PATH) + + + def get_input(self) -> str: + device = PipeWireRecord.get_device() + + self.cleanup() + os.mkfifo(FIFO_PATH) + + while True: + with open(FIFO_PATH): + pass + # TODO "I'm listening" + + try: + ps = subprocess.Popen((f"pw-record --target {device} {RECORD_PATH}",), shell=True) + with open(FIFO_PATH): + print("finished") + ps.send_signal(signal.SIGINT) + # TODO "acknowledged" + except: + if "ps" in locals(): + ps.kill() + # TODO "error" + # TODO exit gracefully or try to recover + raise StopIteration + + model = whisper.load_model("base") + + audio = whisper.load_audio(RECORD_PATH) + audio = whisper.pad_or_trim(audio) + + mel = whisper.log_mel_spectrogram(audio).to(model.device) + options = whisper.DecodingOptions(language="en", fp16=False) + result = whisper.decode(model, mel, options) + result_text = result.text.replace(",", "").replace(".", "").lower() + + print(result_text) + + yield result_text + + @staticmethod + def get_device() -> str: + already_warned = False + + while True: + ps = subprocess.Popen(('pw-cli ls | \\grep -Poi "(?<=node.name = \\").*mic.*(?=\\")"',), shell=True, stdout=subprocess.PIPE) + ps.wait() + + if ps.returncode == 0: + return ps.stdout.read().decode().strip() + + elif not already_warned: + already_warned = True + # TODO warn about device not found + + sleep(3)
\ No newline at end of file diff --git a/modules/input_handlers/stdin_input.py b/modules/input_handlers/stdin_input.py new file mode 100644 index 0000000..3d338c1 --- /dev/null +++ b/modules/input_handlers/stdin_input.py @@ -0,0 +1,16 @@ +import sys + +from .input_handler import InputHandler + + +class StdinInput(InputHandler): + def cleanup(self) -> None: + pass + + def get_input(self) -> str: + for line in sys.stdin: + line = line.strip() + if not line: + continue + + yield line
\ No newline at end of file diff --git a/modules/intents/process.py b/modules/intents/process.py index bc834b6..48ade48 100644 --- a/modules/intents/process.py +++ b/modules/intents/process.py @@ -1,9 +1,11 @@ import os from typing import Dict from .sway import HesNavigate +from ..config import Config -def HesExecuteProcess(data: Dict): + +def HesExecuteProcess(data: Dict, config: Config) -> None: if "workspace" in data: - HesNavigate(data) + HesNavigate(data, config) os.popen(data["process"].strip()) diff --git a/modules/intents/sway.py b/modules/intents/sway.py index 5ab1b25..b5bd9fe 100644 --- a/modules/intents/sway.py +++ b/modules/intents/sway.py @@ -1,5 +1,9 @@ import os from typing import Dict +from ..config import Config -def HesNavigate(data: Dict): - os.popen(f"swaymsg workspace {data["workspace"]}") +def HesNavigate(data: Dict, config: Config) -> None: + os.popen(f"swaymsg workspace {data['workspace']}") + +def HesLock(data: Dict, config: Config) -> None: + os.popen(config.lock)
\ No newline at end of file diff --git a/modules/responses/__init__.py b/modules/responses/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/modules/responses/__init__.py diff --git a/modules/responses/libnotify.py b/modules/responses/libnotify.py new file mode 100644 index 0000000..0bc774f --- /dev/null +++ b/modules/responses/libnotify.py @@ -0,0 +1,15 @@ +import os + +from .response_handler import ResponseHandler +from ..config import Config + + +class LibNotify(ResponseHandler): + def __init__(self, config: Config): + self.config = config + self.hestia_images = {} + for path in os.listdir(self.config.hestia_images): + self.hestia_images[os.path.splitext(os.path.basename(path))[0]] = os.path.join(self.config.hestia_images, path) + + def respond(self, text: str) -> None: + os.popen(f'notify-send "Hestia " "{text}" -i "{self.hestia_images["hestia"]}" --app-name=hestia')
\ No newline at end of file diff --git a/modules/responses/response_handler.py b/modules/responses/response_handler.py new file mode 100644 index 0000000..9cc0cf0 --- /dev/null +++ b/modules/responses/response_handler.py @@ -0,0 +1,6 @@ +from abc import ABC, abstractmethod + +class ResponseHandler(ABC): + @abstractmethod + def respond(self, response: str): + pass
\ No newline at end of file diff --git a/modules/responses/response_manager.py b/modules/responses/response_manager.py new file mode 100644 index 0000000..34c0a41 --- /dev/null +++ b/modules/responses/response_manager.py @@ -0,0 +1,32 @@ +import yaml + +from pathlib import Path + +from ..config import Config +from ..responses.responses import Responses +from ..hassil.util import merge_dict +from .response_handler import ResponseHandler +from .libnotify import LibNotify + +class ResponseManager: + def __init__(self, config: Config): + response_dict = {"responses": {}} + + response_yaml_path = Path(config.responses_dir) + response_yaml_file_paths = response_yaml_path.glob("*.yaml") + for yaml_file_path in response_yaml_file_paths: + with open(yaml_file_path, "r", encoding="utf-8") as yaml_file: + merge_dict(response_dict, yaml.safe_load(yaml_file)) + + self.responses = Responses.from_dict(response_dict) + self.respond_handlers: list[ResponseHandler] = [LibNotify(config)] + + def respond(self, response: str, intent_name: str): + response_key = intent_name if response == "default" else response + if response_key not in self.responses.responses: + print(f"No response found for: {response_key}") + return + + response_text = self.responses.responses[response_key].sentence_texts[0] + for handler in self.respond_handlers: + handler.respond(response_text) diff --git a/modules/responses/responses.py b/modules/responses/responses.py new file mode 100644 index 0000000..f89a1fe --- /dev/null +++ b/modules/responses/responses.py @@ -0,0 +1,29 @@ +from dataclasses import dataclass, field +from typing import List, Dict, Any + +@dataclass(frozen=True) +class Response: + name: str + sentence_texts: List[str] + +@dataclass(frozen=True) +class Responses: + responses: Dict[str, Response] + + @staticmethod + def from_dict(input_dict: Dict[str, Any]) -> "Responses": + # responses: + # ResponseName: + # data: + # - sentences: + # - "<sentence>" + return Responses( + responses={ + response_name: Response( + name=response_name, + sentence_texts=response_dict["sentences"] + ) + for response_name, response_dict in input_dict["responses"].items() + }, + ) + diff --git a/record.sh b/record.sh new file mode 100755 index 0000000..ce866da --- /dev/null +++ b/record.sh @@ -0,0 +1,15 @@ +#!/bin/bash -eu + +FIFO_PATH="/tmp/hestia-listening" + +if [[ -p "$FIFO_PATH" ]]; then + echo >> "$FIFO_PATH" +else + SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) + MODULE_NAME=$(basename "$SCRIPT_DIR") + cd "$SCRIPT_DIR" + source venv/bin/activate + cd .. + python -m "$MODULE_NAME" +fi + diff --git a/requirements.txt b/requirements.txt index 80335f8..1294733 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ PyYAML>=6.0 unicode-rbnf>=1 +openai-whisper diff --git a/resources/hestia_images/hestia.jpg b/resources/hestia_images/hestia.jpg Binary files differnew file mode 100644 index 0000000..3091af0 --- /dev/null +++ b/resources/hestia_images/hestia.jpg diff --git a/responses/process_HesExecuteProcess.yaml b/responses/process_HesExecuteProcess.yaml new file mode 100644 index 0000000..31646ff --- /dev/null +++ b/responses/process_HesExecuteProcess.yaml @@ -0,0 +1,4 @@ +responses: + HesExecuteProcess: + sentences: + - "Starting it now."
\ No newline at end of file diff --git a/sentences/_common.yaml b/sentences/_common.yaml index 7c22012..1d5422d 100644 --- a/sentences/_common.yaml +++ b/sentences/_common.yaml @@ -50,8 +50,9 @@ lists: out: 10 expansion_rules: + the_my: "[(the|my) ]" workspace: "(workspace {workspace}|{workspace_word:workspace} workspace)" - in_workspace: "(in|on|at) [the ]<workspace>" + in_workspace: "(in|on|at) <the_my><workspace>" skip_words: - "please" diff --git a/sentences/sway_HesLock.yaml b/sentences/sway_HesLock.yaml new file mode 100644 index 0000000..a246f83 --- /dev/null +++ b/sentences/sway_HesLock.yaml @@ -0,0 +1,8 @@ +language: "en" +intents: + HesLock: + data: + - sentences: + - "lock[ <the_my>(screen|computer)]" + slots: + domain: "sway"
\ No newline at end of file |