From d29d3d0613f33dccd2e06a5d8039347b4e455acc Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 11 Jan 2024 17:12:25 -0600 Subject: [PATCH] Working on installer --- README.md | 8 + ....txt => requirements_audio_enhancement.txt | 1 - requirements_vad.txt | 1 + script/_installer.py | 660 ++++++++++++++++++ script/installer.sh | 482 +++++++++++++ setup.py | 35 +- 6 files changed, 1176 insertions(+), 11 deletions(-) rename requirements_extra.txt => requirements_audio_enhancement.txt (55%) create mode 100644 requirements_vad.txt create mode 100755 script/_installer.py create mode 100755 script/installer.sh diff --git a/README.md b/README.md index 57c6383..b9c0355 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,14 @@ Requires: ## Installation +Install the necessary system dependencies: + +``` sh +sudo apt-get install python3-venv python3-pip +``` + +Then run the install script: + ``` sh script/setup ``` diff --git a/requirements_extra.txt b/requirements_audio_enhancement.txt similarity index 55% rename from requirements_extra.txt rename to requirements_audio_enhancement.txt index 0663e97..b1c879a 100644 --- a/requirements_extra.txt +++ b/requirements_audio_enhancement.txt @@ -1,2 +1 @@ -pysilero-vad==1.0.0 webrtc-noise-gain==1.2.3 diff --git a/requirements_vad.txt b/requirements_vad.txt new file mode 100644 index 0000000..1bcf838 --- /dev/null +++ b/requirements_vad.txt @@ -0,0 +1 @@ +pysilero-vad==1.0.0 diff --git a/script/_installer.py b/script/_installer.py new file mode 100755 index 0000000..ade72fa --- /dev/null +++ b/script/_installer.py @@ -0,0 +1,660 @@ +#!/usr/bin/env python3 +import array +import math +import logging +import json +import functools +import shlex +import subprocess +import shutil +import sys +import time +from concurrent.futures import ThreadPoolExecutor, Future +from enum import Enum +from dataclasses import dataclass, fields, asdict, field +from pathlib import Path +from typing import Any, Dict, List, Tuple, Optional, Union + +_DIR = Path(__file__).parent +_PROGRAM_DIR = _DIR.parent +_LOCAL_DIR = _PROGRAM_DIR / "local" +_SETTINGS_PATH = _LOCAL_DIR / "settings.json" +_LOGGER = logging.getLogger() + +_RECORD_SECONDS = 8 +_RECORD_RMS_MIN = 30 + +TITLE = "Wyoming Satellite" +WIDTH = "75" +HEIGHT = "20" +LIST_HEIGHT = "12" + +ItemType = Union[str, Tuple[str, str]] + + +class SatelliteType(str, Enum): + ALWAYS_STREAMING = "always" + VAD = "vad" + WAKE = "wake" + + +class WakeWordSystem(str, Enum): + OPENWAKEWORD = "openWakeWord" + PORCUPINE1 = "porcupine1" + SNOWBOY = "snowboy" + + +@dataclass +class Settings: + microphone_device: Optional[str] = None + + sound_device: Optional[str] = None + feedback_sounds: List[str] = field(default_factory=list) + + wake_word_system: Optional[WakeWordSystem] = None + wake_word: Dict[WakeWordSystem, str] = field(default_factory=dict) + + satellite_name: str = "Wyoming Satellite" + satellite_type: SatelliteType = SatelliteType.ALWAYS_STREAMING + + @staticmethod + def load() -> "Settings": + kwargs: Dict[str, Any] = {} + + if _SETTINGS_PATH.exists(): + _LOGGER.debug("Loading settings from %s", _SETTINGS_PATH) + with open(_SETTINGS_PATH, "r", encoding="utf-8") as settings_file: + settings_dict = json.load(settings_file) + + for settings_field in fields(Settings): + value = settings_dict.get(settings_field.name) + if value is not None: + kwargs[settings_field.name] = value + + return Settings(**kwargs) + + def save(self) -> None: + _LOGGER.debug("Saving settings to %s", _SETTINGS_PATH) + + settings_dict = asdict(self) + _SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True) + + with open(_SETTINGS_PATH, "w", encoding="utf-8") as settings_file: + json.dump(settings_dict, settings_file, ensure_ascii=False, indent=2) + + +# ----------------------------------------------------------------------------- + + +def main() -> None: + _LOCAL_DIR.mkdir(parents=True, exist_ok=True) + logging.basicConfig( + filename=_LOCAL_DIR / "installer.log", filemode="w", level=logging.DEBUG + ) + + settings = Settings.load() + + while True: + choice = main_menu(settings) + + if choice == "mic": + configure_microphone(settings) + elif choice == "snd": + configure_sound(settings) + elif choice == "wake": + configure_wake_word(settings) + elif choice == "name": + set_satellite_name(settings) + elif choice == "type": + set_satellite_type(settings) + elif choice == "services": + pass + else: + break + + +# ----------------------------------------------------------------------------- + + +def main_menu(settings: Settings) -> Optional[str]: + items = [("mic", "Configure Microphone"), ("snd", "Configure Sound")] + + if settings.satellite_type == SatelliteType.WAKE: + items.append(("wake", "Configure Wake Word")) + + items.extend( + [ + ("name", "Set Satellite Name"), + ("type", "Set Satellite Type"), + ("services", "Generate Services"), + ] + ) + + return menu( + "Main Menu", + items, + "--ok-button", + "Select", + "--cancel-button", + "Exit", + ) + + +# ----------------------------------------------------------------------------- +# Microphone +# ----------------------------------------------------------------------------- + + +def configure_microphone(settings: Settings) -> None: + while True: + choice = microphone_menu() + + if choice == "detect": + best_device: Optional[str] = None + best_rms: Optional[float] = None + + devices = get_microphone_devices() + with ThreadPoolExecutor() as executor: + futures: Dict[str, Future] = {} + for device in devices: + futures[device] = executor.submit(_record_proc, device) + + gauge("Speak loudly into the microphone.", _RECORD_SECONDS) + for device, future in futures.items(): + device_rms = future.result() + if device_rms < _RECORD_RMS_MIN: + continue + + if (best_rms is None) or (device_rms > best_rms): + best_device = device + best_rms = device_rms + + if best_device is not None: + msgbox(f"Successfully detected microphone: {best_device}") + settings.microphone_device = best_device + settings.save() + else: + msgbox("Audio was not detected from any microphone") + elif choice == "list": + microphone_device = radiolist( + "Select ALSA Device:", + get_microphone_devices(), + settings.microphone_device, + ) + if microphone_device: + settings.microphone_device = microphone_device + settings.save() + elif choice == "manual": + microphone_device = inputbox( + "Enter ALSA Device:", settings.microphone_device + ) + if microphone_device: + settings.microphone_device = microphone_device + settings.save() + elif choice == "respeaker": + if yesno( + "ReSpeaker drivers for the Raspberry Pi will now be compiled and installed. " + "This will take a while and require a reboot. " + "Continue?" + ): + subprocess.check_call( + ["sudo", str(_PROGRAM_DIR / "etc" / "install-respeaker-drivers.sh")] + ) + + msgbox( + "Driver installation complete. " + "Please reboot your Raspberry Pi and re-run the installer. " + 'Once rebooted, select the "seeed" microphone.' + ) + + sys.exit(0) + else: + break + + +def microphone_menu() -> Optional[str]: + return menu( + "Configure Microphone", + [ + ("detect", "Autodetect"), + ("list", "Select From List"), + ("manual", "Enter Manually"), + ("respeaker", "Install ReSpeaker Drivers"), + ], + "--ok-button", + "Select", + "--cancel-button", + "Back", + ) + + +def get_microphone_devices() -> List[str]: + devices = [] + lines = subprocess.check_output(["arecord", "-L"]).decode("utf-8").splitlines() + for line in lines: + line = line.strip() + if line.startswith("plughw:"): + devices.append(line) + + return devices + + +def _record_proc(device: str) -> float: + try: + audio = subprocess.check_output( + [ + "arecord", + "-q", + "-D", + device, + "-r", + "16000", + "-c", + "1", + "-f", + "S16_LE", + "-t", + "raw", + "-d", + str(_RECORD_SECONDS), + ], + stderr=subprocess.DEVNULL, + ) + + # 16-bit mono + audio_array = array.array("h", audio) + rms = math.sqrt( + (1 / len(audio_array) * sum(x * x for x in audio_array.tolist())) + ) + return rms + except Exception: + _LOGGER.exception("Error recording from device: %s", device) + + return 0 + + +# ----------------------------------------------------------------------------- +# Sound +# ----------------------------------------------------------------------------- + + +def configure_sound(settings: Settings) -> None: + while True: + choice = sound_menu() + + if choice == "detect": + pass + elif choice == "list": + sound_device = radiolist( + "Select ALSA Device:", + get_sound_devices(), + settings.sound_device, + ) + if sound_device: + settings.sound_device = sound_device + settings.save() + elif choice == "manual": + sound_device = inputbox("Enter ALSA Device:", settings.sound_device) + if sound_device: + settings.sound_device = sound_device + settings.save() + elif choice == "disable": + settings.sound_device = None + settings.save() + msgbox("Sound disabled") + elif choice == "feedback": + feedback_sounds = checklist( + "Enabled Sounds:", + [("awake", "On wake-up"), ("done", "After voice command")], + settings.feedback_sounds, + ) + + if feedback_sounds is not None: + settings.feedback_sounds = feedback_sounds + settings.save() + else: + break + + +def sound_menu() -> Optional[str]: + return menu( + "Configure Sound", + [ + ("detect", "Autodetect"), + ("list", "Select From List"), + ("manual", "Enter Manually"), + ("disable", "Disable Sound"), + ("feedback", "Toggle Feedback Sounds"), + ], + "--ok-button", + "Select", + "--cancel-button", + "Back", + ) + + +def get_sound_devices() -> List[str]: + devices = [] + lines = subprocess.check_output(["aplay", "-L"]).decode("utf-8").splitlines() + for line in lines: + line = line.strip() + if line.startswith("plughw:"): + devices.append(line) + + return devices + + +# ----------------------------------------------------------------------------- +# Wake Word +# ----------------------------------------------------------------------------- + + +def configure_wake_word(settings: Settings) -> None: + while True: + choice = wake_word_menu(settings) + + if choice == "system": + wake_word_system = radiolist( + "Wake Word System:", + [v.value for v in WakeWordSystem], + settings.wake_word_system, + ) + if wake_word_system is not None: + settings.wake_word_system = wake_word_system + install_wake_word(settings) + elif choice == "wake_word": + pass + else: + break + + +def wake_word_menu(settings: Settings) -> Optional[str]: + items = [("system", "Select System")] + if settings.wake_word_system is not None: + items.append(("wake_word", "Select Wake Word")) + + return menu( + "Configure Wake Word", + items, + "--ok-button", + "Select", + "--cancel-button", + "Back", + ) + + +def install_wake_word(settings: Settings) -> None: + if settings.wake_word_system == WakeWordSystem.OPENWAKEWORD: + settings.wake_word.setdefault(WakeWordSystem.OPENWAKEWORD, "ok_nabu") + + oww_dir = _LOCAL_DIR / "wyoming-openwakeword" + if not oww_dir.exists(): + success = run_with_gauge( + "Installing openWakeWord", + [ + [ + "git", + "clone", + "https://github.com/rhasspy/wyoming-openwakeword.git", + str(oww_dir), + ], + [str(oww_dir / "script" / "setup")], + ], + ) + + if success: + msgbox("openWakeWord installed successfully") + else: + msgbox( + "An error occurred while installing openWakeWord. " + "See local/installer.log for details." + ) + + settings.wake_word_system = None + settings.save() + + try: + shutil.rmtree(oww_dir) + except Exception: + pass + elif settings.wake_word_system == WakeWordSystem.PORCUPINE1: + settings.wake_word.setdefault(WakeWordSystem.PORCUPINE1, "porcupine") + elif settings.wake_word_system == WakeWordSystem.SNOWBOY: + settings.wake_word.setdefault(WakeWordSystem.SNOWBOY, "snowboy") + + +# ----------------------------------------------------------------------------- +# Satellite +# ----------------------------------------------------------------------------- + + +def set_satellite_name(settings: Settings) -> None: + name = inputbox("Satellite Name:", settings.satellite_name) + if name: + settings.satellite_name = name + settings.save() + + +def set_satellite_type(settings: Settings) -> None: + satellite_type = radiolist( + "Satellite Type:", + [ + (SatelliteType.ALWAYS_STREAMING, "Always streaming"), + (SatelliteType.VAD, "Voice activity detection"), + (SatelliteType.WAKE, "Local wake word detection"), + ], + settings.satellite_type, + ) + if satellite_type is not None: + settings.satellite_type = satellite_type + settings.save() + + +# ----------------------------------------------------------------------------- + + +def whiptail(*args) -> Optional[str]: + proc = subprocess.Popen( + ["whiptail", "--title", TITLE] + list(args), + stderr=subprocess.PIPE, + ) + + _stdout, stderr = proc.communicate() + if proc.returncode != 0: + return None + + return stderr.decode("utf-8") + + +def menu(text: str, items: List[ItemType], *args) -> Optional[str]: + assert items, "No items" + + item_map: Dict[str, ItemType] = {} + item_args: List[str] = [] + for i, item in enumerate(items): + item_id = str(i) + item_args.append(item_id) + + if isinstance(item, str): + item_map[item_id] = item + item_args.append(item) + else: + item_map[item_id] = item[0] + item_args.append(item[1]) + + result = whiptail( + "--notags", *args, "--menu", text, HEIGHT, WIDTH, LIST_HEIGHT, *item_args + ) + return item_map.get(result) + + +def inputbox(text: str, init: Optional[str] = None) -> Optional[str]: + return whiptail("--inputbox", text, HEIGHT, WIDTH, init or "") + + +def radiolist( + text: str, + items: List[ItemType], + selected_item: Any, + *args, +) -> Optional[str]: + assert items, "No items" + + item_map: Dict[str, ItemType] = {} + item_args: List[str] = [] + for i, item in enumerate(items): + item_id = str(i) + item_args.append(item_id) + + if isinstance(item, str): + item_map[item_id] = item + item_args.append(item) + + if item == selected_item: + item_args.append("1") + else: + item_args.append("0") + else: + item_map[item_id] = item[0] + item_args.append(item[1]) + + if item[0] == selected_item: + item_args.append("1") + else: + item_args.append("0") + + result = whiptail( + "--notags", *args, "--radiolist", text, HEIGHT, WIDTH, LIST_HEIGHT, *item_args + ) + + if result is None: + return None + + return item_map.get(result, result) + + +def checklist( + text: str, + items: List[ItemType], + selected_items: List[Any], + *args, +) -> List[str]: + assert items, "No items" + + item_map: Dict[str, ItemType] = {} + item_args: List[str] = [] + for i, item in enumerate(items): + item_id = str(i) + item_args.append(item_id) + + if isinstance(item, str): + item_map[item_id] = item + item_args.append(item) + + if item in selected_items: + item_args.append("1") + else: + item_args.append("0") + else: + item_map[item_id] = item[0] + item_args.append(item[1]) + + if item[0] in selected_items: + item_args.append("1") + else: + item_args.append("0") + + result = whiptail( + "--notags", *args, "--checklist", text, HEIGHT, WIDTH, LIST_HEIGHT, *item_args + ) + + if result is None: + return None + + return [ + item_map.get(result_item, result_item) for result_item in shlex.split(result) + ] + + +def yesno(text: str) -> bool: + return whiptail("--yesno", text, HEIGHT, WIDTH) is not None + + +def msgbox(text: str) -> None: + whiptail("--msgbox", text, HEIGHT, WIDTH) + + +def gauge(text: str, seconds: int, parts: int = 20) -> None: + proc = subprocess.Popen( + ["whiptail", "--title", TITLE, "--gauge", text, HEIGHT, WIDTH, "0"], + stdin=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + assert proc.stdin is not None + + percent = 0 + while percent <= 100: + time.sleep(seconds / parts) + percent += int(100 / parts) + print(percent, file=proc.stdin, flush=True) + + proc.communicate() + + +def run_with_gauge(text: str, commands: List[List[str]]) -> bool: + proc = subprocess.Popen( + ["whiptail", "--title", TITLE, "--gauge", text, HEIGHT, WIDTH, "0"], + stdin=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + assert proc.stdin is not None + percent = 0 + seconds = 5 + parts = 20 + + with ThreadPoolExecutor() as executor: + for command in commands: + future = executor.submit(_run_command, command) + while not future.done(): + time.sleep(seconds / parts) + percent += int(100 / parts) + if percent > 100: + percent = 0 + + print(percent, file=proc.stdin, flush=True) + + if not future.result(): + # Error occurred + return False + + proc.communicate() + return True + + +def _run_command(command: List[str]) -> bool: + try: + proc = subprocess.Popen( + command, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + _stdout, stderr = proc.communicate() + if proc.returncode != 0: + _LOGGER.error("Error running command: %s", command) + _LOGGER.error(stderr) + return False + except Exception: + _LOGGER.exception("Error running command: %s", command) + return False + + return True + + +# ----------------------------------------------------------------------------- + + +if __name__ == "__main__": + main() diff --git a/script/installer.sh b/script/installer.sh new file mode 100755 index 0000000..1fabe58 --- /dev/null +++ b/script/installer.sh @@ -0,0 +1,482 @@ +#!/usr/bin/env bash +set -eu + +title='Wyoming Satellite' + +# Directory of *this* script +this_dir="$( cd "$( dirname "$0" )" && pwd )" +program_dir="$(realpath "${this_dir}/..")" + +# Directory to store stuff that won't interfere with git +local_dir="${program_dir}/local" +mkdir -p "${local_dir}" + +debug_enabled='' +system_deps='' + +satellite_name='' +satellite_type='' + +wake_word_system='' +wake_word='' +custom_wake_word_dir="${local_dir}/custom-wake-words" +mkdir -p "${custom_wake_word_dir}" + +noise_suppression='' + +microphone_command='arecord -q -r 16000 -c 1 -f S16_LE -t raw' +speaker_command='aplay -q -r 22050 -c 1 -f S16_LE -t raw' +speaker_sounds=() + +pushd () { + command pushd "$@" > /dev/null +} + +popd () { + command popd "$@" > /dev/null +} + +# Args +for arg in "$@"; do + if [ "${arg}" = '--debug' ]; then + debug_enabled='1' + fi +done + +if [ -n "${debug_enabled}" ]; then + whiptail --title "${title}" --msgbox \ + 'Debug mode is enabled. This will cause all services to log verbosely, and audio to be recorded in local/debug-recording. Make sure to re-run the installer without debug mode before deploying the satellite.' \ + 10 75 +fi + +# ----------------------------------------------------------------------------- +# System dependencies +# ----------------------------------------------------------------------------- + +commands=('whiptail' 'git' 'python3' 'pip3') +for command in "${commands[@]}"; +do + if ! command -v "${command}" &> /dev/null; then + system_deps='1' + break + fi +done + +if command -v python3 &> /dev/null; then + if ! python3 -c 'import venv'; then + system_deps='1' + fi +fi + +if [ -n "${system_deps}" ]; then + echo 'Installing system dependencies' + sudo apt-get update + sudo apt-get install --yes --no-install-recommends \ + whiptail python3-venv python3-pip git +fi + +# ----------------------------------------------------------------------------- +# Microphone +# ----------------------------------------------------------------------------- + +function get_microphone { + microphones=() + while read -r microphone; do + microphones+=("${microphone}" "${microphone}" 0) + done < <(arecord -L | grep 'plughw:') + + whiptail --title "${title}" --notags \ + --radiolist 'Select Microphone:' 20 70 \ + 12 "${microphones[@]}" \ + 'custom' 'Custom ALSA Device' 0 \ + 'respeaker' 'Install ReSpeaker Drivers' 0 +} + +function get_custom_microphone { + whiptail --title "${title}" --inputbox \ + 'ALSA Input Device Name:' \ + 8 75 +} + +microphone='' +while [ -z "${microphone}" ]; do + microphone="$(get_microphone 3>&1 1>&2 2>&3)" +done + +if [ "${microphone}" = 'custom' ]; then + # Custom ALSA device name from "arecord -L" + microphone='' + while [ -z "${microphone}" ]; do + microphone="$(get_custom_microphone 3>&1 1>&2 2>&3)" + done +elif [ "${microphone}" = 'respeaker' ]; then + # ReSpeaker driver installation (requires reboot) + whiptail --title "${title}" --yesno \ + 'ReSpeaker drivers for the Raspberry Pi will now be compiled and installed. This will take a while and require a reboot. Continue?' \ + 8 75 + + echo 'Installing ReSpeaker drivers' + "${program_dir}/etc/install-respeaker-drivers.sh" + + whiptail --title "${title}" --msgbox \ + 'Driver installation complete. The system will now reboot. Re-run the installer and select the "seeed" microphone.' \ + 8 75 + + sudo reboot +fi + +# Add device +microphone_command="${microphone_command} -D ${microphone}" + +# ----------------------------------------------------------------------------- +# Speaker +# ----------------------------------------------------------------------------- + +function get_speaker { + speakers=() + while read -r speaker; do + speakers+=("${speaker}" "${speaker}" 0) + done < <(aplay -L | grep 'plughw:') + + whiptail --title "${title}" --notags \ + --radiolist 'Select Speaker:' 20 70 \ + 12 "${speakers[@]}" \ + 'custom' 'Custom ALSA Device' 0 \ + 'no_speaker' 'No Audio Output' 0 +} + +function get_custom_speaker { + whiptail --title "${title}" --inputbox \ + 'ALSA Output Device Name:' \ + 8 75 +} + +function get_speaker_sounds { + whiptail --title "${title}" --checklist \ + 'Play Sounds?' \ + 10 50 2 \ + 'awake' 'On wake-up' 0 \ + 'done' 'After voice command' 0 +} + +speaker='' +while [ -z "${speaker}" ]; do + speaker="$(get_speaker 3>&1 1>&2 2>&3)" +done + +if [ "${speaker}" = 'custom' ]; then + # Custom ALSA device name from "aplay -L" + speaker='' + while [ -z "${speaker}" ]; do + speaker="$(get_custom_speaker 3>&1 1>&2 2>&3)" + done +fi + +if [ "${speaker}" = 'no_speaker' ]; then + # No audio output + speaker_command='' +else + # Add device + speaker_command="${speaker_command} -D ${speaker}" + + # awake/done sounds + speaker_sounds="$(get_speaker_sounds 3>&1 1>&2 2>&3)" +fi + +# ----------------------------------------------------------------------------- +# wyoming-satellite +# ----------------------------------------------------------------------------- + +if [ ! -d "${program_dir}/wyoming_satellite" ]; then + if [ ! -d "${this_dir}/wyoming-satellite" ]; then + # Download source here + git clone 'https://github.com/rhasspy/wyoming-satellite.git' + fi + + program_dir="${this_dir}/wyoming-satellite" +fi + +# Virtual environment +venv="${program_dir}/.venv" +pip="${venv}/bin/pip3" +if [ ! -d "${venv}" ]; then + echo 'Creating virtual environment' + "${program_dir}/script/setup" +fi + +# Satellite name +function get_satellite_name { + whiptail --title "${title}" --inputbox \ + 'Satellite Name:' \ + 8 75 \ + 'Wyoming Satellite' +} + +while [ -z "${satellite_name}" ]; do + satellite_name="$(get_satellite_name 3>&1 1>&2 2>&3)" +done + +# ----------------------------------------------------------------------------- +# Satellite type +# ----------------------------------------------------------------------------- + +function get_satellite_type { + whiptail --title "${title}" --notags \ + --radiolist 'Select Satellite Type:' 10 50 \ + 3 \ + always 'Always streaming' 1 \ + vad 'Voice activity detection' 0 \ + wake 'Local wake word detection' 0 +} + +function get_wake_word_system { + # TODO: Support porcupine1 and snowboy + echo 'openWakeWord' + # whiptail --title "${title}" --notags \ + # --radiolist 'Select Wake Word System:' 10 50 \ + # 3 \ + # openWakeWord 'openWakeWord' 1 \ + # porcupine1 'porcupine1' 0 \ + # snowboy 'snowboy' 0 +} + +function install_openWakeWord { + if [ ! -d '/usr/share/doc/libopenblas-dev' ]; then + echo 'Installing system dependencies' + sudo apt-get install --yes --no-install-recommends \ + libopenblas-dev + fi + + oww_dir="${local_dir}/wyoming-openwakeword" + if [ ! -d "${oww_dir}" ]; then + echo 'Installing openWakeWord' + + # Download + pushd "${local_dir}" + git clone 'https://github.com/rhasspy/wyoming-openwakeword.git' + popd + + # Install + "${oww_dir}/script/setup" + fi +} + +function list_openWakeWord { + whiptail --title "${title}" --notags \ + --radiolist 'Select Wake Word:' 15 50 \ + 6 \ + ok_nabu 'ok nabu' 1 \ + hey_jarvis 'hey jarvis' 0 \ + alexa 'alexa' 0 \ + hey_rhasspy 'hey rhasspy' 0 \ + hey_mycroft 'hey mycroft' 0 \ + community 'Community Wake Words' 0 +} + +satellite_type="$(get_satellite_type 3>&1 1>&2 2>&3)" + +if [ "${satellite_type}" = 'vad' ]; then + # Install vad requirements + "${pip}" install \ + --extra-index-url 'https://www.piwheels.org/simple' \ + -f 'https://synesthesiam.github.io/prebuilt-apps/' \ + -r "${program_dir}/requirements_vad.txt" +elif [ "${satellite_type}" = 'wake' ]; then + # Install openWakeWord + wake_word_system="$(get_wake_word_system 3>&1 1>&2 2>&3)" + + if [ "${wake_word_system}" = 'openWakeWord' ]; then + install_openWakeWord + mkdir -p "${custom_wake_word_dir}/openWakeWord" + + # Choose wake word + wake_word="$(list_openWakeWord 3>&1 1>&2 2>&3)" + + if [ "${wake_word}" = 'community' ]; then + # Download community wake words + community_wake_word_dir="${local_dir}/home-assistant-wakewords-collection" + if [ ! -d "${community_wake_word_dir}" ]; then + echo 'Downloading community wake words' + pushd "${local_dir}" + git clone 'https://github.com/fwartner/home-assistant-wakewords-collection.git' + popd + fi + + # Select community wake word + community_wake_words=() + + pushd "${community_wake_word_dir}" + while read -r wake_word_model; do + wake_word_name="$(basename "${wake_word_model}" .tflite)" + community_wake_words+=("${wake_word_model}" "${wake_word_name}" 0) + done < <(find . -name '*.tflite' -type f | sort) + popd + + function list_community_wake_words { + whiptail --title "${title}" --notags \ + --radiolist 'Select Wake Word:' 25 80 \ + 20 ${community_wake_words[@]} + } + + wake_word="$(list_community_wake_words 3>&1 1>&2 2>&3)" + cp "${community_wake_word_dir}/${wake_word}" \ + "${custom_wake_word_dir}/openWakeWord/" + + wake_word="$(basename "${wake_word}" .tflite)" + fi # community wake word + fi # openWakeWord + +fi # local wake word detection + +# ----------------------------------------------------------------------------- +# Audio enhancements +# ----------------------------------------------------------------------------- + +function get_noise_suppression { + whiptail --title "${title}" --notags --radiolist \ + 'Noise Suppression Level:' \ + 15 75 6 \ + '' 'Off' 1 \ + '1' 'Low' 0 \ + '2' 'Medium' 0 \ + '3' 'High' 0 \ + '4' 'Max' 0 +} + +noise_suppression="$(get_noise_suppression 3>&1 1>&2 2>&3)" + +if [ -n "${noise_suppression}" ]; then + # Install audio enhancement requirements + "${pip}" install \ + --extra-index-url 'https://www.piwheels.org/simple' \ + -f 'https://synesthesiam.github.io/prebuilt-apps/' \ + -r "${program_dir}/requirements_audio_enhancement.txt" +fi + +# ----------------------------------------------------------------------------- +# Services +# ----------------------------------------------------------------------------- + +services_dir="${local_dir}/services" +mkdir -p "${services_dir}" + +wake_service_requires='' +event_service_requires='' + +# Disable any previous services +echo 'Stopping previous services' +for service in 'wyoming-satellite' 'wyoming-openwakeword'; do + sudo systemctl disable --now "${service}.service" 2>/dev/null || true +done + +echo 'Creating systemd unit files' +new_services=('wyoming-satellite.service') + +# openWakeWord +if [ "${wake_word_system}" = 'openWakeWord' ]; then + new_services+=('wyoming-openwakeword.service') + wake_service_requires='Requires=wyoming-openwakeword.service' + + wake_word_command=("${local_dir}/wyoming-openwakeword/script/run") + wake_word_command+=('--uri' 'tcp://127.0.0.1:10400') + wake_word_command+=('--custom-model-dir' "'${custom_wake_word_dir}/openWakeWord'") + + if [ -n "${debug_enabled}" ]; then + wake_word_command+=('--debug') + fi + + cat > "${services_dir}/wyoming-openwakeword.service" < "${services_dir}/wyoming-satellite.service" < List[str]: + if not req_path.is_file(): + return [] + + requirements: List[str] = [] + with open(req_path, "r", encoding="utf-8") as req_file: + for line in req_file: + line = line.strip() + if not line: + continue + + requirements.append(line) + + return requirements + + +install_requires = get_requirements(this_dir / "requirements.txt") +extras_require = { + "silerovad": get_requirements(this_dir / "requirements_vad.txt"), + "webrtc": get_requirements(this_dir / "requirements_audio_enhancement.txt"), +} # ----------------------------------------------------------------------------- @@ -23,7 +41,7 @@ setup( author="Michael Hansen", author_email="mike@rhasspy.org", packages=setuptools.find_packages(), - install_requires=requirements, + install_requires=install_requires, classifiers=[ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", @@ -35,10 +53,7 @@ setup( "Programming Language :: Python :: 3.11", ], keywords="rhasspy wyoming satellite", - extras_require={ - "silerovad": ["pysilero-vad==1.0.0"], - "webrtc": ["webrtc-noise-gain==1.2.3"], - }, + extras_require=extras_require, entry_points={ "console_scripts": ["wyoming-satellite = wyoming_satellite:__main__.run"] },