Initial commit: Javis project
This commit is contained in:
commit
435c645f36
164 files changed
+35279
No files matched your search
@@ -0,0 +1,162 @@
|
||||
# javis-slave on a Raspberry Pi
|
||||
|
||||
Turn a Raspberry Pi with a USB microphone and a speaker into a Javis satellite:
|
||||
it detects the wake word locally, streams the utterance to the Javis master on
|
||||
your LAN, and plays the spoken reply. No GPU, no models beyond the tiny
|
||||
openWakeWord ONNX file.
|
||||
|
||||
## Hardware
|
||||
|
||||
| Part | Notes |
|
||||
|------|-------|
|
||||
| Raspberry Pi 3B+ / 4 / 5 / Zero 2 W | 64-bit Raspberry Pi OS (Bookworm or newer). Zero 2 W works headless; wake-word inference uses ~15 % of one core. |
|
||||
| USB microphone or USB sound card + mic | Most USB mics only offer 44.1/48 kHz — the slave resamples to 16 kHz automatically. Conference-style mics (e.g. Jabra, Anker) give the best wake-word range. |
|
||||
| Speaker | 3.5 mm jack, HDMI, USB DAC or an I²S HAT. |
|
||||
| Optional: official 7" touch display | Use `setup-pi.sh --gui` for the same touch UI as the Surface build; brightness control works via `/sys/class/backlight`. |
|
||||
|
||||
## Install (5 minutes)
|
||||
|
||||
```bash
|
||||
# On the Pi
|
||||
git clone <your-repo> javis && cd javis/javis-slave # or scp the javis-slave folder
|
||||
sudo ./setup-pi.sh # headless
|
||||
# sudo ./setup-pi.sh --gui # with touch display
|
||||
# add --with-vad for silero-vad end-pointing (pulls PyTorch, ~200 MB; RMS gate is used otherwise)
|
||||
```
|
||||
|
||||
The script installs apt dependencies, creates `/opt/javis-slave/.venv`,
|
||||
pre-downloads the wake-word models, grants the service user audio/backlight/power
|
||||
permissions and installs a `systemd` unit (`javis-slave.service`, enabled but not
|
||||
started).
|
||||
|
||||
## Configure & pair
|
||||
|
||||
1. **Pick audio devices**
|
||||
|
||||
```bash
|
||||
cd /opt/javis-slave
|
||||
export JAVIS_SLAVE_CONFIG_DIR=/opt/javis-slave/config
|
||||
.venv/bin/python -m javis_slave --list-devices
|
||||
```
|
||||
|
||||
Note the index of your mic (`max_input_channels > 0`) and speaker.
|
||||
|
||||
2. **Enable the API on the master** (`config.yaml`), then restart Javis:
|
||||
|
||||
```yaml
|
||||
api:
|
||||
enabled: true
|
||||
host: 0.0.0.0
|
||||
port: 8765
|
||||
```
|
||||
|
||||
Open the **Devices** tab → *Generate pairing code*. The tab shows the
|
||||
master's LAN address and the 6-digit code (valid 5 min, single use).
|
||||
|
||||
3. **Pair and test in the foreground**
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m javis_slave --headless \
|
||||
--input-device 1 --output-device 0 \
|
||||
--pair 192.168.1.10:8765 123456 --name Kueche
|
||||
```
|
||||
|
||||
Say the wake word (default *hey jarvis*) — you should hear the ascending
|
||||
two-tone chime, speak, and hear the reply. Device settings are persisted in
|
||||
`$JAVIS_SLAVE_CONFIG_DIR/slave_config.json`. Stop with Ctrl+C.
|
||||
|
||||
4. **Run as a service**
|
||||
|
||||
```bash
|
||||
sudo systemctl start javis-slave
|
||||
journalctl -u javis-slave -f
|
||||
```
|
||||
|
||||
Assign a room in the master's Devices tab (e.g. `Küche`); the slave picks it
|
||||
up within a minute and every utterance is tagged with it for the LLM.
|
||||
|
||||
With `setup-pi.sh --gui` the system service stays disabled and the touch UI
|
||||
autostarts in the desktop session instead (`~/.config/autostart/javis-slave.desktop`,
|
||||
`--kiosk` = fullscreen, hidden cursor; Esc leaves fullscreen, F11 toggles).
|
||||
|
||||
Logs: `journalctl -u javis-slave` (service) and
|
||||
`$JAVIS_SLAVE_CONFIG_DIR/logs/slave.log` (rotating, both modes).
|
||||
|
||||
## Audible feedback (no screen needed)
|
||||
|
||||
| Sound | Meaning |
|
||||
|-------|---------|
|
||||
| Two rising tones | Wake word heard — speak now |
|
||||
| Two falling low tones | Something failed (master unreachable, mic error) |
|
||||
| Spoken error phrase | The master received the request but its turn failed (STT/LLM); it sends its configured `agent.error_phrase` as speech |
|
||||
|
||||
## Remote control from the master
|
||||
|
||||
The slave runs a small HTTP control server on port 8766 (`/mcp/v1/...`) that
|
||||
the [javis-slave-mcp](../javis-slave-mcp/README.md) server exposes as tools, so
|
||||
you can say *"mach die Küche leiser"*. On Linux the endpoints map to:
|
||||
|
||||
| Endpoint | Backend |
|
||||
|----------|---------|
|
||||
| `/mcp/v1/volume/*` | `pactl` (PipeWire/Pulse) → falls back to `amixer` (ALSA) |
|
||||
| `/mcp/v1/brightness/*` | `/sys/class/backlight/*/brightness` (udev rule from `setup-pi.sh`) |
|
||||
| `/mcp/v1/power/*` | `systemctl suspend`, `shutdown` via a scoped sudoers entry |
|
||||
| `/mcp/v1/mic/*`, `/status`, `/restart` | Engine mute / status / restart |
|
||||
|
||||
## CLI reference
|
||||
|
||||
```
|
||||
javis-slave --headless service mode, no window
|
||||
javis-slave --fullscreen | --kiosk GUI fullscreen / fullscreen + hidden cursor
|
||||
javis-slave --pair HOST:PORT CODE --name N pair (works with and without --headless)
|
||||
javis-slave --list-devices print sounddevice table
|
||||
javis-slave --input-device N --output-device N --input-gain G --api-port P
|
||||
persist settings and start
|
||||
```
|
||||
|
||||
Environment: `JAVIS_SLAVE_CONFIG_DIR` overrides the config folder
|
||||
(default `~/.javis-slave`).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**No devices listed / "Error querying device"** — `sudo apt install libportaudio2`
|
||||
and make sure the user is in the `audio` group (`groups`; log out/in after
|
||||
`setup-pi.sh`).
|
||||
|
||||
**Mic opens at 48 kHz, wake word never fires** — normal; the log line
|
||||
`mic started @ 48000Hz→16000Hz` confirms resampling is active. Raise
|
||||
`--input-gain 2.0` if the level is low; check with `arecord -d 3 test.wav`.
|
||||
Stereo-only USB cards are opened with both channels and mixed to mono.
|
||||
|
||||
**USB mic unplugged / stops delivering audio** — after ~2 minutes of silence
|
||||
from the device the engine logs `reopening device` and re-opens it; if the
|
||||
device is gone the engine start is retried every 10 s.
|
||||
|
||||
**"Master … not reachable"** — the master must have `api.enabled: true` and its
|
||||
Windows firewall must allow inbound TCP 8765 (Javis is a Python process; allow
|
||||
`python.exe` on private networks).
|
||||
|
||||
**Wake word too sensitive / not sensitive enough** — change
|
||||
`wake_word.threshold` on the *master*; slaves pull it on the next config poll
|
||||
(≤ 60 s).
|
||||
|
||||
**Reply is silent but text appears in the master's log** — the master's TTS has
|
||||
no voice profile with reference clips; add one in the Voices tab. Playback
|
||||
device problems show up as `speaker does not support … Hz` (auto-resampled) or
|
||||
an ALSA error in `journalctl`.
|
||||
|
||||
**Service dies at boot** — USB audio may enumerate after the service starts;
|
||||
`Restart=always` + `RestartSec=5` recover automatically. The engine also
|
||||
retries every 10 s while the mic is unavailable.
|
||||
|
||||
**Pi Zero 2 W CPU load** — skip `--with-vad`; the RMS gate is much cheaper than
|
||||
Silero on a single core. Keep only one wake-word model.
|
||||
|
||||
## Uninstall
|
||||
|
||||
```bash
|
||||
sudo systemctl disable --now javis-slave
|
||||
sudo rm /etc/systemd/system/javis-slave.service /etc/sudoers.d/javis-slave \
|
||||
/etc/udev/rules.d/90-javis-backlight.rules
|
||||
sudo rm -rf /opt/javis-slave
|
||||
```
|
||||
@@ -0,0 +1,89 @@
|
||||
# javis-slave
|
||||
|
||||
Satellite app for [Javis](../README.md). Runs on a tablet (designed for a
|
||||
Surface Pro 7) **or headless on a Raspberry Pi**, listens for the wake word
|
||||
locally, and sends recorded utterances to the Javis master over the LAN for
|
||||
transcription, LLM reasoning and speech synthesis. The reply is shown on
|
||||
screen (GUI mode) and played back through the device's speakers.
|
||||
|
||||
→ **Raspberry Pi guide:** [RASPBERRY_PI.md](RASPBERRY_PI.md)
|
||||
|
||||
## How it works
|
||||
|
||||
1. **Pairing** — On the master, open the **Devices** tab and click
|
||||
*Generate pairing code*. On the slave, enter the master's IP, port and the
|
||||
6-digit code (setup screen, or `--pair HOST:PORT CODE`). The master issues
|
||||
a permanent per-device API key and the device appears in its Devices list.
|
||||
2. **Room assignment** — In the master's Devices tab, type a room name for
|
||||
the device (e.g. `Küche`) and click *Save room*. The slave picks it up on
|
||||
its next config refresh; every utterance from this device is tagged
|
||||
`[Room: Küche]` for the LLM.
|
||||
3. **Wake word** — The slave uses the same openWakeWord model name the master
|
||||
is configured with (sent during pairing / config refresh) and detects the
|
||||
wake word fully locally. Built-in models ship with openWakeWord; a custom
|
||||
model (e.g. `rufus`, see [docs/WAKE_WORD.md](../docs/WAKE_WORD.md)) is
|
||||
downloaded from the master on first use. A short rising chime confirms
|
||||
detection.
|
||||
4. **Turns** — After the wake word, the utterance is recorded locally
|
||||
(Silero VAD or RMS end-pointing), uploaded as WAV to `POST /api/v1/turn`,
|
||||
and the master's reply text + synthesized speech are returned and played.
|
||||
If the master's turn fails it returns its spoken error phrase instead, so a
|
||||
screenless satellite still gets audible feedback; connection problems play
|
||||
a low falling tone.
|
||||
|
||||
## Setup
|
||||
|
||||
This folder is fully standalone — it can be copied on its own to a
|
||||
satellite device without the rest of the Javis repo. It vendors its own small
|
||||
copy of the audio stack (`javis_slave/audio_io/`), so there's no cross-project
|
||||
dependency.
|
||||
|
||||
On the **master** (`config.yaml`):
|
||||
|
||||
```yaml
|
||||
api:
|
||||
enabled: true
|
||||
host: 0.0.0.0
|
||||
port: 8765
|
||||
```
|
||||
|
||||
On the **slave**:
|
||||
|
||||
```bat
|
||||
run.bat :: Windows tablet (installs the GUI extra)
|
||||
```
|
||||
|
||||
```bash
|
||||
./run.sh # Linux with display
|
||||
JAVIS_SLAVE_HEADLESS=1 ./run.sh --headless # Linux without display
|
||||
sudo ./setup-pi.sh # Raspberry Pi: apt deps + venv + systemd service
|
||||
```
|
||||
|
||||
The first run creates `.venv` and installs the dependencies declared in
|
||||
`pyproject.toml`. Optional extras: `gui` (PySide6/qasync, needed for the
|
||||
window) and `vad` (silero-vad + PyTorch; without it an RMS energy gate is used).
|
||||
|
||||
### Command line
|
||||
|
||||
```
|
||||
javis-slave touch GUI
|
||||
javis-slave --fullscreen | --kiosk fullscreen / fullscreen + hidden cursor
|
||||
javis-slave --headless no window (services, Pi)
|
||||
javis-slave --pair 192.168.1.10:8765 123456 --name Kueche
|
||||
javis-slave --list-devices show mic/speaker indices
|
||||
javis-slave --input-device 1 --output-device 0 --input-gain 1.5 --api-port 8766
|
||||
```
|
||||
|
||||
Settings are stored in `%LOCALAPPDATA%\JavisSlave\slave_config.json`
|
||||
(Windows) or `~/.javis-slave/slave_config.json` (Linux); override the folder
|
||||
with `JAVIS_SLAVE_CONFIG_DIR`. A rotating log is written to `<config dir>/logs/slave.log`.
|
||||
|
||||
## Notes
|
||||
|
||||
- Transport is plain HTTP — use on a trusted LAN only.
|
||||
- Microphones/speakers that don't support 16 kHz are opened at their native
|
||||
rate and resampled automatically (common for USB audio on Linux).
|
||||
- System control endpoints (volume / brightness / power, see
|
||||
[javis-slave-mcp](../javis-slave-mcp/README.md)) work on Windows (pycaw,
|
||||
screen-brightness-control) and Linux (`pactl`/`amixer`, `/sys/class/backlight`,
|
||||
`systemctl`).
|
||||
@@ -0,0 +1,25 @@
|
||||
[Unit]
|
||||
Description=Javis Slave (headless voice satellite)
|
||||
Documentation=file:///opt/javis-slave/RASPBERRY_PI.md
|
||||
After=network-online.target sound.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# Adjust User/paths if you installed somewhere else (setup-pi.sh fills these in).
|
||||
User=pi
|
||||
WorkingDirectory=/opt/javis-slave
|
||||
Environment=JAVIS_SLAVE_CONFIG_DIR=/opt/javis-slave/config
|
||||
# PipeWire/PulseAudio (user session) is not available to a system service;
|
||||
# PortAudio falls back to ALSA, which is what we want on a Pi.
|
||||
Environment=PULSE_SERVER=none
|
||||
ExecStart=/opt/javis-slave/.venv/bin/python -m javis_slave --headless
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
# Give the mic/USB audio time to enumerate after boot before the first retry.
|
||||
TimeoutStartSec=90
|
||||
# Slightly higher priority for glitch-free capture on small boards.
|
||||
Nice=-5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,52 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "javis-slave"
|
||||
version = "0.1.0"
|
||||
description = "Touch-optimized satellite companion for Javis — local wake word, remote brain."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10,<3.14"
|
||||
license = { text = "MIT" }
|
||||
dependencies = [
|
||||
# Master API client
|
||||
"httpx>=0.27",
|
||||
# HTTP server for MCP control (plain uvicorn: no uvloop/httptools builds on small ARM boards)
|
||||
"fastapi>=0.104",
|
||||
"uvicorn>=0.24",
|
||||
"pydantic>=2.0",
|
||||
# Windows system control (volume, brightness, power) — Linux uses amixer/pactl,
|
||||
# /sys/class/backlight and systemctl, no extra packages.
|
||||
"pycaw>=20220226; sys_platform == 'win32'",
|
||||
"screen-brightness-control>=0.20; sys_platform == 'win32'",
|
||||
# Audio stack (vendored copy of javis.audio_io — standalone, no repo dependency)
|
||||
"numpy>=1.24,<2.0",
|
||||
"sounddevice>=0.4.6",
|
||||
"soundfile>=0.12",
|
||||
"scipy>=1.11",
|
||||
"openwakeword>=0.6.0",
|
||||
"onnxruntime>=1.17",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
# Touch UI (Surface / Pi with display). Not needed for `--headless`.
|
||||
gui = [
|
||||
"PySide6>=6.6",
|
||||
"qasync>=0.27",
|
||||
]
|
||||
# Neural end-pointing. Pulls in PyTorch (~200 MB on aarch64); without it the
|
||||
# engine falls back to an RMS energy gate automatically.
|
||||
vad = [
|
||||
"silero-vad>=5.1",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
javis-slave = "javis_slave.__main__:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 110
|
||||
target-version = "py310"
|
||||
@@ -0,0 +1,16 @@
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
|
||||
if not exist ".venv\Scripts\python.exe" (
|
||||
echo Creating virtual environment...
|
||||
python -m venv .venv
|
||||
call .venv\Scripts\activate.bat
|
||||
python -m pip install --upgrade pip
|
||||
rem gui = PySide6/qasync for the touch UI; vad = silero-vad (+PyTorch) for better end-pointing
|
||||
pip install -e ".[gui,vad]"
|
||||
) else (
|
||||
call .venv\Scripts\activate.bat
|
||||
)
|
||||
|
||||
python -m javis_slave %*
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage: ./run.sh [javis-slave args]
|
||||
# JAVIS_SLAVE_HEADLESS=1 ./run.sh --headless # skip the Qt/PySide6 install (Pi without display)
|
||||
# JAVIS_SLAVE_VAD=1 ./run.sh # also install silero-vad (+ PyTorch)
|
||||
set -euo pipefail
|
||||
|
||||
cd -- "$(dirname -- "${BASH_SOURCE[0]}")"
|
||||
|
||||
if [[ ! -x ".venv/bin/python" ]]; then
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
echo "Error: python3 is required." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python_version="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
|
||||
case "$python_version" in
|
||||
3.10|3.11|3.12|3.13) ;;
|
||||
*) echo "Error: javis-slave requires Python 3.10–3.13 (found $python_version)." >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
extras=""
|
||||
if [[ "${JAVIS_SLAVE_HEADLESS:-0}" != "1" ]]; then extras="gui"; fi
|
||||
if [[ "${JAVIS_SLAVE_VAD:-0}" == "1" ]]; then extras="${extras:+$extras,}vad"; fi
|
||||
|
||||
echo "Creating virtual environment (extras: ${extras:-none})..."
|
||||
python3 -m venv .venv
|
||||
.venv/bin/python -m pip install --upgrade pip
|
||||
if [[ -n "$extras" ]]; then
|
||||
.venv/bin/python -m pip install -e ".[${extras}]"
|
||||
else
|
||||
.venv/bin/python -m pip install -e .
|
||||
fi
|
||||
fi
|
||||
|
||||
exec .venv/bin/python -m javis_slave "$@"
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-shot Raspberry Pi installer for javis-slave (headless or with touch display).
|
||||
#
|
||||
# sudo ./setup-pi.sh # headless satellite (mic + speaker)
|
||||
# sudo ./setup-pi.sh --gui # Pi with official touch display
|
||||
# sudo ./setup-pi.sh --with-vad # also install silero-vad (+PyTorch, ~200 MB)
|
||||
#
|
||||
# Idempotent: re-running upgrades the venv and rewrites the service unit.
|
||||
set -euo pipefail
|
||||
|
||||
INSTALL_DIR="${INSTALL_DIR:-/opt/javis-slave}"
|
||||
SERVICE_USER="${SERVICE_USER:-${SUDO_USER:-pi}}"
|
||||
WITH_GUI=0
|
||||
WITH_VAD=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--gui) WITH_GUI=1 ;;
|
||||
--with-vad) WITH_VAD=1 ;;
|
||||
-h|--help) sed -n '2,9p' "$0"; exit 0 ;;
|
||||
*) echo "unknown option: $arg" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "Run with sudo (installs apt packages and a systemd unit)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SRC_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
echo "==> apt packages"
|
||||
apt-get update -qq
|
||||
# portaudio: sounddevice; libsndfile: soundfile; alsa-utils: amixer/aplay/arecord;
|
||||
# libatomic1 + libopenblas: onnxruntime/numpy wheels on aarch64.
|
||||
apt-get install -y -qq python3 python3-venv python3-dev libportaudio2 libsndfile1 \
|
||||
alsa-utils libatomic1 libopenblas0 rsync
|
||||
if [[ $WITH_GUI -eq 1 ]]; then
|
||||
# Qt platform plugin deps for PySide6 wheels on Raspberry Pi OS + colour emoji
|
||||
# glyphs used by the orb/buttons (🎤 🔊 ⚙).
|
||||
apt-get install -y -qq libxcb-cursor0 libxkbcommon-x11-0 libegl1 libgl1 libglib2.0-0 \
|
||||
libfontconfig1 libdbus-1-3 fonts-noto-color-emoji
|
||||
fi
|
||||
|
||||
echo "==> copying to ${INSTALL_DIR}"
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
if [[ "$SRC_DIR" != "$INSTALL_DIR" ]]; then
|
||||
rsync -a --delete --exclude .venv --exclude config --exclude '__pycache__' \
|
||||
"$SRC_DIR"/ "$INSTALL_DIR"/
|
||||
fi
|
||||
mkdir -p "$INSTALL_DIR/config"
|
||||
chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR"
|
||||
|
||||
echo "==> python venv"
|
||||
extras=""
|
||||
[[ $WITH_GUI -eq 1 ]] && extras="gui"
|
||||
[[ $WITH_VAD -eq 1 ]] && extras="${extras:+$extras,}vad"
|
||||
sudo -u "$SERVICE_USER" bash -c "
|
||||
set -e
|
||||
cd '$INSTALL_DIR'
|
||||
[[ -x .venv/bin/python ]] || python3 -m venv .venv
|
||||
.venv/bin/python -m pip install --upgrade pip wheel
|
||||
if [[ -n '$extras' ]]; then .venv/bin/python -m pip install -e '.[$extras]'; else .venv/bin/python -m pip install -e .; fi
|
||||
# Pre-download openWakeWord ONNX models so the service works offline later.
|
||||
.venv/bin/python -c 'from openwakeword.utils import download_models; download_models()' || true
|
||||
"
|
||||
|
||||
echo "==> permissions (audio, backlight)"
|
||||
usermod -aG audio,video "$SERVICE_USER"
|
||||
cat > /etc/udev/rules.d/90-javis-backlight.rules <<'EOF'
|
||||
# Let members of 'video' set the display brightness (javis-slave /mcp/v1/brightness).
|
||||
SUBSYSTEM=="backlight", ACTION=="add", RUN+="/bin/chgrp video /sys/class/backlight/%k/brightness", RUN+="/bin/chmod g+w /sys/class/backlight/%k/brightness"
|
||||
EOF
|
||||
udevadm control --reload-rules || true
|
||||
# Passwordless power control for the service user (only the exact commands used).
|
||||
cat > /etc/sudoers.d/javis-slave <<EOF
|
||||
$SERVICE_USER ALL=(root) NOPASSWD: /usr/bin/systemctl suspend, /usr/sbin/shutdown
|
||||
EOF
|
||||
chmod 0440 /etc/sudoers.d/javis-slave
|
||||
|
||||
echo "==> systemd unit"
|
||||
sed -e "s|^User=.*|User=$SERVICE_USER|" \
|
||||
-e "s|/opt/javis-slave|$INSTALL_DIR|g" \
|
||||
"$INSTALL_DIR/javis-slave.service" > /etc/systemd/system/javis-slave.service
|
||||
systemctl daemon-reload
|
||||
if [[ $WITH_GUI -eq 1 ]]; then
|
||||
# With a display the touch UI runs in the user's desktop session (kiosk),
|
||||
# not as a system service — both would fight over the microphone.
|
||||
systemctl disable javis-slave.service >/dev/null 2>&1 || true
|
||||
AUTOSTART_DIR="$(getent passwd "$SERVICE_USER" | cut -d: -f6)/.config/autostart"
|
||||
sudo -u "$SERVICE_USER" mkdir -p "$AUTOSTART_DIR"
|
||||
cat > "$AUTOSTART_DIR/javis-slave.desktop" <<EOF
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Javis Slave
|
||||
Exec=env JAVIS_SLAVE_CONFIG_DIR=$INSTALL_DIR/config $INSTALL_DIR/.venv/bin/python -m javis_slave --kiosk
|
||||
X-GNOME-Autostart-enabled=true
|
||||
EOF
|
||||
chown "$SERVICE_USER":"$SERVICE_USER" "$AUTOSTART_DIR/javis-slave.desktop"
|
||||
else
|
||||
systemctl enable javis-slave.service
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Done. Next steps:
|
||||
|
||||
1. Find your audio devices:
|
||||
sudo -u $SERVICE_USER JAVIS_SLAVE_CONFIG_DIR=$INSTALL_DIR/config \\
|
||||
$INSTALL_DIR/.venv/bin/python -m javis_slave --list-devices
|
||||
2. Pair with the master (generate a code in its Devices tab), e.g.:
|
||||
sudo -u $SERVICE_USER JAVIS_SLAVE_CONFIG_DIR=$INSTALL_DIR/config \\
|
||||
$INSTALL_DIR/.venv/bin/python -m javis_slave --headless \\
|
||||
--input-device 1 --output-device 0 --pair 192.168.1.10:8765 123456 --name Kueche
|
||||
Press Ctrl+C once you hear the wake chime working, then:
|
||||
EOF
|
||||
if [[ $WITH_GUI -eq 1 ]]; then
|
||||
echo " 3. Reboot (or log out/in): the touch UI autostarts in kiosk mode."
|
||||
echo " Manual start: $INSTALL_DIR/.venv/bin/python -m javis_slave --kiosk"
|
||||
else
|
||||
echo " 3. sudo systemctl start javis-slave"
|
||||
echo " journalctl -u javis-slave -f"
|
||||
fi
|
||||
@@ -0,0 +1,2 @@
|
||||
"""javis-slave — touch-optimized satellite companion for Javis."""
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,128 @@
|
||||
"""javis-slave entry point."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
|
||||
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="javis-slave",
|
||||
description="Javis satellite: local wake word, remote brain.",
|
||||
epilog=(
|
||||
"Examples:\n"
|
||||
" javis-slave # touch GUI (needs the [gui] extra)\n"
|
||||
" javis-slave --list-devices # find your mic/speaker indices\n"
|
||||
" javis-slave --headless --pair 192.168.1.10:8765 123456 --name Kueche\n"
|
||||
" javis-slave --headless # Raspberry Pi service mode"
|
||||
),
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
p.add_argument("--headless", action="store_true",
|
||||
help="Run without a window (Raspberry Pi / systemd). Logs to stderr.")
|
||||
p.add_argument("--fullscreen", action="store_true",
|
||||
help="Start the GUI fullscreen (F11 toggles, Esc leaves).")
|
||||
p.add_argument("--kiosk", action="store_true",
|
||||
help="Fullscreen with hidden mouse cursor for touch panels.")
|
||||
p.add_argument("--pair", nargs=2, metavar=("HOST:PORT", "CODE"),
|
||||
help="Pair with the master using a code from its Devices tab, then start.")
|
||||
p.add_argument("--name", default=None,
|
||||
help="Device name shown on the master (default: hostname). Used with --pair.")
|
||||
p.add_argument("--list-devices", action="store_true",
|
||||
help="Print audio devices with their indices and exit.")
|
||||
p.add_argument("--input-device", type=int, default=None, metavar="N",
|
||||
help="Persist microphone device index N (see --list-devices).")
|
||||
p.add_argument("--output-device", type=int, default=None, metavar="N",
|
||||
help="Persist speaker device index N (see --list-devices).")
|
||||
p.add_argument("--input-gain", type=float, default=None, metavar="G",
|
||||
help="Persist microphone gain multiplier (1.0 = unchanged).")
|
||||
p.add_argument("--api-port", type=int, default=None, metavar="PORT",
|
||||
help="Persist the local MCP control server port (default 8766).")
|
||||
return p.parse_args(argv)
|
||||
|
||||
|
||||
def _apply_device_overrides(args: argparse.Namespace) -> None:
|
||||
"""Persist CLI-provided audio settings so services can be configured non-interactively."""
|
||||
if all(v is None for v in (args.input_device, args.output_device, args.input_gain, args.api_port)):
|
||||
return
|
||||
from .config import load_config, save_config
|
||||
cfg = load_config()
|
||||
if args.input_device is not None:
|
||||
cfg.input_device = args.input_device
|
||||
if args.output_device is not None:
|
||||
cfg.output_device = args.output_device
|
||||
if args.input_gain is not None:
|
||||
cfg.input_gain = args.input_gain
|
||||
if args.api_port is not None:
|
||||
cfg.api_port = args.api_port
|
||||
save_config(cfg)
|
||||
|
||||
|
||||
def _parse_pair(args: argparse.Namespace):
|
||||
if not args.pair:
|
||||
return None
|
||||
import socket
|
||||
hostport, code = args.pair
|
||||
host, _, port_s = hostport.rpartition(":")
|
||||
if not host or not port_s.isdigit():
|
||||
print(f"--pair expects HOST:PORT, got {hostport!r}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
name = args.name or socket.gethostname()
|
||||
return host, int(port_s), code.strip(), name
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parse_args(argv)
|
||||
|
||||
if args.list_devices:
|
||||
from .audio_io.stream import list_audio_devices
|
||||
print(list_audio_devices())
|
||||
return 0
|
||||
|
||||
_apply_device_overrides(args)
|
||||
pair = _parse_pair(args)
|
||||
|
||||
if args.headless:
|
||||
from .headless import run_headless
|
||||
return run_headless(pair)
|
||||
|
||||
try:
|
||||
import PySide6 # noqa: F401
|
||||
except ImportError:
|
||||
print(
|
||||
"PySide6 is not installed. Either install the GUI extra "
|
||||
"(pip install -e '.[gui]') or run with --headless.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if pair is not None:
|
||||
# Pair first (no window needed), then launch the GUI already paired.
|
||||
import asyncio
|
||||
from .api_client import MasterClient, MasterError
|
||||
from .config import load_config, save_config
|
||||
from .headless import pair_cli
|
||||
|
||||
async def _do_pair() -> int:
|
||||
cfg = load_config()
|
||||
save_config(cfg)
|
||||
client = MasterClient(cfg)
|
||||
try:
|
||||
await pair_cli(cfg, client, *pair)
|
||||
return 0
|
||||
except MasterError as exc:
|
||||
print(f"pairing failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
rc = asyncio.run(_do_pair())
|
||||
if rc != 0:
|
||||
return rc
|
||||
|
||||
from .app import run_app
|
||||
return run_app(fullscreen=args.fullscreen, kiosk=args.kiosk)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,286 @@
|
||||
"""HTTP client for the Javis master API."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Awaitable, Callable, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import SlaveConfig, WakeWordSettings
|
||||
from .logging_setup import get_logger
|
||||
|
||||
log = get_logger("api")
|
||||
|
||||
|
||||
class MasterError(Exception):
|
||||
"""Raised when the master rejects a request or is unreachable."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class PairResult:
|
||||
api_key: str
|
||||
room: Optional[str]
|
||||
wake_word: WakeWordSettings
|
||||
|
||||
|
||||
@dataclass
|
||||
class TurnResult:
|
||||
user_text: str
|
||||
reply_text: str
|
||||
audio_wav: Optional[bytes] # decoded WAV bytes (16-bit PCM)
|
||||
sample_rate: Optional[int]
|
||||
error: Optional[str] = None # master-side failure; reply_text is the spoken error phrase
|
||||
|
||||
|
||||
def _http_detail(r: httpx.Response) -> str:
|
||||
"""Best-effort human-readable detail from a FastAPI error body."""
|
||||
try:
|
||||
detail = r.json().get("detail")
|
||||
if detail:
|
||||
return f" — {detail}"
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _wake_word_from(payload: dict) -> WakeWordSettings:
|
||||
ww = payload.get("wake_word") or {}
|
||||
return WakeWordSettings(
|
||||
model=str(ww.get("model", "hey_jarvis")),
|
||||
threshold=float(ww.get("threshold", 0.5)),
|
||||
vad_threshold=float(ww.get("vad_threshold", 0.5)),
|
||||
silence_ms=int(ww.get("silence_ms", 800)),
|
||||
)
|
||||
|
||||
|
||||
class MasterClient:
|
||||
"""Async client for the master's ``/api/v1`` endpoints."""
|
||||
|
||||
def __init__(self, cfg: SlaveConfig):
|
||||
self.cfg = cfg
|
||||
self._client = httpx.AsyncClient(timeout=httpx.Timeout(10.0, read=180.0))
|
||||
# Connection health tracking (Alexa-style connection monitoring)
|
||||
self._connection_ok: bool = True
|
||||
self._retry_count: int = 0
|
||||
self._max_retries: int = 10
|
||||
self._backoff_base: float = 1.0 # start at 1s
|
||||
self._backoff_max: float = 30.0 # cap at 30s
|
||||
self._pending_reconnect: bool = False
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Close the underlying HTTP client (release the connection pool)."""
|
||||
await self._client.aclose()
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
"""Current connection health status."""
|
||||
return self._connection_ok
|
||||
|
||||
@property
|
||||
def retry_count(self) -> int:
|
||||
"""Number of consecutive failed attempts."""
|
||||
return self._retry_count
|
||||
|
||||
@property
|
||||
def backoff_seconds(self) -> float:
|
||||
"""Current exponential backoff duration in seconds."""
|
||||
return min(self._backoff_base * (2 ** self._retry_count), self._backoff_max)
|
||||
|
||||
def mark_connected(self) -> None:
|
||||
"""Reset retry state on successful connection."""
|
||||
if not self._connection_ok:
|
||||
log.info("connection restored")
|
||||
self._connection_ok = True
|
||||
self._retry_count = 0
|
||||
|
||||
def mark_disconnected(self, exc: Exception) -> None:
|
||||
"""Record a failed attempt with exponential backoff."""
|
||||
self._connection_ok = False
|
||||
self._retry_count += 1
|
||||
log.warning(f"connection lost (attempt {self._retry_count}/{self._max_retries}); "
|
||||
f"reconnect in {self.backoff_seconds:.0f}s: {exc}")
|
||||
|
||||
def should_reconnect(self) -> bool:
|
||||
"""Whether another reconnection attempt is warranted."""
|
||||
if not self._connection_ok and self._retry_count < self._max_retries:
|
||||
return True
|
||||
if self._retry_count >= self._max_retries:
|
||||
log.error("max reconnection attempts reached; requires manual intervention")
|
||||
return False
|
||||
|
||||
def _headers(self) -> dict:
|
||||
return {
|
||||
"X-Device-Id": self.cfg.device_id,
|
||||
"Authorization": f"Bearer {self.cfg.api_key}",
|
||||
}
|
||||
|
||||
async def _retryable_request(
|
||||
self,
|
||||
request: Callable[[], Awaitable[httpx.Response]],
|
||||
retries: Optional[int] = None,
|
||||
) -> httpx.Response:
|
||||
"""Execute an HTTP request with exponential backoff reconnection.
|
||||
|
||||
``request`` is a zero-arg callable that produces a *fresh* awaitable
|
||||
(e.g. ``lambda: self._client.post(...)``). A new request is built on
|
||||
every attempt so a failed attempt never re-awaits a closed coroutine.
|
||||
|
||||
Args:
|
||||
request: Zero-arg callable producing the request awaitable.
|
||||
retries: Max number of retries (default ``self._max_retries``).
|
||||
"""
|
||||
if retries is None:
|
||||
retries = self._max_retries
|
||||
last_exc: Optional[Exception] = None
|
||||
for attempt in range(retries + 1):
|
||||
try:
|
||||
r = await request()
|
||||
self.mark_connected()
|
||||
return r
|
||||
except httpx.HTTPError as e:
|
||||
last_exc = e
|
||||
self.mark_disconnected(e)
|
||||
if attempt >= retries:
|
||||
raise MasterError(
|
||||
f"Master nach {retries + 1} Versuchen nicht erreichbar."
|
||||
) from e
|
||||
wait = min(self._backoff_base * (2 ** attempt), self._backoff_max)
|
||||
log.info(f"retrying in {wait:.0f}s (attempt {attempt + 1}/{retries})")
|
||||
await asyncio.sleep(wait)
|
||||
raise last_exc # type: ignore[misc]
|
||||
|
||||
async def pair(self, host: str, port: int, pairing_code: str,
|
||||
device_name: str) -> PairResult:
|
||||
try:
|
||||
# Pairing codes are single-use: one retry is enough, and a wrong
|
||||
# IP must fail in seconds, not after the full backoff ladder.
|
||||
r = await self._retryable_request(
|
||||
lambda: self._client.post(
|
||||
f"http://{host}:{port}/api/v1/pair",
|
||||
json={
|
||||
"pairing_code": pairing_code,
|
||||
"device_id": self.cfg.device_id,
|
||||
"device_name": device_name,
|
||||
},
|
||||
),
|
||||
retries=1,
|
||||
)
|
||||
except MasterError as e:
|
||||
raise e
|
||||
if r.status_code == 401:
|
||||
raise MasterError("Pairing-Code ungültig oder abgelaufen.")
|
||||
if r.status_code == 429:
|
||||
raise MasterError("Zu viele Versuche — bitte kurz warten.")
|
||||
if r.status_code != 200:
|
||||
raise MasterError(f"Pairing fehlgeschlagen (HTTP {r.status_code}){_http_detail(r)}.")
|
||||
data = r.json()
|
||||
return PairResult(
|
||||
api_key=str(data["api_key"]),
|
||||
room=data.get("room"),
|
||||
wake_word=_wake_word_from(data),
|
||||
)
|
||||
|
||||
async def fetch_config(self) -> tuple[Optional[str], WakeWordSettings]:
|
||||
"""Return ``(room, wake_word)`` from the master."""
|
||||
try:
|
||||
r = await self._retryable_request(
|
||||
lambda: self._client.get(
|
||||
f"{self.cfg.base_url}/api/v1/config", headers=self._headers(), timeout=10.0,
|
||||
)
|
||||
)
|
||||
except MasterError as e:
|
||||
raise e
|
||||
if r.status_code == 401:
|
||||
raise MasterError("Gerät nicht (mehr) autorisiert — bitte neu koppeln.")
|
||||
if r.status_code != 200:
|
||||
raise MasterError(f"Config-Abruf fehlgeschlagen (HTTP {r.status_code}).")
|
||||
data = r.json()
|
||||
return data.get("room"), _wake_word_from(data)
|
||||
|
||||
async def list_slaves(self) -> list[dict]:
|
||||
"""Return the master's slave list (``GET /api/v1/slaves``) as raw dicts.
|
||||
|
||||
Uses this device's own pairing credentials, so the caller (e.g. the
|
||||
MCP pass-through endpoints) need not know the master's address or the
|
||||
auth header format. Bounded to a single attempt (``retries=0``) so a
|
||||
down master fails fast instead of running the full 10-retry backoff —
|
||||
an HTTP/MCP query must not block for minutes.
|
||||
"""
|
||||
try:
|
||||
r = await self._retryable_request(
|
||||
lambda: self._client.get(
|
||||
f"{self.cfg.base_url}/api/v1/slaves",
|
||||
headers=self._headers(), timeout=10.0,
|
||||
),
|
||||
retries=0,
|
||||
)
|
||||
except MasterError as e:
|
||||
raise e
|
||||
if r.status_code != 200:
|
||||
raise MasterError(f"Slave-list query failed (HTTP {r.status_code}).")
|
||||
data = r.json()
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
async def send_turn(self, wav_bytes: bytes) -> TurnResult:
|
||||
"""Upload a recorded utterance; block until the master's reply."""
|
||||
try:
|
||||
# The turn POST runs a full STT+LLM+TTS on the master and is NOT
|
||||
# idempotent — limit retries so a flaky network can't re-execute
|
||||
# (and duplicate) the same utterance many times (see review A-2).
|
||||
r = await self._retryable_request(
|
||||
lambda: self._client.post(
|
||||
f"{self.cfg.base_url}/api/v1/turn",
|
||||
headers=self._headers(),
|
||||
files={"audio": ("utterance.wav", wav_bytes, "audio/wav")},
|
||||
),
|
||||
retries=1,
|
||||
)
|
||||
except MasterError as e:
|
||||
raise e
|
||||
if r.status_code == 401:
|
||||
raise MasterError("Gerät nicht (mehr) autorisiert — bitte neu koppeln.")
|
||||
if r.status_code != 200:
|
||||
raise MasterError(f"Anfrage fehlgeschlagen (HTTP {r.status_code}){_http_detail(r)}.")
|
||||
data = r.json()
|
||||
audio_b64 = data.get("tts_audio_b64")
|
||||
return TurnResult(
|
||||
user_text=str(data.get("user_text", "")),
|
||||
reply_text=str(data.get("reply_text", "")),
|
||||
audio_wav=base64.b64decode(audio_b64) if audio_b64 else None,
|
||||
sample_rate=data.get("sample_rate"),
|
||||
error=data.get("error") or None,
|
||||
)
|
||||
|
||||
async def check_health(self, host: str, port: int) -> bool:
|
||||
"""Unauthenticated reachability probe (``GET /api/v1/health``), single attempt."""
|
||||
try:
|
||||
r = await self._client.get(f"http://{host}:{port}/api/v1/health", timeout=5.0)
|
||||
return r.status_code == 200
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
|
||||
async def download_wake_model(self, dest: "Path") -> bool:
|
||||
"""Fetch the master's custom wake-word ``.onnx`` into *dest*.
|
||||
|
||||
Returns False when the master uses a built-in model (HTTP 404) or is
|
||||
unreachable; never raises.
|
||||
"""
|
||||
try:
|
||||
r = await self._client.get(
|
||||
f"{self.cfg.base_url}/api/v1/wake_word/model",
|
||||
headers=self._headers(), timeout=60.0,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
log.warning(f"wake model download failed: {exc}")
|
||||
return False
|
||||
if r.status_code != 200 or not r.content:
|
||||
return False
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = dest.with_suffix(".onnx.part")
|
||||
tmp.write_bytes(r.content)
|
||||
tmp.replace(dest)
|
||||
log.info(f"downloaded custom wake model from master → {dest} ({len(r.content)} bytes)")
|
||||
return True
|
||||
@@ -0,0 +1,282 @@
|
||||
"""App wiring: qasync event loop, pairing flow, engine ↔ UI bridge."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
from .logging_setup import get_logger
|
||||
|
||||
from .api_client import MasterClient, MasterError
|
||||
from .audio_engine import AudioEngine
|
||||
from .config import SlaveConfig, load_config, save_config
|
||||
from .http_server import build_http_app
|
||||
|
||||
log = get_logger("slave.app")
|
||||
|
||||
# Re-try starting the audio engine (mic not ready / unplugged) this often.
|
||||
_ENGINE_RETRY_S = 10.0
|
||||
|
||||
|
||||
def run_app(*, fullscreen: bool = False, kiosk: bool = False) -> int:
|
||||
from PySide6 import QtWidgets
|
||||
import qasync
|
||||
|
||||
from .main_window import SlaveWindow
|
||||
|
||||
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv)
|
||||
app.setApplicationName("Javis Slave")
|
||||
app.setStyle("Fusion")
|
||||
|
||||
loop = qasync.QEventLoop(app)
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
cfg = load_config()
|
||||
save_config(cfg) # persist generated device_id on first run
|
||||
client = MasterClient(cfg)
|
||||
window = SlaveWindow()
|
||||
engine: Optional[AudioEngine] = None
|
||||
engine_task: Optional[asyncio.Task] = None
|
||||
http_task: Optional[asyncio.Task] = None
|
||||
http_server = None
|
||||
retry_task: Optional[asyncio.Task] = None
|
||||
last_state: str = "idle"
|
||||
last_error: Optional[str] = None
|
||||
|
||||
# ── HTTP server state getters ─────────────────────────────────────────
|
||||
|
||||
def _get_engine() -> Optional[AudioEngine]:
|
||||
return engine
|
||||
|
||||
def _get_state() -> str:
|
||||
return last_state
|
||||
|
||||
def _get_error() -> Optional[str]:
|
||||
return last_error
|
||||
|
||||
def _get_mic_level() -> tuple[float, float]:
|
||||
if engine is None:
|
||||
return 0.0, 0.0
|
||||
# Owned by the engine (set in _wait_for_wake), exposed publicly (A-11).
|
||||
return engine.last_rms, engine.last_score
|
||||
|
||||
def _get_master_client() -> MasterClient:
|
||||
"""Provide the shared MasterClient to the HTTP server for pass-throughs."""
|
||||
return client
|
||||
|
||||
async def _restart_engine() -> None:
|
||||
"""Stop and restart the audio engine (wired to ``POST /mcp/v1/restart``).
|
||||
|
||||
Defined *after* ``_stop_engine`` / ``_start_engine`` below so we can
|
||||
reference them via closure; the handler in http_server awaits it and
|
||||
converts exceptions into a 500-style ``ControlResponse(success=False)``.
|
||||
"""
|
||||
await _stop_engine()
|
||||
await _start_engine()
|
||||
|
||||
# ── HTTP server lifecycle ─────────────────────────────────────────────
|
||||
|
||||
async def _start_http_server() -> None:
|
||||
nonlocal http_task, http_server
|
||||
if http_task is not None:
|
||||
return # idempotent: don't start twice
|
||||
try:
|
||||
from uvicorn import Server, Config
|
||||
http_app = build_http_app(
|
||||
cfg,
|
||||
_get_engine, _get_state, _get_error, _get_mic_level,
|
||||
on_restart=_restart_engine,
|
||||
master_client_getter=_get_master_client,
|
||||
)
|
||||
# Bind to the configured host/port (A-6: was 127.0.0.1:8766, so
|
||||
# remote MCP controllers couldn't reach it). Distinct from master:8765.
|
||||
# loop="none"/lifespan="off": the qasync loop is already running.
|
||||
config = Config(app=http_app, host=cfg.api_host, port=cfg.api_port,
|
||||
log_level="warning", loop="none", lifespan="off")
|
||||
http_server = Server(config)
|
||||
http_task = asyncio.create_task(http_server.serve())
|
||||
log.info(f"HTTP server started on {cfg.api_host}:{cfg.api_port} for MCP control")
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.exception(f"failed to start HTTP server: {e}")
|
||||
|
||||
async def _stop_http_server() -> None:
|
||||
nonlocal http_task, http_server
|
||||
if http_task is not None:
|
||||
if http_server is not None:
|
||||
http_server.should_exit = True
|
||||
try:
|
||||
await asyncio.wait_for(http_task, timeout=5.0)
|
||||
except (asyncio.TimeoutError, asyncio.CancelledError, Exception): # noqa: BLE001
|
||||
http_task.cancel()
|
||||
http_task = None
|
||||
http_server = None
|
||||
|
||||
# ── engine lifecycle ──────────────────────────────────────────────────
|
||||
|
||||
async def _start_engine() -> None:
|
||||
nonlocal engine, engine_task, last_state, last_error
|
||||
last_error = None
|
||||
engine = AudioEngine(cfg, client)
|
||||
|
||||
# Capture mic level in engine for HTTP access
|
||||
def _on_mic_level(rms: float, score: float) -> None:
|
||||
window.engineMicLevel.emit(rms, score)
|
||||
|
||||
def _on_state(state: str) -> None:
|
||||
nonlocal last_state
|
||||
last_state = state
|
||||
window.engineState.emit(state)
|
||||
|
||||
def _on_error(msg: str) -> None:
|
||||
nonlocal last_error
|
||||
last_error = msg
|
||||
window.engineError.emit(msg)
|
||||
|
||||
engine.on_state = _on_state
|
||||
engine.on_mic_level = _on_mic_level
|
||||
engine.on_turn = window.engineTurn.emit
|
||||
engine.on_error = _on_error
|
||||
engine.on_connection = window.engineConnection.emit
|
||||
engine.on_room = window.engineRoom.emit
|
||||
|
||||
try:
|
||||
await engine.start()
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.exception(f"audio engine failed to start: {e}")
|
||||
window.engineError.emit(
|
||||
f"Audio-Start fehlgeschlagen: {e} — neuer Versuch in {_ENGINE_RETRY_S:.0f}s")
|
||||
engine = None
|
||||
_schedule_engine_retry()
|
||||
return
|
||||
|
||||
engine_task = asyncio.create_task(engine.run())
|
||||
window.engineRoom.emit(cfg.room)
|
||||
window.show_main()
|
||||
|
||||
def _schedule_engine_retry() -> None:
|
||||
"""Retry the engine start until it succeeds (mic plugged in late, USB re-enumeration)."""
|
||||
nonlocal retry_task
|
||||
if retry_task is not None and not retry_task.done():
|
||||
return
|
||||
|
||||
async def _retry() -> None:
|
||||
await asyncio.sleep(_ENGINE_RETRY_S)
|
||||
if engine is None and cfg.paired:
|
||||
await _start_engine()
|
||||
|
||||
retry_task = asyncio.ensure_future(_retry())
|
||||
|
||||
async def _stop_engine() -> None:
|
||||
nonlocal engine, engine_task, last_state, retry_task
|
||||
last_state = "idle"
|
||||
if retry_task is not None:
|
||||
retry_task.cancel()
|
||||
retry_task = None
|
||||
# The MCP control server is independent of pairing state — it keeps
|
||||
# running across re-pairs, so it is NOT stopped here (only at shutdown).
|
||||
if engine_task is not None:
|
||||
engine_task.cancel()
|
||||
try:
|
||||
await engine_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
engine_task = None
|
||||
if engine is not None:
|
||||
await engine.stop()
|
||||
engine = None
|
||||
|
||||
# ── pairing flow ──────────────────────────────────────────────────────
|
||||
|
||||
def _on_pair_requested(host: str, port: int, code: str, name: str) -> None:
|
||||
async def _do_pair():
|
||||
import socket
|
||||
name_final = name or socket.gethostname()
|
||||
if not await client.check_health(host, port):
|
||||
window.setup_screen.show_error(
|
||||
f"Master unter {host}:{port} nicht erreichbar — IP/Port prüfen und "
|
||||
"sicherstellen, dass dort api.enabled: true gesetzt ist.")
|
||||
return
|
||||
try:
|
||||
result = await client.pair(host, port, code, name_final)
|
||||
except MasterError as e:
|
||||
window.setup_screen.show_error(str(e))
|
||||
return
|
||||
cfg.master_host = host
|
||||
cfg.master_port = port
|
||||
cfg.api_key = result.api_key
|
||||
cfg.device_name = name_final
|
||||
cfg.room = result.room
|
||||
cfg.wake_word = result.wake_word
|
||||
save_config(cfg)
|
||||
log.info(f"paired with master {host}:{port}")
|
||||
await _start_engine()
|
||||
|
||||
asyncio.ensure_future(_do_pair())
|
||||
|
||||
window.setup_screen.pairRequested.connect(_on_pair_requested)
|
||||
|
||||
# ── re-pair (settings) ────────────────────────────────────────────────
|
||||
|
||||
def _on_settings() -> None:
|
||||
def _repair():
|
||||
async def _do():
|
||||
await _stop_engine()
|
||||
cfg.api_key = ""
|
||||
save_config(cfg)
|
||||
window.setup_screen.pair_btn.setEnabled(True)
|
||||
window.setup_screen.status_lbl.setText("")
|
||||
window.show_setup()
|
||||
asyncio.ensure_future(_do())
|
||||
|
||||
window.show_settings_dialog(cfg.device_id, cfg.device_name, _repair)
|
||||
|
||||
window.main_screen.settingsRequested.connect(_on_settings)
|
||||
|
||||
# ── mute toggle ───────────────────────────────────────────────────────
|
||||
|
||||
def _on_mute_toggled(muted: bool) -> None:
|
||||
if engine is None:
|
||||
return
|
||||
# Route through the engine so capture is actually paused/resumed and the
|
||||
# state callbacks fire (I-3: was a raw `engine.muted = muted` write).
|
||||
asyncio.ensure_future(engine.mute() if muted else engine.unmute())
|
||||
log.info(f"mute toggled: {muted}")
|
||||
|
||||
window.main_screen.muteToggled.connect(_on_mute_toggled)
|
||||
|
||||
# ── startup ───────────────────────────────────────────────────────────
|
||||
|
||||
# Choose the initial page *before* showing the window so the setup screen
|
||||
# doesn't flash for a frame (A-17).
|
||||
if cfg.paired:
|
||||
window.show_main()
|
||||
else:
|
||||
window.show_setup()
|
||||
if kiosk:
|
||||
window.set_kiosk(True)
|
||||
elif fullscreen:
|
||||
window.showFullScreen()
|
||||
else:
|
||||
window.show()
|
||||
|
||||
# Start the MCP control server regardless of pairing state (A-7: it was
|
||||
# only started after a successful engine start, so unpaired slaves had none).
|
||||
asyncio.ensure_future(_start_http_server())
|
||||
|
||||
if cfg.paired:
|
||||
asyncio.ensure_future(_start_engine())
|
||||
|
||||
async def _shutdown() -> None:
|
||||
await _stop_engine()
|
||||
await _stop_http_server()
|
||||
await client.aclose()
|
||||
|
||||
with loop:
|
||||
loop.run_forever()
|
||||
# The Qt app has quit; finish the async teardown so the mic/HTTP
|
||||
# server are released instead of being lost as a fire-and-forget task.
|
||||
try:
|
||||
loop.run_until_complete(_shutdown())
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("shutdown failed")
|
||||
return 0
|
||||
@@ -0,0 +1,395 @@
|
||||
"""Wake-word → record → upload → playback loop.
|
||||
|
||||
Uses javis-slave's own vendored audio stack (``javis_slave.audio_io``): a
|
||||
self-contained copy of MicStream, WakeWordDetector, Silero VAD and
|
||||
``record_utterance`` so this app has no dependency on the parent ``javis``
|
||||
package and can be deployed standalone to a satellite device.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .audio_io.capture import record_utterance
|
||||
from .audio_io.stream import MicStream, play_pcm
|
||||
from .audio_io.vad import make_vad
|
||||
from .audio_io.wake_word import WakeWordDetector, pretrained_names
|
||||
from .api_client import MasterClient, MasterError
|
||||
from .config import SlaveConfig, WakeWordSettings, config_dir, save_config
|
||||
from .logging_setup import get_logger
|
||||
|
||||
log = get_logger("slave.audio")
|
||||
|
||||
# Refresh room/wake-word settings from the master this often.
|
||||
_CONFIG_POLL_S = 60.0
|
||||
# Re-enter the wake loop if the mic yields nothing for this long.
|
||||
_WAKE_STALE_S = 60.0
|
||||
# Stop an in-progress recording if no audio arrives for this long (mic disconnect).
|
||||
_RECORD_STALE_S = 30.0
|
||||
# After this many consecutive silent wake-loop exits, reopen the mic device.
|
||||
_MIC_RESTART_AFTER_STALE = 2
|
||||
|
||||
|
||||
def _encode_wav(audio: np.ndarray, sample_rate: int) -> bytes:
|
||||
import soundfile as sf
|
||||
buf = io.BytesIO()
|
||||
sf.write(buf, np.asarray(audio, dtype=np.float32), sample_rate,
|
||||
format="WAV", subtype="PCM_16")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _tones(sr: int, freqs: list[float], dur: float, amp: float) -> np.ndarray:
|
||||
"""Concatenate short sine tones with a click-free fade-out (matches the master's chimes)."""
|
||||
out = []
|
||||
n = int(sr * dur)
|
||||
fade_n = max(1, int(sr * 0.008))
|
||||
for f in freqs:
|
||||
t = np.linspace(0, dur, n, endpoint=False, dtype=np.float32)
|
||||
w = np.sin(2 * np.pi * f * t) * amp
|
||||
w[-fade_n:] *= np.linspace(1.0, 0.0, fade_n, dtype=np.float32)
|
||||
out.append(w.astype(np.float32))
|
||||
return np.concatenate(out)
|
||||
|
||||
|
||||
class AudioEngine:
|
||||
"""Runs the satellite listen loop and reports to the UI via callbacks.
|
||||
|
||||
All callbacks are plain sync callables invoked from the asyncio loop —
|
||||
the Qt layer bridges them to signals.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: SlaveConfig, client: MasterClient):
|
||||
self.cfg = cfg
|
||||
self.client = client
|
||||
self._stop = asyncio.Event()
|
||||
self.mic: Optional[MicStream] = None
|
||||
self.wake: Optional[WakeWordDetector] = None
|
||||
self.vad = None
|
||||
self._config_task: Optional[asyncio.Task] = None
|
||||
# Set when last response ended with '?'; next recording will use 3x silence timeout
|
||||
self._last_response_is_question: bool = False
|
||||
|
||||
# UI callbacks (assigned by the app layer).
|
||||
self.on_state: Callable[[str], None] = lambda s: None
|
||||
self.on_mic_level: Callable[[float, float], None] = lambda rms, score: None
|
||||
self.on_turn: Callable[[str, str], None] = lambda user, reply: None
|
||||
self.on_error: Callable[[str], None] = lambda msg: None
|
||||
self.on_connection: Callable[[bool, int, float], None] = lambda ok, retry, backoff: None
|
||||
self.on_room: Callable[[Optional[str]], None] = lambda room: None
|
||||
self.muted: bool = False # Alexa-style mute state
|
||||
# Serialise mute/unmute so a check-then-act race can't leave the mic
|
||||
# paused while the engine believes it's unmuted (review A-3).
|
||||
self._mute_lock = asyncio.Lock()
|
||||
# Set while unmuted so waiters wake immediately instead of polling (A-4).
|
||||
self._unmuted = asyncio.Event()
|
||||
self._unmuted.set()
|
||||
# Latest mic level / wake score — read by the UI (public, no _last_*).
|
||||
self.last_rms: float = 0.0
|
||||
self.last_score: float = 0.0
|
||||
|
||||
# ---------------- lifecycle ----------------
|
||||
|
||||
async def start(self) -> None:
|
||||
self.mic = MicStream(
|
||||
sample_rate=self.cfg.sample_rate,
|
||||
input_device=self.cfg.input_device,
|
||||
input_gain=self.cfg.input_gain,
|
||||
)
|
||||
await self.mic.start()
|
||||
self.vad = make_vad(threshold=self.cfg.wake_word.vad_threshold)
|
||||
self.wake = await self._load_wake(self.cfg.wake_word.model, self.cfg.wake_word.threshold)
|
||||
log.info(f"audio engine ready (wake word: '{self.cfg.wake_word.model}')")
|
||||
|
||||
@staticmethod
|
||||
def _wake_model_dir() -> "Path":
|
||||
return config_dir() / "models"
|
||||
|
||||
async def _load_wake(self, name: str, threshold: float) -> WakeWordDetector:
|
||||
"""Build the detector; fetch a custom model from the master when missing locally."""
|
||||
model_dir = self._wake_model_dir()
|
||||
if name not in pretrained_names() and not (model_dir / f"{name}.onnx").is_file():
|
||||
log.info(f"wake word '{name}' is custom — fetching model from master")
|
||||
if not await self.client.download_wake_model(model_dir / f"{name}.onnx"):
|
||||
raise RuntimeError(
|
||||
f"custom wake word model '{name}' not available from master — "
|
||||
f"copy {name}.onnx to {model_dir} manually")
|
||||
return await asyncio.to_thread(WakeWordDetector, name, threshold, [model_dir])
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._stop.set()
|
||||
self._unmuted.set() # wake any waiters so they exit
|
||||
if self.mic is not None:
|
||||
await self.mic.stop()
|
||||
if self._config_task is not None:
|
||||
self._config_task.cancel()
|
||||
# Release native models / free the GPU so the process can exit cleanly.
|
||||
self.wake = None
|
||||
self.vad = None
|
||||
self._config_task = None
|
||||
self.mic = None
|
||||
# ---------------- mute ----------------
|
||||
|
||||
async def mute(self) -> None:
|
||||
"""Mute the mic: suspend capture and stop wake/turn processing."""
|
||||
async with self._mute_lock:
|
||||
self.muted = True
|
||||
self._unmuted.clear()
|
||||
if self.mic is not None:
|
||||
self.mic.pause()
|
||||
self.on_state("muted")
|
||||
|
||||
async def unmute(self) -> None:
|
||||
"""Unmute: re-enable capture and resume the wake loop."""
|
||||
async with self._mute_lock:
|
||||
self.muted = False
|
||||
if self.mic is not None:
|
||||
self.mic.resume()
|
||||
self._unmuted.set()
|
||||
self.on_state("idle")
|
||||
|
||||
async def toggle_mute(self) -> bool:
|
||||
"""Toggle mute; returns the new muted state."""
|
||||
if self.muted:
|
||||
await self.unmute()
|
||||
else:
|
||||
await self.mute()
|
||||
return self.muted
|
||||
|
||||
async def _wait_unmuted_or_stop(self) -> None:
|
||||
"""Block until either the engine is unmuted or a stop is requested.
|
||||
|
||||
Waits on the ``_unmuted`` event (set by :meth:`unmute`/:meth:`stop`)
|
||||
instead of busy-polling, so there's no 0.1s wakeup latency. If a stop
|
||||
is requested while muted, this returns immediately so the outer loop
|
||||
can exit.
|
||||
"""
|
||||
unmut = asyncio.ensure_future(self._unmuted.wait())
|
||||
stopp = asyncio.ensure_future(self._stop.wait())
|
||||
try:
|
||||
done, _ = await asyncio.wait(
|
||||
{unmut, stopp}, return_when=asyncio.FIRST_COMPLETED)
|
||||
finally:
|
||||
unmut.cancel()
|
||||
stopp.cancel()
|
||||
if self._stop.is_set():
|
||||
return
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Main loop: wake → record → upload → play. Never raises."""
|
||||
self._config_task = asyncio.ensure_future(self._config_poll_loop())
|
||||
# Bounded backoff so a persistently-failing turn loop doesn't hot-spin,
|
||||
# but stays responsive once things recover (review A-13).
|
||||
consecutive_errors = 0
|
||||
stale_exits = 0
|
||||
try:
|
||||
while not self._stop.is_set():
|
||||
if self.muted:
|
||||
# Idle until the API/user unmutes (or we're told to stop).
|
||||
await self._wait_unmuted_or_stop()
|
||||
continue
|
||||
try:
|
||||
woke = await self._wait_for_wake()
|
||||
if not woke:
|
||||
if self._stop.is_set() or self.muted:
|
||||
continue
|
||||
# The mic yielded nothing for _WAKE_STALE_S: USB device
|
||||
# unplugged / driver stalled. Reopen instead of looping forever.
|
||||
stale_exits += 1
|
||||
if stale_exits >= _MIC_RESTART_AFTER_STALE and self.mic is not None:
|
||||
log.warning("mic silent for too long — reopening device")
|
||||
self.on_error("Mikrofon liefert keine Daten — Gerät wird neu geöffnet …")
|
||||
await self.mic.restart()
|
||||
stale_exits = 0
|
||||
continue
|
||||
stale_exits = 0
|
||||
await self._handle_turn()
|
||||
consecutive_errors = 0
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001
|
||||
consecutive_errors += 1
|
||||
log.exception(f"slave turn failed: {e}")
|
||||
self.on_error(str(e))
|
||||
await self._play_error_tone()
|
||||
self.on_state("idle")
|
||||
await asyncio.sleep(min(0.5 * (2 ** (consecutive_errors - 1)), 30.0))
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
if self._config_task is not None:
|
||||
self._config_task.cancel()
|
||||
self._config_task = None
|
||||
|
||||
# ---------------- phases ----------------
|
||||
|
||||
async def _wait_for_wake(self) -> bool:
|
||||
assert self.wake is not None and self.mic is not None
|
||||
self.on_state("idle")
|
||||
self.wake.reset()
|
||||
self.mic.drain()
|
||||
last_pub = 0.0
|
||||
async for chunk in self.mic.chunks(stale_timeout=_WAKE_STALE_S):
|
||||
if self._stop.is_set():
|
||||
return False
|
||||
if self.muted:
|
||||
# Muted mid-loop: drop stale audio and exit the wake phase;
|
||||
# the outer loop will re-enter _wait_for_wake, where the
|
||||
# callback is a no-op while paused so the loop idles.
|
||||
self.mic.drain()
|
||||
self.on_state("muted")
|
||||
return False
|
||||
score = self.wake.feed(chunk)
|
||||
now = time.monotonic()
|
||||
if now - last_pub > 0.1:
|
||||
last_pub = now
|
||||
rms = float(np.sqrt(np.mean(chunk.astype(np.float32) ** 2))) if len(chunk) else 0.0
|
||||
self.last_rms = rms
|
||||
self.last_score = float(score)
|
||||
self.on_mic_level(rms, float(score))
|
||||
if self.wake.triggered(score):
|
||||
log.info(f"wake! score={score:.2f}")
|
||||
# Same ascending two-tone as the master so the cue is familiar.
|
||||
asyncio.ensure_future(self._play_chime([880.0, 1320.0], 0.06, 0.18))
|
||||
return True
|
||||
if self._stop.is_set():
|
||||
return False
|
||||
return False
|
||||
|
||||
async def _play_chime(self, freqs: list[float], dur: float, amp: float) -> None:
|
||||
"""Fire-and-forget feedback tone; never raises (a screenless Pi relies on these)."""
|
||||
try:
|
||||
await play_pcm(_tones(self.cfg.sample_rate, freqs, dur, amp),
|
||||
self.cfg.sample_rate, output_device=self.cfg.output_device)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.debug(f"chime failed (non-critical): {exc}")
|
||||
|
||||
async def _play_error_tone(self) -> None:
|
||||
"""Low descending two-tone: "something went wrong"."""
|
||||
await self._play_chime([440.0, 330.0], 0.12, 0.2)
|
||||
|
||||
async def _handle_turn(self) -> None:
|
||||
assert self.mic is not None
|
||||
self.on_state("listening")
|
||||
# If last response was a question, triple the listening timeout
|
||||
silence_override = None
|
||||
if self._last_response_is_question:
|
||||
base_silence = self.cfg.wake_word.silence_ms
|
||||
silence_override = base_silence * 3
|
||||
log.debug(f"last response was a question; extending silence timeout to {silence_override}ms (was {base_silence}ms)")
|
||||
self._last_response_is_question = False # reset flag for next turn
|
||||
audio = await record_utterance(
|
||||
self.cfg.sample_rate, self.cfg.wake_word.silence_ms, self.mic, self.vad,
|
||||
silence_ms_override=silence_override,
|
||||
stale_timeout=_RECORD_STALE_S)
|
||||
if audio is None or len(audio) < 1600:
|
||||
log.info("no utterance captured")
|
||||
self.on_state("idle")
|
||||
return
|
||||
|
||||
self.on_state("thinking")
|
||||
wav = _encode_wav(audio, self.cfg.sample_rate)
|
||||
try:
|
||||
result = await self.client.send_turn(wav)
|
||||
self.on_connection(True, 0, 0.0) # (ok=True, retry_count=0, backoff=0)
|
||||
self.client.mark_connected()
|
||||
except MasterError as e:
|
||||
self.client.mark_disconnected(e)
|
||||
if self.client.should_reconnect():
|
||||
# Emit connection health with retry countdown (Alexa-style)
|
||||
self.on_connection(False, self.client.retry_count, self.client.backoff_seconds)
|
||||
self.on_error(f"Verbindungsfehler — Wiederverbindung in {self.client.backoff_seconds:.0f}s …")
|
||||
else:
|
||||
self.on_error(str(e))
|
||||
await self._play_error_tone()
|
||||
self.on_state("idle")
|
||||
return
|
||||
|
||||
if result.error:
|
||||
# Master completed the request but the turn itself failed; it
|
||||
# already put the spoken error phrase into reply_text/audio.
|
||||
log.warning(f"master reported turn error: {result.error}")
|
||||
self.on_error(result.error)
|
||||
if not result.audio_wav:
|
||||
await self._play_error_tone()
|
||||
|
||||
if result.user_text or result.reply_text:
|
||||
self.on_turn(result.user_text, result.reply_text)
|
||||
# Check if response ended with '?' to trigger extended listening on next turn
|
||||
if result.reply_text.rstrip().endswith("?"):
|
||||
log.debug("assistant response ends with '?'; will extend listening timeout on next turn")
|
||||
self._last_response_is_question = True
|
||||
|
||||
if result.audio_wav and result.sample_rate:
|
||||
self.on_state("speaking")
|
||||
try:
|
||||
import soundfile as sf
|
||||
samples, sr = sf.read(io.BytesIO(result.audio_wav),
|
||||
dtype="float32", always_2d=False)
|
||||
if samples.ndim > 1:
|
||||
samples = samples.mean(axis=1)
|
||||
await play_pcm(samples, int(sr),
|
||||
output_device=self.cfg.output_device,
|
||||
cancel_event=self._stop)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning(f"reply playback failed: {e}")
|
||||
self.on_state("idle")
|
||||
|
||||
# ---------------- config refresh ----------------
|
||||
|
||||
async def _config_poll_loop(self) -> None:
|
||||
"""Periodically pull room + wake-word settings from the master."""
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
room, ww = await self.client.fetch_config()
|
||||
self.on_connection(True, 0, 0.0)
|
||||
self.client.mark_connected()
|
||||
changed = self._apply_master_settings(room, ww)
|
||||
if changed:
|
||||
save_config(self.cfg)
|
||||
self.on_room(room)
|
||||
except MasterError as e:
|
||||
self.client.mark_disconnected(e)
|
||||
if self.client.should_reconnect():
|
||||
self.on_connection(False, self.client.retry_count, self.client.backoff_seconds)
|
||||
else:
|
||||
self.on_error("Maximale Wiederverbindungsversuche erreicht. Bitte manuell neu koppeln.")
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("config refresh failed unexpectedly")
|
||||
try:
|
||||
await asyncio.wait_for(self._stop.wait(), timeout=_CONFIG_POLL_S)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
def _apply_master_settings(self, room: Optional[str], ww: WakeWordSettings) -> bool:
|
||||
changed = False
|
||||
if room != self.cfg.room:
|
||||
self.cfg.room = room
|
||||
self.on_room(room)
|
||||
changed = True
|
||||
old = self.cfg.wake_word
|
||||
if (ww.model, ww.threshold, ww.vad_threshold, ww.silence_ms) != (
|
||||
old.model, old.threshold, old.vad_threshold, old.silence_ms):
|
||||
log.info(f"wake-word settings updated by master: {ww}")
|
||||
rebuild_model = ww.model != old.model
|
||||
self.cfg.wake_word = ww
|
||||
if self.wake is not None:
|
||||
if rebuild_model:
|
||||
# Loading may download the model — do it off the poll loop.
|
||||
asyncio.ensure_future(self._swap_wake_model(ww.model, ww.threshold))
|
||||
else:
|
||||
self.wake.threshold = ww.threshold
|
||||
self.vad = make_vad(threshold=ww.vad_threshold)
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
async def _swap_wake_model(self, name: str, threshold: float) -> None:
|
||||
try:
|
||||
self.wake = await self._load_wake(name, threshold)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.error(f"failed to load wake model '{name}': {e}")
|
||||
self.on_error(f"Wake-Word-Modell '{name}' konnte nicht geladen werden.")
|
||||
@@ -0,0 +1,6 @@
|
||||
"""javis-slave's own copy of the Javis audio I/O stack.
|
||||
|
||||
Vendored (not imported from the parent ``javis`` package) so this subproject
|
||||
can be deployed standalone to a satellite device without the rest of the
|
||||
Javis repo present.
|
||||
"""
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Utterance capture: VAD-segments speech from a mic stream.
|
||||
|
||||
Vendored from ``javis.audio_io.capture`` — decoupled from the master's
|
||||
``Config`` dataclass so this module has no dependency on the ``javis``
|
||||
package; takes plain ``sample_rate``/``silence_ms`` instead.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..logging_setup import get_logger
|
||||
from .stream import MicStream
|
||||
from .vad import VAD
|
||||
|
||||
log = get_logger("audio.capture")
|
||||
|
||||
|
||||
async def record_utterance(
|
||||
sample_rate: int,
|
||||
silence_ms: int,
|
||||
mic: MicStream,
|
||||
vad: VAD,
|
||||
max_seconds: float = 20.0,
|
||||
initial_grace_ms: int = 1500,
|
||||
silence_ms_override: Optional[int] = None,
|
||||
stale_timeout: Optional[float] = None,
|
||||
) -> Optional[np.ndarray]:
|
||||
"""Capture audio until trailing silence (*silence_ms*) is observed.
|
||||
|
||||
Args:
|
||||
sample_rate: Audio sample rate in Hz.
|
||||
silence_ms: Silence timeout (ms) before stopping recording.
|
||||
mic: Microphone stream.
|
||||
vad: Voice Activity Detector.
|
||||
max_seconds: Maximum recording duration in seconds.
|
||||
initial_grace_ms: Grace period before speech is expected (ms).
|
||||
silence_ms_override: Override the silence timeout (ms).
|
||||
When set, this value is used instead of the silence_ms parameter.
|
||||
stale_timeout: If set, recording stops early after this many seconds
|
||||
of silence (mic disconnect guard).
|
||||
|
||||
Returns the recorded float32 mono numpy array at the mic's sample rate, or
|
||||
None if no speech was detected within the grace period.
|
||||
"""
|
||||
# Use override if provided, otherwise use the passed silence_ms
|
||||
effective_silence_ms = silence_ms_override if silence_ms_override is not None else silence_ms
|
||||
chunk_ms = mic.chunk_ms
|
||||
|
||||
silence_chunks_needed = max(1, effective_silence_ms // chunk_ms)
|
||||
grace_chunks = max(1, initial_grace_ms // chunk_ms)
|
||||
max_chunks = int(max_seconds * 1000 / chunk_ms)
|
||||
|
||||
vad.reset()
|
||||
collected: list[np.ndarray] = []
|
||||
silent_run = 0
|
||||
seen_speech = False
|
||||
chunks_seen = 0
|
||||
|
||||
async for chunk in mic.chunks(stale_timeout=stale_timeout):
|
||||
chunks_seen += 1
|
||||
collected.append(chunk)
|
||||
result = vad.process(chunk)
|
||||
|
||||
if result.is_speech:
|
||||
seen_speech = True
|
||||
silent_run = 0
|
||||
else:
|
||||
silent_run += 1
|
||||
|
||||
if seen_speech and silent_run >= silence_chunks_needed:
|
||||
break
|
||||
if not seen_speech and chunks_seen >= grace_chunks:
|
||||
log.debug("no speech during grace window")
|
||||
return None
|
||||
if chunks_seen >= max_chunks:
|
||||
log.info("max utterance length reached")
|
||||
break
|
||||
|
||||
audio = np.concatenate(collected) if collected else np.zeros(0, dtype=np.float32)
|
||||
log.info(f"utterance: {len(audio)/sample_rate:.2f}s")
|
||||
return audio
|
||||
@@ -0,0 +1,404 @@
|
||||
"""Async microphone capture and speaker playback.
|
||||
|
||||
Vendored from ``javis.audio_io.stream`` — decoupled from the master's
|
||||
``Config`` dataclass so this module has no dependency on the ``javis``
|
||||
package.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import AsyncIterator, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..logging_setup import get_logger
|
||||
|
||||
log = get_logger("audio.stream")
|
||||
|
||||
# Warn at most once per 10 seconds when chunks are being dropped.
|
||||
_DROP_WARN_INTERVAL_S = 10.0
|
||||
|
||||
|
||||
class _StreamResampler:
|
||||
"""Block-wise resampler that carries filter state and phase across calls.
|
||||
|
||||
``scipy.signal.resample_poly`` is stateless and smears its filter edges
|
||||
over every 30 ms block (for 44.1→16 kHz the filter is longer than the
|
||||
block itself), so it is unusable on a live mic. This does a stateful FIR
|
||||
low-pass (when decimating) followed by linear interpolation with the
|
||||
fractional read position preserved between blocks — click-free and cheap
|
||||
enough for a Pi Zero.
|
||||
"""
|
||||
|
||||
def __init__(self, src_rate: int, dst_rate: int):
|
||||
from scipy.signal import firwin
|
||||
self.src, self.dst = src_rate, dst_rate
|
||||
self._step = src_rate / dst_rate
|
||||
self._phase = 0.0
|
||||
self._last = 0.0
|
||||
self._taps = None
|
||||
self._zi = None
|
||||
if src_rate > dst_rate:
|
||||
# Cut at 90 % of the target Nyquist, normalised to the source Nyquist.
|
||||
cutoff = min(0.99, 0.9 * (dst_rate / 2) / (src_rate / 2))
|
||||
self._taps = firwin(63, cutoff).astype(np.float32)
|
||||
self._zi = np.zeros(len(self._taps) - 1, dtype=np.float32)
|
||||
|
||||
def process(self, x: np.ndarray) -> np.ndarray:
|
||||
if self._taps is not None:
|
||||
from scipy.signal import lfilter
|
||||
x, self._zi = lfilter(self._taps, [1.0], x, zi=self._zi)
|
||||
n = len(x)
|
||||
if n == 0:
|
||||
return x.astype(np.float32)
|
||||
# Only positions up to the last real sample (n-1) can be interpolated
|
||||
# now; anything in (n-1, n) needs the next block's first sample and is
|
||||
# carried over as a negative phase (between ``_last`` and x[0]).
|
||||
last_idx = n - 1
|
||||
if self._phase <= last_idx:
|
||||
count = int((last_idx - self._phase) // self._step) + 1
|
||||
pos = self._phase + self._step * np.arange(count)
|
||||
pos = pos[pos <= last_idx]
|
||||
else:
|
||||
pos = np.empty(0)
|
||||
xp = np.arange(-1, n)
|
||||
fp = np.concatenate(([self._last], x))
|
||||
out = np.interp(pos, xp, fp).astype(np.float32)
|
||||
self._phase = (pos[-1] + self._step - n) if len(pos) else (self._phase - n)
|
||||
self._last = float(x[-1])
|
||||
return out
|
||||
|
||||
|
||||
class MicStream:
|
||||
"""Async iterator over fixed-size mic chunks (mono float32).
|
||||
|
||||
Always yields 16 kHz (``sample_rate``) audio. If the device refuses that
|
||||
rate (common for USB mics on Raspberry Pi / ALSA, which only expose 44.1 or
|
||||
48 kHz), the stream is opened at the device's native rate and resampled.
|
||||
"""
|
||||
|
||||
_SENTINEL = object()
|
||||
|
||||
def __init__(self, sample_rate: int = 16000, input_device: Optional[int] = None,
|
||||
input_gain: float = 1.0, chunk_ms: int = 30):
|
||||
self.sr = sample_rate
|
||||
self.device = input_device
|
||||
self.gain = input_gain
|
||||
self.chunk_ms = chunk_ms
|
||||
self.chunk_samples = int(self.sr * chunk_ms / 1000)
|
||||
self._queue: asyncio.Queue = asyncio.Queue(maxsize=128)
|
||||
self._stream = None
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self._paused: bool = False
|
||||
# Native device rate when it differs from ``sr`` (resampling active).
|
||||
self._device_sr: int = sample_rate
|
||||
self._resampler: Optional[_StreamResampler] = None
|
||||
# Drop-warning rate limiter.
|
||||
self._drop_count: int = 0
|
||||
self._last_drop_warn: float = 0.0
|
||||
|
||||
@property
|
||||
def paused(self) -> bool:
|
||||
"""Whether capture is temporarily suspended (mic muted)."""
|
||||
return self._paused
|
||||
|
||||
def _cb(self, indata, frames, time_info, status): # noqa: D401
|
||||
if status:
|
||||
log.debug(f"mic status: {status}")
|
||||
if self._paused:
|
||||
return
|
||||
# Stereo-only devices: average to mono rather than taking one channel,
|
||||
# so a mic wired to the right input still works.
|
||||
if indata.shape[1] > 1:
|
||||
chunk = indata.mean(axis=1, dtype=np.float32)
|
||||
else:
|
||||
chunk = indata[:, 0].copy().astype(np.float32)
|
||||
if self._resampler is not None:
|
||||
chunk = self._resampler.process(chunk)
|
||||
if self.gain != 1.0:
|
||||
chunk *= self.gain
|
||||
if self._loop and not self._loop.is_closed():
|
||||
self._loop.call_soon_threadsafe(self._enqueue, chunk)
|
||||
|
||||
def _enqueue(self, chunk: np.ndarray) -> None:
|
||||
# Drop oldest to keep mic latency bounded when the loop is busy.
|
||||
if self._queue.full():
|
||||
try:
|
||||
self._queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
pass
|
||||
self._drop_count += 1
|
||||
now = time.monotonic()
|
||||
if now - self._last_drop_warn >= _DROP_WARN_INTERVAL_S:
|
||||
log.warning(f"MicStream: dropped {self._drop_count} chunk(s) (queue full)")
|
||||
self._drop_count = 0
|
||||
self._last_drop_warn = now
|
||||
try:
|
||||
self._queue.put_nowait(chunk)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
|
||||
async def start(self) -> None:
|
||||
import sounddevice as sd
|
||||
self._loop = asyncio.get_running_loop()
|
||||
self._paused = False
|
||||
native = _device_default_rate(self.device, input=True)
|
||||
max_ch = _device_max_channels(self.device, input=True)
|
||||
# Preferred first; then the device's native rate / channel count. ALSA
|
||||
# "hw:" devices on a Pi frequently accept only one exact combination.
|
||||
attempts: list[tuple[int, int]] = [(self.sr, 1)]
|
||||
if native and native != self.sr:
|
||||
attempts.append((native, 1))
|
||||
if max_ch and max_ch > 1:
|
||||
attempts.append((self.sr, max_ch))
|
||||
if native and native != self.sr:
|
||||
attempts.append((native, max_ch))
|
||||
last_exc: Optional[Exception] = None
|
||||
for rate, channels in attempts:
|
||||
try:
|
||||
stream = sd.InputStream(
|
||||
samplerate=rate,
|
||||
channels=channels,
|
||||
dtype="float32",
|
||||
blocksize=int(rate * self.chunk_ms / 1000),
|
||||
device=self.device,
|
||||
callback=self._cb,
|
||||
)
|
||||
stream.start()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_exc = exc
|
||||
log.debug(f"mic open failed @ {rate} Hz / {channels} ch: {exc}")
|
||||
continue
|
||||
self._stream = stream
|
||||
self._device_sr = rate
|
||||
self._resampler = _StreamResampler(rate, self.sr) if rate != self.sr else None
|
||||
if (rate, channels) != (self.sr, 1):
|
||||
log.warning(f"mic does not support {self.sr} Hz mono; capturing at "
|
||||
f"{rate} Hz / {channels} ch and converting")
|
||||
log.info(f"mic started @ {rate}Hz→{self.sr}Hz, chunk={self.chunk_ms}ms, device={self.device}")
|
||||
return
|
||||
raise RuntimeError(f"could not open microphone (device={self.device}): {last_exc}")
|
||||
|
||||
def drain(self) -> None:
|
||||
"""Discard all buffered chunks. Call before starting a fresh listen session
|
||||
to avoid processing stale audio accumulated during upload/playback."""
|
||||
drained = 0
|
||||
while True:
|
||||
try:
|
||||
self._queue.get_nowait()
|
||||
drained += 1
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
if drained:
|
||||
log.debug(f"MicStream: drained {drained} stale chunk(s)")
|
||||
|
||||
def pause(self) -> None:
|
||||
"""Suspend capture (mute). The PortAudio stream keeps running, but the
|
||||
callback drops incoming samples so no audio is enqueued or processed.
|
||||
Buffered chunks are discarded so a stale wake-word can't fire on resume."""
|
||||
self._paused = True
|
||||
self.drain()
|
||||
log.info("MicStream: capture paused (muted)")
|
||||
|
||||
def resume(self) -> None:
|
||||
"""Re-enable capture after :meth:`pause`. Any audio buffered while
|
||||
paused is dropped first so playback isn't re-processed."""
|
||||
self.drain()
|
||||
self._paused = False
|
||||
log.info("MicStream: capture resumed")
|
||||
|
||||
def _drain_and_sentinel(self) -> None:
|
||||
"""Drain queue then enqueue the sentinel so chunks() always unblocks.
|
||||
|
||||
The sentinel is *guaranteed* to be enqueued: if the queue is full the
|
||||
oldest chunk is discarded and we retry, so a chunks() consumer can never
|
||||
block forever waiting for it.
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
self._queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
while True:
|
||||
try:
|
||||
self._queue.put_nowait(self._SENTINEL)
|
||||
return
|
||||
except asyncio.QueueFull:
|
||||
try:
|
||||
self._queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
|
||||
async def stop(self, *, sentinel: bool = True) -> None:
|
||||
if self._stream:
|
||||
try:
|
||||
self._stream.stop()
|
||||
self._stream.close()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# A hot-unplugged USB device raises here; nothing left to close.
|
||||
log.debug(f"mic close raised (ignored): {exc}")
|
||||
self._stream = None
|
||||
log.info("mic stopped")
|
||||
# Drain first so the sentinel is never dropped when the queue is full.
|
||||
if sentinel and self._loop and not self._loop.is_closed():
|
||||
self._loop.call_soon_threadsafe(self._drain_and_sentinel)
|
||||
|
||||
async def restart(self) -> None:
|
||||
"""Reopen the device (USB re-plug / driver stall) without ending active consumers."""
|
||||
await self.stop(sentinel=False)
|
||||
self.drain()
|
||||
await self.start()
|
||||
|
||||
async def chunks(
|
||||
self, stale_timeout: Optional[float] = None
|
||||
) -> AsyncIterator[np.ndarray]:
|
||||
"""Yield mic chunks. If *stale_timeout* is given and the queue stays
|
||||
empty for that many seconds (possible mic disconnect), the generator
|
||||
exits after logging a warning instead of blocking forever."""
|
||||
while True:
|
||||
try:
|
||||
if stale_timeout is not None:
|
||||
chunk = await asyncio.wait_for(
|
||||
self._queue.get(), timeout=stale_timeout
|
||||
)
|
||||
else:
|
||||
chunk = await self._queue.get()
|
||||
except asyncio.TimeoutError:
|
||||
log.warning(
|
||||
f"MicStream: no audio for {stale_timeout:.0f}s — "
|
||||
"possible microphone disconnect; re-entering wake loop"
|
||||
)
|
||||
return
|
||||
if chunk is self._SENTINEL:
|
||||
return
|
||||
yield chunk
|
||||
|
||||
|
||||
def _output_channels(output_device) -> int:
|
||||
"""Return the channel count to use when opening an OutputStream.
|
||||
|
||||
Professional audio interfaces refuse ``channels=1`` on Windows MME with
|
||||
PortAudio error -9998. We query the device's actual max_output_channels
|
||||
and cap at 2 (stereo); mono audio is tiled to fill.
|
||||
"""
|
||||
import sounddevice as sd
|
||||
try:
|
||||
dev_idx = output_device if output_device is not None else sd.default.device[1]
|
||||
info = sd.query_devices(dev_idx)
|
||||
return min(max(1, int(info["max_output_channels"])), 2)
|
||||
except Exception:
|
||||
return 1
|
||||
|
||||
|
||||
def _device_default_rate(device, *, input: bool) -> Optional[int]:
|
||||
"""Native sample rate reported by PortAudio for *device* (None if unknown)."""
|
||||
import sounddevice as sd
|
||||
try:
|
||||
idx = device if device is not None else sd.default.device[0 if input else 1]
|
||||
return int(round(float(sd.query_devices(idx)["default_samplerate"])))
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def _device_max_channels(device, *, input: bool) -> Optional[int]:
|
||||
import sounddevice as sd
|
||||
try:
|
||||
idx = device if device is not None else sd.default.device[0 if input else 1]
|
||||
key = "max_input_channels" if input else "max_output_channels"
|
||||
return int(sd.query_devices(idx)[key])
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def _resample_ratio(src: int, dst: int) -> tuple[int, int]:
|
||||
"""Reduced (up, down) integers for ``scipy.signal.resample_poly``."""
|
||||
from math import gcd
|
||||
g = gcd(src, dst)
|
||||
return dst // g, src // g
|
||||
|
||||
|
||||
def list_audio_devices() -> str:
|
||||
"""Human-readable device table (``--list-devices``)."""
|
||||
import sounddevice as sd
|
||||
return str(sd.query_devices())
|
||||
|
||||
|
||||
async def play_pcm(samples: np.ndarray, sample_rate: int,
|
||||
output_device: Optional[int] = None,
|
||||
cancel_event: Optional[asyncio.Event] = None) -> None:
|
||||
"""Play a float32 mono numpy array. Awaitable + cancellable via event.
|
||||
|
||||
Writes happen on a worker thread so the asyncio loop stays responsive.
|
||||
Cancellation aborts the device stream immediately rather than draining
|
||||
the buffer.
|
||||
"""
|
||||
import sounddevice as sd
|
||||
|
||||
samples = np.ascontiguousarray(np.asarray(samples, dtype=np.float32))
|
||||
if samples.ndim == 1:
|
||||
samples = samples[:, np.newaxis]
|
||||
|
||||
n_ch = _output_channels(output_device)
|
||||
if samples.shape[1] < n_ch:
|
||||
samples = np.repeat(samples, n_ch, axis=1)
|
||||
|
||||
# ~20 ms blocks: short enough to react to interruption, long enough to
|
||||
# avoid underruns on Windows WASAPI.
|
||||
block = max(256, sample_rate // 50)
|
||||
try:
|
||||
stream = sd.OutputStream(
|
||||
samplerate=sample_rate,
|
||||
channels=n_ch,
|
||||
dtype="float32",
|
||||
device=output_device,
|
||||
blocksize=block,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# Pi HDMI / cheap USB DACs often only accept 44.1/48 kHz.
|
||||
native = _device_default_rate(output_device, input=False)
|
||||
if native is None or native == sample_rate:
|
||||
raise
|
||||
log.warning(f"speaker does not support {sample_rate} Hz ({exc}); resampling to {native} Hz")
|
||||
from scipy.signal import resample_poly
|
||||
up, down = _resample_ratio(sample_rate, native)
|
||||
samples = np.ascontiguousarray(resample_poly(samples, up, down, axis=0).astype(np.float32))
|
||||
sample_rate = native
|
||||
block = max(256, sample_rate // 50)
|
||||
stream = sd.OutputStream(
|
||||
samplerate=sample_rate,
|
||||
channels=n_ch,
|
||||
dtype="float32",
|
||||
device=output_device,
|
||||
blocksize=block,
|
||||
)
|
||||
|
||||
def _writer() -> None:
|
||||
i = 0
|
||||
total = len(samples)
|
||||
while i < total:
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
return
|
||||
end = min(i + block, total)
|
||||
stream.write(samples[i:end])
|
||||
i = end
|
||||
|
||||
stream.start()
|
||||
cancelled = False
|
||||
try:
|
||||
await asyncio.to_thread(_writer)
|
||||
cancelled = bool(cancel_event is not None and cancel_event.is_set())
|
||||
finally:
|
||||
try:
|
||||
if cancelled:
|
||||
stream.abort()
|
||||
else:
|
||||
stream.stop()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
try:
|
||||
stream.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if cancelled:
|
||||
log.info("playback cancelled")
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Voice activity detection using silero-vad (with graceful fallback).
|
||||
|
||||
Vendored from ``javis.audio_io.vad`` — decoupled from the master's hardware
|
||||
diagnostics helper so this module has no dependency on the ``javis`` package.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..logging_setup import get_logger
|
||||
|
||||
log = get_logger("audio.vad")
|
||||
|
||||
|
||||
@dataclass
|
||||
class VADResult:
|
||||
is_speech: bool
|
||||
probability: float
|
||||
|
||||
|
||||
class VAD(Protocol):
|
||||
sample_rate: int
|
||||
threshold: float
|
||||
|
||||
def reset(self) -> None: ...
|
||||
def process(self, chunk: np.ndarray) -> VADResult: ...
|
||||
|
||||
|
||||
class NullVAD:
|
||||
"""Fallback VAD used when torch/silero is unavailable.
|
||||
|
||||
Uses a simple RMS energy gate so the satellite still works (with reduced
|
||||
accuracy) instead of refusing to start.
|
||||
"""
|
||||
|
||||
def __init__(self, sample_rate: int = 16000, threshold: float = 0.5):
|
||||
self.sample_rate = sample_rate
|
||||
# Re-interpret the silero threshold (0..1) as an RMS gate.
|
||||
self.threshold = max(0.005, min(0.05, threshold * 0.02))
|
||||
log.warning("Using NullVAD (RMS energy gate) — silero-vad/torch unavailable")
|
||||
|
||||
def reset(self) -> None:
|
||||
return
|
||||
|
||||
def process(self, chunk: np.ndarray) -> VADResult:
|
||||
if chunk.size == 0:
|
||||
return VADResult(False, 0.0)
|
||||
rms = float(np.sqrt(np.mean(np.square(chunk.astype(np.float32)))))
|
||||
return VADResult(is_speech=rms >= self.threshold, probability=rms)
|
||||
|
||||
|
||||
def make_vad(sample_rate: int = 16000, threshold: float = 0.5) -> VAD:
|
||||
"""Construct a VAD, falling back to NullVAD if torch/silero fails to load."""
|
||||
try:
|
||||
return SileroVAD(sample_rate=sample_rate, threshold=threshold)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.error(f"SileroVAD unavailable: {e}")
|
||||
return NullVAD(sample_rate=sample_rate, threshold=threshold)
|
||||
|
||||
|
||||
class SileroVAD:
|
||||
"""Wraps silero-vad. Expects 16 kHz mono float32 chunks of 30 or 32 ms."""
|
||||
|
||||
def __init__(self, sample_rate: int = 16000, threshold: float = 0.5):
|
||||
import torch
|
||||
from silero_vad import load_silero_vad
|
||||
self.sample_rate = sample_rate
|
||||
self.threshold = threshold
|
||||
self.model = load_silero_vad()
|
||||
self._window_samples = 512 if sample_rate == 16000 else 256
|
||||
# Pre-allocated tensor to avoid per-call allocations on the hot path.
|
||||
self._buf = torch.zeros(self._window_samples, dtype=torch.float32)
|
||||
log.info(f"silero-vad loaded (sr={sample_rate})")
|
||||
|
||||
def reset(self) -> None:
|
||||
try:
|
||||
self.model.reset_states()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def process(self, chunk: np.ndarray) -> VADResult:
|
||||
"""Process a single window; returns speech probability."""
|
||||
import torch
|
||||
w = self._window_samples
|
||||
n = len(chunk)
|
||||
if n >= w:
|
||||
self._buf.copy_(torch.from_numpy(np.ascontiguousarray(chunk[:w], dtype=np.float32)))
|
||||
else:
|
||||
self._buf[:n].copy_(torch.from_numpy(np.ascontiguousarray(chunk, dtype=np.float32)))
|
||||
self._buf[n:].zero_()
|
||||
with torch.inference_mode():
|
||||
prob = float(self.model(self._buf, self.sample_rate).item())
|
||||
return VADResult(is_speech=prob >= self.threshold, probability=prob)
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Wake word detection using openWakeWord.
|
||||
|
||||
Vendored from ``javis.audio_io.wake_word`` (only the logger import differs)
|
||||
so javis-slave has no dependency on the parent ``javis`` package.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..logging_setup import get_logger
|
||||
|
||||
log = get_logger("audio.wake")
|
||||
|
||||
|
||||
def pretrained_names() -> set[str]:
|
||||
try:
|
||||
import openwakeword
|
||||
return set(openwakeword.MODELS.keys())
|
||||
except Exception: # noqa: BLE001
|
||||
return {"alexa", "hey_mycroft", "hey_jarvis", "hey_rhasspy", "timer", "weather"}
|
||||
|
||||
|
||||
def resolve_wake_model(name: str, search_dirs: Iterable[Path] = ()) -> tuple[str, str, Optional[Path]]:
|
||||
"""Map a model name to ``(model_arg, prediction_key, custom_path)``.
|
||||
|
||||
Built-in names pass through; a custom ``.onnx`` (path or stem found in
|
||||
*search_dirs*) is keyed by its filename stem, as openWakeWord does.
|
||||
"""
|
||||
name = (name or "hey_jarvis").strip()
|
||||
p = Path(name).expanduser()
|
||||
if p.suffix.lower() == ".onnx" and p.is_file():
|
||||
return str(p), p.stem, p
|
||||
stem = p.stem if p.suffix else name
|
||||
for d in search_dirs:
|
||||
cand = Path(d) / f"{stem}.onnx"
|
||||
if cand.is_file():
|
||||
return str(cand), stem, cand
|
||||
if name not in pretrained_names():
|
||||
raise FileNotFoundError(
|
||||
f"wake word model '{name}' is not built-in and no {stem}.onnx was found in "
|
||||
f"{[str(d) for d in search_dirs]}")
|
||||
return name, name, None
|
||||
|
||||
|
||||
class WakeWordDetector:
|
||||
"""openWakeWord wrapper. Feeds 16 kHz int16 frames; emits scores per chunk."""
|
||||
|
||||
OWW_FRAME_SAMPLES = 1280 # openWakeWord expects 80 ms @ 16 kHz
|
||||
|
||||
def __init__(self, model_name: str = "hey_jarvis", threshold: float = 0.5,
|
||||
search_dirs: Iterable[Path] = ()):
|
||||
from openwakeword.model import Model
|
||||
from openwakeword.utils import download_models
|
||||
# Ensure ONNX models are present (tflite_runtime has no Windows wheels).
|
||||
try:
|
||||
download_models()
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning(f"openWakeWord model download skipped: {e}")
|
||||
model_arg, key, custom = resolve_wake_model(model_name, search_dirs)
|
||||
self.model = Model(
|
||||
wakeword_models=[model_arg],
|
||||
inference_framework="onnx",
|
||||
)
|
||||
self.model_name = key
|
||||
self.custom_path: Optional[Path] = custom
|
||||
self.threshold = threshold
|
||||
# Single pre-allocated 80 ms int16 frame + fill index — avoids per-chunk
|
||||
# list churn and np.concatenate calls in the wake-word hot path.
|
||||
self._frame = np.empty(self.OWW_FRAME_SAMPLES, dtype=np.int16)
|
||||
self._fill = 0
|
||||
log.info(f"openWakeWord loaded ({model_arg}, key={key}, threshold={threshold})")
|
||||
|
||||
def reset(self) -> None:
|
||||
self._fill = 0
|
||||
try:
|
||||
self.model.reset()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def feed(self, chunk_f32: np.ndarray) -> float:
|
||||
"""Feed an arbitrary-length mono float32 chunk; return max score across complete frames."""
|
||||
# Convert to int16 in one allocation (oww expectation).
|
||||
scaled = chunk_f32 * 32768.0
|
||||
np.clip(scaled, -32768.0, 32767.0, out=scaled)
|
||||
i16 = scaled.astype(np.int16, copy=False)
|
||||
|
||||
max_score = 0.0
|
||||
pos = 0
|
||||
n = len(i16)
|
||||
frame_size = self.OWW_FRAME_SAMPLES
|
||||
while pos < n:
|
||||
free = frame_size - self._fill
|
||||
take = min(free, n - pos)
|
||||
self._frame[self._fill:self._fill + take] = i16[pos:pos + take]
|
||||
self._fill += take
|
||||
pos += take
|
||||
if self._fill == frame_size:
|
||||
preds = self.model.predict(self._frame)
|
||||
score = float(preds.get(self.model_name, 0.0))
|
||||
if score > max_score:
|
||||
max_score = score
|
||||
self._fill = 0
|
||||
return max_score
|
||||
|
||||
def triggered(self, score: float) -> bool:
|
||||
return score >= self.threshold
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Slave-local configuration: master address, credentials, audio devices.
|
||||
|
||||
Persisted to ``%LOCALAPPDATA%/JavisSlave/slave_config.json`` (or
|
||||
``~/.javis-slave/slave_config.json`` on non-Windows). Set
|
||||
``JAVIS_SLAVE_CONFIG_DIR`` to override (useful for systemd services).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def config_dir() -> Path:
|
||||
override = os.environ.get("JAVIS_SLAVE_CONFIG_DIR")
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
base = os.environ.get("LOCALAPPDATA")
|
||||
if base:
|
||||
return Path(base) / "JavisSlave"
|
||||
return Path.home() / ".javis-slave"
|
||||
|
||||
|
||||
def config_path() -> Path:
|
||||
return config_dir() / "slave_config.json"
|
||||
|
||||
|
||||
@dataclass
|
||||
class WakeWordSettings:
|
||||
"""Wake-word block received from the master."""
|
||||
model: str = "hey_jarvis"
|
||||
threshold: float = 0.5
|
||||
vad_threshold: float = 0.5
|
||||
silence_ms: int = 800
|
||||
|
||||
|
||||
@dataclass
|
||||
class SlaveConfig:
|
||||
device_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
device_name: str = ""
|
||||
master_host: str = ""
|
||||
master_port: int = 8765
|
||||
api_key: str = ""
|
||||
room: Optional[str] = None # cached; refreshed from master
|
||||
input_device: Optional[int] = None
|
||||
output_device: Optional[int] = None
|
||||
input_gain: float = 1.0
|
||||
sample_rate: int = 16000
|
||||
wake_word: WakeWordSettings = field(default_factory=WakeWordSettings)
|
||||
# Local MCP control server bind (review A-6: was hardcoded to 127.0.0.1,
|
||||
# unreachable by remote controllers). 0.0.0.0 lets other devices reach it.
|
||||
api_host: str = "0.0.0.0"
|
||||
api_port: int = 8766
|
||||
|
||||
@property
|
||||
def paired(self) -> bool:
|
||||
return bool(self.master_host and self.api_key)
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
return f"http://{self.master_host}:{self.master_port}"
|
||||
|
||||
|
||||
def load_config() -> SlaveConfig:
|
||||
path = config_path()
|
||||
cfg = SlaveConfig()
|
||||
if path.exists():
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
ww_raw = raw.pop("wake_word", {}) or {}
|
||||
for key, val in raw.items():
|
||||
if hasattr(cfg, key) and key != "wake_word":
|
||||
setattr(cfg, key, val)
|
||||
for key, val in ww_raw.items():
|
||||
if hasattr(cfg.wake_word, key):
|
||||
setattr(cfg.wake_word, key, val)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return cfg
|
||||
|
||||
|
||||
def save_config(cfg: SlaveConfig) -> None:
|
||||
path = config_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(asdict(cfg), indent=2), encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Headless runtime: wake word → master → speaker, without Qt.
|
||||
|
||||
Used on displayless satellites (Raspberry Pi + mic + speaker) via
|
||||
``python -m javis_slave --headless``. Reuses :class:`AudioEngine`,
|
||||
:class:`MasterClient` and the MCP control HTTP server exactly like the GUI
|
||||
app; state and errors go to the log instead of the screen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import signal
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
from .api_client import MasterClient, MasterError
|
||||
from .audio_engine import AudioEngine
|
||||
from .config import SlaveConfig, load_config, save_config
|
||||
from .http_server import build_http_app
|
||||
from .logging_setup import get_logger
|
||||
|
||||
log = get_logger("slave.headless")
|
||||
|
||||
# Re-try the initial engine start (mic not ready at boot, master down) this often.
|
||||
_START_RETRY_S = 10.0
|
||||
|
||||
|
||||
async def pair_cli(cfg: SlaveConfig, client: MasterClient, host: str, port: int,
|
||||
code: str, name: str) -> None:
|
||||
"""Pair from the command line and persist credentials."""
|
||||
if not await client.check_health(host, port):
|
||||
raise MasterError(
|
||||
f"Master at {host}:{port} not reachable — is api.enabled: true in its config.yaml?")
|
||||
result = await client.pair(host, port, code, name)
|
||||
cfg.master_host = host
|
||||
cfg.master_port = port
|
||||
cfg.api_key = result.api_key
|
||||
cfg.device_name = name
|
||||
cfg.room = result.room
|
||||
cfg.wake_word = result.wake_word
|
||||
save_config(cfg)
|
||||
log.info(f"paired with master {host}:{port} as {name!r} (room={result.room!r})")
|
||||
|
||||
|
||||
async def run_headless_async(cfg: SlaveConfig, client: MasterClient) -> int:
|
||||
if not cfg.paired:
|
||||
log.error("device is not paired. Run: python -m javis_slave --pair HOST:PORT CODE --name NAME")
|
||||
return 2
|
||||
|
||||
engine: Optional[AudioEngine] = None
|
||||
engine_task: Optional[asyncio.Task] = None
|
||||
state = {"value": "idle", "error": None}
|
||||
stop = asyncio.Event()
|
||||
|
||||
def _on_state(s: str) -> None:
|
||||
if s != state["value"]:
|
||||
state["value"] = s
|
||||
log.info(f"state -> {s}")
|
||||
|
||||
def _on_error(msg: str) -> None:
|
||||
state["error"] = msg
|
||||
log.error(msg)
|
||||
|
||||
def _on_turn(user: str, reply: str) -> None:
|
||||
log.info(f"user: {user}")
|
||||
log.info(f"javis: {reply}")
|
||||
|
||||
def _on_room(room) -> None:
|
||||
log.info(f"room: {room or '(none)'}")
|
||||
|
||||
def _on_connection(ok: bool, retry: int, backoff: float) -> None:
|
||||
if not ok:
|
||||
log.warning(f"master unreachable (attempt {retry}, next try in {backoff:.0f}s)")
|
||||
|
||||
async def _start_engine() -> None:
|
||||
nonlocal engine, engine_task
|
||||
while not stop.is_set():
|
||||
eng = AudioEngine(cfg, client)
|
||||
eng.on_state = _on_state
|
||||
eng.on_error = _on_error
|
||||
eng.on_turn = _on_turn
|
||||
eng.on_room = _on_room
|
||||
eng.on_connection = _on_connection
|
||||
try:
|
||||
await eng.start()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.error(f"audio engine failed to start ({exc}); retrying in {_START_RETRY_S:.0f}s")
|
||||
try:
|
||||
await asyncio.wait_for(stop.wait(), timeout=_START_RETRY_S)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
return
|
||||
engine = eng
|
||||
engine_task = asyncio.create_task(eng.run())
|
||||
log.info(f"listening for wake word '{cfg.wake_word.model}' (room={cfg.room!r})")
|
||||
return
|
||||
|
||||
async def _stop_engine() -> None:
|
||||
nonlocal engine, engine_task
|
||||
if engine_task is not None:
|
||||
engine_task.cancel()
|
||||
try:
|
||||
await engine_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
engine_task = None
|
||||
if engine is not None:
|
||||
await engine.stop()
|
||||
engine = None
|
||||
|
||||
async def _restart() -> None:
|
||||
await _stop_engine()
|
||||
await _start_engine()
|
||||
|
||||
# MCP control server (same endpoints as the GUI app).
|
||||
http_task: Optional[asyncio.Task] = None
|
||||
server = None
|
||||
try:
|
||||
from uvicorn import Config as UvConfig, Server
|
||||
http_app = build_http_app(
|
||||
cfg,
|
||||
lambda: engine, lambda: state["value"], lambda: state["error"],
|
||||
lambda: (engine.last_rms, engine.last_score) if engine else (0.0, 0.0),
|
||||
on_restart=_restart,
|
||||
master_client_getter=lambda: client,
|
||||
)
|
||||
server = Server(UvConfig(app=http_app, host=cfg.api_host, port=cfg.api_port,
|
||||
log_level="warning", loop="none", lifespan="off"))
|
||||
http_task = asyncio.create_task(server.serve())
|
||||
log.info(f"MCP control server on http://{cfg.api_host}:{cfg.api_port}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.error(f"HTTP server not started: {exc}")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
try:
|
||||
loop.add_signal_handler(sig, stop.set)
|
||||
except (NotImplementedError, RuntimeError):
|
||||
pass # Windows: fall back to KeyboardInterrupt
|
||||
|
||||
await _start_engine()
|
||||
try:
|
||||
await stop.wait()
|
||||
except (KeyboardInterrupt, asyncio.CancelledError):
|
||||
# Windows has no add_signal_handler: asyncio.run cancels us on Ctrl+C.
|
||||
# Swallow it so the teardown below still runs.
|
||||
pass
|
||||
log.info("shutting down …")
|
||||
await _stop_engine()
|
||||
if server is not None:
|
||||
server.should_exit = True
|
||||
if http_task is not None:
|
||||
try:
|
||||
await asyncio.wait_for(http_task, timeout=5.0)
|
||||
except (asyncio.TimeoutError, asyncio.CancelledError, Exception): # noqa: BLE001
|
||||
http_task.cancel()
|
||||
await client.aclose()
|
||||
return 0
|
||||
|
||||
|
||||
def run_headless(pair: Optional[tuple[str, int, str, str]] = None) -> int:
|
||||
cfg = load_config()
|
||||
save_config(cfg)
|
||||
client = MasterClient(cfg)
|
||||
|
||||
async def _main() -> int:
|
||||
if pair is not None:
|
||||
host, port, code, name = pair
|
||||
try:
|
||||
await pair_cli(cfg, client, host, port, code, name)
|
||||
except MasterError as exc:
|
||||
log.error(f"pairing failed: {exc}")
|
||||
await client.aclose()
|
||||
return 1
|
||||
return await run_headless_async(cfg, client)
|
||||
|
||||
try:
|
||||
return asyncio.run(_main())
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run_headless())
|
||||
@@ -0,0 +1,444 @@
|
||||
"""HTTP server for remote control of the slave device via MCP.
|
||||
|
||||
Exposes endpoints for:
|
||||
- Device status and info
|
||||
- Audio control (mute, volume)
|
||||
- Wake-word and room settings
|
||||
- Audio engine state
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING, Awaitable, Callable, Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .audio_engine import AudioEngine # I-6: was .app (circular import risk)
|
||||
from .config import SlaveConfig
|
||||
|
||||
from .logging_setup import get_logger # I-7: route through the javis_slave root logger
|
||||
from .system_control import (
|
||||
get_brightness_controller,
|
||||
get_power_controller,
|
||||
get_volume_controller,
|
||||
)
|
||||
|
||||
log = get_logger("http")
|
||||
|
||||
|
||||
# ---- Models ----
|
||||
|
||||
|
||||
class SlaveStatus(BaseModel):
|
||||
"""Current status of the slave device."""
|
||||
device_id: str
|
||||
device_name: str
|
||||
room: Optional[str] = None
|
||||
paired: bool
|
||||
engine_running: bool
|
||||
audio_state: str # "idle", "listening", "thinking", "speaking", "muted", "error"
|
||||
muted: bool = False
|
||||
mic_level: float = 0.0
|
||||
wake_word_score: float = 0.0
|
||||
last_error: Optional[str] = None
|
||||
|
||||
|
||||
class DeviceInfo(BaseModel):
|
||||
"""Static device information."""
|
||||
device_id: str
|
||||
device_name: str
|
||||
sample_rate: int
|
||||
input_device: Optional[int] = None
|
||||
output_device: Optional[int] = None
|
||||
wake_word_model: str
|
||||
|
||||
|
||||
class AudioState(BaseModel):
|
||||
"""Audio system state."""
|
||||
mic_enabled: bool
|
||||
output_enabled: bool
|
||||
muted: bool = False
|
||||
mic_level: float
|
||||
wake_word_score: float
|
||||
|
||||
|
||||
class RoomInfo(BaseModel):
|
||||
"""Room/location info."""
|
||||
room: Optional[str] = None
|
||||
device_name: str
|
||||
|
||||
|
||||
class ControlResponse(BaseModel):
|
||||
"""Response for control operations."""
|
||||
success: bool
|
||||
message: str = ""
|
||||
|
||||
|
||||
class VolumeResponse(BaseModel):
|
||||
"""Volume control response."""
|
||||
success: bool
|
||||
message: str = ""
|
||||
volume: Optional[float] = None # 0.0-1.0
|
||||
|
||||
|
||||
class BrightnessResponse(BaseModel):
|
||||
"""Brightness control response."""
|
||||
success: bool
|
||||
message: str = ""
|
||||
brightness: Optional[int] = None # 0-100
|
||||
|
||||
|
||||
class PowerResponse(BaseModel):
|
||||
"""Power control response."""
|
||||
success: bool
|
||||
message: str = ""
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Health check response."""
|
||||
ok: bool
|
||||
device_id: str
|
||||
engine_running: bool
|
||||
|
||||
|
||||
class MasterSlaveInfo(BaseModel):
|
||||
"""A slave device as reported by the master (master-side auth)."""
|
||||
device_id: str
|
||||
name: str
|
||||
room: Optional[str] = None
|
||||
ip_address: Optional[str] = None
|
||||
last_seen: Optional[str] = None
|
||||
last_seen_ip: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class MasterSlaveIp(BaseModel):
|
||||
"""IP info for a single slave, proxied from the master."""
|
||||
device_id: str
|
||||
ip_address: Optional[str] = None
|
||||
last_seen_ip: Optional[str] = None
|
||||
last_seen: Optional[str] = None
|
||||
|
||||
|
||||
# ---- Factory ----
|
||||
|
||||
|
||||
def build_http_app(
|
||||
cfg: SlaveConfig,
|
||||
engine_getter: Callable[[], Optional[AudioEngine]],
|
||||
state_getter: Callable[[], str],
|
||||
error_getter: Callable[[], Optional[str]],
|
||||
mic_level_getter: Callable[[], tuple[float, float]], # (rms, score)
|
||||
on_restart: Optional[Callable[[], Awaitable[None]]] = None,
|
||||
master_client_getter: Optional[Callable[[], object]] = None,
|
||||
) -> FastAPI:
|
||||
"""Create FastAPI app with slave control endpoints.
|
||||
|
||||
Args:
|
||||
cfg: Slave configuration (device_id, pairing info, etc.)
|
||||
engine_getter: Callable that returns the current AudioEngine or None
|
||||
state_getter: Callable that returns current audio state string
|
||||
error_getter: Callable that returns last error message or None
|
||||
mic_level_getter: Callable that returns (rms, wake_word_score) tuple
|
||||
on_restart: Optional async callback invoked by ``POST /mcp/v1/restart``
|
||||
to perform a real stop+start of the audio engine. When omitted,
|
||||
the endpoint returns a 501 to make the "not wired" state explicit
|
||||
instead of a misleading 200.
|
||||
master_client_getter: Optional callable returning a ``MasterClient``
|
||||
used by the ``/mcp/v1/master/*`` pass-through endpoints to query
|
||||
the master with this slave's own credentials. When omitted, those
|
||||
endpoints return 501.
|
||||
"""
|
||||
app = FastAPI(title="Javis Slave Control")
|
||||
|
||||
# ---- Status Endpoints ----
|
||||
|
||||
@app.get("/mcp/v1/status", response_model=SlaveStatus)
|
||||
async def get_status() -> SlaveStatus:
|
||||
"""Get current device status."""
|
||||
engine = engine_getter()
|
||||
rms, score = mic_level_getter()
|
||||
return SlaveStatus(
|
||||
device_id=cfg.device_id,
|
||||
device_name=cfg.device_name,
|
||||
room=cfg.room,
|
||||
paired=cfg.paired,
|
||||
engine_running=engine is not None,
|
||||
audio_state=state_getter(),
|
||||
muted=bool(engine is not None and engine.muted),
|
||||
mic_level=rms,
|
||||
wake_word_score=score,
|
||||
last_error=error_getter(),
|
||||
)
|
||||
|
||||
@app.get("/mcp/v1/device-info", response_model=DeviceInfo)
|
||||
async def get_device_info() -> DeviceInfo:
|
||||
"""Get static device information."""
|
||||
return DeviceInfo(
|
||||
device_id=cfg.device_id,
|
||||
device_name=cfg.device_name,
|
||||
sample_rate=cfg.sample_rate,
|
||||
input_device=cfg.input_device,
|
||||
output_device=cfg.output_device,
|
||||
wake_word_model=cfg.wake_word.model,
|
||||
)
|
||||
|
||||
@app.get("/mcp/v1/room", response_model=RoomInfo)
|
||||
async def get_room() -> RoomInfo:
|
||||
"""Get room/location information."""
|
||||
return RoomInfo(room=cfg.room, device_name=cfg.device_name)
|
||||
|
||||
@app.get("/mcp/v1/audio-state", response_model=AudioState)
|
||||
async def get_audio_state() -> AudioState:
|
||||
"""Get audio system state."""
|
||||
engine = engine_getter()
|
||||
rms, score = mic_level_getter()
|
||||
muted = bool(engine is not None and engine.muted)
|
||||
return AudioState(
|
||||
mic_enabled=engine is not None and not muted,
|
||||
output_enabled=engine is not None,
|
||||
muted=muted,
|
||||
mic_level=rms,
|
||||
wake_word_score=score,
|
||||
)
|
||||
|
||||
# ---- Control Endpoints ----
|
||||
|
||||
@app.post("/mcp/v1/mic/mute", response_model=ControlResponse)
|
||||
async def mute_mic() -> ControlResponse:
|
||||
"""Mute the microphone."""
|
||||
engine = engine_getter()
|
||||
if engine is None or engine.mic is None:
|
||||
raise HTTPException(status_code=400, detail="Engine not running")
|
||||
try:
|
||||
await engine.mute()
|
||||
return ControlResponse(success=True, message="Microphone muted")
|
||||
except Exception as e:
|
||||
return ControlResponse(success=False, message=str(e))
|
||||
|
||||
@app.post("/mcp/v1/mic/unmute", response_model=ControlResponse)
|
||||
async def unmute_mic() -> ControlResponse:
|
||||
"""Unmute the microphone."""
|
||||
engine = engine_getter()
|
||||
if engine is None or engine.mic is None:
|
||||
raise HTTPException(status_code=400, detail="Engine not running")
|
||||
try:
|
||||
await engine.unmute()
|
||||
return ControlResponse(success=True, message="Microphone unmuted")
|
||||
except Exception as e:
|
||||
return ControlResponse(success=False, message=str(e))
|
||||
|
||||
@app.post("/mcp/v1/mic/toggle", response_model=ControlResponse)
|
||||
async def toggle_mic() -> ControlResponse:
|
||||
"""Toggle microphone on/off."""
|
||||
engine = engine_getter()
|
||||
if engine is None or engine.mic is None:
|
||||
raise HTTPException(status_code=400, detail="Engine not running")
|
||||
try:
|
||||
now_muted = await engine.toggle_mute()
|
||||
if now_muted:
|
||||
return ControlResponse(success=True, message="Microphone muted")
|
||||
return ControlResponse(success=True, message="Microphone unmuted")
|
||||
except Exception as e:
|
||||
return ControlResponse(success=False, message=str(e))
|
||||
|
||||
@app.post("/mcp/v1/restart", response_model=ControlResponse)
|
||||
async def restart_audio_engine() -> ControlResponse:
|
||||
"""Restart the audio engine (for recovery from errors).
|
||||
|
||||
Delegates to the app's ``on_restart`` callback (stop + start). If the
|
||||
app did not wire a callback, return 501 so callers don't receive a
|
||||
misleading 200 for a restart that will not happen.
|
||||
"""
|
||||
if on_restart is None:
|
||||
raise HTTPException(status_code=501, detail="Engine restart not available")
|
||||
log.info("Audio engine restart requested via HTTP")
|
||||
try:
|
||||
await on_restart()
|
||||
return ControlResponse(success=True, message="Audio engine restarted")
|
||||
except Exception as e: # noqa: BLE001
|
||||
return ControlResponse(success=False, message=str(e))
|
||||
|
||||
# ---- Master Pass-Through Endpoints ----
|
||||
# These proxy the master's /api/v1/slaves endpoints so an MCP client can
|
||||
# query the master using THIS slave's own credentials. This removes the
|
||||
# MCP's need to hardcode master host + auth (it used to call :8765 with
|
||||
# no headers and always get 401).
|
||||
|
||||
@app.get("/mcp/v1/master/slaves", response_model=list[MasterSlaveInfo])
|
||||
async def master_list_slaves() -> list[MasterSlaveInfo]:
|
||||
"""List all slave devices known to the master (authenticated pass-through)."""
|
||||
if master_client_getter is None:
|
||||
raise HTTPException(status_code=501, detail="Master query not available")
|
||||
client = master_client_getter()
|
||||
try:
|
||||
data = await client.list_slaves()
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise HTTPException(status_code=502, detail=f"Master query failed: {e}")
|
||||
return [MasterSlaveInfo(**item) for item in data]
|
||||
|
||||
@app.get("/mcp/v1/master/slaves/{device_id}/ip", response_model=MasterSlaveIp)
|
||||
async def master_slave_ip(device_id: str) -> MasterSlaveIp:
|
||||
"""Return the IP info for a specific slave device (authenticated pass-through)."""
|
||||
if master_client_getter is None:
|
||||
raise HTTPException(status_code=501, detail="Master query not available")
|
||||
client = master_client_getter()
|
||||
try:
|
||||
data = await client.list_slaves()
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise HTTPException(status_code=502, detail=f"Master query failed: {e}")
|
||||
for item in data:
|
||||
if item.get("device_id") == device_id:
|
||||
return MasterSlaveIp(
|
||||
device_id=device_id,
|
||||
ip_address=item.get("ip_address"),
|
||||
last_seen_ip=item.get("last_seen_ip"),
|
||||
last_seen=item.get("last_seen"),
|
||||
)
|
||||
raise HTTPException(status_code=404, detail=f"Slave '{device_id}' not found on master")
|
||||
|
||||
# ---- Volume Control Endpoints ----
|
||||
# Controllers shell out (pactl/amixer/systemctl on Linux) — run them off
|
||||
# the event loop so a slow call can't stall wake-word processing.
|
||||
|
||||
@app.get("/mcp/v1/volume", response_model=VolumeResponse)
|
||||
async def get_volume() -> VolumeResponse:
|
||||
"""Get current master volume level (0.0-1.0)."""
|
||||
volume = await asyncio.to_thread(get_volume_controller().get_volume)
|
||||
if volume is not None:
|
||||
return VolumeResponse(success=True, volume=volume, message=f"Volume: {volume * 100:.1f}%")
|
||||
return VolumeResponse(success=False, message="Failed to get volume")
|
||||
|
||||
@app.post("/mcp/v1/volume/set", response_model=VolumeResponse)
|
||||
async def set_volume(
|
||||
level: float = Query(..., ge=0.0, le=1.0),
|
||||
) -> VolumeResponse:
|
||||
"""Set master volume level (0.0-1.0)."""
|
||||
if await asyncio.to_thread(get_volume_controller().set_volume, level):
|
||||
return VolumeResponse(success=True, volume=level, message=f"Volume set to {level * 100:.1f}%")
|
||||
return VolumeResponse(success=False, message="Failed to set volume")
|
||||
|
||||
@app.post("/mcp/v1/volume/increase", response_model=VolumeResponse)
|
||||
async def increase_volume(
|
||||
amount: float = Query(0.05, ge=0.0, le=1.0),
|
||||
) -> VolumeResponse:
|
||||
"""Increase master volume by *amount* (0.0–1.0)."""
|
||||
new_volume = await asyncio.to_thread(get_volume_controller().increase_volume, amount)
|
||||
if new_volume is not None:
|
||||
return VolumeResponse(success=True, volume=new_volume, message=f"Volume increased to {new_volume * 100:.1f}%")
|
||||
return VolumeResponse(success=False, message="Failed to increase volume")
|
||||
|
||||
@app.post("/mcp/v1/volume/decrease", response_model=VolumeResponse)
|
||||
async def decrease_volume(
|
||||
amount: float = Query(0.05, ge=0.0, le=1.0),
|
||||
) -> VolumeResponse:
|
||||
"""Decrease master volume by *amount* (0.0–1.0)."""
|
||||
new_volume = await asyncio.to_thread(get_volume_controller().decrease_volume, amount)
|
||||
if new_volume is not None:
|
||||
return VolumeResponse(success=True, volume=new_volume, message=f"Volume decreased to {new_volume * 100:.1f}%")
|
||||
return VolumeResponse(success=False, message="Failed to decrease volume")
|
||||
|
||||
@app.post("/mcp/v1/volume/mute", response_model=VolumeResponse)
|
||||
async def mute_volume() -> VolumeResponse:
|
||||
"""Mute master audio output."""
|
||||
if await asyncio.to_thread(get_volume_controller().set_mute, True):
|
||||
return VolumeResponse(success=True, message="Audio muted")
|
||||
return VolumeResponse(success=False, message="Failed to mute audio")
|
||||
|
||||
@app.post("/mcp/v1/volume/unmute", response_model=VolumeResponse)
|
||||
async def unmute_volume() -> VolumeResponse:
|
||||
"""Unmute master audio output."""
|
||||
if await asyncio.to_thread(get_volume_controller().set_mute, False):
|
||||
return VolumeResponse(success=True, message="Audio unmuted")
|
||||
return VolumeResponse(success=False, message="Failed to unmute audio")
|
||||
|
||||
# ---- Brightness Control Endpoints ----
|
||||
|
||||
@app.get("/mcp/v1/brightness", response_model=BrightnessResponse)
|
||||
async def get_brightness() -> BrightnessResponse:
|
||||
"""Get current display brightness (0-100)."""
|
||||
brightness = await asyncio.to_thread(get_brightness_controller().get_brightness)
|
||||
if brightness is not None:
|
||||
return BrightnessResponse(success=True, brightness=brightness, message=f"Brightness: {brightness}%")
|
||||
return BrightnessResponse(success=False, message="Failed to get brightness")
|
||||
|
||||
@app.post("/mcp/v1/brightness/set", response_model=BrightnessResponse)
|
||||
async def set_brightness(
|
||||
level: int = Query(..., ge=0, le=100),
|
||||
) -> BrightnessResponse:
|
||||
"""Set display brightness (0-100)."""
|
||||
if await asyncio.to_thread(get_brightness_controller().set_brightness, level):
|
||||
return BrightnessResponse(success=True, brightness=level, message=f"Brightness set to {level}%")
|
||||
return BrightnessResponse(success=False, message="Failed to set brightness")
|
||||
|
||||
@app.post("/mcp/v1/brightness/increase", response_model=BrightnessResponse)
|
||||
async def increase_brightness(
|
||||
amount: int = Query(5, ge=0, le=100),
|
||||
) -> BrightnessResponse:
|
||||
"""Increase display brightness by *amount* percentage points."""
|
||||
new_brightness = await asyncio.to_thread(get_brightness_controller().increase_brightness, amount)
|
||||
if new_brightness is not None:
|
||||
return BrightnessResponse(success=True, brightness=new_brightness, message=f"Brightness increased to {new_brightness}%")
|
||||
return BrightnessResponse(success=False, message="Failed to increase brightness")
|
||||
|
||||
@app.post("/mcp/v1/brightness/decrease", response_model=BrightnessResponse)
|
||||
async def decrease_brightness(
|
||||
amount: int = Query(5, ge=0, le=100),
|
||||
) -> BrightnessResponse:
|
||||
"""Decrease display brightness by *amount* percentage points."""
|
||||
new_brightness = await asyncio.to_thread(get_brightness_controller().decrease_brightness, amount)
|
||||
if new_brightness is not None:
|
||||
return BrightnessResponse(success=True, brightness=new_brightness, message=f"Brightness decreased to {new_brightness}%")
|
||||
return BrightnessResponse(success=False, message="Failed to decrease brightness")
|
||||
|
||||
# ---- Power Control Endpoints ----
|
||||
|
||||
@app.post("/mcp/v1/power/sleep", response_model=PowerResponse)
|
||||
async def sleep_system() -> PowerResponse:
|
||||
"""Put system to sleep."""
|
||||
if await asyncio.to_thread(get_power_controller().sleep):
|
||||
return PowerResponse(success=True, message="Sleep command sent")
|
||||
return PowerResponse(success=False, message="Failed to send sleep command")
|
||||
|
||||
@app.post("/mcp/v1/power/shutdown", response_model=PowerResponse)
|
||||
async def shutdown_system(
|
||||
delay: int = Query(0, ge=0, le=3600),
|
||||
) -> PowerResponse:
|
||||
"""Shutdown system. *delay* is seconds (0 = immediate, max 1h)."""
|
||||
if await asyncio.to_thread(get_power_controller().shutdown, delay):
|
||||
if delay > 0:
|
||||
return PowerResponse(success=True, message=f"System will shutdown in {delay} seconds")
|
||||
return PowerResponse(success=True, message="Shutdown command sent")
|
||||
return PowerResponse(success=False, message="Failed to send shutdown command")
|
||||
|
||||
@app.post("/mcp/v1/power/restart", response_model=PowerResponse)
|
||||
async def restart_system(
|
||||
delay: int = Query(0, ge=0, le=3600),
|
||||
) -> PowerResponse:
|
||||
"""Restart system. *delay* is seconds (0 = immediate, max 1h)."""
|
||||
if await asyncio.to_thread(get_power_controller().restart, delay):
|
||||
if delay > 0:
|
||||
return PowerResponse(success=True, message=f"System will restart in {delay} seconds")
|
||||
return PowerResponse(success=True, message="Restart command sent")
|
||||
return PowerResponse(success=False, message="Failed to send restart command")
|
||||
|
||||
@app.post("/mcp/v1/power/cancel", response_model=PowerResponse)
|
||||
async def cancel_shutdown() -> PowerResponse:
|
||||
"""Cancel pending shutdown or restart."""
|
||||
if await asyncio.to_thread(get_power_controller().cancel_shutdown):
|
||||
return PowerResponse(success=True, message="Shutdown/restart cancelled")
|
||||
return PowerResponse(success=False, message="No pending shutdown/restart to cancel")
|
||||
|
||||
@app.get("/mcp/v1/health", response_model=HealthResponse)
|
||||
async def health() -> HealthResponse:
|
||||
"""Health check endpoint."""
|
||||
engine = engine_getter()
|
||||
return HealthResponse(
|
||||
ok=True,
|
||||
device_id=cfg.device_id,
|
||||
engine_running=engine is not None,
|
||||
)
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Minimal stdlib logging helper (kept dependency-free from the javis package).
|
||||
|
||||
Logs go to stderr and to a rotating file under ``<config dir>/logs/slave.log``
|
||||
so tablet/Pi problems can be diagnosed after the fact.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
_configured = False
|
||||
_FMT = logging.Formatter(
|
||||
"%(asctime)s | %(levelname)-7s | %(name)s - %(message)s", datefmt="%H:%M:%S",
|
||||
)
|
||||
|
||||
|
||||
def _configure_once() -> None:
|
||||
global _configured
|
||||
if _configured:
|
||||
return
|
||||
root = logging.getLogger("javis_slave")
|
||||
root.setLevel(logging.INFO)
|
||||
root.propagate = False
|
||||
|
||||
handler = logging.StreamHandler(sys.stderr)
|
||||
handler.setFormatter(_FMT)
|
||||
root.addHandler(handler)
|
||||
|
||||
try:
|
||||
from .config import config_dir
|
||||
log_dir = config_dir() / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
fh = RotatingFileHandler(log_dir / "slave.log", maxBytes=2_000_000,
|
||||
backupCount=3, encoding="utf-8")
|
||||
fh.setFormatter(logging.Formatter(
|
||||
"%(asctime)s | %(levelname)-7s | %(name)s - %(message)s"))
|
||||
root.addHandler(fh)
|
||||
except Exception as exc: # noqa: BLE001 (read-only FS etc. — stderr still works)
|
||||
root.warning(f"file logging disabled: {exc}")
|
||||
_configured = True
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
_configure_once()
|
||||
return logging.getLogger(f"javis_slave.{name}")
|
||||
@@ -0,0 +1,584 @@
|
||||
"""Touch-optimized PySide6 UI for javis-slave (Surface Pro 7 kiosk style).
|
||||
|
||||
Same VS Code Dark Modern palette as the Javis master GUI, with all touch
|
||||
targets ≥ 48 px.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Optional
|
||||
|
||||
from PySide6 import QtCore, QtGui, QtWidgets
|
||||
from PySide6.QtCore import QTimer
|
||||
|
||||
# ── palette (mirrors javis/gui/main_window.py) ────────────────────────────────
|
||||
CL_BG = "#1e1e1e"
|
||||
CL_SURFACE = "#252526"
|
||||
CL_SURFACE2 = "#2d2d2d"
|
||||
CL_INPUT = "#3c3c3c"
|
||||
CL_ACCENT = "#DA7756"
|
||||
CL_ACCENT2 = "#e0886a"
|
||||
CL_TEXT = "#cccccc"
|
||||
CL_MUTED = "#858585"
|
||||
CL_BORDER = "#3c3c3c"
|
||||
|
||||
# Gain applied to raw mic RMS before driving the orb pulse (A-12: was magic 8.0).
|
||||
# Typical speech RMS is ~0.01–0.1, so ×8 maps it into the visible 0..1 range.
|
||||
_RMS_GAIN = 8.0
|
||||
|
||||
STATE_COLORS = {
|
||||
"idle": "#3c3c3c",
|
||||
"listening": "#4ec994",
|
||||
"thinking": CL_ACCENT,
|
||||
"speaking": "#4ec994",
|
||||
"muted": "#f14c4c",
|
||||
"error": "#f14c4c",
|
||||
}
|
||||
|
||||
STATE_LABELS = {
|
||||
"idle": "Sag das Wake-Word …",
|
||||
"listening": "Ich höre zu …",
|
||||
"thinking": "Einen Moment …",
|
||||
"speaking": "Antwort wird abgespielt …",
|
||||
"muted": "Stumm geschaltet",
|
||||
"error": "Fehler",
|
||||
}
|
||||
|
||||
_INPUT_QSS = (
|
||||
f"QLineEdit {{ background: {CL_INPUT}; color: {CL_TEXT}; border: 1px solid {CL_BORDER};"
|
||||
f" border-radius: 12px; padding: 14px 18px; font-size: 16pt; }}"
|
||||
f"QLineEdit:focus {{ border: 2px solid {CL_ACCENT}; }}"
|
||||
)
|
||||
|
||||
_PRIMARY_BTN_QSS = (
|
||||
f"QPushButton {{ background: {CL_ACCENT}; color: #1e1e1e; font-weight: 700;"
|
||||
f" border: 0; border-radius: 14px; padding: 16px 32px; font-size: 16pt; }}"
|
||||
f"QPushButton:pressed {{ background: {CL_ACCENT2}; }}"
|
||||
f"QPushButton:disabled {{ background: {CL_INPUT}; color: {CL_MUTED}; }}"
|
||||
)
|
||||
|
||||
_GHOST_BTN_QSS = (
|
||||
f"QPushButton {{ background: transparent; border: 1px solid {CL_BORDER}; color: {CL_MUTED};"
|
||||
f" padding: 12px 24px; border-radius: 12px; font-size: 13pt; }}"
|
||||
f"QPushButton:pressed {{ background: {CL_SURFACE2}; color: {CL_TEXT}; }}"
|
||||
)
|
||||
|
||||
|
||||
class StateOrb(QtWidgets.QWidget):
|
||||
"""Large animated circle reflecting the agent state.
|
||||
|
||||
Alexa-style visual feedback:
|
||||
- Color indicates device state (idle/listening/thinking/speaking/error)
|
||||
- Pulse ring shows mic activity level in real-time
|
||||
- Mute overlay (red X) when microphone is disabled
|
||||
- Retry indicator (spinning dots) during reconnection attempts
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._state = "idle"
|
||||
self._level = 0.0 # mic RMS 0..1 (pulse while idle/listening)
|
||||
self._muted = False # explicit mute state overlay
|
||||
self._reconnecting = False # showing retry indicator
|
||||
self.setMinimumSize(260, 260)
|
||||
|
||||
def set_state(self, state: str) -> None:
|
||||
self._state = state
|
||||
self.update()
|
||||
|
||||
def set_level(self, level: float) -> None:
|
||||
self._level = max(0.0, min(1.0, level))
|
||||
self.update()
|
||||
|
||||
def set_muted(self, muted: bool) -> None:
|
||||
"""Set the mute overlay state (Alexa-style mute indicator)."""
|
||||
self._muted = muted
|
||||
self.update()
|
||||
|
||||
def set_reconnecting(self, reconnecting: bool) -> None:
|
||||
"""Show/hide reconnection retry indicator."""
|
||||
self._reconnecting = reconnecting
|
||||
self.update()
|
||||
|
||||
def is_muted(self) -> bool:
|
||||
return self._muted
|
||||
|
||||
def paintEvent(self, event): # noqa: N802
|
||||
p = QtGui.QPainter(self)
|
||||
p.setRenderHint(QtGui.QPainter.Antialiasing)
|
||||
rect = self.rect()
|
||||
size = min(rect.width(), rect.height())
|
||||
base_r = size * 0.32
|
||||
pulse = base_r * (1.0 + 0.35 * self._level)
|
||||
center = rect.center()
|
||||
|
||||
color = QtGui.QColor(STATE_COLORS.get(self._state, CL_INPUT))
|
||||
|
||||
# Outer pulse ring.
|
||||
ring = QtGui.QColor(color)
|
||||
ring.setAlpha(60)
|
||||
p.setBrush(ring)
|
||||
p.setPen(QtCore.Qt.NoPen)
|
||||
p.drawEllipse(center, pulse, pulse)
|
||||
|
||||
# Core.
|
||||
p.setBrush(color)
|
||||
p.drawEllipse(center, base_r, base_r)
|
||||
|
||||
# Icon glyph — dark on bright cores, light on the dark idle core so it stays visible.
|
||||
bright_core = self._state in ("listening", "thinking", "speaking", "muted", "error")
|
||||
p.setPen(QtGui.QPen(QtGui.QColor("#1e1e1e" if bright_core else CL_TEXT), 3))
|
||||
font = p.font()
|
||||
font.setPointSizeF(size * 0.11)
|
||||
p.setFont(font)
|
||||
glyph = {"idle": "🎤", "listening": "🎤", "thinking": "…",
|
||||
"speaking": "🔊", "muted": "🔇", "error": "!"}.get(self._state, "")
|
||||
p.drawText(QtCore.QRectF(rect), QtCore.Qt.AlignCenter, glyph)
|
||||
|
||||
# Mute overlay (Alexa-style red X indicator).
|
||||
if self._muted:
|
||||
mute_r = base_r * 0.5
|
||||
mute_center = QtCore.QPointF(center.x() + size * 0.28,
|
||||
center.y() - size * 0.28)
|
||||
p.setPen(QtGui.QPen(QtGui.QColor("#f14c4c"), max(2, size // 80)))
|
||||
p.drawLine(
|
||||
QtCore.QPointF(mute_center.x() - mute_r, mute_center.y() - mute_r),
|
||||
QtCore.QPointF(mute_center.x() + mute_r, mute_center.y() + mute_r))
|
||||
p.drawLine(
|
||||
QtCore.QPointF(mute_center.x() + mute_r, mute_center.y() - mute_r),
|
||||
QtCore.QPointF(mute_center.x() - mute_r, mute_center.y() + mute_r))
|
||||
|
||||
# Reconnecting indicator (pulsing outer ring).
|
||||
if self._reconnecting:
|
||||
p.setPen(QtGui.QPen(QtGui.QColor("#f59e0b"), max(2, size // 60)))
|
||||
ring_r = base_r * 1.35
|
||||
p.drawEllipse(center, ring_r, ring_r)
|
||||
|
||||
p.end()
|
||||
|
||||
|
||||
class SetupScreen(QtWidgets.QWidget):
|
||||
"""First-run pairing: master address + pairing code."""
|
||||
|
||||
pairRequested = QtCore.Signal(str, int, str, str) # host, port, code, name
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setStyleSheet(f"background: {CL_BG};")
|
||||
outer = QtWidgets.QVBoxLayout(self)
|
||||
outer.setAlignment(QtCore.Qt.AlignCenter)
|
||||
|
||||
card = QtWidgets.QFrame()
|
||||
card.setMaximumWidth(620)
|
||||
card.setStyleSheet(
|
||||
f"QFrame {{ background: {CL_SURFACE}; border: 1px solid {CL_BORDER};"
|
||||
f" border-radius: 18px; }}")
|
||||
v = QtWidgets.QVBoxLayout(card)
|
||||
v.setContentsMargins(40, 36, 40, 36)
|
||||
v.setSpacing(18)
|
||||
|
||||
title = QtWidgets.QLabel("Javis Slave koppeln")
|
||||
title.setStyleSheet(
|
||||
f"color: {CL_TEXT}; font-size: 24pt; font-weight: 800; border: 0;")
|
||||
title.setAlignment(QtCore.Qt.AlignCenter)
|
||||
v.addWidget(title)
|
||||
|
||||
hint = QtWidgets.QLabel(
|
||||
"Erzeuge auf dem Javis-Master im Tab „Devices“ einen Pairing-Code\n"
|
||||
"und trage hier die angezeigte Adresse und den Code ein.")
|
||||
hint.setStyleSheet(f"color: {CL_MUTED}; font-size: 12pt; border: 0;")
|
||||
hint.setAlignment(QtCore.Qt.AlignCenter)
|
||||
v.addWidget(hint)
|
||||
|
||||
self.host_edit = QtWidgets.QLineEdit()
|
||||
self.host_edit.setPlaceholderText("Master-IP (z. B. 192.168.1.20)")
|
||||
self.host_edit.setStyleSheet(_INPUT_QSS)
|
||||
v.addWidget(self.host_edit)
|
||||
|
||||
self.port_edit = QtWidgets.QLineEdit("8765")
|
||||
self.port_edit.setPlaceholderText("Port")
|
||||
self.port_edit.setValidator(QtGui.QIntValidator(1, 65535, self))
|
||||
self.port_edit.setStyleSheet(_INPUT_QSS)
|
||||
v.addWidget(self.port_edit)
|
||||
|
||||
self.code_edit = QtWidgets.QLineEdit()
|
||||
self.code_edit.setPlaceholderText("Pairing-Code")
|
||||
self.code_edit.setMaxLength(6)
|
||||
self.code_edit.setStyleSheet(_INPUT_QSS + "QLineEdit { letter-spacing: 6px; }")
|
||||
v.addWidget(self.code_edit)
|
||||
|
||||
self.name_edit = QtWidgets.QLineEdit()
|
||||
self.name_edit.setPlaceholderText("Gerätename (z. B. Tablet Küche)")
|
||||
self.name_edit.setStyleSheet(_INPUT_QSS)
|
||||
v.addWidget(self.name_edit)
|
||||
|
||||
self.status_lbl = QtWidgets.QLabel("")
|
||||
self.status_lbl.setStyleSheet("color: #f14c4c; font-size: 12pt; border: 0;")
|
||||
self.status_lbl.setAlignment(QtCore.Qt.AlignCenter)
|
||||
self.status_lbl.setWordWrap(True)
|
||||
v.addWidget(self.status_lbl)
|
||||
|
||||
self.pair_btn = QtWidgets.QPushButton("Koppeln")
|
||||
self.pair_btn.setStyleSheet(_PRIMARY_BTN_QSS)
|
||||
self.pair_btn.setCursor(QtCore.Qt.PointingHandCursor)
|
||||
self.pair_btn.clicked.connect(self._submit)
|
||||
v.addWidget(self.pair_btn)
|
||||
|
||||
outer.addWidget(card)
|
||||
|
||||
def _submit(self) -> None:
|
||||
host = self.host_edit.text().strip()
|
||||
port = int(self.port_edit.text() or "8765")
|
||||
code = self.code_edit.text().strip()
|
||||
name = self.name_edit.text().strip()
|
||||
if not host or not code:
|
||||
self.status_lbl.setText("Bitte Master-IP und Pairing-Code eingeben.")
|
||||
return
|
||||
self.status_lbl.setStyleSheet(f"color: {CL_MUTED}; font-size: 12pt; border: 0;")
|
||||
self.status_lbl.setText("Verbinde …")
|
||||
self.pair_btn.setEnabled(False)
|
||||
self.pairRequested.emit(host, port, code, name)
|
||||
|
||||
def show_error(self, message: str) -> None:
|
||||
self.status_lbl.setStyleSheet("color: #f14c4c; font-size: 12pt; border: 0;")
|
||||
self.status_lbl.setText(message)
|
||||
self.pair_btn.setEnabled(True)
|
||||
|
||||
|
||||
class MainScreen(QtWidgets.QWidget):
|
||||
"""Paired mode: state orb, transcript card, status badges.
|
||||
|
||||
Alexa-style UX features:
|
||||
- Mute toggle button on the orb
|
||||
- Connection health monitoring with retry countdown
|
||||
- Transcript history (scrollable conversation log)
|
||||
- Visual mute indicator overlay
|
||||
"""
|
||||
|
||||
settingsRequested = QtCore.Signal()
|
||||
muteToggled = QtCore.Signal(bool) # new signal for mute toggle
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setStyleSheet(f"background: {CL_BG};")
|
||||
outer = QtWidgets.QVBoxLayout(self)
|
||||
outer.setContentsMargins(28, 20, 28, 24)
|
||||
outer.setSpacing(16)
|
||||
|
||||
# ── top bar: room badge · connection health · mute · settings ──────
|
||||
top = QtWidgets.QHBoxLayout()
|
||||
self.room_lbl = QtWidgets.QLabel("Raum: —")
|
||||
self.room_lbl.setStyleSheet(
|
||||
f"color: {CL_TEXT}; background: {CL_SURFACE2}; border-radius: 14px;"
|
||||
f"padding: 10px 22px; font-size: 14pt; font-weight: 600;")
|
||||
top.addWidget(self.room_lbl)
|
||||
top.addStretch(1)
|
||||
|
||||
# Connection status with retry countdown
|
||||
self.conn_lbl = QtWidgets.QLabel("● verbunden")
|
||||
self.conn_lbl.setStyleSheet(
|
||||
"color: #4ec994; font-size: 13pt; padding: 10px;")
|
||||
top.addWidget(self.conn_lbl)
|
||||
|
||||
# Mute toggle button (Alexa-style red ring when muted)
|
||||
self.mute_btn = QtWidgets.QPushButton("🎤")
|
||||
self.mute_btn.setFixedSize(56, 56)
|
||||
self.mute_btn.setCursor(QtCore.Qt.PointingHandCursor)
|
||||
self.mute_btn.setStyleSheet(
|
||||
f"QPushButton {{ background: {CL_SURFACE2}; color: {CL_TEXT}; border: 0;"
|
||||
f" border-radius: 14px; font-size: 18pt; }}"
|
||||
f"QPushButton:checked {{ background: #f14c4c; color: white; }}"
|
||||
f"QPushButton:pressed {{ background: {CL_INPUT}; color: {CL_TEXT}; }}"
|
||||
f"QPushButton:disabled {{ background: {CL_SURFACE}; color: {CL_MUTED}; }}")
|
||||
self.mute_btn.setCheckable(True) # Alexa-style toggle state
|
||||
self.mute_btn.clicked.connect(self._on_mute_toggled)
|
||||
top.addWidget(self.mute_btn)
|
||||
|
||||
gear = QtWidgets.QPushButton("⚙")
|
||||
gear.setFixedSize(56, 56)
|
||||
gear.setCursor(QtCore.Qt.PointingHandCursor)
|
||||
gear.setStyleSheet(
|
||||
f"QPushButton {{ background: {CL_SURFACE2}; color: {CL_MUTED}; border: 0;"
|
||||
f" border-radius: 14px; font-size: 18pt; }}"
|
||||
f"QPushButton:pressed {{ background: {CL_INPUT}; color: {CL_TEXT}; }}")
|
||||
gear.clicked.connect(self.settingsRequested.emit)
|
||||
top.addWidget(gear)
|
||||
outer.addLayout(top)
|
||||
|
||||
# Retry countdown label (hidden by default, shown during reconnection)
|
||||
self.retry_lbl = QtWidgets.QLabel("")
|
||||
self.retry_lbl.setStyleSheet(
|
||||
f"color: #f59e0b; font-size: 12pt; font-weight: 600; border: 0;")
|
||||
self.retry_lbl.setAlignment(QtCore.Qt.AlignCenter)
|
||||
self.retry_lbl.setVisible(False)
|
||||
outer.addWidget(self.retry_lbl)
|
||||
|
||||
# ── state orb + label ─────────────────────────────────────────────
|
||||
self.orb = StateOrb()
|
||||
outer.addWidget(self.orb, 2)
|
||||
self.state_lbl = QtWidgets.QLabel(STATE_LABELS["idle"])
|
||||
self.state_lbl.setAlignment(QtCore.Qt.AlignCenter)
|
||||
self.state_lbl.setStyleSheet(f"color: {CL_MUTED}; font-size: 16pt;")
|
||||
outer.addWidget(self.state_lbl)
|
||||
|
||||
# ── transcript card (scrollable conversation history) ──────────────
|
||||
card = QtWidgets.QFrame()
|
||||
card.setStyleSheet(
|
||||
f"QFrame {{ background: {CL_SURFACE}; border: 1px solid {CL_BORDER};"
|
||||
f" border-radius: 18px; }}")
|
||||
cv = QtWidgets.QVBoxLayout(card)
|
||||
cv.setContentsMargins(28, 22, 28, 22)
|
||||
cv.setSpacing(10)
|
||||
|
||||
# Header label
|
||||
history_header = QtWidgets.QLabel("Konversation")
|
||||
history_header.setStyleSheet(f"color: {CL_MUTED}; font-size: 11pt; border: 0;")
|
||||
cv.addWidget(history_header)
|
||||
|
||||
# Transcript history (replaces single-turn display).
|
||||
# NOTE: this layout must belong to *exactly one* parent — we set it on
|
||||
# ``transcript_container`` (inside the scroll area) below, so it must
|
||||
# NOT also be added to ``cv`` (Qt raises RuntimeError: "Attempting to
|
||||
# set QLayout ... when the QLayout already has a parent").
|
||||
self.transcript_layout = QtWidgets.QVBoxLayout()
|
||||
self.transcript_layout.setSpacing(8)
|
||||
|
||||
# Scroll area for transcript
|
||||
self.transcript_scroll = QtWidgets.QScrollArea()
|
||||
self.transcript_scroll.setWidgetResizable(True)
|
||||
self.transcript_scroll.setFrameShape(QtWidgets.QFrame.NoFrame)
|
||||
self.transcript_scroll.setStyleSheet("background: transparent; border: 0;")
|
||||
self.transcript_container = QtWidgets.QWidget()
|
||||
self.transcript_container.setLayout(self.transcript_layout)
|
||||
self.transcript_scroll.setWidget(self.transcript_container)
|
||||
QtWidgets.QScroller.grabGesture(
|
||||
self.transcript_scroll.viewport(), QtWidgets.QScroller.TouchGesture)
|
||||
cv.addWidget(self.transcript_scroll, 1)
|
||||
|
||||
outer.addWidget(card, 3)
|
||||
|
||||
# Track last state for change detection
|
||||
self._last_state = "idle"
|
||||
|
||||
def _on_mute_toggled(self) -> None:
|
||||
"""Handle mute button toggle."""
|
||||
is_muted = self.mute_btn.isChecked()
|
||||
self.muteToggled.emit(is_muted)
|
||||
|
||||
# ── slots (called via Qt signals from the engine bridge) ──────────────
|
||||
|
||||
def set_state(self, state: str) -> None:
|
||||
self.orb.set_state(state)
|
||||
self.orb.set_muted(state == "muted") # sync mute overlay with state
|
||||
label = STATE_LABELS.get(state, state)
|
||||
if label:
|
||||
self.state_lbl.setText(label)
|
||||
# Mute toggle stays in place (enabled/disabled) so the top bar never
|
||||
# jumps while the state cycles; it is only inactive mid-turn.
|
||||
interactive_states = ("idle", "listening", "speaking", "muted")
|
||||
self.mute_btn.setEnabled(state in interactive_states)
|
||||
# Sync the button's checked state to match the engine's mute state.
|
||||
want_checked = (state == "muted")
|
||||
if self.mute_btn.isChecked() != want_checked:
|
||||
self.mute_btn.blockSignals(True)
|
||||
self.mute_btn.setChecked(want_checked)
|
||||
self.mute_btn.blockSignals(False)
|
||||
|
||||
def set_mic_level(self, rms: float, score: float) -> None:
|
||||
self.orb.set_level(min(1.0, rms * _RMS_GAIN))
|
||||
|
||||
def _ensure_transcript_spacer(self) -> None:
|
||||
"""Remove existing spacer (if any) and add a fresh one at the end."""
|
||||
# Walk backwards to find and remove any existing spacer items
|
||||
while self.transcript_layout.count() > 0:
|
||||
item = self.transcript_layout.itemAt(self.transcript_layout.count() - 1)
|
||||
if isinstance(item, QtWidgets.QSpacerItem):
|
||||
self.transcript_layout.removeItem(item)
|
||||
else:
|
||||
break
|
||||
spacer = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum,
|
||||
QtWidgets.QSizePolicy.Expanding)
|
||||
self.transcript_layout.addSpacerItem(spacer)
|
||||
|
||||
def _prune_transcript(self, max_bubbles: int = 40) -> None:
|
||||
"""Keep at most *max_bubbles* bubbles in the transcript layout (oldest first)."""
|
||||
count = self.transcript_layout.count()
|
||||
bubbles = count
|
||||
if count > 0 and isinstance(self.transcript_layout.itemAt(count - 1), QtWidgets.QSpacerItem):
|
||||
bubbles -= 1
|
||||
excess = bubbles - max_bubbles
|
||||
for _ in range(max(0, excess)):
|
||||
item = self.transcript_layout.takeAt(0)
|
||||
if item is not None and item.widget():
|
||||
item.widget().deleteLater()
|
||||
|
||||
def set_turn(self, user_text: str, reply_text: str) -> None:
|
||||
"""Add a new conversation turn to the transcript history (Alexa-style)."""
|
||||
# User message bubble
|
||||
user_bubble = QtWidgets.QLabel(f"👤 {user_text}")
|
||||
user_bubble.setStyleSheet(
|
||||
f"background: {CL_SURFACE2}; color: {CL_TEXT}; border-radius: 12px;"
|
||||
f"padding: 12px 18px; font-size: 14pt;")
|
||||
user_bubble.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
|
||||
user_bubble.setWordWrap(True)
|
||||
self.transcript_layout.addWidget(user_bubble)
|
||||
|
||||
# Assistant message bubble (if any)
|
||||
if reply_text:
|
||||
reply_bubble = QtWidgets.QLabel(f"🤖 {reply_text}")
|
||||
reply_bubble.setStyleSheet(
|
||||
f"background: {CL_ACCENT}; color: #1e1e1e; border-radius: 12px;"
|
||||
f"padding: 12px 18px; font-size: 14pt; font-weight: 500;")
|
||||
reply_bubble.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter)
|
||||
reply_bubble.setWordWrap(True)
|
||||
self.transcript_layout.addWidget(reply_bubble)
|
||||
|
||||
self._prune_transcript()
|
||||
# Spacer to push content to bottom (like Alexa's conversation history)
|
||||
self._ensure_transcript_spacer()
|
||||
|
||||
# Auto-scroll to bottom
|
||||
QTimer.singleShot(50, self._scroll_transcript_to_bottom)
|
||||
|
||||
def _scroll_transcript_to_bottom(self) -> None:
|
||||
"""Auto-scroll transcript to show latest messages."""
|
||||
vbar = self.transcript_scroll.verticalScrollBar()
|
||||
vbar.setValue(vbar.maximum())
|
||||
|
||||
def set_connection(self, ok: bool, retry_count: int = 0, backoff: float = 0.0) -> None:
|
||||
if ok:
|
||||
self.conn_lbl.setText("● verbunden")
|
||||
self.conn_lbl.setStyleSheet("color: #4ec994; font-size: 13pt; padding: 10px;")
|
||||
self.retry_lbl.setVisible(False)
|
||||
self.orb.set_reconnecting(False)
|
||||
else:
|
||||
if retry_count > 0 and backoff > 0:
|
||||
# Show retry countdown (Alexa-style progressive feedback)
|
||||
text = f"○ trennung · versuch {retry_count} in {backoff:.0f}s"
|
||||
self.conn_lbl.setText(text)
|
||||
self.conn_lbl.setStyleSheet("color: #f59e0b; font-size: 13pt; padding: 10px;")
|
||||
self.retry_lbl.setVisible(True)
|
||||
self.retry_lbl.setText(f"Wiederverbindungsversuch in {backoff:.0f}s …")
|
||||
self.orb.set_reconnecting(True)
|
||||
else:
|
||||
self.conn_lbl.setText("● getrennt")
|
||||
self.conn_lbl.setStyleSheet("color: #f14c4c; font-size: 13pt; padding: 10px;")
|
||||
|
||||
def set_room(self, room: Optional[str]) -> None:
|
||||
self.room_lbl.setText(f"Raum: {room}" if room else "Raum: —")
|
||||
|
||||
def show_error(self, message: str) -> None:
|
||||
"""Show a recoverable error to the user (Alexa-style progressive disclosure)."""
|
||||
self._prune_transcript()
|
||||
error_bubble = QtWidgets.QLabel(f"⚠ {message}")
|
||||
error_bubble.setStyleSheet(
|
||||
f"color: #f14c4c; font-size: 14pt; border: 0; padding: 12px;")
|
||||
error_bubble.setWordWrap(True)
|
||||
self.transcript_layout.addWidget(error_bubble)
|
||||
self._ensure_transcript_spacer()
|
||||
QTimer.singleShot(50, self._scroll_transcript_to_bottom)
|
||||
|
||||
|
||||
class SlaveWindow(QtWidgets.QMainWindow):
|
||||
"""Top-level window switching between setup and main screens.
|
||||
|
||||
Wired to engine callbacks that bridge asyncio events to Qt signals:
|
||||
- engineState: device state changes (idle/listening/thinking/speaking/error)
|
||||
- engineMicLevel: real-time audio level for orb pulse animation
|
||||
- engineTurn: conversation turn results (user text + assistant reply)
|
||||
- engineError: error messages from the engine
|
||||
- engineConnection: connection health updates with retry countdown
|
||||
- engineRoom: room assignment changes
|
||||
"""
|
||||
|
||||
# Bridged engine events (emitted from asyncio, consumed on the Qt side).
|
||||
engineState = QtCore.Signal(str)
|
||||
engineMicLevel = QtCore.Signal(float, float)
|
||||
engineTurn = QtCore.Signal(str, str)
|
||||
engineError = QtCore.Signal(str)
|
||||
engineConnection = QtCore.Signal(bool, int, float) # (ok, retry_count, backoff)
|
||||
engineRoom = QtCore.Signal(object)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Javis Slave")
|
||||
self.resize(1368, 912) # Surface Pro 7 logical resolution @150 %
|
||||
self.setStyleSheet(f"background: {CL_BG};")
|
||||
|
||||
self.stack = QtWidgets.QStackedWidget()
|
||||
self.setup_screen = SetupScreen()
|
||||
self.main_screen = MainScreen()
|
||||
self.stack.addWidget(self.setup_screen)
|
||||
self.stack.addWidget(self.main_screen)
|
||||
self.setCentralWidget(self.stack)
|
||||
|
||||
self.engineState.connect(self.main_screen.set_state)
|
||||
self.engineMicLevel.connect(self.main_screen.set_mic_level)
|
||||
self.engineTurn.connect(self.main_screen.set_turn)
|
||||
self.engineError.connect(self.main_screen.show_error)
|
||||
self.engineConnection.connect(self.main_screen.set_connection)
|
||||
self.engineRoom.connect(lambda r: self.main_screen.set_room(r))
|
||||
|
||||
QtGui.QShortcut(QtGui.QKeySequence("F11"), self, activated=self._toggle_fullscreen)
|
||||
# Kiosk: fullscreen with a hidden cursor (touch panels); Esc leaves fullscreen.
|
||||
self._kiosk = False
|
||||
QtGui.QShortcut(QtGui.QKeySequence("Esc"), self, activated=self._leave_fullscreen)
|
||||
|
||||
def set_kiosk(self, enabled: bool) -> None:
|
||||
self._kiosk = enabled
|
||||
if enabled:
|
||||
self.showFullScreen()
|
||||
QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.CursorShape.BlankCursor)
|
||||
|
||||
def _leave_fullscreen(self) -> None:
|
||||
if self.isFullScreen():
|
||||
self.showNormal()
|
||||
if self._kiosk:
|
||||
QtWidgets.QApplication.restoreOverrideCursor()
|
||||
|
||||
def show_setup(self) -> None:
|
||||
self.stack.setCurrentWidget(self.setup_screen)
|
||||
|
||||
def show_main(self) -> None:
|
||||
self.stack.setCurrentWidget(self.main_screen)
|
||||
|
||||
def _toggle_fullscreen(self) -> None:
|
||||
if self.isFullScreen():
|
||||
self.showNormal()
|
||||
else:
|
||||
self.showFullScreen()
|
||||
|
||||
def show_settings_dialog(self, device_id: str, device_name: str,
|
||||
on_repair: Callable[[], None]) -> None:
|
||||
dlg = QtWidgets.QDialog(self)
|
||||
dlg.setWindowTitle("Einstellungen")
|
||||
dlg.setStyleSheet(f"background: {CL_SURFACE}; color: {CL_TEXT};")
|
||||
v = QtWidgets.QVBoxLayout(dlg)
|
||||
v.setContentsMargins(28, 24, 28, 24)
|
||||
v.setSpacing(14)
|
||||
info = QtWidgets.QLabel(
|
||||
f"Gerät: {device_name or '—'}\nGeräte-ID: {device_id}")
|
||||
info.setStyleSheet(f"color: {CL_MUTED}; font-size: 12pt;")
|
||||
info.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse)
|
||||
v.addWidget(info)
|
||||
btn_fs = QtWidgets.QPushButton("Vollbild umschalten (F11)")
|
||||
btn_fs.setStyleSheet(_GHOST_BTN_QSS)
|
||||
btn_fs.clicked.connect(lambda: (self._toggle_fullscreen(), dlg.accept()))
|
||||
v.addWidget(btn_fs)
|
||||
btn_repair = QtWidgets.QPushButton("Neu koppeln …")
|
||||
btn_repair.setStyleSheet(_GHOST_BTN_QSS)
|
||||
|
||||
def _do_repair():
|
||||
ret = QtWidgets.QMessageBox.question(
|
||||
dlg, "Neu koppeln",
|
||||
"Verbindung zum Master trennen und neu koppeln?")
|
||||
if ret == QtWidgets.QMessageBox.Yes:
|
||||
dlg.accept()
|
||||
on_repair()
|
||||
|
||||
btn_repair.clicked.connect(_do_repair)
|
||||
v.addWidget(btn_repair)
|
||||
btn_close = QtWidgets.QPushButton("Schließen")
|
||||
btn_close.setStyleSheet(_PRIMARY_BTN_QSS)
|
||||
btn_close.clicked.connect(dlg.accept)
|
||||
v.addWidget(btn_close)
|
||||
dlg.exec()
|
||||
@@ -0,0 +1,558 @@
|
||||
"""System control module for Javis Slave (volume, brightness, power).
|
||||
|
||||
Windows: pycaw for audio, screen_brightness_control for display, ``shutdown``
|
||||
for power. Linux / Raspberry Pi: ``pactl`` (PipeWire/PulseAudio) or ``amixer``
|
||||
(ALSA) for audio, ``/sys/class/backlight`` for display, ``systemctl`` for power.
|
||||
The HTTP layer only uses the ``get_*_controller()`` factories, which pick the
|
||||
backend for the current platform.
|
||||
"""
|
||||
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .logging_setup import get_logger
|
||||
|
||||
logger = get_logger("system")
|
||||
|
||||
_IS_LINUX = platform.system() == "Linux"
|
||||
|
||||
|
||||
class WindowsControlError(Exception):
|
||||
"""Exception for Windows control operations."""
|
||||
pass
|
||||
|
||||
|
||||
class VolumeController:
|
||||
"""Control master volume on Windows."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize volume controller."""
|
||||
self._pycaw_available = False
|
||||
self._device = None
|
||||
self._interface = None
|
||||
|
||||
if platform.system() != "Windows":
|
||||
logger.warning("Volume control only available on Windows")
|
||||
return
|
||||
|
||||
try:
|
||||
from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume
|
||||
|
||||
devices = AudioUtilities.GetSpeakers()
|
||||
self._device = devices.Activate(
|
||||
IAudioEndpointVolume._iid_, 0, None
|
||||
)
|
||||
self._interface = self._device
|
||||
self._pycaw_available = True
|
||||
logger.info("Pycaw audio control initialized")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to initialize pycaw: {e}")
|
||||
|
||||
def get_volume(self) -> Optional[float]:
|
||||
"""Get current master volume (0.0 to 1.0).
|
||||
|
||||
Returns:
|
||||
Volume level 0.0-1.0, or None if unavailable.
|
||||
"""
|
||||
if not self._pycaw_available or not self._interface:
|
||||
return None
|
||||
|
||||
try:
|
||||
return self._interface.GetMasterVolumeLevelScalar()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get volume: {e}")
|
||||
return None
|
||||
|
||||
def set_volume(self, level: float) -> bool:
|
||||
"""Set master volume to level (0.0 to 1.0).
|
||||
|
||||
Args:
|
||||
level: Volume level 0.0-1.0.
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise.
|
||||
"""
|
||||
if not self._pycaw_available or not self._interface:
|
||||
return False
|
||||
|
||||
level = max(0.0, min(1.0, level))
|
||||
|
||||
try:
|
||||
self._interface.SetMasterVolumeLevelScalar(level, None)
|
||||
logger.info(f"Set volume to {level * 100:.1f}%")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to set volume: {e}")
|
||||
return False
|
||||
|
||||
def is_muted(self) -> Optional[bool]:
|
||||
"""Check if audio is muted.
|
||||
|
||||
Returns:
|
||||
True if muted, False if not, None if unavailable.
|
||||
"""
|
||||
if not self._pycaw_available or not self._interface:
|
||||
return None
|
||||
|
||||
try:
|
||||
return self._interface.GetMute()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get mute state: {e}")
|
||||
return None
|
||||
|
||||
def set_mute(self, muted: bool) -> bool:
|
||||
"""Set mute state.
|
||||
|
||||
Args:
|
||||
muted: True to mute, False to unmute.
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise.
|
||||
"""
|
||||
if not self._pycaw_available or not self._interface:
|
||||
return False
|
||||
|
||||
try:
|
||||
self._interface.SetMute(muted, None)
|
||||
state = "muted" if muted else "unmuted"
|
||||
logger.info(f"Audio {state}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to set mute: {e}")
|
||||
return False
|
||||
|
||||
def increase_volume(self, amount: float = 0.05) -> Optional[float]:
|
||||
"""Increase volume by amount.
|
||||
|
||||
Args:
|
||||
amount: Amount to increase (0.0-1.0).
|
||||
|
||||
Returns:
|
||||
New volume level, or None if failed.
|
||||
"""
|
||||
current = self.get_volume()
|
||||
if current is None:
|
||||
return None
|
||||
|
||||
new_level = min(1.0, current + amount)
|
||||
if self.set_volume(new_level):
|
||||
return new_level
|
||||
return None
|
||||
|
||||
def decrease_volume(self, amount: float = 0.05) -> Optional[float]:
|
||||
"""Decrease volume by amount.
|
||||
|
||||
Args:
|
||||
amount: Amount to decrease (0.0-1.0).
|
||||
|
||||
Returns:
|
||||
New volume level, or None if failed.
|
||||
"""
|
||||
current = self.get_volume()
|
||||
if current is None:
|
||||
return None
|
||||
|
||||
new_level = max(0.0, current - amount)
|
||||
if self.set_volume(new_level):
|
||||
return new_level
|
||||
return None
|
||||
|
||||
|
||||
class BrightnessController:
|
||||
"""Control display brightness on Windows."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize brightness controller."""
|
||||
self._available = False
|
||||
|
||||
if platform.system() != "Windows":
|
||||
logger.warning("Brightness control only available on Windows")
|
||||
return
|
||||
|
||||
try:
|
||||
import screen_brightness_control
|
||||
self._brightness = screen_brightness_control
|
||||
self._available = True
|
||||
logger.info("Screen brightness control initialized")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to initialize brightness control: {e}")
|
||||
|
||||
def get_brightness(self) -> Optional[int]:
|
||||
"""Get current display brightness (0-100).
|
||||
|
||||
Returns:
|
||||
Brightness percentage 0-100, or None if unavailable.
|
||||
"""
|
||||
if not self._available:
|
||||
return None
|
||||
|
||||
try:
|
||||
brightness = self._brightness.get_brightness()
|
||||
# get_brightness() returns a list if multiple displays
|
||||
if isinstance(brightness, list):
|
||||
return int(brightness[0])
|
||||
return int(brightness)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get brightness: {e}")
|
||||
return None
|
||||
|
||||
def set_brightness(self, level: int) -> bool:
|
||||
"""Set display brightness (0-100).
|
||||
|
||||
Args:
|
||||
level: Brightness percentage 0-100.
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise.
|
||||
"""
|
||||
if not self._available:
|
||||
return False
|
||||
|
||||
level = max(0, min(100, level))
|
||||
|
||||
try:
|
||||
self._brightness.set_brightness(level)
|
||||
logger.info(f"Set brightness to {level}%")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to set brightness: {e}")
|
||||
return False
|
||||
|
||||
def increase_brightness(self, amount: int = 5) -> Optional[int]:
|
||||
"""Increase brightness by amount.
|
||||
|
||||
Args:
|
||||
amount: Amount to increase (percentage points).
|
||||
|
||||
Returns:
|
||||
New brightness level, or None if failed.
|
||||
"""
|
||||
current = self.get_brightness()
|
||||
if current is None:
|
||||
return None
|
||||
|
||||
new_level = min(100, current + amount)
|
||||
if self.set_brightness(new_level):
|
||||
return new_level
|
||||
return None
|
||||
|
||||
def decrease_brightness(self, amount: int = 5) -> Optional[int]:
|
||||
"""Decrease brightness by amount.
|
||||
|
||||
Args:
|
||||
amount: Amount to decrease (percentage points).
|
||||
|
||||
Returns:
|
||||
New brightness level, or None if failed.
|
||||
"""
|
||||
current = self.get_brightness()
|
||||
if current is None:
|
||||
return None
|
||||
|
||||
new_level = max(0, current - amount)
|
||||
if self.set_brightness(new_level):
|
||||
return new_level
|
||||
return None
|
||||
|
||||
|
||||
class PowerController:
|
||||
"""Control power settings on Windows."""
|
||||
|
||||
@staticmethod
|
||||
def sleep() -> bool:
|
||||
"""Put system to sleep.
|
||||
|
||||
Returns:
|
||||
True if command sent, False on error.
|
||||
"""
|
||||
if platform.system() != "Windows":
|
||||
logger.error("Power control only available on Windows")
|
||||
return False
|
||||
|
||||
try:
|
||||
subprocess.run(["rundll32.exe", "powrprof.dll,SetSuspendState", "0", "1", "0"], check=False)
|
||||
logger.info("Sent sleep command")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to sleep: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def shutdown(delay: int = 0) -> bool:
|
||||
"""Shutdown system.
|
||||
|
||||
Args:
|
||||
delay: Delay in seconds before shutdown.
|
||||
|
||||
Returns:
|
||||
True if command sent, False on error.
|
||||
"""
|
||||
if platform.system() != "Windows":
|
||||
logger.error("Power control only available on Windows")
|
||||
return False
|
||||
|
||||
try:
|
||||
if delay > 0:
|
||||
subprocess.run(["shutdown", "/s", "/t", str(delay)], check=False)
|
||||
logger.info(f"Scheduled shutdown in {delay} seconds")
|
||||
else:
|
||||
subprocess.run(["shutdown", "/s", "/t", "0"], check=False)
|
||||
logger.info("Sent shutdown command")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to shutdown: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def restart(delay: int = 0) -> bool:
|
||||
"""Restart system.
|
||||
|
||||
Args:
|
||||
delay: Delay in seconds before restart.
|
||||
|
||||
Returns:
|
||||
True if command sent, False on error.
|
||||
"""
|
||||
if platform.system() != "Windows":
|
||||
logger.error("Power control only available on Windows")
|
||||
return False
|
||||
|
||||
try:
|
||||
if delay > 0:
|
||||
subprocess.run(["shutdown", "/r", "/t", str(delay)], check=False)
|
||||
logger.info(f"Scheduled restart in {delay} seconds")
|
||||
else:
|
||||
subprocess.run(["shutdown", "/r", "/t", "0"], check=False)
|
||||
logger.info("Sent restart command")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to restart: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def cancel_shutdown() -> bool:
|
||||
"""Cancel pending shutdown/restart.
|
||||
|
||||
Returns:
|
||||
True if command sent, False on error.
|
||||
"""
|
||||
if platform.system() != "Windows":
|
||||
logger.error("Power control only available on Windows")
|
||||
return False
|
||||
|
||||
try:
|
||||
subprocess.run(["shutdown", "/a"], check=False)
|
||||
logger.info("Cancelled pending shutdown/restart")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to cancel shutdown: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ── Linux / Raspberry Pi backends ────────────────────────────────────────────
|
||||
|
||||
def _run(cmd: list[str], timeout: float = 5.0) -> Optional[str]:
|
||||
"""Run *cmd*; return stdout on success, None on any failure."""
|
||||
try:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
logger.error(f"{cmd[0]} failed: {exc}")
|
||||
return None
|
||||
if proc.returncode != 0:
|
||||
logger.error(f"{' '.join(cmd)} exited {proc.returncode}: {proc.stderr.strip()}")
|
||||
return None
|
||||
return proc.stdout
|
||||
|
||||
|
||||
class LinuxVolumeController:
|
||||
"""Master volume via ``pactl`` (PipeWire/PulseAudio) with ``amixer`` (ALSA) fallback."""
|
||||
|
||||
def __init__(self, mixer_control: str = "Master"):
|
||||
self._pactl = shutil.which("pactl")
|
||||
self._amixer = shutil.which("amixer")
|
||||
self._control = mixer_control
|
||||
if not (self._pactl or self._amixer):
|
||||
logger.warning("Neither pactl nor amixer found — volume control unavailable")
|
||||
else:
|
||||
logger.info(f"Linux volume control via {'pactl' if self._pactl else 'amixer'}")
|
||||
|
||||
def _amixer_control(self) -> str:
|
||||
# Pi HDMI/headphone cards expose "PCM" or "Headphone" instead of "Master".
|
||||
out = _run([self._amixer, "scontrols"]) or ""
|
||||
names = [line.split("'")[1] for line in out.splitlines() if "'" in line]
|
||||
for cand in (self._control, "Master", "PCM", "Headphone", "Speaker", "Digital"):
|
||||
if cand in names:
|
||||
return cand
|
||||
return names[0] if names else self._control
|
||||
|
||||
def get_volume(self) -> Optional[float]:
|
||||
if self._pactl:
|
||||
out = _run([self._pactl, "get-sink-volume", "@DEFAULT_SINK@"])
|
||||
if out:
|
||||
for tok in out.replace("/", " ").split():
|
||||
if tok.endswith("%") and tok[:-1].isdigit():
|
||||
return min(1.0, int(tok[:-1]) / 100.0)
|
||||
if self._amixer:
|
||||
out = _run([self._amixer, "get", self._amixer_control()])
|
||||
if out:
|
||||
import re
|
||||
m = re.search(r"\[(\d+)%\]", out)
|
||||
if m:
|
||||
return int(m.group(1)) / 100.0
|
||||
return None
|
||||
|
||||
def set_volume(self, level: float) -> bool:
|
||||
level = max(0.0, min(1.0, level))
|
||||
pct = f"{int(round(level * 100))}%"
|
||||
if self._pactl and _run([self._pactl, "set-sink-volume", "@DEFAULT_SINK@", pct]) is not None:
|
||||
logger.info(f"Set volume to {pct}")
|
||||
return True
|
||||
if self._amixer and _run([self._amixer, "set", self._amixer_control(), pct]) is not None:
|
||||
logger.info(f"Set volume to {pct}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_muted(self) -> Optional[bool]:
|
||||
if self._pactl:
|
||||
out = _run([self._pactl, "get-sink-mute", "@DEFAULT_SINK@"])
|
||||
if out:
|
||||
return "yes" in out.lower()
|
||||
if self._amixer:
|
||||
out = _run([self._amixer, "get", self._amixer_control()])
|
||||
if out:
|
||||
return "[off]" in out
|
||||
return None
|
||||
|
||||
def set_mute(self, muted: bool) -> bool:
|
||||
if self._pactl and _run([self._pactl, "set-sink-mute", "@DEFAULT_SINK@", "1" if muted else "0"]) is not None:
|
||||
return True
|
||||
if self._amixer and _run([self._amixer, "set", self._amixer_control(), "mute" if muted else "unmute"]) is not None:
|
||||
return True
|
||||
return False
|
||||
|
||||
def increase_volume(self, amount: float = 0.05) -> Optional[float]:
|
||||
cur = self.get_volume()
|
||||
if cur is None:
|
||||
return None
|
||||
new = min(1.0, cur + amount)
|
||||
return new if self.set_volume(new) else None
|
||||
|
||||
def decrease_volume(self, amount: float = 0.05) -> Optional[float]:
|
||||
cur = self.get_volume()
|
||||
if cur is None:
|
||||
return None
|
||||
new = max(0.0, cur - amount)
|
||||
return new if self.set_volume(new) else None
|
||||
|
||||
|
||||
class LinuxBrightnessController:
|
||||
"""Backlight via ``/sys/class/backlight/*`` (official Pi touch display, DSI/eDP panels).
|
||||
|
||||
Writing needs permission: add the user to the ``video`` group and install
|
||||
the udev rule from RASPBERRY_PI.md, or run as root.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._dev: Optional[Path] = None
|
||||
for cand in sorted(Path("/sys/class/backlight").glob("*")):
|
||||
if (cand / "brightness").exists() and (cand / "max_brightness").exists():
|
||||
self._dev = cand
|
||||
break
|
||||
if self._dev is None:
|
||||
logger.warning("No /sys/class/backlight device — brightness control unavailable")
|
||||
else:
|
||||
logger.info(f"Linux brightness control via {self._dev}")
|
||||
|
||||
def _max(self) -> int:
|
||||
return max(1, int((self._dev / "max_brightness").read_text().strip())) # type: ignore[operator]
|
||||
|
||||
def get_brightness(self) -> Optional[int]:
|
||||
if self._dev is None:
|
||||
return None
|
||||
try:
|
||||
cur = int((self._dev / "brightness").read_text().strip())
|
||||
return int(round(cur * 100 / self._max()))
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.error(f"Failed to read brightness: {exc}")
|
||||
return None
|
||||
|
||||
def set_brightness(self, level: int) -> bool:
|
||||
if self._dev is None:
|
||||
return False
|
||||
level = max(0, min(100, level))
|
||||
raw = int(round(level * self._max() / 100))
|
||||
try:
|
||||
(self._dev / "brightness").write_text(str(raw))
|
||||
logger.info(f"Set brightness to {level}%")
|
||||
return True
|
||||
except OSError as exc:
|
||||
logger.error(f"Failed to set brightness (permissions? see RASPBERRY_PI.md): {exc}")
|
||||
return False
|
||||
|
||||
def increase_brightness(self, amount: int = 5) -> Optional[int]:
|
||||
cur = self.get_brightness()
|
||||
if cur is None:
|
||||
return None
|
||||
new = min(100, cur + amount)
|
||||
return new if self.set_brightness(new) else None
|
||||
|
||||
def decrease_brightness(self, amount: int = 5) -> Optional[int]:
|
||||
cur = self.get_brightness()
|
||||
if cur is None:
|
||||
return None
|
||||
new = max(0, cur - amount)
|
||||
return new if self.set_brightness(new) else None
|
||||
|
||||
|
||||
class LinuxPowerController:
|
||||
"""Power via ``systemctl`` / ``shutdown`` (needs polkit rights or passwordless sudo — see RASPBERRY_PI.md)."""
|
||||
|
||||
@staticmethod
|
||||
def _priv(cmd: list[str]) -> list[str]:
|
||||
import os
|
||||
if os.geteuid() == 0 or not shutil.which("sudo"):
|
||||
return cmd
|
||||
return ["sudo", "-n", *cmd]
|
||||
|
||||
def sleep(self) -> bool:
|
||||
return _run(self._priv(["systemctl", "suspend"])) is not None
|
||||
|
||||
def shutdown(self, delay: int = 0) -> bool:
|
||||
when = f"+{max(1, (delay + 59) // 60)}" if delay > 0 else "now"
|
||||
return _run(self._priv(["shutdown", "-h", when])) is not None
|
||||
|
||||
def restart(self, delay: int = 0) -> bool:
|
||||
when = f"+{max(1, (delay + 59) // 60)}" if delay > 0 else "now"
|
||||
return _run(self._priv(["shutdown", "-r", when])) is not None
|
||||
|
||||
def cancel_shutdown(self) -> bool:
|
||||
return _run(self._priv(["shutdown", "-c"])) is not None
|
||||
|
||||
|
||||
# Global controllers — one backend per platform.
|
||||
if _IS_LINUX:
|
||||
_volume = LinuxVolumeController()
|
||||
_brightness = LinuxBrightnessController()
|
||||
_power = LinuxPowerController()
|
||||
else:
|
||||
_volume = VolumeController()
|
||||
_brightness = BrightnessController()
|
||||
_power = PowerController()
|
||||
|
||||
|
||||
def get_volume_controller() -> VolumeController:
|
||||
"""Get global volume controller."""
|
||||
return _volume
|
||||
|
||||
|
||||
def get_brightness_controller() -> BrightnessController:
|
||||
"""Get global brightness controller."""
|
||||
return _brightness
|
||||
|
||||
|
||||
def get_power_controller() -> PowerController:
|
||||
"""Get global power controller."""
|
||||
return _power
|
||||
@@ -0,0 +1,558 @@
|
||||
"""System control module for Javis Slave (volume, brightness, power).
|
||||
|
||||
Windows: pycaw for audio, screen_brightness_control for display, ``shutdown``
|
||||
for power. Linux / Raspberry Pi: ``pactl`` (PipeWire/PulseAudio) or ``amixer``
|
||||
(ALSA) for audio, ``/sys/class/backlight`` for display, ``systemctl`` for power.
|
||||
The HTTP layer only uses the ``get_*_controller()`` factories, which pick the
|
||||
backend for the current platform.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_IS_WINDOWS = platform.system() == "Windows"
|
||||
_IS_LINUX = platform.system() == "Linux"
|
||||
|
||||
|
||||
class WindowsControlError(Exception):
|
||||
"""Exception for Windows control operations."""
|
||||
pass
|
||||
|
||||
|
||||
class VolumeController:
|
||||
"""Control master volume on Windows."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize volume controller."""
|
||||
self._pycaw_available = False
|
||||
self._device = None
|
||||
self._interface = None
|
||||
|
||||
if platform.system() != "Windows":
|
||||
logger.warning("Volume control only available on Windows")
|
||||
return
|
||||
|
||||
try:
|
||||
from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume
|
||||
|
||||
devices = AudioUtilities.GetSpeakers()
|
||||
self._device = devices.Activate(
|
||||
IAudioEndpointVolume._iid_, 0, None
|
||||
)
|
||||
self._interface = self._device
|
||||
self._pycaw_available = True
|
||||
logger.info("Pycaw audio control initialized")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to initialize pycaw: {e}")
|
||||
|
||||
def get_volume(self) -> Optional[float]:
|
||||
"""Get current master volume (0.0 to 1.0).
|
||||
|
||||
Returns:
|
||||
Volume level 0.0-1.0, or None if unavailable.
|
||||
"""
|
||||
if not self._pycaw_available or not self._interface:
|
||||
return None
|
||||
|
||||
try:
|
||||
return self._interface.GetMasterVolumeLevelScalar()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get volume: {e}")
|
||||
return None
|
||||
|
||||
def set_volume(self, level: float) -> bool:
|
||||
"""Set master volume to level (0.0 to 1.0).
|
||||
|
||||
Args:
|
||||
level: Volume level 0.0-1.0.
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise.
|
||||
"""
|
||||
if not self._pycaw_available or not self._interface:
|
||||
return False
|
||||
|
||||
level = max(0.0, min(1.0, level))
|
||||
|
||||
try:
|
||||
self._interface.SetMasterVolumeLevelScalar(level, None)
|
||||
logger.info(f"Set volume to {level * 100:.1f}%")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to set volume: {e}")
|
||||
return False
|
||||
|
||||
def is_muted(self) -> Optional[bool]:
|
||||
"""Check if audio is muted.
|
||||
|
||||
Returns:
|
||||
True if muted, False if not, None if unavailable.
|
||||
"""
|
||||
if not self._pycaw_available or not self._interface:
|
||||
return None
|
||||
|
||||
try:
|
||||
return self._interface.GetMute()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get mute state: {e}")
|
||||
return None
|
||||
|
||||
def set_mute(self, muted: bool) -> bool:
|
||||
"""Set mute state.
|
||||
|
||||
Args:
|
||||
muted: True to mute, False to unmute.
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise.
|
||||
"""
|
||||
if not self._pycaw_available or not self._interface:
|
||||
return False
|
||||
|
||||
try:
|
||||
self._interface.SetMute(muted, None)
|
||||
state = "muted" if muted else "unmuted"
|
||||
logger.info(f"Audio {state}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to set mute: {e}")
|
||||
return False
|
||||
|
||||
def increase_volume(self, amount: float = 0.05) -> Optional[float]:
|
||||
"""Increase volume by amount.
|
||||
|
||||
Args:
|
||||
amount: Amount to increase (0.0-1.0).
|
||||
|
||||
Returns:
|
||||
New volume level, or None if failed.
|
||||
"""
|
||||
current = self.get_volume()
|
||||
if current is None:
|
||||
return None
|
||||
|
||||
new_level = min(1.0, current + amount)
|
||||
if self.set_volume(new_level):
|
||||
return new_level
|
||||
return None
|
||||
|
||||
def decrease_volume(self, amount: float = 0.05) -> Optional[float]:
|
||||
"""Decrease volume by amount.
|
||||
|
||||
Args:
|
||||
amount: Amount to decrease (0.0-1.0).
|
||||
|
||||
Returns:
|
||||
New volume level, or None if failed.
|
||||
"""
|
||||
current = self.get_volume()
|
||||
if current is None:
|
||||
return None
|
||||
|
||||
new_level = max(0.0, current - amount)
|
||||
if self.set_volume(new_level):
|
||||
return new_level
|
||||
return None
|
||||
|
||||
|
||||
class BrightnessController:
|
||||
"""Control display brightness on Windows."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize brightness controller."""
|
||||
self._available = False
|
||||
|
||||
if platform.system() != "Windows":
|
||||
logger.warning("Brightness control only available on Windows")
|
||||
return
|
||||
|
||||
try:
|
||||
import screen_brightness_control
|
||||
self._brightness = screen_brightness_control
|
||||
self._available = True
|
||||
logger.info("Screen brightness control initialized")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to initialize brightness control: {e}")
|
||||
|
||||
def get_brightness(self) -> Optional[int]:
|
||||
"""Get current display brightness (0-100).
|
||||
|
||||
Returns:
|
||||
Brightness percentage 0-100, or None if unavailable.
|
||||
"""
|
||||
if not self._available:
|
||||
return None
|
||||
|
||||
try:
|
||||
brightness = self._brightness.get_brightness()
|
||||
# get_brightness() returns a list if multiple displays
|
||||
if isinstance(brightness, list):
|
||||
return int(brightness[0])
|
||||
return int(brightness)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get brightness: {e}")
|
||||
return None
|
||||
|
||||
def set_brightness(self, level: int) -> bool:
|
||||
"""Set display brightness (0-100).
|
||||
|
||||
Args:
|
||||
level: Brightness percentage 0-100.
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise.
|
||||
"""
|
||||
if not self._available:
|
||||
return False
|
||||
|
||||
level = max(0, min(100, level))
|
||||
|
||||
try:
|
||||
self._brightness.set_brightness(level)
|
||||
logger.info(f"Set brightness to {level}%")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to set brightness: {e}")
|
||||
return False
|
||||
|
||||
def increase_brightness(self, amount: int = 5) -> Optional[int]:
|
||||
"""Increase brightness by amount.
|
||||
|
||||
Args:
|
||||
amount: Amount to increase (percentage points).
|
||||
|
||||
Returns:
|
||||
New brightness level, or None if failed.
|
||||
"""
|
||||
current = self.get_brightness()
|
||||
if current is None:
|
||||
return None
|
||||
|
||||
new_level = min(100, current + amount)
|
||||
if self.set_brightness(new_level):
|
||||
return new_level
|
||||
return None
|
||||
|
||||
def decrease_brightness(self, amount: int = 5) -> Optional[int]:
|
||||
"""Decrease brightness by amount.
|
||||
|
||||
Args:
|
||||
amount: Amount to decrease (percentage points).
|
||||
|
||||
Returns:
|
||||
New brightness level, or None if failed.
|
||||
"""
|
||||
current = self.get_brightness()
|
||||
if current is None:
|
||||
return None
|
||||
|
||||
new_level = max(0, current - amount)
|
||||
if self.set_brightness(new_level):
|
||||
return new_level
|
||||
return None
|
||||
|
||||
|
||||
class PowerController:
|
||||
"""Control power settings on Windows."""
|
||||
|
||||
@staticmethod
|
||||
def sleep() -> bool:
|
||||
"""Put system to sleep.
|
||||
|
||||
Returns:
|
||||
True if command sent, False on error.
|
||||
"""
|
||||
if platform.system() != "Windows":
|
||||
logger.error("Power control only available on Windows")
|
||||
return False
|
||||
|
||||
try:
|
||||
subprocess.run(["rundll32.exe", "powrprof.dll,SetSuspendState", "0", "1", "0"], check=False)
|
||||
logger.info("Sent sleep command")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to sleep: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def shutdown(delay: int = 0) -> bool:
|
||||
"""Shutdown system.
|
||||
|
||||
Args:
|
||||
delay: Delay in seconds before shutdown.
|
||||
|
||||
Returns:
|
||||
True if command sent, False on error.
|
||||
"""
|
||||
if platform.system() != "Windows":
|
||||
logger.error("Power control only available on Windows")
|
||||
return False
|
||||
|
||||
try:
|
||||
if delay > 0:
|
||||
subprocess.run(["shutdown", "/s", "/t", str(delay)], check=False)
|
||||
logger.info(f"Scheduled shutdown in {delay} seconds")
|
||||
else:
|
||||
subprocess.run(["shutdown", "/s", "/t", "0"], check=False)
|
||||
logger.info("Sent shutdown command")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to shutdown: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def restart(delay: int = 0) -> bool:
|
||||
"""Restart system.
|
||||
|
||||
Args:
|
||||
delay: Delay in seconds before restart.
|
||||
|
||||
Returns:
|
||||
True if command sent, False on error.
|
||||
"""
|
||||
if platform.system() != "Windows":
|
||||
logger.error("Power control only available on Windows")
|
||||
return False
|
||||
|
||||
try:
|
||||
if delay > 0:
|
||||
subprocess.run(["shutdown", "/r", "/t", str(delay)], check=False)
|
||||
logger.info(f"Scheduled restart in {delay} seconds")
|
||||
else:
|
||||
subprocess.run(["shutdown", "/r", "/t", "0"], check=False)
|
||||
logger.info("Sent restart command")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to restart: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def cancel_shutdown() -> bool:
|
||||
"""Cancel pending shutdown/restart.
|
||||
|
||||
Returns:
|
||||
True if command sent, False on error.
|
||||
"""
|
||||
if platform.system() != "Windows":
|
||||
logger.error("Power control only available on Windows")
|
||||
return False
|
||||
|
||||
try:
|
||||
subprocess.run(["shutdown", "/a"], check=False)
|
||||
logger.info("Cancelled pending shutdown/restart")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to cancel shutdown: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ── Linux / Raspberry Pi backends ────────────────────────────────────────────
|
||||
|
||||
def _run(cmd: list[str], timeout: float = 5.0) -> Optional[str]:
|
||||
"""Run *cmd*; return stdout on success, None on any failure."""
|
||||
try:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
logger.error(f"{cmd[0]} failed: {exc}")
|
||||
return None
|
||||
if proc.returncode != 0:
|
||||
logger.error(f"{' '.join(cmd)} exited {proc.returncode}: {proc.stderr.strip()}")
|
||||
return None
|
||||
return proc.stdout
|
||||
|
||||
|
||||
class LinuxVolumeController:
|
||||
"""Master volume via ``pactl`` (PipeWire/PulseAudio) with ``amixer`` (ALSA) fallback."""
|
||||
|
||||
def __init__(self, mixer_control: str = "Master"):
|
||||
self._pactl = shutil.which("pactl")
|
||||
self._amixer = shutil.which("amixer")
|
||||
self._control = mixer_control
|
||||
if not (self._pactl or self._amixer):
|
||||
logger.warning("Neither pactl nor amixer found — volume control unavailable")
|
||||
else:
|
||||
logger.info(f"Linux volume control via {'pactl' if self._pactl else 'amixer'}")
|
||||
|
||||
def _amixer_control(self) -> str:
|
||||
# Pi HDMI/headphone cards expose "PCM" or "Headphone" instead of "Master".
|
||||
out = _run([self._amixer, "scontrols"]) or ""
|
||||
names = [line.split("'")[1] for line in out.splitlines() if "'" in line]
|
||||
for cand in (self._control, "Master", "PCM", "Headphone", "Speaker", "Digital"):
|
||||
if cand in names:
|
||||
return cand
|
||||
return names[0] if names else self._control
|
||||
|
||||
def get_volume(self) -> Optional[float]:
|
||||
if self._pactl:
|
||||
out = _run([self._pactl, "get-sink-volume", "@DEFAULT_SINK@"])
|
||||
if out:
|
||||
for tok in out.replace("/", " ").split():
|
||||
if tok.endswith("%") and tok[:-1].isdigit():
|
||||
return min(1.0, int(tok[:-1]) / 100.0)
|
||||
if self._amixer:
|
||||
out = _run([self._amixer, "get", self._amixer_control()])
|
||||
if out:
|
||||
import re
|
||||
m = re.search(r"\[(\d+)%\]", out)
|
||||
if m:
|
||||
return int(m.group(1)) / 100.0
|
||||
return None
|
||||
|
||||
def set_volume(self, level: float) -> bool:
|
||||
level = max(0.0, min(1.0, level))
|
||||
pct = f"{int(round(level * 100))}%"
|
||||
if self._pactl and _run([self._pactl, "set-sink-volume", "@DEFAULT_SINK@", pct]) is not None:
|
||||
logger.info(f"Set volume to {pct}")
|
||||
return True
|
||||
if self._amixer and _run([self._amixer, "set", self._amixer_control(), pct]) is not None:
|
||||
logger.info(f"Set volume to {pct}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_muted(self) -> Optional[bool]:
|
||||
if self._pactl:
|
||||
out = _run([self._pactl, "get-sink-mute", "@DEFAULT_SINK@"])
|
||||
if out:
|
||||
return "yes" in out.lower()
|
||||
if self._amixer:
|
||||
out = _run([self._amixer, "get", self._amixer_control()])
|
||||
if out:
|
||||
return "[off]" in out
|
||||
return None
|
||||
|
||||
def set_mute(self, muted: bool) -> bool:
|
||||
if self._pactl and _run([self._pactl, "set-sink-mute", "@DEFAULT_SINK@", "1" if muted else "0"]) is not None:
|
||||
return True
|
||||
if self._amixer and _run([self._amixer, "set", self._amixer_control(), "mute" if muted else "unmute"]) is not None:
|
||||
return True
|
||||
return False
|
||||
|
||||
def increase_volume(self, amount: float = 0.05) -> Optional[float]:
|
||||
cur = self.get_volume()
|
||||
if cur is None:
|
||||
return None
|
||||
new = min(1.0, cur + amount)
|
||||
return new if self.set_volume(new) else None
|
||||
|
||||
def decrease_volume(self, amount: float = 0.05) -> Optional[float]:
|
||||
cur = self.get_volume()
|
||||
if cur is None:
|
||||
return None
|
||||
new = max(0.0, cur - amount)
|
||||
return new if self.set_volume(new) else None
|
||||
|
||||
|
||||
class LinuxBrightnessController:
|
||||
"""Backlight via ``/sys/class/backlight/*`` (official Pi touch display, DSI/eDP panels).
|
||||
|
||||
Writing needs permission: add the user to the ``video`` group and install
|
||||
the udev rule from RASPBERRY_PI.md, or run as root.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._dev: Optional[Path] = None
|
||||
for cand in sorted(Path("/sys/class/backlight").glob("*")):
|
||||
if (cand / "brightness").exists() and (cand / "max_brightness").exists():
|
||||
self._dev = cand
|
||||
break
|
||||
if self._dev is None:
|
||||
logger.warning("No /sys/class/backlight device — brightness control unavailable")
|
||||
else:
|
||||
logger.info(f"Linux brightness control via {self._dev}")
|
||||
|
||||
def _max(self) -> int:
|
||||
return max(1, int((self._dev / "max_brightness").read_text().strip())) # type: ignore[operator]
|
||||
|
||||
def get_brightness(self) -> Optional[int]:
|
||||
if self._dev is None:
|
||||
return None
|
||||
try:
|
||||
cur = int((self._dev / "brightness").read_text().strip())
|
||||
return int(round(cur * 100 / self._max()))
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.error(f"Failed to read brightness: {exc}")
|
||||
return None
|
||||
|
||||
def set_brightness(self, level: int) -> bool:
|
||||
if self._dev is None:
|
||||
return False
|
||||
level = max(0, min(100, level))
|
||||
raw = int(round(level * self._max() / 100))
|
||||
try:
|
||||
(self._dev / "brightness").write_text(str(raw))
|
||||
logger.info(f"Set brightness to {level}%")
|
||||
return True
|
||||
except OSError as exc:
|
||||
logger.error(f"Failed to set brightness (permissions? see RASPBERRY_PI.md): {exc}")
|
||||
return False
|
||||
|
||||
def increase_brightness(self, amount: int = 5) -> Optional[int]:
|
||||
cur = self.get_brightness()
|
||||
if cur is None:
|
||||
return None
|
||||
new = min(100, cur + amount)
|
||||
return new if self.set_brightness(new) else None
|
||||
|
||||
def decrease_brightness(self, amount: int = 5) -> Optional[int]:
|
||||
cur = self.get_brightness()
|
||||
if cur is None:
|
||||
return None
|
||||
new = max(0, cur - amount)
|
||||
return new if self.set_brightness(new) else None
|
||||
|
||||
|
||||
class LinuxPowerController:
|
||||
"""Power via ``systemctl`` / ``shutdown`` (needs polkit rights or passwordless sudo — see RASPBERRY_PI.md)."""
|
||||
|
||||
@staticmethod
|
||||
def _priv(cmd: list[str]) -> list[str]:
|
||||
import os
|
||||
if os.geteuid() == 0 or not shutil.which("sudo"):
|
||||
return cmd
|
||||
return ["sudo", "-n", *cmd]
|
||||
|
||||
def sleep(self) -> bool:
|
||||
return _run(self._priv(["systemctl", "suspend"])) is not None
|
||||
|
||||
def shutdown(self, delay: int = 0) -> bool:
|
||||
when = f"+{max(1, (delay + 59) // 60)}" if delay > 0 else "now"
|
||||
return _run(self._priv(["shutdown", "-h", when])) is not None
|
||||
|
||||
def restart(self, delay: int = 0) -> bool:
|
||||
when = f"+{max(1, (delay + 59) // 60)}" if delay > 0 else "now"
|
||||
return _run(self._priv(["shutdown", "-r", when])) is not None
|
||||
|
||||
def cancel_shutdown(self) -> bool:
|
||||
return _run(self._priv(["shutdown", "-c"])) is not None
|
||||
|
||||
|
||||
# Global controllers — one backend per platform.
|
||||
if _IS_LINUX:
|
||||
_volume = LinuxVolumeController()
|
||||
_brightness = LinuxBrightnessController()
|
||||
_power = LinuxPowerController()
|
||||
else:
|
||||
_volume = VolumeController()
|
||||
_brightness = BrightnessController()
|
||||
_power = PowerController()
|
||||
|
||||
|
||||
def get_volume_controller() -> VolumeController:
|
||||
"""Get global volume controller."""
|
||||
return _volume
|
||||
|
||||
|
||||
def get_brightness_controller() -> BrightnessController:
|
||||
"""Get global brightness controller."""
|
||||
return _brightness
|
||||
|
||||
|
||||
def get_power_controller() -> PowerController:
|
||||
"""Get global power controller."""
|
||||
return _power
|
||||
Reference in new issue
Block a user