Загрузка исходника

This commit is contained in:
2026-05-09 11:49:44 +03:00
commit 87502c55e0
87 changed files with 7700 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Command-line installer."""
+174
View File
@@ -0,0 +1,174 @@
"""Command-line installer."""
import logging
from typing import List, Optional
from .const import LOCAL_DIR, PROGRAM_DIR, SatelliteType, Settings
from .drivers import install_drivers
from .microphone import configure_microphone
from .packages import (
can_import,
install_packages,
install_packages_nogui,
packages_installed,
)
from .satellite import configure_satellite
from .services import generate_services, install_services, stop_services
from .speakers import configure_speakers
from .wake_word import configure_wake_word
from .whiptail import ItemType, error, menu, msgbox, passwordbox, run_with_gauge
_LOGGER = logging.getLogger()
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()
if not packages_installed("whiptail"):
install_packages_nogui("whiptail")
choice: Optional[str] = None
while True:
choice = main_menu(choice)
if choice == "satellite":
configure_satellite(settings)
elif choice == "microphone":
configure_microphone(settings)
elif choice == "speakers":
configure_speakers(settings)
elif choice == "wake":
configure_wake_word(settings)
elif choice == "drivers":
install_drivers(settings)
elif choice == "apply":
apply_settings(settings)
else:
break
def main_menu(last_choice: Optional[str]) -> Optional[str]:
items: List[ItemType] = [
("satellite", "Satellite"),
("microphone", "Microphone"),
("speakers", "Speakers"),
("wake", "Wake Word"),
("drivers", "Drivers"),
("apply", "Apply Settings"),
]
return menu(
"Main",
items,
selected_item=last_choice,
menu_args=["--ok-button", "Select", "--cancel-button", "Exit"],
)
# -----------------------------------------------------------------------------
def pip_install(*args) -> List[str]:
return [
str(PROGRAM_DIR / ".venv" / "bin" / "pip3"),
"install",
"--extra-index-url",
"https://www.piwheels.org/simple",
"-f",
"https://synesthesiam.github.io/prebuilt-apps/",
] + list(args)
def apply_settings(settings: Settings) -> None:
if settings.mic.device is None:
msgbox("Please configure microphone")
return
if (settings.satellite.type == SatelliteType.WAKE) and (
settings.wake.system is None
):
msgbox("Please set wake word system")
return
password: Optional[str] = None
if not packages_installed("python3-pip", "python3-venv"):
password = passwordbox("sudo password:")
if not password:
return
success = install_packages(
"Installing Python packages...", password, "python3-pip", "python3-venv"
)
if not success:
error("installing pip/venv for Python")
return
# Satellite venv
venv_dir = PROGRAM_DIR / ".venv"
if not venv_dir.exists():
result = run_with_gauge(
"Creating virtual environment...", [str(PROGRAM_DIR / "script" / "setup")]
)
if not result:
error("creating virtual environment")
return
# silero (vad)
if (settings.satellite.type == SatelliteType.VAD) and (
not can_import("pysilero_vad")
):
result = run_with_gauge(
"Installing vad...",
[pip_install("-e", f"{PROGRAM_DIR}[silerovad]")],
)
if not result:
error("installing vad")
return
# webrtc (audio enhancements)
if ((settings.mic.noise_suppression > 0) or (settings.mic.auto_gain > 0)) and (
not can_import("webrtc_noise_gain")
):
result = run_with_gauge(
"Installing audio enhancements...",
[pip_install("-e", f"{PROGRAM_DIR}[webrtc]")],
)
if not result:
error("installing audio enhancements")
return
if (
settings.satellite.event_service_command
and (
("2mic" in settings.satellite.event_service_command)
or ("4mic" in settings.satellite.event_service_command)
)
) and (not can_import("gpiozero", "spidev")):
result = run_with_gauge(
"Installing event requirements...",
[pip_install("-e", f"{PROGRAM_DIR}[respeaker]")],
)
if not result:
error("installing event requirements")
return
generate_services(settings)
if password is None:
password = passwordbox("sudo password:")
if not password:
return
stop_services(password)
install_services(settings, password)
# -----------------------------------------------------------------------------
if __name__ == "__main__":
main()
+114
View File
@@ -0,0 +1,114 @@
"""Constants and dataclasses."""
import json
import logging
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import List, Optional
from .dataclasses_json import DataClassJsonMixin
_DIR = Path(__file__).parent
_LOGGER = logging.getLogger()
PROGRAM_DIR = _DIR.parent
LOCAL_DIR = PROGRAM_DIR / "local"
SETTINGS_PATH = LOCAL_DIR / "settings.json"
SERVICES_DIR = LOCAL_DIR / "services"
RECORD_SECONDS = 5
RECORD_RMS_MIN = 30
TITLE = "Wyoming Satellite"
WIDTH = "75"
HEIGHT = "20"
LIST_HEIGHT = "12"
class SatelliteType(str, Enum):
ALWAYS_STREAMING = "always"
VAD = "vad"
WAKE = "wake"
@dataclass
class SatelliteSettings(DataClassJsonMixin):
name: str = "Wyoming Satellite"
type: SatelliteType = SatelliteType.ALWAYS_STREAMING
debug: bool = False
event_service_command: Optional[List[str]] = None
@dataclass
class MicrophoneSettings(DataClassJsonMixin):
device: Optional[str] = None
noise_suppression: int = 0
auto_gain: int = 0
volume_multiplier: float = 1.0
@dataclass
class SpeakerSettings(DataClassJsonMixin):
device: Optional[str] = None
volume_multiplier: float = 1.0
feedback_sounds: List[str] = field(default_factory=list)
@dataclass
class OpenWakeWordSettings(DataClassJsonMixin):
wake_word: str = "ok_nabu"
threshold: float = 0.5
trigger_level: int = 1
@dataclass
class Porcupine1Settings(DataClassJsonMixin):
wake_word: str = "porcupine"
sensitivity: float = 0.5
@dataclass
class SnowboySettings(DataClassJsonMixin):
wake_word: str = "snowboy"
sensitivity: float = 0.5
class WakeWordSystem(str, Enum):
OPENWAKEWORD = "openWakeWord"
PORCUPINE1 = "porcupine1"
SNOWBOY = "snowboy"
@dataclass
class WakeWordSettings(DataClassJsonMixin):
system: Optional[WakeWordSystem] = None
openwakeword: OpenWakeWordSettings = field(default_factory=OpenWakeWordSettings)
porcupine1: Porcupine1Settings = field(default_factory=Porcupine1Settings)
snowboy: SnowboySettings = field(default_factory=SnowboySettings)
@dataclass
class Settings(DataClassJsonMixin):
satellite: SatelliteSettings = field(default_factory=SatelliteSettings)
mic: MicrophoneSettings = field(default_factory=MicrophoneSettings)
snd: SpeakerSettings = field(default_factory=SpeakerSettings)
wake: WakeWordSettings = field(default_factory=WakeWordSettings)
@staticmethod
def load() -> "Settings":
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)
return Settings.from_dict(settings_dict)
return Settings()
def save(self) -> None:
_LOGGER.debug("Saving settings to %s", SETTINGS_PATH)
settings_dict = self.to_dict()
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)
+70
View File
@@ -0,0 +1,70 @@
"""Implement a tiny subset of dataclasses_json for config."""
from collections.abc import Mapping, Sequence
from dataclasses import asdict, fields, is_dataclass
from typing import Any, Dict, Type
class DataClassJsonMixin:
"""Adds from_dict to dataclass."""
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> Any:
"""Parse dataclasses recursively."""
kwargs: Dict[str, Any] = {}
cls_fields = {field.name: field for field in fields(cls)}
for key, value in data.items():
if key not in cls_fields:
# Skip unknown fields
continue
field = cls_fields[key]
if is_dataclass(field.type):
assert issubclass(field.type, DataClassJsonMixin), field.type
kwargs[key] = field.type.from_dict(value)
else:
kwargs[key] = _decode(value, field.type)
# Fill in optional fields with None
for field in cls_fields.values():
if (field.name not in kwargs) and _is_optional(field.type):
kwargs[field.name] = None
return cls(**kwargs)
def to_dict(self) -> Dict[str, Any]:
"""Alias for asdict."""
return asdict(self)
def _decode(value: Any, target_type: Type) -> Any:
"""Decode value using (possibly generic) type."""
if is_dataclass(target_type):
assert issubclass(target_type, DataClassJsonMixin), target_type
return target_type.from_dict(value) if value is not None else None
if hasattr(target_type, "__args__"):
# Optional[T]
if type(None) in target_type.__args__:
optional_type = target_type.__args__[0]
return _decode(value, optional_type)
# List[T]
if isinstance(value, Sequence):
list_type = target_type.__args__[0]
return [_decode(item, list_type) for item in value]
# Dict[str, T]
if isinstance(value, Mapping):
value_type = target_type.__args__[1]
return {
map_key: _decode(map_value, value_type)
for map_key, map_value in value.items()
}
return value
def _is_optional(target_type: Type):
"""True if type is Optional"""
return hasattr(target_type, "__args__") and (type(None) in target_type.__args__)
+50
View File
@@ -0,0 +1,50 @@
"""Hardware drivers."""
import sys
from typing import Optional
from .const import PROGRAM_DIR, Settings
from .whiptail import error, menu, msgbox, passwordbox, run_with_gauge, yesno
def install_drivers(settings: Settings) -> None:
choice: Optional[str] = None
while True:
choice = menu(
"Main > Drivers",
[("respeaker", "Install ReSpeaker Drivers")],
selected_item=choice,
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
)
if choice == "respeaker":
if yesno(
"ReSpeaker drivers for the Raspberry Pi will now be compiled and installed. "
"This will take a long time and require a reboot. "
"Continue?"
):
password = passwordbox("sudo password:")
success = run_with_gauge(
"Installing drivers...",
[
[
"sudo",
"-S",
str(PROGRAM_DIR / "etc" / "install-respeaker-drivers.sh"),
]
],
sudo_password=password,
)
if not success:
error("installing ReSpeaker drivers")
return
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
+214
View File
@@ -0,0 +1,214 @@
"""Microphone settings."""
import array
import logging
import math
import subprocess
from concurrent.futures import Future, ThreadPoolExecutor
from typing import Dict, List, Optional
from .const import RECORD_RMS_MIN, RECORD_SECONDS, Settings
from .whiptail import gauge, inputbox, menu, msgbox, radiolist
_LOGGER = logging.getLogger()
def configure_microphone(settings: Settings) -> None:
choice: Optional[str] = None
while True:
choice = microphone_menu(choice)
if choice == "detect":
# Automatically detect microphone with hightest RMS
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 is None:
_LOGGER.warning("Failed to record from microphone %s", device)
continue
_LOGGER.debug(
"Microphone %s got RMS %s (min: %s)",
device,
device_rms,
RECORD_RMS_MIN,
)
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.mic.device = best_device
settings.save()
else:
msgbox(
"Audio was not detected from any microphone.\n"
"If a satellite is currently running, you may need to stop it."
)
elif choice == "list":
# arecord -L
microphone_device = radiolist(
"Select ALSA Device:", get_microphone_devices(), settings.mic.device
)
if microphone_device:
settings.mic.device = microphone_device
settings.save()
elif choice == "manual":
microphone_device = inputbox("Enter ALSA Device:", settings.mic.device)
if microphone_device:
settings.mic.device = microphone_device
settings.save()
elif choice == "settings":
configure_audio_settings(settings)
else:
break
def microphone_menu(last_choice: Optional[str]) -> Optional[str]:
return menu(
"Main > Microphone",
[
("detect", "Autodetect"),
("list", "Select From List"),
("manual", "Enter Manually"),
("settings", "Audio Settings"),
],
selected_item=last_choice,
menu_args=["--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()
# default = PulseAudio
if (line == "default") or line.startswith("plughw:"):
devices.append(line)
return devices
def _record_proc(device: str) -> Optional[float]:
try:
proc = subprocess.Popen(
[
"arecord",
"-q",
"-D",
device,
"-r",
"16000",
"-c",
"1",
"-f",
"S16_LE",
"-t",
"raw",
"-d",
str(RECORD_SECONDS),
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
audio, stderr = proc.communicate()
if proc.returncode != 0:
_LOGGER.error(
"Error recording from device %s: %s", device, stderr.decode("utf-8")
)
return None
# 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 None
return 0
def configure_audio_settings(settings: Settings) -> None:
choice: Optional[str] = None
while True:
choice = audio_settings_menu(choice)
if choice == "noise":
noise_suppression = radiolist(
"Noise Suppression Level",
[(0, "Off"), (1, "Low"), (2, "Medium"), (3, "High"), (4, "Maximum")],
settings.mic.noise_suppression,
)
if noise_suppression is not None:
settings.mic.noise_suppression = noise_suppression
settings.save()
elif choice == "gain":
while True:
auto_gain = inputbox("Auto Gain (0-31 dbFS)", settings.mic.auto_gain)
if auto_gain is None:
break
try:
auto_gain_int = int(auto_gain)
except ValueError:
msgbox("Invalid value")
continue
if 0 <= auto_gain_int <= 31:
settings.mic.auto_gain = auto_gain_int
settings.save()
break
msgbox("Must be 0-31")
elif choice == "multiplier":
while True:
volume_multiplier = inputbox(
"Volume Multiplier (1 = default)", settings.mic.volume_multiplier
)
if volume_multiplier is None:
break
try:
volume_multiplier_float = float(volume_multiplier)
except ValueError:
msgbox("Invalid value")
continue
if volume_multiplier_float > 0:
settings.mic.volume_multiplier = volume_multiplier_float
settings.save()
break
msgbox("Must be > 0")
else:
break
def audio_settings_menu(last_choice: Optional[str]) -> Optional[str]:
return menu(
"Main > Microphone > Audio Settings",
[
("noise", "Noise Suppression"),
("gain", "Auto Gain"),
("multiplier", "Volume Multiplier"),
],
selected_item=last_choice,
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
)
+87
View File
@@ -0,0 +1,87 @@
"""Install system packages."""
import logging
import subprocess
from .const import PROGRAM_DIR
from .whiptail import run_with_gauge
_LOGGER = logging.getLogger()
def packages_installed(*packages) -> bool:
for package in packages:
try:
subprocess.check_call(
["dpkg", "--status", str(package)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception:
return False
return True
def install_packages_nogui(*packages, update: bool = True) -> bool:
assert packages, "No packages"
commands = []
if update:
commands.append(["sudo", "apt-get", "update"])
commands.append(
["sudo", "apt-get", "install", "--yes"] + [str(p) for p in packages]
)
try:
for command in commands:
subprocess.check_call(
command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
except Exception:
_LOGGER.exception("Unexpected error installing packages: %s", packages)
return False
return True
def install_packages(
text: str, sudo_password: str, *packages, update: bool = True
) -> bool:
assert packages, "No packages"
commands = []
if update:
commands.append(["sudo", "-S", "apt-get", "update"])
commands.append(
["sudo", "-S", "apt-get", "install", "--yes"] + [str(p) for p in packages]
)
return run_with_gauge(text, commands, sudo_password=sudo_password)
def can_import(*names) -> bool:
assert names, "No names"
venv_dir = PROGRAM_DIR / ".venv"
if not venv_dir.exists():
return False
try:
import venv
except ImportError:
return False
context = venv.EnvBuilder().ensure_directories(PROGRAM_DIR / ".venv")
try:
subprocess.check_call(
[context.env_exe, "-c", "; ".join(f"import {name}" for name in names)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception:
return False
return True
+120
View File
@@ -0,0 +1,120 @@
"""Satellite settings."""
from typing import Optional
from .const import PROGRAM_DIR, SatelliteType, Settings
from .whiptail import error, inputbox, menu, passwordbox, radiolist, run_with_gauge
def configure_satellite(settings: Settings) -> None:
choice: Optional[str] = None
while True:
choice = satellite_menu(choice)
if choice == "name":
name = inputbox("Satellite Name:", settings.satellite.name)
if name:
settings.satellite.name = name
settings.save()
elif choice == "type":
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 = SatelliteType(satellite_type)
settings.save()
elif choice == "feedback":
configure_feedback(settings)
elif choice in ("restart", "stop", "start"):
password = passwordbox("sudo password:")
if not password:
continue
command = ["sudo", "-S", "systemctl", choice, "wyoming-satellite.service"]
text = {"restart": "Restarting", "stop": "Stopping", "start": "Starting"}[
choice
]
success = run_with_gauge(
f"{text} satellite...", [command], sudo_password=password
)
if not success:
error(f"{text.lower()} satellite")
elif choice == "debug":
debug = radiolist(
"Debug Mode:",
[
("disabled", "Disabled"),
("enabled", "Enabled"),
],
"enabled" if settings.satellite.debug else "disabled",
)
if debug is not None:
settings.satellite.debug = debug == "enabled"
settings.save()
else:
break
def satellite_menu(last_choice: Optional[str]) -> Optional[str]:
return menu(
"Main > Satellite",
[
("name", "Satellite Name"),
("type", "Satellite Type"),
("feedback", "Feedback"),
("restart", "Restart Services"),
("stop", "Stop Services"),
("start", "Start Services"),
("debug", "Set Debug Mode"),
],
selected_item=last_choice,
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
)
def configure_feedback(settings: Settings) -> None:
choice: Optional[str] = None
while True:
choice = menu(
"Main > Satellite > Feedback",
[("respeaker", "ReSpeaker")],
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
)
if choice == "respeaker":
selected_service: Optional[str] = None
if settings.satellite.event_service_command:
if "2mic" in settings.satellite.event_service_command[0]:
selected_service = "2mic"
elif "4mic" in settings.satellite.event_service_command[0]:
selected_service = "4mic"
event_service = radiolist(
"Event Service:",
[
("none", "None"),
("2mic", "2mic LEDs"),
("4mic", "4mic LEDs"),
],
selected_service,
)
if event_service is not None:
if event_service == "none":
settings.satellite.event_service_command = None
else:
settings.satellite.event_service_command = [
str(PROGRAM_DIR / "script" / f"run_{event_service}"),
"--uri",
"tcp://127.0.0.1:10500",
]
settings.save()
else:
break
+262
View File
@@ -0,0 +1,262 @@
"""Systemd service management."""
import shlex
import subprocess
from pathlib import Path
from typing import List
from .const import (
LOCAL_DIR,
PROGRAM_DIR,
SERVICES_DIR,
SatelliteType,
Settings,
WakeWordSystem,
)
from .whiptail import error, msgbox, run_with_gauge
def stop_services(password: str) -> None:
stop_commands = []
for service in ("satellite", "wakeword", "event"):
service_filename = f"wyoming-{service}.service"
service_path = Path("/etc/systemd/system") / service_filename
if not service_path.exists():
continue
stop_commands.append(["sudo", "-S", "systemctl", "stop", service_filename])
stop_commands.append(["sudo", "-S", "systemctl", "disable", service_filename])
run_with_gauge("Stopping Services...", stop_commands, sudo_password=password)
def generate_services(settings: Settings) -> None:
SERVICES_DIR.mkdir(parents=True, exist_ok=True)
user_id = subprocess.check_output(["id", "-u"], text=True).strip()
user_name = subprocess.check_output(["id", "--name", "-u"], text=True).strip()
satellite_command: List[str] = [
str(PROGRAM_DIR / "script" / "run"),
"--name",
settings.satellite.name,
"--uri",
"tcp://0.0.0.0:10700",
"--mic-command",
f"arecord -D {settings.mic.device} -q -r 16000 -c 1 -f S16_LE -t raw",
]
satellite_requires: List[str] = []
if settings.snd.device is not None:
# Audio output
satellite_command.extend(
[
"--snd-command",
f"aplay -D {settings.snd.device} -q -r 22050 -c 1 -f S16_LE -t raw",
]
)
if settings.snd.volume_multiplier != 1.0:
satellite_command.extend(
["--snd-volume-multiplier", str(settings.snd.volume_multiplier)]
)
for sound_name in settings.snd.feedback_sounds:
# Try local/sounds first
sound_path = LOCAL_DIR / "sounds" / f"{sound_name}.wav"
if not sound_path.exists():
sound_path = PROGRAM_DIR / "sounds" / f"{sound_name}.wav"
satellite_command.extend([f"--{sound_name}-wav", str(sound_path)])
if settings.mic.noise_suppression > 0:
satellite_command.extend(
["--mic-noise-suppression", str(settings.mic.noise_suppression)]
)
if settings.mic.auto_gain > 0:
satellite_command.extend(["--mic-auto-gain", str(settings.mic.auto_gain)])
if settings.mic.volume_multiplier != 1.0:
satellite_command.extend(
["--mic-volume-multiplier", str(settings.mic.volume_multiplier)]
)
if settings.satellite.type == SatelliteType.VAD:
# Voice activity detect
satellite_command.append("--vad")
elif settings.satellite.type == SatelliteType.WAKE:
# Local wake word detection
assert settings.wake.system is not None, "Wake word system not set"
wake_word_service = "wyoming-wakeword"
wake_word_dir = LOCAL_DIR / (
"wyoming-" + WakeWordSystem(settings.wake.system).value.lower()
)
wake_word_command = [
str(wake_word_dir / "script" / "run"),
"--uri",
"tcp://127.0.0.1:10400",
]
if settings.wake.system == WakeWordSystem.OPENWAKEWORD:
wake_word = settings.wake.openwakeword.wake_word
wake_word_command.extend(
[
"--threshold",
str(settings.wake.openwakeword.threshold),
"--trigger-level",
str(settings.wake.openwakeword.trigger_level),
"--custom-model-dir",
str(LOCAL_DIR / "custom-wake-words" / "openWakeWord"),
]
)
elif settings.wake.system == WakeWordSystem.PORCUPINE1:
wake_word = settings.wake.porcupine1.wake_word
wake_word_command.extend(
["--sensitivity", str(settings.wake.porcupine1.sensitivity)]
)
elif settings.wake.system == WakeWordSystem.SNOWBOY:
wake_word = settings.wake.snowboy.wake_word
wake_word_command.extend(
[
"--sensitivity",
str(settings.wake.snowboy.sensitivity),
"--custom-model-dir",
str(LOCAL_DIR / "custom-wake-words" / "snowboy"),
]
)
else:
raise ValueError(settings.wake.system)
if settings.satellite.debug:
wake_word_command.append("--debug")
wake_word_command_str = shlex.join(wake_word_command)
with open(
SERVICES_DIR / f"{wake_word_service}.service", "w", encoding="utf-8"
) as service_file:
print("[Unit]", file=service_file)
print(
f"Description={WakeWordSystem(settings.wake.system).value}",
file=service_file,
)
print("", file=service_file)
print("[Service]", file=service_file)
print("Type=simple", file=service_file)
print(f"User={user_name}", file=service_file)
print(f"ExecStart={wake_word_command_str}", file=service_file)
print(f"WorkingDirectory={wake_word_dir}", file=service_file)
print("Restart=always", file=service_file)
print("RestartSec=1", file=service_file)
print("", file=service_file)
print("[Install]", file=service_file)
print("WantedBy=default.target", file=service_file)
satellite_command.extend(
[
"--wake-uri",
"tcp://127.0.0.1:10400",
"--wake-word-name",
wake_word,
]
)
satellite_requires.append(f"{wake_word_service}.service")
if settings.satellite.event_service_command:
event_service = "wyoming-event"
event_command_str = shlex.join(settings.satellite.event_service_command)
with open(
SERVICES_DIR / f"{event_service}.service", "w", encoding="utf-8"
) as service_file:
print("[Unit]", file=service_file)
print("Description=Event service", file=service_file)
print("", file=service_file)
print("[Service]", file=service_file)
print("Type=simple", file=service_file)
print(f"User={user_name}", file=service_file)
print(f"ExecStart={event_command_str}", file=service_file)
print(f"WorkingDirectory={PROGRAM_DIR}", file=service_file)
print("Restart=always", file=service_file)
print("RestartSec=1", file=service_file)
print("", file=service_file)
print("[Install]", file=service_file)
print("WantedBy=default.target", file=service_file)
satellite_command.extend(["--event-uri", "tcp://127.0.0.1:10500"])
satellite_requires.append(f"{event_service}.service")
if settings.satellite.debug:
satellite_command.extend(
["--debug", "--debug-recording-dir", str(LOCAL_DIR / "debug-recording")]
)
satellite_command_str = shlex.join(satellite_command)
with open(
SERVICES_DIR / "wyoming-satellite.service", "w", encoding="utf-8"
) as service_file:
print("[Unit]", file=service_file)
print("Description=Wyoming Satellite", file=service_file)
print("Wants=network-online.target", file=service_file)
print("After=network-online.target", file=service_file)
for requires in satellite_requires:
print(f"Requires={requires}", file=service_file)
print("", file=service_file)
print("[Service]", file=service_file)
print("Type=simple", file=service_file)
print(f"User={user_name}", file=service_file)
# For PulseAudio
print(f"Environment=XDG_RUNTIME_DIR=/run/user/{user_id}", file=service_file)
print(f"ExecStart={satellite_command_str}", file=service_file)
print(f"WorkingDirectory={PROGRAM_DIR}", file=service_file)
print("Restart=always", file=service_file)
print("RestartSec=1", file=service_file)
print("", file=service_file)
print("[Install]", file=service_file)
print("WantedBy=default.target", file=service_file)
def install_services(settings: Settings, password: str):
# Install and run
installed_services = ["satellite"]
if settings.satellite.type == SatelliteType.WAKE:
assert settings.wake.system is not None, "No wake word system"
installed_services.append("wakeword")
if settings.satellite.event_service_command:
installed_services.append("event")
install_commands = []
for service in installed_services:
service_filename = f"wyoming-{service}.service"
install_commands.append(
[
"sudo",
"-S",
"cp",
str(SERVICES_DIR / service_filename),
"/etc/systemd/system/",
]
)
install_commands.append(["sudo", "-S", "systemctl", "daemon-reload"])
# Copy first, then enable and start
for service in installed_services:
install_commands.append(["sudo", "-S", "systemctl", "enable", service_filename])
install_commands.append(
["sudo", "-S", "systemctl", "enable", "--now", "wyoming-satellite.service"]
)
success = run_with_gauge(
"Installing Services...", install_commands, sudo_password=password
)
if success:
msgbox("Successfully installed services")
else:
error("installing services")
+150
View File
@@ -0,0 +1,150 @@
"""Speaker settings."""
import logging
import subprocess
from typing import List, Optional
from .const import PROGRAM_DIR, Settings
from .whiptail import checklist, inputbox, menu, msgbox, radiolist
_LOGGER = logging.getLogger()
def configure_speakers(settings: Settings) -> None:
choice: Optional[str] = None
while True:
choice = speakers_menu(choice)
if choice == "play":
if settings.snd.device is None:
msgbox("No speaker device set")
continue
test_sound_device(settings.snd.device)
elif choice == "test":
sound_device = test_speakers()
if sound_device is not None:
settings.snd.device = sound_device
settings.save()
elif choice == "list":
sound_device = radiolist(
"Select ALSA Device:",
get_sound_devices(),
settings.snd.device,
)
if sound_device:
settings.snd.device = sound_device
settings.save()
elif choice == "manual":
sound_device = inputbox("Enter ALSA Device:", settings.snd.device)
if sound_device:
settings.snd.device = sound_device
settings.save()
elif choice == "disable":
settings.snd.device = None
settings.save()
msgbox("Sound disabled")
elif choice == "feedback":
feedback_sounds = checklist(
"Enabled Sounds:",
[("awake", "On wake-up"), ("done", "After voice command")],
settings.snd.feedback_sounds,
)
if feedback_sounds is not None:
settings.snd.feedback_sounds = feedback_sounds
settings.save()
elif choice == "multiplier":
while True:
volume_multiplier = inputbox(
"Volume Multiplier (1 = default)", settings.snd.volume_multiplier
)
if volume_multiplier is None:
break
try:
volume_multiplier_float = float(volume_multiplier)
except ValueError:
msgbox("Invalid value")
continue
if volume_multiplier_float > 0:
settings.snd.volume_multiplier = volume_multiplier_float
settings.save()
break
msgbox("Must be > 0")
else:
break
def speakers_menu(last_choice: Optional[str]) -> Optional[str]:
return menu(
"Main > Speakers",
[
("play", "Play Sound"),
("test", "Test All Speakers"),
("list", "Select From List"),
("manual", "Enter Manually"),
("disable", "Disable Sound"),
("multiplier", "Volume Multiplier"),
("feedback", "Toggle Feedback Sounds"),
],
selected_item=last_choice,
menu_args=["--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()
# default = PulseAudio
if (line == "default") or line.startswith("plughw:"):
devices.append(line)
return devices
def test_speakers() -> Optional[str]:
devices = get_sound_devices()
if not devices:
msgbox("No speakers found")
return None
device = devices.pop()
while True:
choice = menu(
f"Device: {device}",
[
("play", "Play Sound"),
("next", "Next Device"),
("choose", "Choose This Device"),
],
)
if choice == "play":
test_sound_device(device)
elif choice == "next":
if not devices:
msgbox("No more devices")
break
device = devices.pop()
elif choice == "choose":
return device
else:
break
return None
def test_sound_device(device: str) -> None:
try:
subprocess.check_call(
["aplay", "-q", "-D", device, str(PROGRAM_DIR / "sounds" / "awake.wav")]
)
except Exception:
_LOGGER.exception("Error testing device: %s", device)
+468
View File
@@ -0,0 +1,468 @@
"""Wake word settings."""
import itertools
import re
import shutil
from pathlib import Path
from typing import Dict, Optional
from .const import LOCAL_DIR, SatelliteType, Settings, WakeWordSystem
from .packages import install_packages, packages_installed
from .whiptail import (
error,
inputbox,
menu,
msgbox,
passwordbox,
radiolist,
run_with_gauge,
yesno,
)
def configure_wake_word(settings: Settings) -> None:
if settings.satellite.type != SatelliteType.WAKE:
if not yesno("Set satellite type to local wake word?"):
return
settings.satellite.type = SatelliteType.WAKE
settings.save()
def star(ww_system: WakeWordSystem) -> str:
return (
f"{ww_system.value} [*]"
if settings.wake.system == ww_system
else ww_system.value
)
choice: Optional[str] = None
while True:
choice = menu(
"Main > Wake Word",
[
("system", "Wake Word System"),
("wake_word", "Choose Wake Word"),
("openWakeWord", star(WakeWordSystem.OPENWAKEWORD)),
("porcupine1", star(WakeWordSystem.PORCUPINE1)),
("snowboy", star(WakeWordSystem.SNOWBOY)),
],
selected_item=choice,
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
)
if choice == "system":
wake_word_system = radiolist(
"Wake Word System:",
[v.value for v in WakeWordSystem],
settings.wake.system,
)
if wake_word_system is None:
continue
wake_word_system = WakeWordSystem(wake_word_system)
install_wake_word(settings, wake_word_system)
elif choice == "wake_word":
select_wake_word(settings)
elif choice == "openWakeWord":
configure_openWakeWord(settings)
elif choice == "porcupine1":
configure_porcupine1(settings)
elif choice == "snowboy":
configure_snowboy(settings)
else:
break
def install_wake_word(settings: Settings, wake_word_system: WakeWordSystem) -> None:
if wake_word_system == WakeWordSystem.OPENWAKEWORD:
oww_dir = LOCAL_DIR / "wyoming-openwakeword"
if not oww_dir.exists():
if not yesno("Install openWakeWord?"):
return
success = run_with_gauge(
"Installing openWakeWord",
[
[
"git",
"clone",
"https://github.com/rhasspy/wyoming-openwakeword.git",
str(oww_dir),
],
[str(oww_dir / "script" / "setup")],
],
)
if not success:
# Clean up
try:
shutil.rmtree(oww_dir)
except Exception:
pass
error("installing openWakeWord")
return
msgbox("openWakeWord installed successfully")
settings.wake.system = wake_word_system
settings.save()
return
if wake_word_system == WakeWordSystem.PORCUPINE1:
porcupine1_dir = LOCAL_DIR / "wyoming-porcupine1"
if not porcupine1_dir.exists():
if not yesno("Install porcupine1?"):
return
success = run_with_gauge(
"Installing porcupine1",
[
[
"git",
"clone",
"https://github.com/rhasspy/wyoming-porcupine1.git",
str(porcupine1_dir),
],
[str(porcupine1_dir / "script" / "setup")],
],
)
if not success:
# Clean up
try:
shutil.rmtree(porcupine1_dir)
except Exception:
pass
error("installing porcupine1")
return
msgbox("porcupine1 installed successfully")
settings.wake.system = wake_word_system
settings.save()
return
if wake_word_system == WakeWordSystem.SNOWBOY:
snowboy_dir = LOCAL_DIR / "wyoming-snowboy"
if not snowboy_dir.exists():
if not yesno("Install snowboy?"):
return
snowboy_packages = ["python3-dev", "swig", "libatlas-base-dev"]
if not packages_installed(*snowboy_packages):
password = passwordbox("sudo password:")
if not password:
return
success = install_packages(
"Installing system packages...", password, *snowboy_packages
)
if not success:
error("installing " + ", ".join(snowboy_packages))
return
success = run_with_gauge(
"Installing snowboy",
[
[
"git",
"clone",
"https://github.com/rhasspy/wyoming-snowboy.git",
str(snowboy_dir),
],
[str(snowboy_dir / "script" / "setup")],
],
)
if not success:
# Clean up
try:
shutil.rmtree(snowboy_dir)
except Exception:
pass
error("installing snowboy")
return
msgbox("snowboy installed successfully")
settings.wake.system = wake_word_system
settings.save()
return
def select_wake_word(settings: Settings) -> None:
if settings.wake.system == WakeWordSystem.OPENWAKEWORD:
oww_dir = LOCAL_DIR / "wyoming-openwakeword"
if not oww_dir.exists():
msgbox("openWakeWord is not installed")
return
custom_wake_word_dir = LOCAL_DIR / "custom-wake-words" / "openWakeWord"
custom_wake_word_dir.mkdir(parents=True, exist_ok=True)
community_wake_word_dir = LOCAL_DIR / "home-assistant-wakewords-collection"
while True:
ww_paths: Dict[str, Path] = {
p.stem: p for p in custom_wake_word_dir.glob("*.tflite")
}
for ww_path in community_wake_word_dir.rglob("*.tflite"):
ww_name = ww_path.stem
if ww_name in ww_paths:
continue
ww_paths[ww_name] = ww_path
for ww_path in (oww_dir / "wyoming_openwakeword" / "models").glob(
"*.tflite"
):
if not re.match("^.+_v[0-9].*$", ww_path.stem):
continue
ww_name = ww_path.stem.rsplit("_", maxsplit=1)[0]
if ww_name in ww_paths:
continue
ww_paths[ww_name] = ww_path
items = sorted(list(ww_paths.keys()))
wake_word = radiolist(
"Wake Word:", items, settings.wake.openwakeword.wake_word
)
if wake_word is None:
break
wake_word_path = ww_paths[wake_word]
if wake_word_path.is_relative_to(community_wake_word_dir):
# Copy to custom directory
shutil.copy(wake_word_path, custom_wake_word_dir)
settings.wake.openwakeword.wake_word = wake_word
settings.save()
break
return
if settings.wake.system == WakeWordSystem.PORCUPINE1:
porcupine1_dir = LOCAL_DIR / "wyoming-porcupine1"
if not porcupine1_dir.exists():
msgbox("porcupine1 is not installed")
return
ww_names = sorted(
list(
set(
p.stem.rsplit("_", maxsplit=1)[0]
for p in (
porcupine1_dir / "wyoming_porcupine1" / "data" / "resources"
).rglob("*.ppn")
)
)
)
wake_word = radiolist(
"Wake Word:", ww_names, settings.wake.porcupine1.wake_word
)
if wake_word is not None:
settings.wake.porcupine1.wake_word = wake_word
settings.save()
return
if settings.wake.system == WakeWordSystem.SNOWBOY:
snowboy_dir = LOCAL_DIR / "wyoming-snowboy"
if not snowboy_dir.exists():
msgbox("snowboy is not installed")
return
custom_wake_word_dir = LOCAL_DIR / "custom-wake-words" / "snowboy"
custom_wake_word_dir.mkdir(parents=True, exist_ok=True)
builtin_wake_words = (snowboy_dir / "wyoming_snowboy" / "data").glob("*.umdl")
custom_wake_words = itertools.chain(
custom_wake_word_dir.glob("*.pmdl"), custom_wake_word_dir.glob("*.umdl")
)
ww_names = sorted(
list(
set(
p.stem
for p in itertools.chain(builtin_wake_words, custom_wake_words)
)
)
)
wake_word = radiolist("Wake Word:", ww_names, settings.wake.snowboy.wake_word)
if wake_word is not None:
settings.wake.snowboy.wake_word = wake_word
settings.save()
return
def configure_openWakeWord(settings: Settings) -> None:
choice: Optional[str] = None
while True:
choice = menu(
"Main > Wake Word > openWakeWord",
[
("community", "Download Community Wake Words"),
("threshold", "Set Threshold"),
("trigger_level", "Set Trigger Level"),
],
selected_item=choice,
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
)
if choice == "community":
community_wake_word_dir = LOCAL_DIR / "home-assistant-wakewords-collection"
if not community_wake_word_dir.exists():
success = run_with_gauge(
"Downloading community wake words...",
[
[
"git",
"clone",
"https://github.com/fwartner/home-assistant-wakewords-collection.git",
str(community_wake_word_dir),
]
],
)
if not success:
error("downloading community wake words")
else:
success = run_with_gauge(
"Updating community wake words...",
[
[
"git",
"-C",
str(community_wake_word_dir),
"pull",
"origin",
"main",
]
],
)
if not success:
error("updating community wake words")
elif choice == "threshold":
while True:
threshold = inputbox(
"Threshold (0-1, 0.5 = default):",
settings.wake.openwakeword.threshold,
)
if threshold is None:
break
try:
threshold_float = float(threshold)
except ValueError:
msgbox("Invalid value")
continue
if 0 < threshold_float < 1:
settings.wake.openwakeword.threshold = threshold_float
settings.save()
break
msgbox("Threshold must be in (0, 1)")
elif choice == "trigger_level":
while True:
trigger_level = inputbox(
"Trigger Level (> 0, 1 = default):",
settings.wake.openwakeword.trigger_level,
)
if trigger_level is None:
break
try:
trigger_level_int = int(trigger_level)
except ValueError:
msgbox("Invalid value")
continue
if trigger_level_int > 0:
settings.wake.openwakeword.trigger_level = trigger_level_int
settings.save()
break
msgbox("Trigger level must be > 0")
else:
break
def configure_porcupine1(settings: Settings) -> None:
choice: Optional[str] = None
while True:
choice = menu(
"Main > Wake Word > porcupine1",
[
("sensitivity", "Set Sensitivity"),
],
selected_item=choice,
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
)
if choice == "sensitivity":
while True:
sensitivity = inputbox(
"Sensitivity (0-1, 0.5 = default):",
settings.wake.porcupine1.sensitivity,
)
if sensitivity is None:
break
try:
sensitivity_float = float(sensitivity)
except ValueError:
msgbox("Invalid value")
continue
if 0 < sensitivity_float < 1:
settings.wake.porcupine1.sensitivity = sensitivity_float
settings.save()
break
msgbox("Sensitivity must be in (0, 1)")
else:
break
def configure_snowboy(settings: Settings) -> None:
choice: Optional[str] = None
while True:
choice = menu(
"Main > Wake Word > snowboy",
[
("sensitivity", "Set Sensitivity"),
],
selected_item=choice,
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
)
if choice == "sensitivity":
while True:
sensitivity = inputbox(
"Sensitivity (0-1, 0.5 = default):",
settings.wake.snowboy.sensitivity,
)
if sensitivity is None:
break
try:
sensitivity_float = float(sensitivity)
except ValueError:
msgbox("Invalid value")
continue
if 0 < sensitivity_float < 1:
settings.wake.snowboy.sensitivity = sensitivity_float
settings.save()
break
msgbox("Sensitivity must be in (0, 1)")
else:
break
+253
View File
@@ -0,0 +1,253 @@
"""Python interface to whiptail command."""
import logging
import shlex
import subprocess
import time
from collections.abc import Sequence
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Dict, List, Optional, Tuple, Union
from .const import HEIGHT, LIST_HEIGHT, TITLE, WIDTH
ItemType = Union[str, Tuple[Any, str]]
_LOGGER = logging.getLogger()
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: Sequence[ItemType],
selected_item: Optional[str] = None,
menu_args: Optional[Sequence[str]] = None,
) -> Optional[str]:
assert items, "No items"
item_map: Dict[Optional[str], str] = {}
item_args: List[str] = []
selected_tag: Optional[str] = None
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 selected_item == item:
selected_tag = item_id
else:
item_map[item_id] = item[0]
item_args.append(item[1])
if selected_item == item[0]:
selected_tag = item_id
menu_args = list(menu_args) if menu_args is not None else []
if selected_tag is not None:
menu_args.extend(["--default-item", selected_tag])
result = whiptail(
"--notags", *menu_args, "--menu", text, HEIGHT, WIDTH, LIST_HEIGHT, *item_args
)
return item_map.get(result)
def inputbox(text: str, init: Optional[Any] = None) -> Optional[str]:
return whiptail(
"--inputbox", text, HEIGHT, WIDTH, str(init) if init is not None else ""
)
def passwordbox(text: str) -> Optional[str]:
return whiptail("--passwordbox", text, HEIGHT, WIDTH)
def radiolist(
text: str,
items: Sequence[ItemType],
selected_item: Any,
*args,
) -> Optional[Any]:
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: Sequence[ItemType],
selected_items: Sequence[Any],
*args,
) -> Optional[List[Any]]:
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 error(reason: str) -> None:
msgbox(
f"An error occurred while {reason}.\n" "See local/installer.log for details."
)
# -----------------------------------------------------------------------------
def run_with_gauge(
text: str, commands: Sequence[Sequence[str]], sudo_password: Optional[str] = None
) -> 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, sudo_password)
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: Sequence[str], sudo_password: Optional[str] = None) -> bool:
try:
assert command
proc_input: Optional[str] = None
if (command[0] == "sudo") and (sudo_password is not None):
proc_input = sudo_password
proc = subprocess.Popen(
command,
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
)
_stdout, stderr = proc.communicate(proc_input)
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