summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.run/hestia.run.xml2
-rw-r--r--__main__.py42
-rw-r--r--config.yaml8
-rw-r--r--modules/config.py37
-rw-r--r--modules/input_handlers/__init__.py0
-rw-r--r--modules/input_handlers/input_handler.py11
-rw-r--r--modules/input_handlers/pipewire_record.py72
-rw-r--r--modules/input_handlers/stdin_input.py16
-rw-r--r--modules/intents/process.py6
-rw-r--r--modules/intents/sway.py8
-rw-r--r--modules/responses/__init__.py0
-rw-r--r--modules/responses/libnotify.py15
-rw-r--r--modules/responses/response_handler.py6
-rw-r--r--modules/responses/response_manager.py32
-rw-r--r--modules/responses/responses.py29
-rwxr-xr-xrecord.sh15
-rw-r--r--requirements.txt1
-rw-r--r--resources/hestia_images/hestia.jpgbin0 -> 43277 bytes
-rw-r--r--responses/process_HesExecuteProcess.yaml4
-rw-r--r--sentences/_common.yaml3
-rw-r--r--sentences/sway_HesLock.yaml8
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
new file mode 100644
index 0000000..3091af0
--- /dev/null
+++ b/resources/hestia_images/hestia.jpg
Binary files differ
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