Initial Commit
This commit is contained in:
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
.whisper
|
||||
.cache
|
||||
src/plugins/*
|
||||
/cache
|
||||
/assets
|
||||
/temp
|
||||
|
||||
### Python ###
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*/__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
37
README.adoc
Normal file
37
README.adoc
Normal file
@@ -0,0 +1,37 @@
|
||||
:source-highlighter: highlight.js
|
||||
:highlightjs-languages: rust
|
||||
:toc: auto
|
||||
|
||||
= *Andromeda*
|
||||
|
||||
Andromeda is meant to be an extensible and flexible personal assistant.
|
||||
|
||||
Andromeda will push my Python and Rust skills to their limits. Python for ease of use, Rust to speed up portions of Andromeda where possible.
|
||||
|
||||
There will be bugs and there will be limitations.
|
||||
|
||||
== *Background and Goals*:
|
||||
|
||||
After not looking at my CutieAssistant project for a few months, I suddenly had a thought for a somewhat unique identifying name. One that had not been used before, and wouldn't come up in common speech.
|
||||
|
||||
Andromeda.
|
||||
|
||||
...and then I remembered https://en.wikipedia.org/wiki/Andromeda_(TV_series)[_Andromeda_] the TV show.
|
||||
|
||||
Well, maybe not quite original, but the name gave the project a personality with a theme of Space (...also very original). This reinvigorated my energy to work on this project, unlike so many before that remain unfinished.
|
||||
|
||||
== *Immediate plans*:
|
||||
|
||||
* [ ] Automatic response map generation.
|
||||
** Currently manually written in assets/service_response_map.json
|
||||
* [ ] Build a plugin template.
|
||||
*** [ ] Include Andromeda version compatibility.
|
||||
** [ ] Mapping of commands.
|
||||
** [ ] Mapping for base required functions and inherited classes.
|
||||
** [ ] Support for automatic update fetching via git.
|
||||
* [ ] Plugin installer.
|
||||
* [ ] Plugin updater.
|
||||
* [ ] Move multiple base Andromeda functionality to seperately packaged plugins.
|
||||
** [ ] andromeda-joke
|
||||
** [ ] andromeda-timer
|
||||
* [ ] Whatever I tackle afterward.
|
||||
223
src/cutie_assistant/__init__.py
Normal file
223
src/cutie_assistant/__init__.py
Normal file
@@ -0,0 +1,223 @@
|
||||
#!~/.pyenv/versions/3.11.6/bin/python
|
||||
#
|
||||
# Copyright (c) 2024 Cutieguwu | Olivia Brooks
|
||||
#
|
||||
# -*- coding: utf-8 -*-
|
||||
# @Title: CutieAssistant
|
||||
# @Author: Cutieguwu | Olivia Brooks
|
||||
# @Description: Personal Voice Assistant
|
||||
#
|
||||
# @Script: __init__.py
|
||||
# @Date Created: 12 Jul, 2024
|
||||
# @Last Modified: 22 Jul, 2024
|
||||
# @Last Modified by: Cutieguwu | Olivia Brooks
|
||||
# --------------------------------------------
|
||||
|
||||
from .utils import install_dependencies, contains_keywords, clean_query, get_threads, convert_to_flac, get_audio_file_name, _load_plugins
|
||||
|
||||
|
||||
install_dependencies({"icecream", "SpeechRecognition", "coqui-tts", "openai-whisper", "pyaudio", "soundfile", "torch", "python-vlc"})
|
||||
|
||||
from icecream import ic
|
||||
from TTS.api import TTS
|
||||
from torch.cuda import is_available as cuda_available
|
||||
import speech_recognition as sr
|
||||
from speech_recognition import WaitTimeoutError
|
||||
import vlc
|
||||
from time import sleep
|
||||
from os import remove as remove_file
|
||||
from .base import Task, WaitTimeTrigger
|
||||
|
||||
|
||||
ic.configureOutput("INFO | ")
|
||||
|
||||
class Assistant:
|
||||
"""
|
||||
Personal Assistant
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
print("INFO | Starting...")
|
||||
|
||||
self.VERSION = [0, 0, 0]
|
||||
|
||||
self.tracked_tasks = []
|
||||
self.plugins = {}
|
||||
|
||||
print("INFO | Loading discovered plugins...")
|
||||
_load_plugins(self)
|
||||
print("INFO | Done.")
|
||||
|
||||
ic(self.plugins)
|
||||
|
||||
self._set_tts()
|
||||
|
||||
self._set_mic_source()
|
||||
|
||||
self.assistantOn = True
|
||||
|
||||
print("INFO | Started.")
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
Main run loop.
|
||||
"""
|
||||
|
||||
while self.assistantOn:
|
||||
self.run_checks()
|
||||
|
||||
query = self.listen()
|
||||
|
||||
if query != ("" or None) and contains_keywords(["execute"], query):
|
||||
self.check_query(query)
|
||||
|
||||
def speak(self, response_map:dict):
|
||||
"""
|
||||
Assistant Responses.
|
||||
"""
|
||||
|
||||
try:
|
||||
match response_map["response_type"]:
|
||||
case "rare":
|
||||
output_path = "temp/output.wav"
|
||||
raise FileNotFoundError # rare types are saved as output.wav
|
||||
|
||||
case "asset":
|
||||
audio_path = "assets/effects/"
|
||||
|
||||
case _: # common and builtin types.
|
||||
audio_path = f"cache/responses/{response_map['response_type']}/"
|
||||
|
||||
audio_path = f"{audio_path}{get_audio_file_name(response_map)}"
|
||||
output_path = f"{audio_path}.wav"
|
||||
|
||||
with open(f"{audio_path}.flac"):
|
||||
playback_path = f"{audio_path}.flac"
|
||||
|
||||
except FileNotFoundError:
|
||||
|
||||
print("INFO | Generating response as none was found...")
|
||||
|
||||
self.TTS.tts_to_file(text=response_map["response"], speaker_wav="assets/speakers/venti.wav", file_path=output_path, language=self.TTS_LANGUAGE)
|
||||
|
||||
print("INFO | Done.")
|
||||
|
||||
if output_path != "temp/output.wav":
|
||||
print("INFO | Response is not rare.\nINFO | Converting to flac...")
|
||||
convert_to_flac(output_path)
|
||||
print("INFO | Done.")
|
||||
playback_path = f"{audio_path}.flac"
|
||||
else:
|
||||
playback_path = output_path
|
||||
|
||||
media_player = vlc.MediaPlayer(playback_path)
|
||||
|
||||
media_player.play()
|
||||
sleep(media_player.get_length() / 1000)
|
||||
|
||||
if response_map["response_type"] == "common":
|
||||
TimedCache(self, playback_path)
|
||||
|
||||
def listen(self):
|
||||
"""
|
||||
Listens for a command set.
|
||||
"""
|
||||
|
||||
try:
|
||||
recognizer = sr.Recognizer()
|
||||
|
||||
with sr.Microphone(device_index=self.MICROPHONE_INDEX) as microphone:
|
||||
recognizer.pause_threshold = 1
|
||||
print("INFO | Adjusting for ambient noise...")
|
||||
recognizer.adjust_for_ambient_noise(microphone)
|
||||
print("INFO | Done.")
|
||||
print("INFO | Recording...")
|
||||
audio = recognizer.listen(microphone, timeout=2, phrase_time_limit=5)
|
||||
print("INFO | Done.")
|
||||
|
||||
print("INFO | Recognizing...")
|
||||
try:
|
||||
if self.TTS_LANGUAGE == "en":
|
||||
query = recognizer.recognize_whisper(audio, model="small.en", language="en")
|
||||
else:
|
||||
query = recognizer.recognize_whisper(audio, model="small", language="en")
|
||||
ic(query)
|
||||
|
||||
print("INFO | Done.")
|
||||
return clean_query(query)
|
||||
|
||||
except Exception as err:
|
||||
ic(err)
|
||||
except WaitTimeoutError:
|
||||
print("INFO | Heard nothing.")
|
||||
except AttributeError:
|
||||
print("Failed to open microphone.")
|
||||
|
||||
def check_query(self, query:str):
|
||||
"""
|
||||
Checks the query and calls an appropriate function.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
def run_checks(self):
|
||||
"""
|
||||
Runs some checks.
|
||||
"""
|
||||
|
||||
self.check_tasks()
|
||||
|
||||
def check_tasks(self):
|
||||
"""
|
||||
Checks and executes triggered tasks.
|
||||
"""
|
||||
|
||||
if len(self.tracked_tasks) == 0:
|
||||
return
|
||||
|
||||
for t in self.tracked_tasks:
|
||||
t.check()
|
||||
|
||||
def _set_tts(self):
|
||||
"""
|
||||
Autoconfigures the TTS engine.
|
||||
"""
|
||||
|
||||
self.TTS_DEVICE = "cuda" if cuda_available() else "cpu"
|
||||
if self.TTS_DEVICE != "cuda":
|
||||
self.CPU_THREADS = get_threads()
|
||||
ic(f"{self.TTS_DEVICE} - {self.CPU_THREADS}")
|
||||
else:
|
||||
ic(self.TTS_DEVICE)
|
||||
|
||||
self.TTS_LANGUAGE = "en"
|
||||
|
||||
print("INFO | Loading Model...")
|
||||
self.TTS = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(self.TTS_DEVICE)
|
||||
print("INFO | Loaded Model.")
|
||||
|
||||
def _set_mic_source(self):
|
||||
"""
|
||||
Configures the microphone source.
|
||||
"""
|
||||
|
||||
print("--------------------------------------------")
|
||||
for index, device in enumerate(sr.Microphone.list_microphone_names()): # Listing only working breaks on most systems.
|
||||
print("Microphone(device_index={0}) - '{1}' ".format(index, device))
|
||||
|
||||
print("--------------------------------------------")
|
||||
self.MICROPHONE_INDEX = int(input("Enter Microphone Index: "))
|
||||
|
||||
class TimedCache(Task):
|
||||
def __init__(self, assistant, path, days:float = 30.0, lifespan=1):
|
||||
Task.__init__(self, assistant)
|
||||
|
||||
self.path = path
|
||||
self.trigger = WaitTimeTrigger(days * 86400, lifespan)
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
Runs the task's function.
|
||||
"""
|
||||
|
||||
remove_file(self.path)
|
||||
182
src/cutie_assistant/base.py
Normal file
182
src/cutie_assistant/base.py
Normal file
@@ -0,0 +1,182 @@
|
||||
#!~/.pyenv/versions/3.11.6/bin/python
|
||||
#
|
||||
# Copyright (c) 2024 Cutieguwu | Olivia Brooks
|
||||
#
|
||||
# -*- coding: utf-8 -*-
|
||||
# @Title: Base Classes
|
||||
# @Author: Cutieguwu | Olivia Brooks
|
||||
# @Description: Base classes to inherit from for functions in CutieAssistant.
|
||||
#
|
||||
# @Script: base.py
|
||||
# @Date Created: 22 Jul, 2024
|
||||
# @Last Modified: 24 Jul, 2024
|
||||
# @Last Modified by: Cutieguwu | Olivia Brooks
|
||||
# --------------------------------------------
|
||||
|
||||
from time import time
|
||||
from json import load as load_json
|
||||
from os.path import dirname
|
||||
from tomllib import load as load_toml
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class Plugin():
|
||||
def __init__(self, plugin, assistant, plugin_file):
|
||||
self.assistant = assistant
|
||||
|
||||
plugin_path = dirname(plugin_file)
|
||||
|
||||
self.EXPERIMENTAL_FEATURES = [].clear() # Reduce memory allocation instead of using None
|
||||
|
||||
self._load_plugin_properties(plugin_path)
|
||||
self._load_keywords(plugin_path)
|
||||
self._register(plugin.__module__)
|
||||
|
||||
self.is_active_background = False
|
||||
|
||||
def _get_compatability(self) -> Enum:
|
||||
"""
|
||||
Checks to ensure that the plugin is compatible with the assistant.
|
||||
"""
|
||||
|
||||
version_max = self.ASSISTANT_VERSION_SUPPORT["max"]
|
||||
version_min = self.ASSISTANT_VERSION_SUPPORT["min"]
|
||||
|
||||
if version_max is None:
|
||||
if version_min is None or self.assistant.VERSION > version_min:
|
||||
return PluginSupport.supported_unknown_future
|
||||
|
||||
elif self.assistant.VERSION < version_min:
|
||||
return PluginSupport.unsupported_old
|
||||
|
||||
elif self.assistant.VERSION > version_max:
|
||||
return PluginSupport.unsupported_new
|
||||
|
||||
else:
|
||||
return PluginSupport.supported
|
||||
|
||||
|
||||
def _load_keywords(self, path):
|
||||
"""
|
||||
Loads keywords from file.
|
||||
"""
|
||||
|
||||
with open(path + "/keywords.json") as f:
|
||||
self.KEYWORDS = load_json(f)
|
||||
|
||||
def _load_plugin_properties(self, path):
|
||||
"""
|
||||
Loads the plugin properties from properties.toml
|
||||
"""
|
||||
|
||||
with open(path + "/properties.toml", "rb") as f:
|
||||
properties = load_toml(f)
|
||||
|
||||
self.NAME = properties["plugin"]["name"]
|
||||
self.VERSION = properties["plugin"]["version"]
|
||||
|
||||
self.ASSISTANT_VERSION_SUPPORT:dict = {}
|
||||
|
||||
for ver in ["min", "max"]:
|
||||
try:
|
||||
self.ASSISTANT_VERSION_SUPPORT[ver] = properties["assistant"]["version"][ver]
|
||||
except KeyError:
|
||||
self.ASSISTANT_VERSION_SUPPORT[ver] = None
|
||||
|
||||
self.IS_SUPPORTED = self._get_compatability()
|
||||
|
||||
try:
|
||||
self.EXPERIMENTAL_FEATURES = properties["assistant"]["features"]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def _register(self, plugin_name):
|
||||
"""
|
||||
Adds `self` as plugin for `self.parent`
|
||||
"""
|
||||
|
||||
self.assistant.plugins[plugin_name] = self
|
||||
|
||||
class Trigger:
|
||||
def reset(self):
|
||||
"""
|
||||
Resets the trigger.
|
||||
"""
|
||||
|
||||
if self.lifespan == 1:
|
||||
raise TriggerLifespanException
|
||||
elif self.lifespan != -1:
|
||||
self.lifespan = self.lifespan - 1
|
||||
|
||||
self.build()
|
||||
|
||||
class Task:
|
||||
def __init__(self, assistant):
|
||||
self.assistant = assistant
|
||||
|
||||
self._register()
|
||||
|
||||
def remove(self):
|
||||
"""
|
||||
Removes the task.
|
||||
"""
|
||||
|
||||
self.assistant.tracked_tasks.remove(self)
|
||||
|
||||
def check(self):
|
||||
"""
|
||||
Checks the task and runs and resets, or removes as needed.
|
||||
"""
|
||||
|
||||
try:
|
||||
self.run()
|
||||
self.trigger.reset()
|
||||
except TriggerLifespanException:
|
||||
self.remove()
|
||||
|
||||
def _register(self):
|
||||
"""
|
||||
Registers the task with the assistant.
|
||||
"""
|
||||
|
||||
self.assistant.tracked_tasks.append(self)
|
||||
|
||||
class WaitTimeTrigger():
|
||||
def __init__(self, wait_duration, lifespan = 1):
|
||||
|
||||
self.build()
|
||||
self.wait_duration = wait_duration
|
||||
self.lifespan = lifespan
|
||||
|
||||
def check(self):
|
||||
"""
|
||||
returns `True` if trigger condition is met.
|
||||
"""
|
||||
|
||||
return True if time() - self.wait_duration >= self.wait_duration else False
|
||||
|
||||
def build(self):
|
||||
"""
|
||||
Builds trigger condition.
|
||||
"""
|
||||
|
||||
self.start_time = time()
|
||||
|
||||
class TriggerLifespanException(Exception):
|
||||
def __init__(self):
|
||||
self.message = "Lifespan of a trigger was spent."
|
||||
|
||||
class PluginSupport(Enum):
|
||||
"""
|
||||
`supported` Enabled; Stable. Plugin is compatible with the assistant.\n
|
||||
`supported_unknown_future` Enabled; Potentially Unstable. Plugin support is unknown; no max version was set in its properties.\n
|
||||
`unsupported_new` Disabled; Stable. Plugin is too new; will not function with the assistant.\n
|
||||
`unsupported_old` Disabled; Stable. Plugin is too old and will not function with the assistant.\n
|
||||
`overridden` Enabled; Potentially Unstable. Only available if plugin is determined as `unsupported_old` so that if max version set and unmaintained, can be made to work.
|
||||
"""
|
||||
|
||||
supported = None
|
||||
supported_unknown_future = None
|
||||
unsupported_new = None
|
||||
unsupported_old = None
|
||||
overridden = None
|
||||
143
src/cutie_assistant/utils.py
Normal file
143
src/cutie_assistant/utils.py
Normal file
@@ -0,0 +1,143 @@
|
||||
#!~/.pyenv/versions/3.11.6/bin/python
|
||||
#
|
||||
# Copyright (c) 2024 Cutieguwu | Olivia Brooks
|
||||
#
|
||||
# -*- coding: utf-8 -*-
|
||||
# @Title: CutieAssistant System Utilities
|
||||
# @Author: Cutieguwu | Olivia Brooks
|
||||
# @Description: Some utilities for running CutieAssistant
|
||||
#
|
||||
# @Script: system_utils.py
|
||||
# @Date Created: 20 Jul, 2024
|
||||
# @Last Modified: 24 Jul, 2024
|
||||
# @Last Modified by: Cutieguwu | Olivia Brooks
|
||||
# --------------------------------------------
|
||||
|
||||
from pkg_resources import working_set
|
||||
from subprocess import run, CalledProcessError
|
||||
from sys import executable
|
||||
from icecream import ic
|
||||
from os import cpu_count
|
||||
from speech_recognition.audio import get_flac_converter
|
||||
from json import load
|
||||
from importlib import import_module
|
||||
from pkgutil import iter_modules
|
||||
import plugins
|
||||
|
||||
|
||||
def install_dependencies(dependencies:set):
|
||||
"""
|
||||
Tries to install and import any missing dependencies from set.
|
||||
"""
|
||||
|
||||
libraries_installed = {
|
||||
pkg.key for pkg in working_set
|
||||
}
|
||||
|
||||
libraries_missing = list(dependencies - libraries_installed) # Lists are faster to iterate over due to lack of hash table.
|
||||
|
||||
try:
|
||||
library = "pip"
|
||||
if len(libraries_missing) != 0:
|
||||
run([executable, "-m", "pip", "install", "--upgrade", "pip"], check=True)
|
||||
for library in libraries_missing:
|
||||
run([executable, "-m", "pip", "install", library], check=True)
|
||||
|
||||
except CalledProcessError:
|
||||
print(f"Error | Cannot find or install {library}")
|
||||
raise SystemExit
|
||||
|
||||
def contains_keywords(keywords:list, query:str) -> bool:
|
||||
"""
|
||||
Checks if a string contains one of the given keywords.
|
||||
"""
|
||||
|
||||
for k in keywords:
|
||||
if f" {k} " in f" {query} ":
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def clean_query(query:str) -> str:
|
||||
"""
|
||||
Cleans a query.
|
||||
"""
|
||||
|
||||
query_clean = ""
|
||||
|
||||
for c in query.lower():
|
||||
if c.isalpha() or c == " ":
|
||||
query_clean = query_clean + c
|
||||
|
||||
return query_clean
|
||||
|
||||
def run_command(command_list:list):
|
||||
"""
|
||||
Runs a command and returns its output.
|
||||
"""
|
||||
|
||||
try:
|
||||
return run(command_list, capture_output=True, check=True).stdout.decode()
|
||||
except Exception as err:
|
||||
ic(f"Error | {command_list} raised {err}")
|
||||
return err
|
||||
|
||||
def get_threads() -> int:
|
||||
"""
|
||||
Gets the number of threads on the system.
|
||||
If `os.cpu_count` returns `None`, sets thread count to `1`.
|
||||
"""
|
||||
|
||||
threads = cpu_count()
|
||||
|
||||
return threads if threads is not None else 1
|
||||
|
||||
def convert_to_flac(source_path:str):
|
||||
"""
|
||||
Converts an audio file to flac.
|
||||
Deletes original.
|
||||
"""
|
||||
|
||||
run_command(
|
||||
[
|
||||
get_flac_converter(),
|
||||
"--delete-input-file",
|
||||
"--best",
|
||||
source_path
|
||||
]
|
||||
)
|
||||
|
||||
def get_response_map(service:str, response:str) -> dict:
|
||||
"""
|
||||
Returns a `dict` of response commonality.
|
||||
"""
|
||||
|
||||
with open("assets/service_response_map.json", "r") as f:
|
||||
response_map = load(f)[service]
|
||||
|
||||
response_map["service"] = service
|
||||
response_map["response"] = response
|
||||
|
||||
return response_map
|
||||
|
||||
def get_audio_file_name(response_map) -> str:
|
||||
"""
|
||||
Names and formats and audio file name.
|
||||
"""
|
||||
|
||||
file_name_response = ""
|
||||
|
||||
for c in response_map["response"]:
|
||||
if c.isalpha():
|
||||
file_name_response = file_name_response + c.lower()
|
||||
else:
|
||||
file_name_response = file_name_response + "-"
|
||||
|
||||
return f'{response_map["service"].upper()}_{file_name_response}'
|
||||
|
||||
def _load_plugins(assistant):
|
||||
"""
|
||||
Loads all discovered plugins.
|
||||
"""
|
||||
|
||||
assistant.plugins = {import_module(name).Plugin(assistant) for finder, name, ispkg in iter_modules(plugins.__path__, plugins.__name__ + ".")}
|
||||
23
src/main.py
Normal file
23
src/main.py
Normal file
@@ -0,0 +1,23 @@
|
||||
#!~/.pyenv/versions/3.11.6/bin/python
|
||||
#
|
||||
# Copyright (c) 2024 Cutieguwu | Olivia Brooks
|
||||
#
|
||||
# -*- coding: utf-8 -*-
|
||||
# @Title: Personal Assistant
|
||||
# @Author: Cutieguwu | Olivia Brooks
|
||||
# @Description: Personal Assistant.
|
||||
#
|
||||
# @Script: main.py
|
||||
# @Date Created: 22 Jul, 2024
|
||||
# @Last Modified: 22 Jul, 2024
|
||||
# @Last Modified by: Cutieguwu | Olivia Brooks
|
||||
# --------------------------------------------
|
||||
|
||||
from cutie_assistant import Assistant
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
assistant = Assistant()
|
||||
assistant.run()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
Reference in New Issue
Block a user