Merge branch 'main' into patch-1
This commit is contained in:
50 files changed
+2475
-921
No files matched your search
@@ -0,0 +1,10 @@
|
||||
*
|
||||
|
||||
!/pyproject.toml
|
||||
!/setup.cfg
|
||||
!/script/*
|
||||
!/docker-entrypoint.sh
|
||||
!/linux_voice_assistant/*
|
||||
!/sounds/*
|
||||
!/wakewords/*
|
||||
!/wakewords/openWakeWord/*
|
||||
@@ -0,0 +1,52 @@
|
||||
# Linux-Voice-Assistant - Docker Environment Configuration
|
||||
# Copy this file to .env and customize for your setup by 'cp .env.example .env'
|
||||
|
||||
### Enable debug mode (optional):
|
||||
# ENABLE_DEBUG="1"
|
||||
|
||||
### List audio devices (optional):
|
||||
# if enabled normal startup is disabled
|
||||
# LIST_DEVICES="1"
|
||||
|
||||
### User ID:
|
||||
# This is used to set the correct permissions for the accessing the audio device and accessing the PulseAudio socket
|
||||
LVA_USER_ID="1000"
|
||||
LVA_USER_GROUP="1000"
|
||||
|
||||
### Name for the client (optional):
|
||||
# by default it uses the HOSTNAME variable from the piCompose environment which includes the MAC from the network card
|
||||
# CLIENT_NAME="My Voice Assistant Speaker"
|
||||
|
||||
### PulseAudio socket path on the host:
|
||||
# PulseAudio Server: /run/user/1000/pulse
|
||||
# Pipewire Server: /run/user/1000/pulse/native
|
||||
LVA_PULSE_SERVER="unix:/run/user/${LVA_USER_ID}/pulse/native"
|
||||
LVA_XDG_RUNTIME_DIR="/run/user/${LVA_USER_ID}"
|
||||
|
||||
### Path to the preferences file (optional):
|
||||
# PREFERENCES_FILE="/app/configuration/preferences.json"
|
||||
|
||||
### Port for the api (optional):
|
||||
# PORT="6053"
|
||||
|
||||
### Audio input device (optional):
|
||||
# AUDIO_INPUT_DEVICE="default"
|
||||
|
||||
### Audio output device (optional):
|
||||
# AUDIO_OUTPUT_DEVICE="default"
|
||||
|
||||
# Enable thinking sound (optional):
|
||||
# ENABLE_THINKING_SOUND="1"
|
||||
|
||||
# Wake model (optional):
|
||||
# WAKE-MODEL="okay_nabu"
|
||||
|
||||
# Refactory seconds (optional):
|
||||
# REFACTORY_SECONDS="2"
|
||||
|
||||
# Sound files (optional):
|
||||
# WAKEUP_SOUND="sounds/wake_word_triggered.flac"
|
||||
# TIMER_FINISHED_SOUND="sounds/timer_finished.flac"
|
||||
# PROCESSING_SOUND="sounds/processing.wav"
|
||||
# MUTE_SOUND="sounds/mute_switch_on.flac"
|
||||
# UNMUTE_SOUND="sounds/mute_switch_off.flac"
|
||||
@@ -0,0 +1,77 @@
|
||||
# GitHub Actions Workflows
|
||||
|
||||
This project uses three separate Docker build workflows that automatically build and publish Docker images to GitHub Container Registry (ghcr.io).
|
||||
|
||||
## Workflows Overview
|
||||
|
||||
| Workflow | Trigger | Image Tag(s) |
|
||||
|----------|---------|--------------|
|
||||
| [docker-build-release.yml](.github/workflows/docker-build-release.yml) | Tags: `v1.2.0`, `v1.2.0-alpha1` | `1.2.0`, `1.2`, `latest` |
|
||||
| [docker-build-nightly.yml](.github/workflows/docker-build-nightly.yml) | Push to `main` | `nightly` |
|
||||
| [docker-build-branch.yml](.github/workflows/docker-build-branch.yml) | All other branches | Branch name |
|
||||
|
||||
## Detailed Description
|
||||
|
||||
### Release Workflow (`docker-build-release.yml`)
|
||||
|
||||
Publishes versioned images for new releases.
|
||||
|
||||
**Triggers:**
|
||||
- Tags matching `v[0-9]+.[0-9]+.[0-9]+` (e.g., `v1.0.0`)
|
||||
- Tags matching `v[0-9]+.[0-9]+.[0-9]+-[a-zA-Z0-9]+` (e.g., `v1.0.0-alpha1`)
|
||||
|
||||
**Image Tags:**
|
||||
```
|
||||
ghcr.io/ohf-voice/linux-voice-assistant:1.0.0
|
||||
ghcr.io/ohf-voice/linux-voice-assistant:1.0
|
||||
ghcr.io/ohf-voice/linux-voice-assistant:1
|
||||
ghcr.io/ohf-voice/linux-voice-assistant:latest
|
||||
```
|
||||
|
||||
### Nightly Workflow (`docker-build-nightly.yml`)
|
||||
|
||||
Automatically builds nightly images from the main branch.
|
||||
|
||||
**Triggers:**
|
||||
- Push to `main` branch
|
||||
|
||||
**Image Tag:**
|
||||
```
|
||||
ghcr.io/ohf-voice/linux-voice-assistant:nightly
|
||||
```
|
||||
|
||||
### Branch Workflow (`docker-build-branch.yml`)
|
||||
|
||||
Builds images for all other branches.
|
||||
|
||||
**Triggers:**
|
||||
- Push to all branches except `main`
|
||||
- No tags
|
||||
|
||||
**Image Tag:**
|
||||
```
|
||||
ghcr.io/ohf-voice/linux-voice-assistant:<branch-name>
|
||||
```
|
||||
|
||||
## Multi-Architecture Support
|
||||
|
||||
All workflows build for multiple architectures:
|
||||
- `linux/amd64` (x86_64)
|
||||
- `linux/aarch64` (ARM 64-bit, e.g., Raspberry Pi 4)
|
||||
|
||||
## Setup Requirements
|
||||
|
||||
**No additional setup needed!** The workflows use `GITHUB_TOKEN` which is automatically provided by GitHub Actions.
|
||||
|
||||
## Creating a Release
|
||||
|
||||
```bash
|
||||
git tag v1.0.0
|
||||
git push origin v1.0.0
|
||||
```
|
||||
|
||||
This creates images with the following tags:
|
||||
- `ghcr.io/ohf-voice/linux-voice-assistant:1.0.0`
|
||||
- `ghcr.io/ohf-voice/linux-voice-assistant:1.0`
|
||||
- `ghcr.io/ohf-voice/linux-voice-assistant:1`
|
||||
- `ghcr.io/ohf-voice/linux-voice-assistant:latest`
|
||||
@@ -0,0 +1,64 @@
|
||||
name: Build branch
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- "main"
|
||||
tags-ignore:
|
||||
- "**"
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
jobs:
|
||||
build-and-push-image:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Extract clean branch name
|
||||
id: extract_branch
|
||||
run: |
|
||||
echo "CLEAN_BRANCH_NAME=$(echo ${{ github.head_ref || github.ref_name }} | sed 's|.*/||')" >> $GITHUB_ENV
|
||||
echo "REGISTRY_LOWER=$(echo '${{ env.REGISTRY }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
||||
echo "REPOSITORY_LOWER=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3.1.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
# Build Docker Images (amd64 and arm64)
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@v5.3.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/aarch64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY_LOWER }}/${{ env.REPOSITORY_LOWER }}:${{ env.CLEAN_BRANCH_NAME }}
|
||||
labels: |
|
||||
branch=${{ env.CLEAN_BRANCH_NAME }}
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v1.4.4
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY }}/${{ github.repository }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: Build main
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
jobs:
|
||||
build-and-push-image:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Set lowercase registry and repository
|
||||
run: |
|
||||
echo "REGISTRY_LOWER=$(echo '${{ env.REGISTRY }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
||||
echo "REPOSITORY_LOWER=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 # v3.1.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
# Build Docker Images (amd64 and arm64)
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Build and push Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # v5.3.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/aarch64
|
||||
push: true
|
||||
tags: ${{ env.REGISTRY_LOWER }}/${{ env.REPOSITORY_LOWER }}:nightly
|
||||
labels: "nightly"
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v1.4.4
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY }}/${{ github.repository }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
name: Build release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v[0-9]+.[0-9]+.[0-9]+" # For releases, e.g. v1.2.0
|
||||
- "v[0-9]+.[0-9]+.[0-9]+-[a-zA-Z0-9]+" # For prereleases, e.g. v1.3.0-alpha1
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
jobs:
|
||||
build-and-push-image:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 # v3.1.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
# Build Docker Images (amd64 and arm64)
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5.5.1
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ github.repository }}
|
||||
# E.g. tag v1.2.0 produces 3 tags: '1.2.0', '1.2', and 'latest'
|
||||
# https://github.com/docker/metadata-action?tab=readme-ov-file#semver
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
- name: Build and push Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # v5.3.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/aarch64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v1
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY }}/${{ github.repository }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
+6
-2
@@ -15,5 +15,9 @@ venv/
|
||||
.mypy_cache/
|
||||
*.egg-info/
|
||||
|
||||
dist/
|
||||
preferences.json
|
||||
/dist/
|
||||
/preferences.json
|
||||
/local/
|
||||
|
||||
# Docker
|
||||
.env
|
||||
+6
-1
@@ -2,8 +2,13 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Add support for custom/external wake words
|
||||
- Add `--download-dir <DIR>` to store downloaded wake word models/configs
|
||||
- Switch to `soundcard` instead of `sounddevice`
|
||||
- Add `--list-input-devices` and `--list-output-devices`
|
||||
- Use `pymicro-wakeword` for microWakeWord
|
||||
- Add zeroconf/mDNS discovery
|
||||
- Support openWakeWord
|
||||
- Support openWakeWord with `pyopen-wakeword`
|
||||
- Support multiple wake words
|
||||
- Save active wake words to preferences JSON file
|
||||
- Refactor main into separate files
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
FROM python:3.13-slim-trixie
|
||||
|
||||
ENV LANG C.UTF-8
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
LABEL \
|
||||
org.opencontainers.image.authors="Open Home Foundation" \
|
||||
org.opencontainers.image.description="Voice assistant for Home Assistant" \
|
||||
org.opencontainers.image.documentation="https://github.com/OHF-Voice/linux-voice-assistant/blob/main/README.md" \
|
||||
org.opencontainers.image.licenses="Apache-2.0" \
|
||||
org.opencontainers.image.source="https://github.com/OHF-Voice/linux-voice-assistant" \
|
||||
org.opencontainers.image.title="Linux-Voice-Assistant" \
|
||||
org.opencontainers.image.url="https://github.com/OHF-Voice/linux-voice-assistant"
|
||||
|
||||
### Install packages:
|
||||
# - avahi-utils: For zeroconf/mDNS discovery by Home Assistant
|
||||
# - pulseaudio-utils: Required by soundcard library for audio I/O
|
||||
# - alsa-utils: ALSA tools for audio device management
|
||||
# - pipewire-bin: Required for pipewire support
|
||||
# - pipewire-alsa: Required for pipewire support
|
||||
# - pipewire-pulse: Required for pipewire support
|
||||
# - build-essential: Required to compile pymicro-features
|
||||
# - libmpv-dev: Required by python-mpv for audio playback
|
||||
# - libasound2-plugins: Required by python-mpv for audio playback
|
||||
# - ca-certificates: For encrypted connections
|
||||
# - iproute2: For ss command in entrypoint (port check)
|
||||
# - procps: For pgrep in healthcheck
|
||||
RUN apt-get update && \
|
||||
apt-get install --yes --no-install-recommends \
|
||||
avahi-utils \
|
||||
pulseaudio-utils \
|
||||
alsa-utils \
|
||||
pipewire-bin \
|
||||
pipewire-alsa \
|
||||
pipewire-pulse \
|
||||
build-essential \
|
||||
libmpv-dev \
|
||||
libasound2-plugins \
|
||||
ca-certificates \
|
||||
iproute2 \
|
||||
procps && \
|
||||
apt-get clean
|
||||
|
||||
### Set workdir:
|
||||
WORKDIR /app
|
||||
|
||||
### Copy all application files:
|
||||
COPY script/ ./script/
|
||||
COPY pyproject.toml ./
|
||||
COPY setup.cfg ./
|
||||
COPY sounds/ ./sounds/
|
||||
COPY ../wakewords/ ./wakewords/
|
||||
COPY linux_voice_assistant/ ./linux_voice_assistant/
|
||||
COPY docker-entrypoint.sh ./
|
||||
|
||||
### Run installation:
|
||||
RUN chmod +x docker-entrypoint.sh
|
||||
RUN ./script/setup
|
||||
|
||||
### Set ports for ESPHome API:
|
||||
EXPOSE 6053
|
||||
|
||||
### Set start script:
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
@@ -1,57 +1,78 @@
|
||||
# Linux Voice Assistant
|
||||
# Linux-Voice-Assistant
|
||||
|
||||
Experimental Linux voice assistant for [Home Assistant][homeassistant] that uses the [ESPHome][esphome] protocol/API (via [aioesphomeapi](https://github.com/esphome/aioesphomeapi)).
|
||||
[](https://github.com/OHF-Voice/linux-voice-assistant/actions/workflows/docker-build-release.yml) [](https://github.com/OHF-Voice/linux-voice-assistant/pkgs/container/linux-voice-assistant) [](https://github.com/OHF-Voice/linux-voice-assistant/blob/main/LICENSE.md) [](https://github.com/OHF-Voice/linux-voice-assistant/commits) [](https://github.com/OHF-Voice/linux-voice-assistant/pkgs/container/linux-voice-assistant)
|
||||
|
||||
Runs on Linux `aarch64` and `x86_64` platforms. Tested with Python 3.13 and Python 3.11.
|
||||
Supports announcments, start/continue conversation, and timers.
|
||||
|
||||
## Installation
|
||||
## Features:
|
||||
|
||||
Install system dependencies (`apt-get`):
|
||||
- Works with [Home Assistant](https://www.home-assistant.io/integrations/esphome/)
|
||||
- Local wake word detection using integrated [OpenWakeWord](https://github.com/dscripka/openWakeWord) or [MicroWakeWord](https://github.com/kahrendt/microWakeWord)
|
||||
- Supports multiple architectures (linux/amd64 and linux/aarch64)
|
||||
- Automated builds with artifact attestation for security
|
||||
- Supports multiple wake words and languages
|
||||
- Supports announcments, start/continue conversation, and timers
|
||||
- Tested with Python 3.13 and Python 3.11.
|
||||
- Prebuild docker image available on [GitHub Container Registry](https://github.com/OHF-Voice/linux-voice-assistant/pkgs/container/linux-voice-assistant)
|
||||
- Prebuild Raspberry Pi image
|
||||
|
||||
* `libportaudio2` or `portaudio19-dev` (for `sounddevice`)
|
||||
* `build-essential` (for `pymicro-features`)
|
||||
* `libmpv-dev` (for `python-mpv`)
|
||||
|
||||
Clone and install project:
|
||||
## Usage:
|
||||
|
||||
### Hardware:
|
||||
|
||||
You can for example use the Raspberry Pi Zero 2W with the [Satellite1 Hat Board](https://futureproofhomes.net/products/satellite1-top-microphone-board), the [Respeaker Lite](https://wiki.seeedstudio.com/reSpeaker_usb_v3/) or the [Respeaker 2Mic_Hat](https://wiki.seeedstudio.com/ReSpeaker_2_Mics_Pi_HAT/). A list for possible compatible hardware can be found in the [PiCompose documentation](https://github.com/florian-asche/PiCompose) but basically any microphone that works with [Pipewire](https://pipewire.org/) can be used with the prebuild image.
|
||||
|
||||
### Software:
|
||||
|
||||
#### Installation:
|
||||
|
||||
We have different installation methods available (Docker, systemd), each with its own dedicated instructions.
|
||||
|
||||
See [Linux-Voice-Assistant - Installation](docs/install.md).
|
||||
|
||||
#### Parameter overview:
|
||||
|
||||
``` sh
|
||||
git clone https://github.com/OHF-Voice/linux-voice-assistant.git
|
||||
cd linux-voice-assistant
|
||||
script/setup
|
||||
usage: __main__.py [-h] [--name NAME] [--audio-input-device AUDIO_INPUT_DEVICE] [--list-input-devices]
|
||||
[--audio-input-block-size AUDIO_INPUT_BLOCK_SIZE] [--audio-output-device AUDIO_OUTPUT_DEVICE]
|
||||
[--list-output-devices] [--wake-word-dir WAKE_WORD_DIR] [--wake-model WAKE_MODEL] [--stop-model STOP_MODEL]
|
||||
[--download-dir DOWNLOAD_DIR] [--refractory-seconds REFRACTORY_SECONDS] [--wakeup-sound WAKEUP_SOUND]
|
||||
[--timer-finished-sound TIMER_FINISHED_SOUND] [--processing-sound PROCESSING_SOUND] [--mute-sound MUTE_SOUND]
|
||||
[--unmute-sound UNMUTE_SOUND] [--preferences-file PREFERENCES_FILE] [--host HOST] [--port PORT]
|
||||
[--enable-thinking-sound] [--debug]
|
||||
```
|
||||
|
||||
## Running
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `--name` | Name of the voice assistant device (required) | - |
|
||||
| `--audio-input-device` | Soundcard name for input device | System default microphone |
|
||||
| `--audio-input-block-size` | Audio input block size in samples | 1024 |
|
||||
| `--audio-output-device` | mpv name for output device | System default speaker |
|
||||
| `--wake-word-dir` | Directory with wake word models (.tflite) and configs (.json) | `wakewords/` |
|
||||
| `--wake-model` | ID of active wake word model | `okay_nabu` |
|
||||
| `--stop-model` | ID of stop model | `stop` |
|
||||
| `--download-dir` | Directory to download custom wake word models, etc. | `local/` |
|
||||
| `--refractory-seconds` | Seconds before wake word can be activated again | 2.0 |
|
||||
| `--wakeup-sound` | Sound file played when wake word is detected | `sounds/wake_word_triggered.flac` |
|
||||
| `--timer-finished-sound` | Sound file played when timer finishes | `sounds/timer_finished.flac` |
|
||||
| `--processing-sound` | Sound played while assistant is processing | `sounds/processing.wav` |
|
||||
| `--mute-sound` | Sound played when muting the assistant | `sounds/mute_switch_on.flac` |
|
||||
| `--unmute-sound` | Sound played when unmuting the assistant | `sounds/mute_switch_off.flac` |
|
||||
| `--preferences-file` | Path to preferences JSON file | `preferences.json` |
|
||||
| `--host` | Address for ESPHome server | `0.0.0.0` |
|
||||
| `--port` | Port for ESPHome server | 6053 |
|
||||
| `--enable-thinking-sound` | Enable thinking sound on startup | False |
|
||||
| `--debug` | Print DEBUG messages to console | False |
|
||||
|
||||
Use `script/run` or `python3 -m linux_voice_assistant`
|
||||
## Build Information:
|
||||
|
||||
You must specify `--name <NAME>` with a name that will be available in Home Assistant.
|
||||
Image builds can be tracked in this repository's `Actions` tab, and utilize [artifact attestation](https://docs.github.com/en/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds) to certify provenance.
|
||||
|
||||
See `--help` for more options.
|
||||
The Docker images are built using GitHub Actions, which provides:
|
||||
|
||||
### Microphone
|
||||
- Automated builds for different architectures
|
||||
- Artifact attestation for build provenance verification
|
||||
- Regular updates and maintenance
|
||||
|
||||
Use `--audio-input-device` to change the microphone device. Use `python3 -m sounddevice` to see the available PortAudio devices.
|
||||
|
||||
The microphone device **must** support 16Khz mono audio.
|
||||
|
||||
### Speaker
|
||||
|
||||
Use `--audio-output-device` to change the speaker device. Use `mpv --audio-device=help` to see the available MPV devices.
|
||||
|
||||
## Wake Word
|
||||
|
||||
Change the default wake word with `--wake-model <id>` where `<id>` is the name of a model in the `wakewords` directory. For example, `--wake-model hey_jarvis` will load `wakewords/hey_jarvis.tflite` by default.
|
||||
|
||||
|
||||
## Connecting to Home Assistant
|
||||
|
||||
1. In Home Assistant, go to "Settings" -> "Device & services"
|
||||
2. Click the "Add integration" button
|
||||
3. Choose "ESPHome" and then "Set up another instance of ESPHome"
|
||||
4. Enter the IP address of your voice satellite with port 6053
|
||||
5. Click "Submit"
|
||||
|
||||
<!-- Links -->
|
||||
[homeassistant]: https://www.home-assistant.io/
|
||||
[esphome]: https://esphome.io/
|
||||
The documentation for the build process can be found in the [GitHub Actions Workflows](.github/workflow.md) file.
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
services:
|
||||
# Fix permissions for wakeword_data and configuration volume
|
||||
fix-permissions:
|
||||
image: "ghcr.io/ohf-voice/linux-voice-assistant:latest"
|
||||
entrypoint: []
|
||||
command: "chown -R ${LVA_USER_ID}:${LVA_USER_GROUP} /app/local /app/configuration"
|
||||
env_file: .env
|
||||
group_add:
|
||||
- audio
|
||||
volumes:
|
||||
# Wakeword data and configuration
|
||||
- wakeword_data:/app/local
|
||||
- configuration:/app/configuration
|
||||
restart: "no"
|
||||
|
||||
# Linux Voice Assistant
|
||||
linux-voice-assistant:
|
||||
container_name: linux-voice-assistant
|
||||
image: "ghcr.io/ohf-voice/linux-voice-assistant:latest"
|
||||
restart: unless-stopped
|
||||
network_mode: "host"
|
||||
user: "${LVA_USER_ID}:${LVA_USER_GROUP}"
|
||||
environment:
|
||||
# Pulseaudio
|
||||
- XDG_RUNTIME_DIR=${LVA_XDG_RUNTIME_DIR}
|
||||
- PULSE_SERVER=${LVA_PULSE_SERVER}
|
||||
env_file: .env
|
||||
group_add:
|
||||
- audio
|
||||
# Priority for audio process
|
||||
cap_add:
|
||||
- SYS_NICE
|
||||
volumes:
|
||||
# Wakeword data and configuration
|
||||
- wakeword_data:/app/local
|
||||
- configuration:/app/configuration
|
||||
# Time and zone
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
# Pulseaudio
|
||||
# /run/user/1000:/run/user/1000
|
||||
- ${LVA_XDG_RUNTIME_DIR}:${LVA_XDG_RUNTIME_DIR}
|
||||
depends_on:
|
||||
- fix-permissions
|
||||
# Health check for process
|
||||
healthcheck:
|
||||
test: ["CMD", "pgrep", "-f", "linux_voice_assistant"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 90s
|
||||
#command:
|
||||
# - --additional-parameters-here
|
||||
# - --but-use-env-if-possible
|
||||
|
||||
|
||||
volumes:
|
||||
wakeword_data:
|
||||
configuration:
|
||||
Executable
+119
@@ -0,0 +1,119 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
### Generate name for this client
|
||||
# Get active interface
|
||||
IFACE=$(ip route | grep default | awk '{print $5}' | head -n 1)
|
||||
|
||||
# Check if IFACE is empty
|
||||
if [ -z "$IFACE" ]; then
|
||||
echo "No active network interface found."
|
||||
fi
|
||||
|
||||
# Check if interface directory exists
|
||||
if [ ! -e "/sys/class/net/$IFACE/address" ]; then
|
||||
echo "Interface $IFACE does not exist or has no MAC address."
|
||||
fi
|
||||
|
||||
# Read MAC address and remove ':'
|
||||
MAC=$(cat /sys/class/net/$IFACE/address | tr -d ':')
|
||||
|
||||
if [ -z "$MAC" ]; then
|
||||
echo "Could not read MAC address."
|
||||
fi
|
||||
|
||||
# Generate CLIENT_NAME from it
|
||||
TEMP_CLIENT_NAME="lva-${MAC}"
|
||||
|
||||
|
||||
### Handlers
|
||||
# Handle parameters
|
||||
EXTRA_ARGS=""
|
||||
|
||||
if [ "$ENABLE_DEBUG" = "1" ]; then
|
||||
EXTRA_ARGS="$EXTRA_ARGS --debug"
|
||||
fi
|
||||
|
||||
CLIENT_NAME=${CLIENT_NAME:-$TEMP_CLIENT_NAME}
|
||||
if [ -n "${CLIENT_NAME}" ]; then
|
||||
EXTRA_ARGS="$EXTRA_ARGS --name $CLIENT_NAME"
|
||||
fi
|
||||
|
||||
PREFERENCES_FILE=${PREFERENCES_FILE:-"/app/configuration/preferences.json"}
|
||||
if [ -n "${PREFERENCES_FILE}" ]; then
|
||||
EXTRA_ARGS="$EXTRA_ARGS --preferences-file $PREFERENCES_FILE"
|
||||
fi
|
||||
|
||||
PORT=${PORT:-6053}
|
||||
if [ -n "${PORT}" ]; then
|
||||
EXTRA_ARGS="$EXTRA_ARGS --port $PORT"
|
||||
fi
|
||||
|
||||
if [ -n "${AUDIO_INPUT_DEVICE}" ]; then
|
||||
EXTRA_ARGS="$EXTRA_ARGS --audio-input-device $AUDIO_INPUT_DEVICE"
|
||||
fi
|
||||
|
||||
if [ -n "${AUDIO_OUTPUT_DEVICE}" ]; then
|
||||
EXTRA_ARGS="$EXTRA_ARGS --audio-output-device $AUDIO_OUTPUT_DEVICE"
|
||||
fi
|
||||
|
||||
if [ "$ENABLE_THINKING_SOUND" = "1" ]; then
|
||||
EXTRA_ARGS="$EXTRA_ARGS --enable-thinking-sound"
|
||||
fi
|
||||
|
||||
|
||||
### Wait for PulseAudio
|
||||
# Wait for PulseAudio to be available before starting the application
|
||||
CP_MAX_RETRIES=30
|
||||
CP_RETRY_DELAY=1
|
||||
### while maybe besser?
|
||||
echo "Checking port $PORT..."
|
||||
for i in $(seq 1 $CP_MAX_RETRIES); do
|
||||
# Check if PulseAudio is running
|
||||
if pactl info >/dev/null 2>&1; then
|
||||
echo "✅ PulseAudio is running"
|
||||
break
|
||||
fi
|
||||
|
||||
if [ $i -eq $CP_MAX_RETRIES ]; then
|
||||
echo "❌ PulseAudio did not start after $CP_MAX_RETRIES seconds"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "⏳ PulseAudio not running yet, retrying in $CP_RETRY_DELAY s..."
|
||||
sleep $CP_RETRY_DELAY
|
||||
done
|
||||
|
||||
|
||||
### Check port availability
|
||||
# PORT variable is used from env
|
||||
PA_MAX_RETRIES=30
|
||||
PA_RETRY_DELAY=2
|
||||
echo "Checking port $PORT..."
|
||||
for i in $(seq 1 $PA_MAX_RETRIES); do
|
||||
# Wait for port to be free (in case of rapid restarts)
|
||||
if ! ss -tln | grep -q ":${PORT} "; then
|
||||
echo "Port $PORT is available"
|
||||
break
|
||||
fi
|
||||
|
||||
if [ $i -eq $PA_MAX_RETRIES ]; then
|
||||
echo "ERROR: Port $PORT still in use after $((PA_MAX_RETRIES * PA_RETRY_DELAY)) seconds"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "Attempt $i/$PA_MAX_RETRIES: Port $PORT in use, waiting ${PA_RETRY_DELAY}s..."
|
||||
sleep $PA_RETRY_DELAY
|
||||
done
|
||||
|
||||
|
||||
### Start application
|
||||
if [ "$LIST_DEVICES" = "1" ]; then
|
||||
echo "list input devices"
|
||||
./script/run "$@" $EXTRA_ARGS --list-input-devices
|
||||
echo "list output devices"
|
||||
./script/run "$@" $EXTRA_ARGS --list-output-devices
|
||||
else
|
||||
echo "starting application"
|
||||
exec ./script/run "$@" $EXTRA_ARGS
|
||||
fi
|
||||
@@ -0,0 +1,36 @@
|
||||
# Additional Audio Settings
|
||||
|
||||
This document is still in an early stage and will be updated soon.
|
||||
|
||||
## Pipewire:
|
||||
|
||||
tbd
|
||||
|
||||
## Pulseaudio:
|
||||
|
||||
### Acoustic Echo Cancellation:
|
||||
|
||||
Enable the echo cancel PulseAudio module:
|
||||
|
||||
``` sh
|
||||
pactl load-module module-echo-cancel \
|
||||
aec_method=webrtc \
|
||||
aec_args="analog_gain_control=0 digital_gain_control=1 noise_suppression=1"
|
||||
```
|
||||
|
||||
Verify that the `echo-cancel-source` and `echo-cancel-sink` devices are present:
|
||||
|
||||
``` sh
|
||||
pactl list short sources
|
||||
pactl list short sinks
|
||||
```
|
||||
|
||||
Use the new devices:
|
||||
|
||||
``` sh
|
||||
# The device names may be different on your system.
|
||||
# Double check with --list-input-devices and --list-output-devices
|
||||
python3 -m linux_voice_assistant ... \
|
||||
--audio-input-device 'Echo-Cancel Source' \
|
||||
--audio-output-device 'pipewire/echo-cancel-sink'
|
||||
```
|
||||
@@ -0,0 +1,165 @@
|
||||
|
||||
# Debugging
|
||||
|
||||
## Explanation of the audio system:
|
||||
|
||||
The Linux audio stack consists of multiple layers working together:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ HOST SYSTEM │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ User Applications │ │
|
||||
│ │ (Browser, Media Player, Voice Assistant, etc.) │ │
|
||||
│ └────────────────┬────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────────────▼────────────────────────────────────┐ │
|
||||
│ │ PipeWire / PulseAudio │ │
|
||||
│ │ (Sound Server) │ │
|
||||
│ │ - Audio mixing & routing │ │
|
||||
│ │ - Device abstraction │ │
|
||||
│ │ - Network audio support │ │
|
||||
│ │ - Per-application volume control │ │
|
||||
│ └────────────────┬────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────────────▼────────────────────────────────────┐ │
|
||||
│ │ ALSA │ │
|
||||
│ │ (Advanced Linux Sound Architecture) │ │
|
||||
│ │ - Kernel-level audio driver framework │ │
|
||||
│ │ - Direct hardware access │ │
|
||||
│ │ - Controls: /dev/snd/ │ │
|
||||
│ └────────────────┬────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────────────▼────────────────────────────────────┐ │
|
||||
│ │ Hardware (Sound Card) │ │
|
||||
│ │ ┌─────────┐ ┌─────────┐ ┌───────────────────┐ │ │
|
||||
│ │ │Microphon│ │ Speaker │ │ DSP/Codec │ │ │
|
||||
│ │ └─────────┘ └─────────┘ └───────────────────┘ │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ LVA_XDG_RUNTIME_DIR=/run/user/1000 │
|
||||
│ └── Socket: /run/user/1000/pipewire-0 │
|
||||
│ Socket: /run/user/1000/pulse/native │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ Device Passthrough
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ DOCKER CONTAINER │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Container Process │ │
|
||||
│ │ (Linux Voice Assistant) │ │
|
||||
│ │ │ │
|
||||
│ │ Accesses audio via: │ │
|
||||
│ │ - PipeWire/PulseAudio client library │ │
|
||||
│ │ - Uses same LVA_XDG_RUNTIME_DIR socket path │ │
|
||||
│ │ │ │
|
||||
│ │ Environment Variables: │ │
|
||||
│ │ - LVA_XDG_RUNTIME_DIR=/run/user/1000 (host path) │ │
|
||||
│ │ - AUDIO_INPUT_DEVICE="default" │ │
|
||||
│ │ - AUDIO_OUTPUT_DEVICE="default" │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Device Access: │
|
||||
│ - Socket: /run/user/1000/pipewire-0 (volume mount) │
|
||||
│ - Socket: /run/user/1000/pulse/native (volume mount) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### How it works:
|
||||
|
||||
1. **Host Layer**:
|
||||
- ALSA provides kernel drivers for audio hardware
|
||||
- PipeWire/PulseAudio sits on top of ALSA as a sound server
|
||||
- Applications connect to PipeWire/PulseAudio, not directly to ALSA, otherwhise the container would need access to the ALSA devices. If you use ALSA only only one application can connect to the device at a time.
|
||||
|
||||
2. **Docker Integration**:
|
||||
- The LVA_XDG_RUNTIME_DIR socket must be mounted from host to container
|
||||
- The container user must have matching UID/GID with host user or the audio group is added.
|
||||
|
||||
3. **Communication Flow**:
|
||||
```
|
||||
Container App → PipeWire Client Library → Host PipeWire/PulseAudio
|
||||
↓ ↓
|
||||
(via mounted socket) (via ALSA drivers)
|
||||
↓ ↓
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Audio Data flows to Hardware │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
4. **Key Configuration Points**:
|
||||
- `LVA_XDG_RUNTIME_DIR` must point to host's runtime directory
|
||||
- Container user UID/GID should match host user
|
||||
|
||||
### Docker Compose Example:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
voice-assistant:
|
||||
image: ghcr.io/ohf-voice/linux-voice-assistant:latest
|
||||
environment:
|
||||
- LVA_XDG_RUNTIME_DIR=/run/user/1000
|
||||
- AUDIO_INPUT_DEVICE=default
|
||||
- AUDIO_OUTPUT_DEVICE=default
|
||||
volumes:
|
||||
- /run/user/1000:/run/user/1000:rw
|
||||
devices:
|
||||
- /dev/snd:/dev/snd
|
||||
group_add:
|
||||
- audio
|
||||
```
|
||||
|
||||
|
||||
## List available audio devices:
|
||||
|
||||
To list available audio devices, run:
|
||||
|
||||
```bash
|
||||
# List audio devices
|
||||
docker run --rm -it -e LIST_DEVICES="1" ghcr.io/florian-asche/linux-voice-assistant:develop-final-docker-version
|
||||
```
|
||||
|
||||
Update the `.env` file with your device names:
|
||||
|
||||
``` ini
|
||||
AUDIO_INPUT_DEVICE="default"
|
||||
AUDIO_OUTPUT_DEVICE="default"
|
||||
```
|
||||
|
||||
|
||||
## Pipewire runtime directory (Optional):
|
||||
|
||||
If you work with the root user, you need to:
|
||||
``` sh
|
||||
export LVA_XDG_RUNTIME_DIR=/run/user/${USERLVA_USER_ID
|
||||
```
|
||||
|
||||
💡 **Note:** Replace `$USERLVA_USER_IDth your actual user id that you want to run the voice assistant.
|
||||
|
||||
|
||||
## Troubleshooting:
|
||||
|
||||
### Audio Device Not Found:
|
||||
|
||||
If the container cannot access audio devices, ensure:
|
||||
|
||||
1. The USERLVA_USER_ID`.env` matches your actual users ID (run `id -u $USER` to check)
|
||||
2. The LVA_XDG_RUNTIME_DIR path exists on the host
|
||||
|
||||
### Permission Denied Errors:
|
||||
|
||||
tbd
|
||||
|
||||
### Low Audio Quality:
|
||||
|
||||
If you experience audio quality issues:
|
||||
|
||||
1. Check the audio device settings with `alsamixer`
|
||||
2. Try setting `AUDIO_INPUT_DEVICE` and `AUDIO_OUTPUT_DEVICE` explicitly
|
||||
3. Adjust the microphone gain in your system settings
|
||||
|
||||
|
||||
## More debugging documentation:
|
||||
|
||||
See [PiCompose](https://github.com/florian-asche/PiCompose/blob/main/docs/pipewire_debugging.md) for more debugging information.
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
# Linux-Voice-Assistant - Installation
|
||||
|
||||
This guide describes how to install PipeWire, Docker, and then set up the Linux-Voice-Assistant using docker-compose manually. The Linux-Voice-Assistant is a voice assistant that runs locally and supports wake word detection, voice activity detection, and audio playback.
|
||||
|
||||
## Prerequisites:
|
||||
|
||||
- A Linux system (tested on Debian/Raspberry Pi OS/Ubuntu)
|
||||
- Docker and Docker Compose installed (Optional but preffered)
|
||||
- A microphone and speaker for audio input/output
|
||||
|
||||
## Overview:
|
||||
|
||||
The installation process can be divided into the following steps:
|
||||
|
||||
```
|
||||
+-----------------------------------------------------------------------+
|
||||
| LINUX-VOICE-ASSISTANT INSTALLATION |
|
||||
+-----------------------------------------------------------------------+
|
||||
|
|
||||
v
|
||||
+-------------------------------+
|
||||
| 0) Use Prebuilt Pi Image? |
|
||||
| +---------+---------+ |
|
||||
| | YES | NO | |
|
||||
| +---------+---------+ |
|
||||
+-------------------------------+
|
||||
/ \
|
||||
/ \
|
||||
v v
|
||||
+-------+ +-----------------------+
|
||||
| READY | | 1) INSTALL AUDIO SYS |
|
||||
| | +-----------------------+
|
||||
+-------+ |
|
||||
v
|
||||
+-------------------------------------------+
|
||||
| Audio Service Options |
|
||||
| +-------------+ +-------------+ |
|
||||
| | Existing | | Install | |
|
||||
| | Pipe/Pulse | | Audio Sys | |
|
||||
| +-------------+ +-------------+ |
|
||||
| | | |
|
||||
| v v |
|
||||
| +-------+ +-----+----------+ |
|
||||
| |READY | | a) PipeWire | |
|
||||
| +-------+ | b) PulseAudio | |
|
||||
| +-----+----------+ |
|
||||
| | |
|
||||
+--------------------------+----------------+
|
||||
/ \
|
||||
v v
|
||||
+---------------------+ +---------------------+
|
||||
| Audio Ready | | 2) INSTALL APP |
|
||||
+---------------------+ +---------------------+
|
||||
|
|
||||
v
|
||||
+---------------------------+
|
||||
| Application Options |
|
||||
| +-----------------------+ |
|
||||
| | a) Docker Compose | |
|
||||
| | b) Bare Metal | |
|
||||
| +-----------------------+ |
|
||||
| | |
|
||||
+-----------+-------#-------+
|
||||
|
|
||||
v
|
||||
+---------------------------+
|
||||
| Application Ready |
|
||||
+---------------------------+
|
||||
|
||||
```
|
||||
|
||||
## Installation Steps:
|
||||
|
||||
### Option A) Use Prebuilt Pi Image:
|
||||
|
||||
The easyiest way to get started is to use the prebuilt image. This image comes with all necessary configurations and drivers preinstalled.
|
||||
|
||||
- Download ready-to-use image from [PiCompose](https://github.com/florian-asche/PiCompose)
|
||||
- Change configuration in /compose/ directory if you need to.
|
||||
- You may need to reboot the board like 3 times for everything to be installed. Especially for the 2MicHat drivers. And give docker-compose some time to download the images. You can watch the logs in /var/logs/picompose.log
|
||||
- More information within the PiCompose project.
|
||||
|
||||
### Option B) Install on your own:
|
||||
|
||||
If you want to setup the software on your own hardware, for example if you want to use LVA on your linux desktop, you can use the docker image.
|
||||
|
||||
#### Step 1: Install Audioservice:
|
||||
|
||||
It is on your own to choose the audio service you want to use.
|
||||
|
||||
- **a) Existing PipeWire/PulseAudio** Use if already installed
|
||||
- **b) Install PipeWire** (recommended): Install PipeWire and configure. See [Install Audioservice - Pipewire](install_audioserver.md)
|
||||
- **c) Install PulseAudio** Install PulseAudio as alternative. See [Install Audioservice - PulseAudio](install_audioserver.md)
|
||||
|
||||
#### Step 2: Install Application:
|
||||
|
||||
You can run the application in two ways:
|
||||
|
||||
- **a) Docker Compose** (recommended): Install Docker, download compose files, configure and start. See [Install Application - Docker Compose](install_application.md)
|
||||
- **b) Bare Metal** Install dependencies, clone repo, setup and create systemd service. See [Install Application - Bare Metal](install_application.md)
|
||||
@@ -0,0 +1,335 @@
|
||||
# Install Application
|
||||
|
||||
You can install the application in different ways. We recommend to use Docker Compose if not the prebuilt image. But if you dont want to use Docker you can also install it directly on your system.
|
||||
|
||||
## A) Docker Compose (recommended):
|
||||
|
||||
Install packages:
|
||||
``` sh
|
||||
sudo apt-get install -y ca-certificates curl wget gnupg lsb-release git jq vim
|
||||
```
|
||||
|
||||
Download and add Docker's official GPG key:
|
||||
``` sh
|
||||
mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
sudo chmod a+r /etc/apt/keyrings/docker.gpg
|
||||
```
|
||||
|
||||
Set up the Docker repository:
|
||||
``` sh
|
||||
echo \
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
|
||||
https://download.docker.com/linux/debian \
|
||||
$(lsb_release -cs) stable" | \
|
||||
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
```
|
||||
|
||||
Install Docker and Docker Compose:
|
||||
``` sh
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
```
|
||||
|
||||
Download the docker-compose.yml and .env.example file from the repository to a folder on your system:
|
||||
``` sh
|
||||
mkdir linux-voice-assistant
|
||||
cd linux-voice-assistant
|
||||
wget https://raw.githubusercontent.com/OHF-Voice/linux-voice-assistant/refs/tags/v1.0.0/docker-compose.yml
|
||||
wget https://raw.githubusercontent.com/OHF-Voice/linux-voice-assistant/refs/tags/v1.0.0/.env.example
|
||||
mv .env.example .env
|
||||
```
|
||||
|
||||
💡 **Note:** Use the latest stable version of the files from the repository. We update this documentation only regulary.
|
||||
|
||||
| Tag | Description | Example |
|
||||
|-----|-------------|---------|
|
||||
| `latest` | Latest stable release | `ghcr.io/ohf-voice/linux-voice-assistant:latest` |
|
||||
| `nightly` | Latest development build | `ghcr.io/ohf-voice/linux-voice-assistant:nightly` |
|
||||
| `x.y.z` | Specific version release | `ghcr.io/ohf-voice/linux-voice-assistant:1.0.0` |
|
||||
| `x.y` | Major.Minor with auto updates | `ghcr.io/ohf-voice/linux-voice-assistant:1.0` |
|
||||
| `<branch>` | Branch-specific build | `ghcr.io/ohf-voice/linux-voice-assistant:my-branch` |
|
||||
|
||||
Edit the .env file and change the values to your needs:
|
||||
``` sh
|
||||
vim .env
|
||||
```
|
||||
|
||||
```ini
|
||||
# Linux-Voice-Assistant - Docker Environment Configuration
|
||||
# Copy this file to .env and customize for your setup by 'cp .env.example .env'
|
||||
|
||||
### Enable debug mode (optional):
|
||||
# ENABLE_DEBUG="1"
|
||||
|
||||
### List audio devices (optional):
|
||||
# if enabled normal startup is disabled
|
||||
# LIST_DEVICES="1"
|
||||
|
||||
### User ID:
|
||||
# This is used to set the correct permissions for the accessing the audio device and accessing the PulseAudio socket
|
||||
USERLVA_USER_ID00"
|
||||
LVA_USER_GROUP="1000"
|
||||
|
||||
### Name for the client (optional):
|
||||
# by default it uses the HOSTNAME variable from the piCompose environment which includes the MAC from the network card
|
||||
# CLIENT_NAME="My Voice Assistant Speaker"
|
||||
|
||||
### PulseAudio socket path on the host:
|
||||
# PulseAudio Server: /run/user/1000/pulse
|
||||
# Pipewire Server: /run/user/1000/pulse/native
|
||||
LVA_PULSE_SERVER="unix:/run/user/${USERLVA_USER_IDlse/native"
|
||||
LVA_XDG_RUNTIME_DIR="/run/user/${USERLVA_USER_ID
|
||||
|
||||
### Path to the preferences file (optional):
|
||||
# PREFERENCES_FILE="/app/configuration/preferences.json"
|
||||
|
||||
### Port for the api (optional):
|
||||
# PORT="6053"
|
||||
|
||||
### Audio input device (optional):
|
||||
# AUDIO_INPUT_DEVICE="default"
|
||||
|
||||
### Audio output device (optional):
|
||||
# AUDIO_OUTPUT_DEVICE="default"
|
||||
|
||||
# Enable thinking sound (optional):
|
||||
# ENABLE_THINKING_SOUND="1"
|
||||
|
||||
# Wake model (optional):
|
||||
# WAKE-MODEL="okay_nabu"
|
||||
|
||||
# Refactory seconds (optional):
|
||||
# REFACTORY_SECONDS="2"
|
||||
|
||||
# Sound files (optional):
|
||||
# WAKEUP_SOUND="sounds/wake_word_triggered.flac"
|
||||
# TIMER_FINISHED_SOUND="sounds/timer_finished.flac"
|
||||
# PROCESSING_SOUND="sounds/processing.wav"
|
||||
# MUTE_SOUND="sounds/mute_switch_on.flac"
|
||||
# UNMUTE_SOUND="sounds/mute_switch_off.flac"
|
||||
```
|
||||
|
||||
You can change various settings here, for example the audio sounds which are played when the wake word is detected or when the timer is finished.
|
||||
|
||||
💡 **Note:** You can exit vim with `:wq` or `:q!` if you dont want to save the changes.
|
||||
|
||||
Start the application:
|
||||
``` sh
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
💡 **Note:** If you want to use the application with a different user, you need to change the user in the .env file. Dont forget to change the UID from the user. The docker container will run until you stop it. It will restart autiomatically after a reboot.
|
||||
|
||||
Check if the application is running:
|
||||
``` sh
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Check the logs:
|
||||
``` sh
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
Stop the service:
|
||||
``` sh
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
Download the latest image:
|
||||
``` sh
|
||||
docker-compose pull
|
||||
```
|
||||
|
||||
|
||||
## B) Bare Metal:
|
||||
|
||||
Install the required packages for the application:
|
||||
|
||||
``` sh
|
||||
sudo apt update
|
||||
sudo apt-get install \
|
||||
avahi-utils \
|
||||
pulseaudio-utils \
|
||||
alsa-utils \
|
||||
pipewire-bin \
|
||||
pipewire-alsa \
|
||||
pipewire-pulse \
|
||||
build-essential \
|
||||
libmpv-dev \
|
||||
libasound2-plugins \
|
||||
ca-certificates \
|
||||
iproute2 \
|
||||
procps \
|
||||
git \
|
||||
jq \
|
||||
curl \
|
||||
wget \
|
||||
vim \
|
||||
python3-venv \
|
||||
python3-dev
|
||||
```
|
||||
|
||||
Clone the repository:
|
||||
|
||||
``` sh
|
||||
git clone https://github.com/OHF-Voice/linux-voice-assistant.git
|
||||
cd linux-voice-assistant
|
||||
chmod +x docker-entrypoint.sh
|
||||
```
|
||||
|
||||
Install the application:
|
||||
|
||||
``` sh
|
||||
script/setup
|
||||
```
|
||||
|
||||
Create a systemd service for the application:
|
||||
|
||||
``` sh
|
||||
sudo systemctl edit --force --full linux-voice-assistant.service
|
||||
```
|
||||
|
||||
Paste the following content into the file:
|
||||
|
||||
``` ini
|
||||
[Unit]
|
||||
Description=Linux-Voice-Assistant
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pi
|
||||
WorkingDirectory=/home/pi/linux-voice-assistant
|
||||
|
||||
Environment=PATH=/home/pi/linux-voice-assistant/.venv/bin:/usr/bin:/bin
|
||||
# Environment=ENABLE_DEBUG="1"
|
||||
# Environment=LIST_DEVICES="1"
|
||||
Environment=USERLVA_USER_ID00"
|
||||
Environment=LVA_USER_GROUP="1000"
|
||||
Environment=CLIENT_NAME="My Voice Assistant Speaker"
|
||||
Environment=LVA_PULSE_SERVER="unix:/run/user/${USERLVA_USER_IDlse/native"
|
||||
Environment=LVA_XDG_RUNTIME_DIR="/run/user/${USERLVA_USER_ID
|
||||
Environment=PREFERENCES_FILE="/home/pi/linux-voice-assistant/preferences.json"
|
||||
Environment=PORT="6053"
|
||||
# Environment=AUDIO_INPUT_DEVICE="default"
|
||||
# Environment=AUDIO_OUTPUT_DEVICE="default"
|
||||
# Environment=ENABLE_THINKING_SOUND="1"
|
||||
# Environment=WAKE-MODEL="okay_nabu"
|
||||
# Environment=REFACTORY_SECONDS="2"
|
||||
# Environment=WAKEUP_SOUND="sounds/wake_word_triggered.flac"
|
||||
# Environment=TIMER_FINISHED_SOUND="sounds/timer_finished.flac"
|
||||
# Environment=PROCESSING_SOUND="sounds/processing.wav"
|
||||
# Environment=MUTE_SOUND="sounds/mute_switch_on.flac"
|
||||
# Environment=UNMUTE_SOUND="sounds/mute_switch_off.flac"
|
||||
# ExecStart=/home/pi/linux-voice-assistant/docker-entrypoint.sh --additional-parameter-if-you-want
|
||||
ExecStart=/home/pi/linux-voice-assistant/docker-entrypoint.sh
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
💡 **Note:** We are using the `docker-entrypoint.sh` script to start the application. This script is located in the root of the repository. But there is no docker used. Only the start script is used.
|
||||
|
||||
💡 **Note:** Replace `USERLVA_USER_IDth your actual user id that you want to run the voice assistant. Do the same for `LVA_USER_GROUP`.
|
||||
|
||||
Reload the systemd daemon and start the service:
|
||||
|
||||
``` sh
|
||||
sudo systemctl daemon-reexec
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable linux-voice-assistant
|
||||
sudo systemctl start linux-voice-assistant
|
||||
```
|
||||
|
||||
💡 **Note:** If you want to change settings you can do this with editing the Environment in the service file.
|
||||
|
||||
If you want to check if the service is running you can use the following command:
|
||||
|
||||
``` sh
|
||||
sudo systemctl status linux-voice-assistant
|
||||
```
|
||||
|
||||
|
||||
## Connecting to Home Assistant:
|
||||
|
||||
1. In Home Assistant, go to "Settings" -> "Device & services"
|
||||
2. Click the "Add integration" button
|
||||
3. Choose "ESPHome" and then "Set up another instance of ESPHome"
|
||||
4. Enter the IP address of your voice satellite with port 6053
|
||||
5. Click "Submit"
|
||||
|
||||
|
||||
## Additional Information:
|
||||
|
||||
### Settings:
|
||||
All optional settings except the audio sounds are disabled by default. You can change them in the UI and they will be saved in the PREFERENCES_FILE. If you set the configuration in the service file, the corresponding settings will be overwritten and you are no longer able to change them in the UI.
|
||||
|
||||
If you want to change the audio sounds you need to change the paths in the service file.
|
||||
|
||||
#### Environment Variables Reference:
|
||||
|
||||
The following variables can be configured in the `.env` or in the service file:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `USERLVA_USER_ID`1000` | User ID for the container (usually 1000 for the first user) |
|
||||
| `GROUP_ID` | `1000` | GROUP ID for the container (usually 1000 for the first users group) |
|
||||
| `CLIENT_NAME` | (optional) | Custom name for this voice assistant instance |
|
||||
| `LVA_PULSE_SERVER` | `unix:/run/user/${USERLVA_USER_IDlse/native` | Path to the PulseAudio/PipeWire socket |
|
||||
| `LVA_XDG_RUNTIME_DIR` | `/run/user/${USERLVA_USER_ID XDG runtime directory |
|
||||
| `ENABLE_DEBUG` | (optional) | Set to "1" to enable debug mode |
|
||||
| `LIST_DEVICES` | (optional) | Set to "1" to list audio devices instead of starting |
|
||||
| `PREFERENCES_FILE` | (optional) | Path to a custom preferences JSON file |
|
||||
| `PORT` | `6053` | API server port |
|
||||
| `AUDIO_INPUT_DEVICE` | `default` | Audio input device name |
|
||||
| `AUDIO_OUTPUT_DEVICE` | `default` | Audio output device name |
|
||||
| `ENABLE_THINKING_SOUND` | (optional) | Set to "1" to enable thinking sound |
|
||||
| `WAKE_MODEL` | `okay_nabu` | Wake word model to use |
|
||||
| `REFACTORY_SECONDS` | `2` | Refractory period in seconds after wake word |
|
||||
| `WAKEUP_SOUND` | `sounds/wake_word_triggered.flac` | Sound file for wake word triggered |
|
||||
| `TIMER_FINISHED_SOUND` | `sounds/timer_finished.flac` | Sound file for timer finished |
|
||||
| `PROCESSING_SOUND` | `sounds/processing.wav` | Sound file for processing state |
|
||||
| `MUTE_SOUND` | `sounds/mute_switch_on.flac` | Sound file for mute on |
|
||||
| `UNMUTE_SOUND` | `sounds/mute_switch_off.flac` | Sound file for Configure Audio Devices
|
||||
|
||||
#### Available Wake Word Models:
|
||||
|
||||
The following wake word models are available:
|
||||
- `okay_nabu` - Default wake word
|
||||
- `alexa` - Alexa wake word
|
||||
- `hey_jarvis` - Jarvis wake word
|
||||
- `hey_mycroft` - Mycroft wake word
|
||||
- `hey_luna` - Luna wake word
|
||||
- `hey_home_assistant` - Home Assistant wake word
|
||||
- `stop` - Stop wake word
|
||||
- `okay_computer` - Okay Computer wake word
|
||||
- `choo_choo_homie` - Choo Choo Homie wake word
|
||||
|
||||
|
||||
### Wake Word:
|
||||
|
||||
💡 **Note:** The default value for this parameter is the `wakewords` directory in the repository.
|
||||
|
||||
Change the default wake word with `--wake-model <id>` where `<id>` is the name of a model in the `wakewords` directory. For example, `--wake-model hey_jarvis` will load `wakewords/hey_jarvis.tflite` by default.
|
||||
|
||||
You can include more wakeword directories by adding `--wake-word-dir <DIR>` where `<DIR>` contains either [microWakeWord][] or [openWakeWord][] config files and `.tflite` models. For example, `--wake-word-dir wakewords/openWakeWord` will include the default wake words for openWakeWord.
|
||||
|
||||
If you want to add [other wakewords][wakewords-collection], make sure to create a small JSON config file to identify it as an openWakeWord model. For example, download the [GLaDOS][glados] model to `glados.tflite` and create `glados.json` with:
|
||||
|
||||
``` json
|
||||
{
|
||||
"type": "openWakeWord",
|
||||
"wake_word": "GLaDOS",
|
||||
"model": "glados.tflite"
|
||||
}
|
||||
```
|
||||
|
||||
Add `--wake-word-dir <DIR>` with the directory containing `glados.tflite` and `glados.json` to your command-line.
|
||||
|
||||
#### Additional Wakewords:
|
||||
|
||||
More wakewords can be found in the [wakewords-collection](https://github.com/fwartner/home-assistant-wakewords-collection) repository.
|
||||
@@ -0,0 +1,135 @@
|
||||
# Install Audioservice
|
||||
|
||||
For Linux-Voice-Assistant a Pulseaudio connection to the soundcard is required. Since PulseAudio is not installed by default on Ubuntu 22.04 we also support Pipewire. You can choose either one (A or B) of them.
|
||||
|
||||
## A) Pipewire (recommended):
|
||||
|
||||
PipeWire is a multimedia server that provides low-latency audio/video handling. Install it with the following commands:
|
||||
|
||||
``` sh
|
||||
# Update package database
|
||||
sudo apt update
|
||||
|
||||
# Install PipeWire and related packages
|
||||
sudo apt install -y pipewire wireplumber pipewire-audio-client-libraries libspa-0.2-bluetooth pipewire-audio pipewire-pulse dfu-util
|
||||
```
|
||||
|
||||
Link the PipeWire configuration for ALSA applicationssudo ln -sf:
|
||||
|
||||
``` sh
|
||||
ln -s /usr/share/alsa/alsa.conf.d/50-pipewire.conf /etc/alsa/conf.d/
|
||||
```
|
||||
|
||||
Allow services to run without an active user session (optional, for headless setups):
|
||||
|
||||
``` sh
|
||||
sudo mkdir -p /var/lib/systemd/linger
|
||||
sudo touch /var/lib/systemd/linger/$USER
|
||||
```
|
||||
|
||||
💡 **Note:** Replace `$USER` with your actual username that you want to run the voice assistant.
|
||||
|
||||
### Configure PipeWire (optional):
|
||||
|
||||
Create the PipeWire configuration directory:
|
||||
|
||||
``` sh
|
||||
mkdir -p "/etc/pipewire"
|
||||
```
|
||||
|
||||
Create the file `/etc/pipewire/pipewire.conf` with the following content (minimal configuration for voice assistant use):
|
||||
|
||||
``` ini
|
||||
# Daemon config file for PipeWire
|
||||
context.properties = {
|
||||
link.max-buffers = 16
|
||||
mem.warn-mlock = true
|
||||
log.level = 3
|
||||
context.num-data-loops = 1
|
||||
core.daemon = true
|
||||
core.name = pipewire-0
|
||||
default.clock.rate = 16000
|
||||
}
|
||||
|
||||
context.modules = [
|
||||
{ name = libpipewire-module-rt
|
||||
args = {
|
||||
nice.level = -11
|
||||
rt.prio = 88
|
||||
}
|
||||
flags = [ ifexists nofail ]
|
||||
}
|
||||
{ name = libpipewire-module-protocol-native }
|
||||
{ name = libpipewire-module-profiler }
|
||||
{ name = libpipewire-module-metadata }
|
||||
{ name = libpipewire-module-spa-device-factory }
|
||||
{ name = libpipewire-module-spa-node-factory }
|
||||
{ name = libpipewire-module-client-node }
|
||||
{ name = libpipewire-module-client-device }
|
||||
{ name = libpipewire-module-portal
|
||||
flags = [ ifexists nofail ]
|
||||
}
|
||||
{ name = libpipewire-module-access
|
||||
condition = [ { module.access = true } ]
|
||||
}
|
||||
{ name = libpipewire-module-adapter }
|
||||
{ name = libpipewire-module-link-factory }
|
||||
{ name = libpipewire-module-session-manager }
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
## B) PulseAudio:
|
||||
|
||||
Make sure that you only run Pulseaudio and there is no Pipewire installed.
|
||||
|
||||
sudo apt remove --purge pipewire pipewire-pulse wireplumber
|
||||
sudo apt autoremove
|
||||
|
||||
Install Pulseaudio
|
||||
|
||||
``` sh
|
||||
sudo apt install pulseaudio pulseaudio-utils dfu-util
|
||||
```
|
||||
|
||||
Enable and start Pulseaudio
|
||||
|
||||
``` sh
|
||||
systemctl --user enable pulseaudio
|
||||
systemctl --user start pulseaudio
|
||||
```
|
||||
|
||||
Check if Pulseaudio is running
|
||||
|
||||
``` sh
|
||||
pulseaudio --check
|
||||
pactl info
|
||||
```
|
||||
|
||||
|
||||
## Additional Information:
|
||||
|
||||
### Set audio volume:
|
||||
|
||||
If your driver or audiodevice is loaded and you can see the device with `aplay -L` then
|
||||
set the audio volume from 0 to 100:
|
||||
|
||||
```bash
|
||||
export LVA_XDG_RUNTIME_DIR=/run/user/${LVA_USER_ID}
|
||||
sudo amixer -c seeed2micvoicec set Headphone 100%
|
||||
sudo amixer -c seeed2micvoicec set Speaker 100%
|
||||
sudo amixer -c Lite set Headphone 100%
|
||||
sudo amixer -c Lite set Speaker 100%
|
||||
sudo alsactl store
|
||||
```
|
||||
|
||||
💡 **Note:** Replace `$LVA_USER_ID` with your actual user id that you want to run the voice assistant.
|
||||
|
||||
Alternatively you can use the following command to set the volume:
|
||||
|
||||
```bash
|
||||
export LVA_XDG_RUNTIME_DIR=/run/user/${LVA_USER_ID}
|
||||
sudo alsamixer
|
||||
```
|
||||
|
||||
💡 **Note:** Replace `$LVA_USER_ID` with your actual user id that you want to run the voice assistant.
|
||||
@@ -1 +0,0 @@
|
||||
https://github.com/tphakala/tflite_c/
|
||||
Binary file not shown.
Binary file not shown.
@@ -3,49 +3,56 @@ import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from queue import Queue
|
||||
from typing import Dict, List, Optional, Union
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
|
||||
import numpy as np
|
||||
import sounddevice as sd
|
||||
import soundcard as sc
|
||||
from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures
|
||||
from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures
|
||||
|
||||
from .microwakeword import MicroWakeWord, MicroWakeWordFeatures
|
||||
from .models import AvailableWakeWord, Preferences, ServerState, WakeWordType
|
||||
from .mpv_player import MpvMediaPlayer
|
||||
from .openwakeword import OpenWakeWord, OpenWakeWordFeatures
|
||||
from .satellite import VoiceSatelliteProtocol
|
||||
from .util import get_mac, is_arm
|
||||
from .util import get_mac
|
||||
from .zeroconf import HomeAssistantZeroconf
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_MODULE_DIR = Path(__file__).parent
|
||||
_REPO_DIR = _MODULE_DIR.parent
|
||||
_WAKEWORDS_DIR = _REPO_DIR / "wakewords"
|
||||
_OWW_DIR = _WAKEWORDS_DIR / "openWakeWord"
|
||||
_SOUNDS_DIR = _REPO_DIR / "sounds"
|
||||
|
||||
if is_arm():
|
||||
_LIB_DIR = _REPO_DIR / "lib" / "linux_arm64"
|
||||
else:
|
||||
_LIB_DIR = _REPO_DIR / "lib" / "linux_amd64"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--name", required=True)
|
||||
parser.add_argument("--name")
|
||||
parser.add_argument(
|
||||
"--audio-input-device",
|
||||
default="default",
|
||||
help="sounddevice name for input device",
|
||||
help="soundcard name for input device (see --list-input-devices)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--list-input-devices",
|
||||
action="store_true",
|
||||
help="List audio input devices and exit",
|
||||
)
|
||||
parser.add_argument("--audio-input-block-size", type=int, default=1024)
|
||||
parser.add_argument("--audio-output-device", help="mpv name for output device")
|
||||
parser.add_argument(
|
||||
"--audio-output-device",
|
||||
help="mpv name for output device (see --list-output-devices)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--list-output-devices",
|
||||
action="store_true",
|
||||
help="List audio output devices and exit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--wake-word-dir",
|
||||
default=[_WAKEWORDS_DIR],
|
||||
@@ -56,6 +63,11 @@ async def main() -> None:
|
||||
"--wake-model", default="okay_nabu", help="Id of active wake model"
|
||||
)
|
||||
parser.add_argument("--stop-model", default="stop", help="Id of stop model")
|
||||
parser.add_argument(
|
||||
"--download-dir",
|
||||
default=_REPO_DIR / "local",
|
||||
help="Directory to download custom wake word models, etc.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--refractory-seconds",
|
||||
default=2.0,
|
||||
@@ -63,23 +75,27 @@ async def main() -> None:
|
||||
help="Seconds before wake word can be activated again",
|
||||
)
|
||||
#
|
||||
parser.add_argument(
|
||||
"--oww-melspectrogram-model",
|
||||
default=_OWW_DIR / "melspectrogram.tflite",
|
||||
help="Path to openWakeWord melspectrogram model",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--oww-embedding-model",
|
||||
default=_OWW_DIR / "embedding_model.tflite",
|
||||
help="Path to openWakeWord embedding model",
|
||||
)
|
||||
#
|
||||
parser.add_argument(
|
||||
"--wakeup-sound", default=str(_SOUNDS_DIR / "wake_word_triggered.flac")
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timer-finished-sound", default=str(_SOUNDS_DIR / "timer_finished.flac")
|
||||
)
|
||||
parser.add_argument(
|
||||
"--processing-sound",
|
||||
default=str(_SOUNDS_DIR / "processing.wav"),
|
||||
help="Short sound to play while assistant is processing (thinking)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mute-sound",
|
||||
default=str(_SOUNDS_DIR / "mute_switch_on.flac"),
|
||||
help="Sound to play when muting the assistant",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--unmute-sound",
|
||||
default=str(_SOUNDS_DIR / "mute_switch_off.flac"),
|
||||
help="Sound to play when unmuting the assistant",
|
||||
)
|
||||
#
|
||||
parser.add_argument("--preferences-file", default=_REPO_DIR / "preferences.json")
|
||||
#
|
||||
@@ -88,19 +104,61 @@ async def main() -> None:
|
||||
default="0.0.0.0",
|
||||
help="Address for ESPHome server (default: 0.0.0.0)",
|
||||
)
|
||||
# Note that default port is also set in docker-entrypoint.sh
|
||||
parser.add_argument(
|
||||
"--port", type=int, default=6053, help="Port for ESPHome server (default: 6053)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--enable-thinking-sound",
|
||||
action="store_true",
|
||||
help="Enable thinking sound on startup",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--debug", action="store_true", help="Print DEBUG messages to console"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.list_input_devices:
|
||||
print("Input devices")
|
||||
print("=" * 13)
|
||||
for idx, mic in enumerate(sc.all_microphones()):
|
||||
print(f"[{idx}]", mic.name)
|
||||
return
|
||||
|
||||
if args.list_output_devices:
|
||||
from mpv import MPV
|
||||
|
||||
player = MPV()
|
||||
print("Output devices")
|
||||
print("=" * 14)
|
||||
|
||||
for speaker in player.audio_device_list: # type: ignore
|
||||
print(speaker["name"] + ":", speaker["description"])
|
||||
return
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
|
||||
_LOGGER.debug(args)
|
||||
|
||||
if not args.name:
|
||||
parser.error("the following arguments are required: --name")
|
||||
|
||||
args.download_dir = Path(args.download_dir)
|
||||
args.download_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Resolve microphone
|
||||
if args.audio_input_device is not None:
|
||||
try:
|
||||
args.audio_input_device = int(args.audio_input_device)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
mic = sc.get_microphone(args.audio_input_device)
|
||||
else:
|
||||
mic = sc.default_microphone()
|
||||
|
||||
# Load available wake words
|
||||
wake_word_dirs = [Path(ww_dir) for ww_dir in args.wake_word_dir]
|
||||
wake_word_dirs.append(args.download_dir / "external_wake_words")
|
||||
available_wake_words: Dict[str, AvailableWakeWord] = {}
|
||||
|
||||
for wake_word_dir in wake_word_dirs:
|
||||
@@ -112,13 +170,18 @@ async def main() -> None:
|
||||
|
||||
with open(model_config_path, "r", encoding="utf-8") as model_config_file:
|
||||
model_config = json.load(model_config_file)
|
||||
model_type = model_config["type"]
|
||||
model_type = WakeWordType(model_config["type"])
|
||||
if model_type == WakeWordType.OPEN_WAKE_WORD:
|
||||
wake_word_path = model_config_path.parent / model_config["model"]
|
||||
else:
|
||||
wake_word_path = model_config_path
|
||||
|
||||
available_wake_words[model_id] = AvailableWakeWord(
|
||||
id=model_id,
|
||||
type=WakeWordType(model_type),
|
||||
wake_word=model_config["wake_word"],
|
||||
trained_languages=model_config.get("trained_languages", []),
|
||||
config_path=model_config_path,
|
||||
wake_word_path=wake_word_path,
|
||||
)
|
||||
|
||||
_LOGGER.debug("Available wake words: %s", list(sorted(available_wake_words.keys())))
|
||||
@@ -133,10 +196,11 @@ async def main() -> None:
|
||||
else:
|
||||
preferences = Preferences()
|
||||
|
||||
libtensorflowlite_c_path = _LIB_DIR / "libtensorflowlite_c.so"
|
||||
_LOGGER.debug("libtensorflowlite_c path: %s", libtensorflowlite_c_path)
|
||||
if args.enable_thinking_sound:
|
||||
preferences.thinking_sound = 1
|
||||
|
||||
# Load wake/stop models
|
||||
active_wake_words: Set[str] = set()
|
||||
wake_models: Dict[str, Union[MicroWakeWord, OpenWakeWord]] = {}
|
||||
if preferences.active_wake_words:
|
||||
# Load preferred models
|
||||
@@ -147,7 +211,8 @@ async def main() -> None:
|
||||
continue
|
||||
|
||||
_LOGGER.debug("Loading wake model: %s", wake_word_id)
|
||||
wake_models[wake_word_id] = wake_word.load(libtensorflowlite_c_path)
|
||||
wake_models[wake_word_id] = wake_word.load()
|
||||
active_wake_words.add(wake_word_id)
|
||||
|
||||
if not wake_models:
|
||||
# Load default model
|
||||
@@ -155,7 +220,8 @@ async def main() -> None:
|
||||
wake_word = available_wake_words[wake_word_id]
|
||||
|
||||
_LOGGER.debug("Loading wake model: %s", wake_word_id)
|
||||
wake_models[wake_word_id] = wake_word.load(libtensorflowlite_c_path)
|
||||
wake_models[wake_word_id] = wake_word.load()
|
||||
active_wake_words.add(wake_word_id)
|
||||
|
||||
# TODO: allow openWakeWord for "stop"
|
||||
stop_model: Optional[MicroWakeWord] = None
|
||||
@@ -165,9 +231,7 @@ async def main() -> None:
|
||||
continue
|
||||
|
||||
_LOGGER.debug("Loading stop model: %s", stop_config_path)
|
||||
stop_model = MicroWakeWord.from_config(
|
||||
stop_config_path, libtensorflowlite_c_path
|
||||
)
|
||||
stop_model = MicroWakeWord.from_config(stop_config_path)
|
||||
break
|
||||
|
||||
assert stop_model is not None
|
||||
@@ -179,27 +243,31 @@ async def main() -> None:
|
||||
entities=[],
|
||||
available_wake_words=available_wake_words,
|
||||
wake_words=wake_models,
|
||||
active_wake_words=active_wake_words,
|
||||
stop_word=stop_model,
|
||||
music_player=MpvMediaPlayer(device=args.audio_output_device),
|
||||
tts_player=MpvMediaPlayer(device=args.audio_output_device),
|
||||
wakeup_sound=args.wakeup_sound,
|
||||
timer_finished_sound=args.timer_finished_sound,
|
||||
processing_sound=args.processing_sound,
|
||||
mute_sound=args.mute_sound,
|
||||
unmute_sound=args.unmute_sound,
|
||||
preferences=preferences,
|
||||
preferences_path=preferences_path,
|
||||
libtensorflowlite_c_path=libtensorflowlite_c_path,
|
||||
oww_melspectrogram_path=Path(args.oww_melspectrogram_model),
|
||||
oww_embedding_path=Path(args.oww_embedding_model),
|
||||
refractory_seconds=args.refractory_seconds,
|
||||
download_dir=args.download_dir,
|
||||
)
|
||||
|
||||
if args.enable_thinking_sound:
|
||||
state.save_preferences()
|
||||
|
||||
process_audio_thread = threading.Thread(
|
||||
target=process_audio, args=(state,), daemon=True
|
||||
target=process_audio,
|
||||
args=(state, mic, args.audio_input_block_size),
|
||||
daemon=True,
|
||||
)
|
||||
process_audio_thread.start()
|
||||
|
||||
def sd_callback(indata, _frames, _time, _status):
|
||||
state.audio_queue.put_nowait(bytes(indata))
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
server = await loop.create_server(
|
||||
lambda: VoiceSatelliteProtocol(state), host=args.host, port=args.port
|
||||
@@ -210,15 +278,6 @@ async def main() -> None:
|
||||
await discovery.register_server()
|
||||
|
||||
try:
|
||||
_LOGGER.debug("Opening audio input device: %s", args.audio_input_device)
|
||||
with sd.RawInputStream(
|
||||
samplerate=16000,
|
||||
blocksize=args.audio_input_block_size,
|
||||
device=args.audio_input_device,
|
||||
dtype="int16",
|
||||
channels=1,
|
||||
callback=sd_callback,
|
||||
):
|
||||
async with server:
|
||||
_LOGGER.info("Server started (host=%s, port=%s)", args.host, args.port)
|
||||
await server.serve_forever()
|
||||
@@ -234,7 +293,7 @@ async def main() -> None:
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def process_audio(state: ServerState):
|
||||
def process_audio(state: ServerState, mic, block_size: int):
|
||||
"""Process audio chunks from the microphone."""
|
||||
|
||||
wake_words: List[Union[MicroWakeWord, OpenWakeWord]] = []
|
||||
@@ -248,10 +307,15 @@ def process_audio(state: ServerState):
|
||||
last_active: Optional[float] = None
|
||||
|
||||
try:
|
||||
_LOGGER.debug("Opening audio input device: %s", mic.name)
|
||||
with mic.recorder(samplerate=16000, channels=1, blocksize=block_size) as mic_in:
|
||||
while True:
|
||||
audio_chunk = state.audio_queue.get()
|
||||
if audio_chunk is None:
|
||||
break
|
||||
audio_chunk_array = mic_in.record(block_size).reshape(-1)
|
||||
audio_chunk = (
|
||||
(np.clip(audio_chunk_array, -1.0, 1.0) * 32767.0)
|
||||
.astype("<i2") # little-endian 16-bit signed
|
||||
.tobytes()
|
||||
)
|
||||
|
||||
if state.satellite is None:
|
||||
continue
|
||||
@@ -259,7 +323,11 @@ def process_audio(state: ServerState):
|
||||
if (not wake_words) or (state.wake_words_changed and state.wake_words):
|
||||
# Update list of wake word models to process
|
||||
state.wake_words_changed = False
|
||||
wake_words = [ww for ww in state.wake_words.values() if ww.is_active]
|
||||
wake_words = [
|
||||
ww
|
||||
for ww in state.wake_words.values()
|
||||
if ww.id in state.active_wake_words
|
||||
]
|
||||
|
||||
has_oww = False
|
||||
for wake_word in wake_words:
|
||||
@@ -267,16 +335,10 @@ def process_audio(state: ServerState):
|
||||
has_oww = True
|
||||
|
||||
if micro_features is None:
|
||||
micro_features = MicroWakeWordFeatures(
|
||||
libtensorflowlite_c_path=state.libtensorflowlite_c_path,
|
||||
)
|
||||
micro_features = MicroWakeWordFeatures()
|
||||
|
||||
if has_oww and (oww_features is None):
|
||||
oww_features = OpenWakeWordFeatures(
|
||||
melspectrogram_model=state.oww_melspectrogram_path,
|
||||
embedding_model=state.oww_embedding_path,
|
||||
libtensorflowlite_c_path=state.libtensorflowlite_c_path,
|
||||
)
|
||||
oww_features = OpenWakeWordFeatures.from_builtin()
|
||||
|
||||
try:
|
||||
state.satellite.handle_audio(audio_chunk)
|
||||
@@ -302,7 +364,7 @@ def process_audio(state: ServerState):
|
||||
if prob > 0.5:
|
||||
activated = True
|
||||
|
||||
if activated:
|
||||
if activated and not state.muted:
|
||||
# Check refractory
|
||||
now = time.monotonic()
|
||||
if (last_active is None) or (
|
||||
@@ -317,13 +379,17 @@ def process_audio(state: ServerState):
|
||||
if state.stop_word.process_streaming(micro_input):
|
||||
stopped = True
|
||||
|
||||
if stopped and state.stop_word.is_active:
|
||||
if (
|
||||
stopped
|
||||
and (state.stop_word.id in state.active_wake_words)
|
||||
and not state.muted
|
||||
):
|
||||
state.satellite.stop()
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error handling audio")
|
||||
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error processing audio")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
from abc import abstractmethod
|
||||
from collections.abc import Iterable
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
@@ -9,8 +10,8 @@ from typing import TYPE_CHECKING, List, Optional
|
||||
# pylint: disable=no-name-in-module
|
||||
from aioesphomeapi._frame_helper.packets import make_plain_text_packets
|
||||
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
|
||||
ConnectRequest,
|
||||
ConnectResponse,
|
||||
AuthenticationRequest,
|
||||
AuthenticationResponse,
|
||||
DisconnectRequest,
|
||||
DisconnectResponse,
|
||||
HelloRequest,
|
||||
@@ -36,6 +37,8 @@ class APIServer(asyncio.Protocol):
|
||||
self._pos: int = 0
|
||||
self._transport = None
|
||||
self._writelines = None
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self._loop_thread_id: Optional[int] = None
|
||||
|
||||
@abstractmethod
|
||||
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
||||
@@ -57,8 +60,8 @@ class APIServer(asyncio.Protocol):
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(msg_inst, ConnectRequest):
|
||||
self.send_messages([ConnectResponse()])
|
||||
if isinstance(msg_inst, AuthenticationRequest):
|
||||
self.send_messages([AuthenticationResponse()])
|
||||
elif isinstance(msg_inst, DisconnectRequest):
|
||||
self.send_messages([DisconnectResponse()])
|
||||
_LOGGER.debug("Disconnect requested")
|
||||
@@ -75,7 +78,7 @@ class APIServer(asyncio.Protocol):
|
||||
self.send_messages(msgs)
|
||||
|
||||
def send_messages(self, msgs: List[message.Message]):
|
||||
if self._writelines is None:
|
||||
if self._writelines is None or not msgs:
|
||||
return
|
||||
|
||||
packets = [
|
||||
@@ -83,11 +86,25 @@ class APIServer(asyncio.Protocol):
|
||||
for msg in msgs
|
||||
]
|
||||
packet_bytes = make_plain_text_packets(packets)
|
||||
if (
|
||||
self._loop is not None
|
||||
and self._loop_thread_id is not None
|
||||
and threading.get_ident() != self._loop_thread_id
|
||||
):
|
||||
self._loop.call_soon_threadsafe(self._writelines, packet_bytes)
|
||||
return
|
||||
|
||||
self._writelines(packet_bytes)
|
||||
|
||||
def connection_made(self, transport) -> None:
|
||||
self._transport = transport
|
||||
self._writelines = transport.writelines
|
||||
try:
|
||||
self._loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
self._loop = None
|
||||
else:
|
||||
self._loop_thread_id = threading.get_ident()
|
||||
|
||||
def data_received(self, data: bytes):
|
||||
if self._buffer is None:
|
||||
@@ -141,6 +158,8 @@ class APIServer(asyncio.Protocol):
|
||||
def connection_lost(self, exc):
|
||||
self._transport = None
|
||||
self._writelines = None
|
||||
self._loop = None
|
||||
self._loop_thread_id = None
|
||||
|
||||
def _read_varuint(self) -> int:
|
||||
"""Read a varuint from the buffer or -1 if the buffer runs out of bytes."""
|
||||
|
||||
@@ -1,22 +1,41 @@
|
||||
from abc import abstractmethod
|
||||
from collections.abc import Iterable
|
||||
from typing import Callable, List, Optional, Union
|
||||
import logging
|
||||
|
||||
# pylint: disable=no-name-in-module
|
||||
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
|
||||
ListEntitiesMediaPlayerResponse,
|
||||
ListEntitiesRequest,
|
||||
ListEntitiesSwitchResponse,
|
||||
MediaPlayerCommandRequest,
|
||||
MediaPlayerStateResponse,
|
||||
SubscribeHomeAssistantStatesRequest,
|
||||
SwitchCommandRequest,
|
||||
SwitchStateResponse,
|
||||
)
|
||||
from aioesphomeapi.model import (
|
||||
EntityCategory,
|
||||
MediaPlayerCommand,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
)
|
||||
from aioesphomeapi.model import MediaPlayerCommand, MediaPlayerState
|
||||
from google.protobuf import message
|
||||
|
||||
from .api_server import APIServer
|
||||
from .mpv_player import MpvMediaPlayer
|
||||
from .util import call_all
|
||||
|
||||
SUPPORTED_MEDIA_PLAYER_FEATURES = (
|
||||
MediaPlayerEntityFeature.PLAY
|
||||
| MediaPlayerEntityFeature.PAUSE
|
||||
| MediaPlayerEntityFeature.STOP
|
||||
| MediaPlayerEntityFeature.PLAY_MEDIA
|
||||
| MediaPlayerEntityFeature.VOLUME_SET
|
||||
| MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
| MediaPlayerEntityFeature.MEDIA_ANNOUNCE
|
||||
)
|
||||
|
||||
|
||||
class ESPHomeEntity:
|
||||
def __init__(self, server: APIServer) -> None:
|
||||
@@ -48,8 +67,10 @@ class MediaPlayerEntity(ESPHomeEntity):
|
||||
self.state = MediaPlayerState.IDLE
|
||||
self.volume = 1.0
|
||||
self.muted = False
|
||||
self.previous_volume = 1.0
|
||||
self.music_player = music_player
|
||||
self.announce_player = announce_player
|
||||
self._log = logging.getLogger(f"{self.__class__.__name__}[{self.key}]")
|
||||
|
||||
def play(
|
||||
self,
|
||||
@@ -93,32 +114,76 @@ class MediaPlayerEntity(ESPHomeEntity):
|
||||
yield self._update_state(MediaPlayerState.PLAYING)
|
||||
|
||||
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
||||
self._log.debug("handle_message called with msg: %s", msg)
|
||||
|
||||
if isinstance(msg, MediaPlayerCommandRequest) and (msg.key == self.key):
|
||||
self._log.debug("MediaPlayerCommandRequest matched for this key")
|
||||
|
||||
if msg.has_media_url:
|
||||
self._log.debug("Message has media URL: %s", msg.media_url)
|
||||
announcement = msg.has_announcement and msg.announcement
|
||||
yield from self.play(msg.media_url, announcement=announcement)
|
||||
|
||||
elif msg.has_command:
|
||||
self._log.debug("Message has command: %s", msg.command)
|
||||
command = MediaPlayerCommand(msg.command)
|
||||
|
||||
if msg.command == MediaPlayerCommand.PAUSE:
|
||||
self._log.debug("Executing PAUSE")
|
||||
self.music_player.pause()
|
||||
yield self._update_state(MediaPlayerState.PAUSED)
|
||||
|
||||
elif msg.command == MediaPlayerCommand.PLAY:
|
||||
self._log.debug("Executing PLAY / RESUME")
|
||||
self.music_player.resume()
|
||||
yield self._update_state(MediaPlayerState.PLAYING)
|
||||
|
||||
elif command == MediaPlayerCommand.STOP:
|
||||
self._log.debug("Executing STOP")
|
||||
self.music_player.stop()
|
||||
yield self._update_state(MediaPlayerState.IDLE)
|
||||
|
||||
elif command == MediaPlayerCommand.MUTE:
|
||||
self._log.debug("Executing MUTE")
|
||||
if not self.muted:
|
||||
self.previous_volume = self.volume
|
||||
self.volume = 0
|
||||
self.music_player.set_volume(0)
|
||||
self.announce_player.set_volume(0)
|
||||
self.muted = True
|
||||
yield self._update_state(self.state)
|
||||
|
||||
elif command == MediaPlayerCommand.UNMUTE:
|
||||
self._log.debug("Executing UNMUTE")
|
||||
if self.muted:
|
||||
self.volume = self.previous_volume
|
||||
self.music_player.set_volume(int(self.volume * 100))
|
||||
self.announce_player.set_volume(int(self.volume * 100))
|
||||
self.muted = False
|
||||
yield self._update_state(self.state)
|
||||
|
||||
elif msg.has_volume:
|
||||
self._log.debug("Message has volume: %.2f", msg.volume)
|
||||
volume = int(msg.volume * 100)
|
||||
self.music_player.set_volume(volume)
|
||||
self.announce_player.set_volume(volume)
|
||||
self.volume = msg.volume
|
||||
yield self._update_state(self.state)
|
||||
|
||||
elif isinstance(msg, ListEntitiesRequest):
|
||||
self._log.debug("ListEntitiesRequest received")
|
||||
yield ListEntitiesMediaPlayerResponse(
|
||||
object_id=self.object_id,
|
||||
key=self.key,
|
||||
name=self.name,
|
||||
supports_pause=True,
|
||||
feature_flags=SUPPORTED_MEDIA_PLAYER_FEATURES,
|
||||
)
|
||||
elif isinstance(msg, SubscribeHomeAssistantStatesRequest):
|
||||
self._log.debug("SubscribeHomeAssistantStatesRequest received")
|
||||
yield self._get_state_message()
|
||||
else:
|
||||
self._log.warning("Unknown message type received: %s", type(msg))
|
||||
|
||||
def _update_state(self, new_state: MediaPlayerState) -> MediaPlayerStateResponse:
|
||||
self.state = new_state
|
||||
@@ -131,3 +196,118 @@ class MediaPlayerEntity(ESPHomeEntity):
|
||||
volume=self.volume,
|
||||
muted=self.muted,
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MuteSwitchEntity(ESPHomeEntity):
|
||||
def __init__(
|
||||
self,
|
||||
server: APIServer,
|
||||
key: int,
|
||||
name: str,
|
||||
object_id: str,
|
||||
get_muted: Callable[[], bool],
|
||||
set_muted: Callable[[bool], None],
|
||||
) -> None:
|
||||
ESPHomeEntity.__init__(self, server)
|
||||
|
||||
self.key = key
|
||||
self.name = name
|
||||
self.object_id = object_id
|
||||
self._get_muted = get_muted
|
||||
self._set_muted = set_muted
|
||||
self._switch_state = (
|
||||
self._get_muted()
|
||||
) # Sync internal state with actual muted value on init
|
||||
|
||||
def update_set_muted(self, set_muted: Callable[[bool], None]) -> None:
|
||||
# Update the callback used to change the mute state.
|
||||
self._set_muted = set_muted
|
||||
|
||||
def update_get_muted(self, get_muted: Callable[[], bool]) -> None:
|
||||
# Update the callback used to read the mute state.
|
||||
self._get_muted = get_muted
|
||||
|
||||
def sync_with_state(self) -> None:
|
||||
# Sync internal switch state with the actual mute state.
|
||||
self._switch_state = self._get_muted()
|
||||
|
||||
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
||||
if isinstance(msg, SwitchCommandRequest) and (msg.key == self.key):
|
||||
# User toggled the switch - update our internal state and trigger actions
|
||||
new_state = bool(msg.state)
|
||||
self._switch_state = new_state
|
||||
self._set_muted(new_state)
|
||||
# Return the new state immediately
|
||||
yield SwitchStateResponse(key=self.key, state=self._switch_state)
|
||||
elif isinstance(msg, ListEntitiesRequest):
|
||||
yield ListEntitiesSwitchResponse(
|
||||
object_id=self.object_id,
|
||||
key=self.key,
|
||||
name=self.name,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
icon="mdi:microphone-off",
|
||||
)
|
||||
elif isinstance(msg, SubscribeHomeAssistantStatesRequest):
|
||||
# Always return our internal switch state
|
||||
self.sync_with_state()
|
||||
yield SwitchStateResponse(key=self.key, state=self._switch_state)
|
||||
|
||||
|
||||
class ThinkingSoundEntity(ESPHomeEntity):
|
||||
def __init__(
|
||||
self,
|
||||
server: APIServer,
|
||||
key: int,
|
||||
name: str,
|
||||
object_id: str,
|
||||
get_thinking_sound_enabled: Callable[[], bool],
|
||||
set_thinking_sound_enabled: Callable[[bool], None],
|
||||
) -> None:
|
||||
ESPHomeEntity.__init__(self, server)
|
||||
|
||||
self.key = key
|
||||
self.name = name
|
||||
self.object_id = object_id
|
||||
self._get_thinking_sound_enabled = get_thinking_sound_enabled
|
||||
self._set_thinking_sound_enabled = set_thinking_sound_enabled
|
||||
self._switch_state = self._get_thinking_sound_enabled() # Sync internal state
|
||||
|
||||
def update_get_thinking_sound_enabled(
|
||||
self, get_thinking_sound_enabled: Callable[[], bool]
|
||||
) -> None:
|
||||
# Update the callback used to read the thinking sound enabled state.
|
||||
self._get_thinking_sound_enabled = get_thinking_sound_enabled
|
||||
|
||||
def update_set_thinking_sound_enabled(
|
||||
self, set_thinking_sound_enabled: Callable[[bool], None]
|
||||
) -> None:
|
||||
# Update the callback used to change the thinking sound enabled state.
|
||||
self._set_thinking_sound_enabled = set_thinking_sound_enabled
|
||||
|
||||
def sync_with_state(self) -> None:
|
||||
# Sync internal switch state with the actual thinking sound enabled state.
|
||||
self._switch_state = self._get_thinking_sound_enabled()
|
||||
|
||||
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
||||
if isinstance(msg, SwitchCommandRequest) and (msg.key == self.key):
|
||||
# User toggled the switch - update our internal state and trigger actions
|
||||
new_state = bool(msg.state)
|
||||
self._switch_state = new_state
|
||||
self._set_thinking_sound_enabled(new_state)
|
||||
# Return the new state immediately
|
||||
yield SwitchStateResponse(key=self.key, state=self._switch_state)
|
||||
elif isinstance(msg, ListEntitiesRequest):
|
||||
yield ListEntitiesSwitchResponse(
|
||||
object_id=self.object_id,
|
||||
key=self.key,
|
||||
name=self.name,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
icon="mdi:music-note",
|
||||
)
|
||||
elif isinstance(msg, SubscribeHomeAssistantStatesRequest):
|
||||
# Always return our internal switch state
|
||||
self.sync_with_state()
|
||||
yield SwitchStateResponse(key=self.key, state=self._switch_state)
|
||||
@@ -1,189 +0,0 @@
|
||||
import ctypes
|
||||
import json
|
||||
import statistics
|
||||
from collections import deque
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
from typing import Deque, List, Union
|
||||
|
||||
import numpy as np
|
||||
from pymicro_features import MicroFrontend
|
||||
|
||||
from .wakeword import TfLiteWakeWord
|
||||
|
||||
SAMPLES_PER_SECOND = 16000
|
||||
SAMPLES_PER_CHUNK = 160 # 10ms
|
||||
BYTES_PER_SAMPLE = 2 # 16-bit
|
||||
BYTES_PER_CHUNK = SAMPLES_PER_CHUNK * BYTES_PER_SAMPLE
|
||||
SECONDS_PER_CHUNK = SAMPLES_PER_CHUNK / SAMPLES_PER_SECOND
|
||||
STRIDE = 3
|
||||
|
||||
|
||||
class MicroWakeWord(TfLiteWakeWord):
|
||||
def __init__(
|
||||
self,
|
||||
id: str, # pylint: disable=redefined-builtin
|
||||
wake_word: str,
|
||||
tflite_model: Union[str, Path],
|
||||
probability_cutoff: float,
|
||||
sliding_window_size: int,
|
||||
trained_languages: List[str],
|
||||
libtensorflowlite_c_path: Union[str, Path],
|
||||
) -> None:
|
||||
TfLiteWakeWord.__init__(self, libtensorflowlite_c_path)
|
||||
|
||||
self.id = id
|
||||
self.wake_word = wake_word
|
||||
self.tflite_model = tflite_model
|
||||
self.probability_cutoff = probability_cutoff
|
||||
self.sliding_window_size = sliding_window_size
|
||||
self.trained_languages = trained_languages
|
||||
|
||||
self.is_active = True
|
||||
|
||||
# Load the model and create interpreter
|
||||
self.model_path = str(Path(tflite_model).resolve()).encode("utf-8")
|
||||
self._load_model()
|
||||
|
||||
self._features: List[np.ndarray] = []
|
||||
self._probabilities: Deque[float] = deque(maxlen=self.sliding_window_size)
|
||||
self._audio_buffer = bytes()
|
||||
|
||||
def _load_model(self) -> None:
|
||||
self.model = self.lib.TfLiteModelCreateFromFile(self.model_path)
|
||||
self.interpreter = self.lib.TfLiteInterpreterCreate(self.model, None)
|
||||
self.lib.TfLiteInterpreterAllocateTensors(self.interpreter)
|
||||
|
||||
# Access input and output tensor
|
||||
self.input_tensor = self.lib.TfLiteInterpreterGetInputTensor(
|
||||
self.interpreter, 0
|
||||
)
|
||||
self.output_tensor = self.lib.TfLiteInterpreterGetOutputTensor(
|
||||
self.interpreter, 0
|
||||
)
|
||||
|
||||
# Get quantization parameters
|
||||
input_q = self.lib.TfLiteTensorQuantizationParams(self.input_tensor)
|
||||
output_q = self.lib.TfLiteTensorQuantizationParams(self.output_tensor)
|
||||
|
||||
self.input_scale, self.input_zero_point = input_q.scale, input_q.zero_point
|
||||
self.output_scale, self.output_zero_point = output_q.scale, output_q.zero_point
|
||||
|
||||
@staticmethod
|
||||
def from_config(
|
||||
config_path: Union[str, Path],
|
||||
libtensorflowlite_c_path: Union[str, Path],
|
||||
) -> "MicroWakeWord":
|
||||
"""Load a microWakeWord model from a JSON config file.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
config_path: str or Path
|
||||
Path to JSON configuration file
|
||||
"""
|
||||
config_path = Path(config_path)
|
||||
with open(config_path, "r", encoding="utf-8") as config_file:
|
||||
config = json.load(config_file)
|
||||
|
||||
micro_config = config["micro"]
|
||||
|
||||
return MicroWakeWord(
|
||||
id=Path(config["model"]).stem,
|
||||
wake_word=config["wake_word"],
|
||||
tflite_model=config_path.parent / config["model"],
|
||||
probability_cutoff=micro_config["probability_cutoff"],
|
||||
sliding_window_size=micro_config["sliding_window_size"],
|
||||
trained_languages=micro_config.get("trained_languages", []),
|
||||
libtensorflowlite_c_path=libtensorflowlite_c_path,
|
||||
)
|
||||
|
||||
def process_streaming(self, features: np.ndarray) -> bool:
|
||||
self._features.append(features)
|
||||
|
||||
if len(self._features) < STRIDE:
|
||||
# Not enough windows
|
||||
return False
|
||||
|
||||
# Allocate and quantize input data
|
||||
quant_features = np.round(
|
||||
np.concatenate(self._features, axis=1) / self.input_scale
|
||||
+ self.input_zero_point
|
||||
).astype(np.uint8)
|
||||
|
||||
# Stride instead of rolling
|
||||
self._features.clear()
|
||||
|
||||
# Set tensor
|
||||
quant_ptr = quant_features.ctypes.data_as(ctypes.c_void_p)
|
||||
self.lib.TfLiteTensorCopyFromBuffer(
|
||||
self.input_tensor, quant_ptr, quant_features.nbytes
|
||||
)
|
||||
|
||||
# Run inference
|
||||
self.lib.TfLiteInterpreterInvoke(self.interpreter)
|
||||
|
||||
# Read output
|
||||
output_bytes = self.lib.TfLiteTensorByteSize(self.output_tensor)
|
||||
output_data = np.empty(output_bytes, dtype=np.uint8)
|
||||
self.lib.TfLiteTensorCopyToBuffer(
|
||||
self.output_tensor,
|
||||
output_data.ctypes.data_as(ctypes.c_void_p),
|
||||
output_bytes,
|
||||
)
|
||||
|
||||
# Dequantize output
|
||||
result = (
|
||||
output_data.astype(np.float32) - self.output_zero_point
|
||||
) * self.output_scale
|
||||
|
||||
self._probabilities.append(result.item())
|
||||
|
||||
if len(self._probabilities) < self.sliding_window_size:
|
||||
# Not enough probabilities
|
||||
return False
|
||||
|
||||
if statistics.mean(self._probabilities) > self.probability_cutoff:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MicroWakeWordFeatures(TfLiteWakeWord):
|
||||
def __init__(
|
||||
self,
|
||||
libtensorflowlite_c_path: Union[str, Path],
|
||||
) -> None:
|
||||
TfLiteWakeWord.__init__(self, libtensorflowlite_c_path)
|
||||
|
||||
self._audio_buffer = bytes()
|
||||
self._frontend = MicroFrontend()
|
||||
|
||||
def process_streaming(self, audio_bytes: bytes) -> Iterable[np.ndarray]:
|
||||
self._audio_buffer += audio_bytes
|
||||
|
||||
if len(self._audio_buffer) < BYTES_PER_CHUNK:
|
||||
# Not enough audio to get features
|
||||
return
|
||||
|
||||
audio_buffer_idx = 0
|
||||
while (audio_buffer_idx + BYTES_PER_CHUNK) <= len(self._audio_buffer):
|
||||
# Process chunk
|
||||
chunk_bytes = self._audio_buffer[
|
||||
audio_buffer_idx : audio_buffer_idx + BYTES_PER_CHUNK
|
||||
]
|
||||
frontend_result = self._frontend.ProcessSamples(chunk_bytes)
|
||||
audio_buffer_idx += frontend_result.samples_read * BYTES_PER_SAMPLE
|
||||
|
||||
if not frontend_result.features:
|
||||
# Not enough audio for a full window
|
||||
continue
|
||||
|
||||
yield np.array(frontend_result.features).reshape(
|
||||
(1, 1, len(frontend_result.features))
|
||||
)
|
||||
|
||||
# Remove processed audio
|
||||
self._audio_buffer = self._audio_buffer[audio_buffer_idx:]
|
||||
@@ -6,13 +6,19 @@ from dataclasses import asdict, dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from queue import Queue
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional, Union
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional, Set, Union
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .entity import ESPHomeEntity, MediaPlayerEntity
|
||||
from .microwakeword import MicroWakeWord
|
||||
from pymicro_wakeword import MicroWakeWord
|
||||
from pyopen_wakeword import OpenWakeWord
|
||||
|
||||
from .entity import (
|
||||
ESPHomeEntity,
|
||||
MediaPlayerEntity,
|
||||
MuteSwitchEntity,
|
||||
ThinkingSoundEntity,
|
||||
)
|
||||
from .mpv_player import MpvMediaPlayer
|
||||
from .openwakeword import OpenWakeWord
|
||||
from .satellite import VoiceSatelliteProtocol
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -29,26 +35,21 @@ class AvailableWakeWord:
|
||||
type: WakeWordType
|
||||
wake_word: str
|
||||
trained_languages: List[str]
|
||||
config_path: Path
|
||||
wake_word_path: Path
|
||||
|
||||
def load(
|
||||
self, libtensorflowlite_c_path: Path
|
||||
) -> "Union[MicroWakeWord, OpenWakeWord]":
|
||||
def load(self) -> "Union[MicroWakeWord, OpenWakeWord]":
|
||||
if self.type == WakeWordType.MICRO_WAKE_WORD:
|
||||
from .microwakeword import MicroWakeWord
|
||||
from pymicro_wakeword import MicroWakeWord
|
||||
|
||||
return MicroWakeWord.from_config(
|
||||
config_path=self.config_path,
|
||||
libtensorflowlite_c_path=libtensorflowlite_c_path,
|
||||
)
|
||||
return MicroWakeWord.from_config(config_path=self.wake_word_path)
|
||||
|
||||
if self.type == WakeWordType.OPEN_WAKE_WORD:
|
||||
from .openwakeword import OpenWakeWord
|
||||
from pyopen_wakeword import OpenWakeWord
|
||||
|
||||
return OpenWakeWord.from_config(
|
||||
config_path=self.config_path,
|
||||
libtensorflowlite_c_path=libtensorflowlite_c_path,
|
||||
)
|
||||
oww_model = OpenWakeWord.from_model(model_path=self.wake_word_path)
|
||||
setattr(oww_model, "wake_word", self.wake_word)
|
||||
|
||||
return oww_model
|
||||
|
||||
raise ValueError(f"Unexpected wake word type: {self.type}")
|
||||
|
||||
@@ -56,6 +57,7 @@ class AvailableWakeWord:
|
||||
@dataclass
|
||||
class Preferences:
|
||||
active_wake_words: List[str] = field(default_factory=list)
|
||||
thinking_sound: int = 0 # 0 = disabled, 1 = enabled
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -66,23 +68,28 @@ class ServerState:
|
||||
entities: "List[ESPHomeEntity]"
|
||||
available_wake_words: "Dict[str, AvailableWakeWord]"
|
||||
wake_words: "Dict[str, Union[MicroWakeWord, OpenWakeWord]]"
|
||||
active_wake_words: Set[str]
|
||||
stop_word: "MicroWakeWord"
|
||||
music_player: "MpvMediaPlayer"
|
||||
tts_player: "MpvMediaPlayer"
|
||||
wakeup_sound: str
|
||||
processing_sound: str
|
||||
timer_finished_sound: str
|
||||
mute_sound: str
|
||||
unmute_sound: str
|
||||
preferences: Preferences
|
||||
preferences_path: Path
|
||||
libtensorflowlite_c_path: Path
|
||||
|
||||
# openWakeWord
|
||||
oww_melspectrogram_path: Path
|
||||
oww_embedding_path: Path
|
||||
download_dir: Path
|
||||
|
||||
media_player_entity: "Optional[MediaPlayerEntity]" = None
|
||||
satellite: "Optional[VoiceSatelliteProtocol]" = None
|
||||
mute_switch_entity: "Optional[MuteSwitchEntity]" = None
|
||||
thinking_sound_entity: "Optional[ThinkingSoundEntity]" = None
|
||||
wake_words_changed: bool = False
|
||||
refractory_seconds: float = 2.0
|
||||
thinking_sound_enabled: bool = False
|
||||
muted: bool = False
|
||||
connected: bool = False
|
||||
|
||||
def save_preferences(self) -> None:
|
||||
"""Save preferences as JSON."""
|
||||
|
||||
@@ -1,94 +1,108 @@
|
||||
"""Media player using mpv in a subprocess."""
|
||||
|
||||
# mpv_player.py
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from threading import Lock
|
||||
from typing import List, Optional, Union
|
||||
from typing import Union, List, Callable, Optional
|
||||
|
||||
from mpv import MPV
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from .player.libmpv import LibMpvPlayer
|
||||
|
||||
|
||||
class MpvMediaPlayer:
|
||||
def __init__(self, device: Optional[str] = None) -> None:
|
||||
self.player = MPV()
|
||||
"""
|
||||
Linux Voice Assistant MediaPlayer implementation based on libmpv.
|
||||
|
||||
if device:
|
||||
self.player["audio-device"] = device
|
||||
This class provides the MediaPlayer interface expected by LVA and
|
||||
delegates all playback logic to LibMpvPlayer.
|
||||
"""
|
||||
|
||||
self.is_playing = False
|
||||
|
||||
self._playlist: List[str] = []
|
||||
def __init__(self, device: str | None = None) -> None:
|
||||
self._log = logging.getLogger(self.__class__.__name__)
|
||||
self._player = LibMpvPlayer(device=device)
|
||||
self._done_callback: Optional[Callable[[], None]] = None
|
||||
self._done_callback_lock = Lock()
|
||||
|
||||
self._duck_volume: int = 50
|
||||
self._unduck_volume: int = 100
|
||||
|
||||
self.player.event_callback("end-file")(self._on_end_file)
|
||||
self._log.debug("MpvMediaPlayer initialized (device=%s)", device)
|
||||
|
||||
def play(
|
||||
self,
|
||||
url: Union[str, List[str]],
|
||||
done_callback: Optional[Callable[[], None]] = None,
|
||||
stop_first: bool = True,
|
||||
stop_first: bool = False,
|
||||
) -> None:
|
||||
self.stop()
|
||||
"""
|
||||
Play a media URL.
|
||||
|
||||
if isinstance(url, str):
|
||||
self._playlist = [url]
|
||||
else:
|
||||
self._playlist = url
|
||||
Args:
|
||||
url: Media URL or list of URLs (LVA currently uses a single URL).
|
||||
done_callback: Optional callback invoked when playback finishes.
|
||||
stop_first: Kept for API compatibility.
|
||||
"""
|
||||
# LVA currently only uses single URLs
|
||||
if isinstance(url, list):
|
||||
self._log.debug("Received URL list, using first entry")
|
||||
url = url[0]
|
||||
|
||||
next_url = self._playlist.pop(0)
|
||||
_LOGGER.debug("Playing %s", next_url)
|
||||
|
||||
self._done_callback = done_callback
|
||||
self.is_playing = True
|
||||
self.player.play(next_url)
|
||||
|
||||
def pause(self) -> None:
|
||||
self.player.pause = True
|
||||
self.is_playing = False
|
||||
|
||||
def resume(self) -> None:
|
||||
self.player.pause = False
|
||||
if self._playlist:
|
||||
self.is_playing = True
|
||||
|
||||
def stop(self) -> None:
|
||||
self.player.stop()
|
||||
self._playlist.clear()
|
||||
|
||||
def duck(self) -> None:
|
||||
self.player.volume = self._duck_volume
|
||||
|
||||
def unduck(self) -> None:
|
||||
self.player.volume = self._unduck_volume
|
||||
|
||||
def set_volume(self, volume: int) -> None:
|
||||
volume = max(0, min(100, volume))
|
||||
self.player.volume = volume
|
||||
|
||||
self._unduck_volume = volume
|
||||
self._duck_volume = volume // 2
|
||||
|
||||
def _on_end_file(self, event) -> None:
|
||||
if self._playlist:
|
||||
self.player.play(self._playlist.pop(0))
|
||||
return
|
||||
|
||||
self.is_playing = False
|
||||
|
||||
todo_callback: Optional[Callable[[], None]] = None
|
||||
with self._done_callback_lock:
|
||||
if self._done_callback:
|
||||
todo_callback = self._done_callback
|
||||
# Track is changing
|
||||
if self._done_callback is not None:
|
||||
self._log.debug(
|
||||
"Stopping active playback before starting new media"
|
||||
)
|
||||
# Not self.stop() → this would call done_callback
|
||||
self._player.stop(for_replacement=True)
|
||||
self._done_callback = None
|
||||
|
||||
if todo_callback:
|
||||
self._log.info("Playing media: %s", url)
|
||||
self._log.debug(
|
||||
"play(url=%s, stop_first=%s, done_callback=%s)",
|
||||
url,
|
||||
stop_first,
|
||||
bool(done_callback),
|
||||
)
|
||||
|
||||
self._done_callback = done_callback
|
||||
self._player.play(url, done_callback=done_callback, stop_first=stop_first)
|
||||
|
||||
def pause(self) -> None:
|
||||
"""Pause playback."""
|
||||
self._log.debug("pause() called")
|
||||
self._player.pause()
|
||||
|
||||
def resume(self) -> None:
|
||||
"""Resume playback."""
|
||||
self._log.debug("resume() called")
|
||||
self._player.resume()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop playback and invoke the done callback if present."""
|
||||
self._log.debug("stop() called")
|
||||
|
||||
self._player.stop()
|
||||
|
||||
if self._done_callback:
|
||||
self._log.debug("Invoking done_callback due to stop()")
|
||||
try:
|
||||
todo_callback()
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error running done callback")
|
||||
self._done_callback()
|
||||
finally:
|
||||
self._done_callback = None
|
||||
|
||||
def set_volume(self, volume: float) -> None:
|
||||
"""
|
||||
Set playback volume.
|
||||
|
||||
Args:
|
||||
volume: Volume in percent (0.0–100.0).
|
||||
"""
|
||||
self._log.debug("set_volume(volume=%.2f)", volume)
|
||||
self._player.set_volume(volume)
|
||||
|
||||
def duck(self, factor: float = 0.5) -> None:
|
||||
"""
|
||||
Temporarily reduce volume.
|
||||
|
||||
Args:
|
||||
factor: Volume multiplier (0.0–1.0).
|
||||
"""
|
||||
self._log.debug("duck(factor=%.2f)", factor)
|
||||
self._player.duck(factor)
|
||||
|
||||
def unduck(self) -> None:
|
||||
"""Restore volume after ducking."""
|
||||
self._log.debug("unduck() called")
|
||||
self._player.unduck()
|
||||
@@ -1,293 +0,0 @@
|
||||
"""openWakeWord implementation."""
|
||||
|
||||
import ctypes as C
|
||||
import json
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
from typing import Final, Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .wakeword import TfLiteWakeWord
|
||||
|
||||
c_void_p = C.c_void_p
|
||||
c_int32 = C.c_int32
|
||||
c_size_t = C.c_size_t
|
||||
|
||||
BATCH_SIZE: Final = 1
|
||||
|
||||
AUTOFILL_SECONDS: Final = 8
|
||||
MAX_SECONDS: Final = 10
|
||||
|
||||
SAMPLE_RATE: Final = 16000 # 16Khz
|
||||
_MAX_SAMPLES: Final = MAX_SECONDS * SAMPLE_RATE
|
||||
|
||||
SAMPLES_PER_CHUNK: Final = 1280 # 80 ms @ 16Khz
|
||||
MS_PER_CHUNK: Final = SAMPLES_PER_CHUNK // SAMPLE_RATE
|
||||
|
||||
# window = 400, hop length = 160
|
||||
MELS_PER_SECOND: Final = 97
|
||||
MAX_MELS: Final = MAX_SECONDS * MELS_PER_SECOND
|
||||
MEL_SAMPLES: Final = 1760
|
||||
NUM_MELS: Final = 32
|
||||
|
||||
EMB_FEATURES: Final = 76 # 775 ms
|
||||
EMB_STEP: Final = 8
|
||||
MAX_EMB: Final = MAX_SECONDS * EMB_STEP
|
||||
WW_FEATURES: Final = 96
|
||||
|
||||
MEL_SHAPE: Final = (BATCH_SIZE, MEL_SAMPLES)
|
||||
EMB_SHAPE: Final = (BATCH_SIZE, EMB_FEATURES, NUM_MELS, 1)
|
||||
|
||||
# melspec = [batch x samples (min: 1280)] => [batch x 1 x window x mels (32)]
|
||||
# stft window size: 25ms (400)
|
||||
# stft window step: 10ms (160)
|
||||
# mel band limits: 60Hz - 3800Hz
|
||||
# mel frequency bins: 32
|
||||
#
|
||||
# embedding = [batch x window x mels (32) x 1] => [batch x 1 x 1 x features (96)]
|
||||
# ww = [batch x window x features (96)] => [batch x probability]
|
||||
|
||||
|
||||
class OpenWakeWord(TfLiteWakeWord):
|
||||
def __init__(
|
||||
self,
|
||||
id: str, # pylint: disable=redefined-builtin
|
||||
wake_word: str,
|
||||
tflite_model: Union[str, Path],
|
||||
libtensorflowlite_c_path: Union[str, Path],
|
||||
):
|
||||
TfLiteWakeWord.__init__(self, libtensorflowlite_c_path)
|
||||
|
||||
self.id = id
|
||||
self.wake_word = wake_word
|
||||
self.tflite_model = tflite_model
|
||||
|
||||
self.is_active = True
|
||||
|
||||
# Load the model and create interpreter
|
||||
self.model_path = str(Path(tflite_model).resolve()).encode("utf-8")
|
||||
self.model = self.lib.TfLiteModelCreateFromFile(self.model_path)
|
||||
self.interpreter = self.lib.TfLiteInterpreterCreate(self.model, None)
|
||||
self.lib.TfLiteInterpreterAllocateTensors(self.interpreter)
|
||||
|
||||
self.input_tensor = self.lib.TfLiteInterpreterGetInputTensor(
|
||||
self.interpreter, c_int32(0)
|
||||
)
|
||||
self.output_tensor = self.lib.TfLiteInterpreterGetOutputTensor(
|
||||
self.interpreter, c_int32(0)
|
||||
)
|
||||
|
||||
num_input_dims = self.lib.TfLiteTensorNumDims(self.input_tensor)
|
||||
input_shape = [
|
||||
self.lib.TfLiteTensorDim(self.input_tensor, i)
|
||||
for i in range(num_input_dims)
|
||||
]
|
||||
self.input_windows = input_shape[1]
|
||||
|
||||
self.new_embeddings: int = 0
|
||||
self.embeddings: np.ndarray = np.zeros(
|
||||
shape=(MAX_EMB, WW_FEATURES), dtype=np.float32
|
||||
)
|
||||
|
||||
def process_streaming(self, embeddings: np.ndarray) -> Iterable[float]:
|
||||
"""Generate probabilities from embeddings."""
|
||||
num_embedding_windows = embeddings.shape[2]
|
||||
|
||||
# Shift
|
||||
self.embeddings[:-num_embedding_windows] = self.embeddings[
|
||||
num_embedding_windows:
|
||||
]
|
||||
|
||||
# Overwrite
|
||||
self.embeddings[-num_embedding_windows:] = embeddings[0, 0, :, :]
|
||||
self.new_embeddings = min(
|
||||
len(self.embeddings),
|
||||
self.new_embeddings + num_embedding_windows,
|
||||
)
|
||||
|
||||
while self.new_embeddings >= self.input_windows:
|
||||
emb_tensor = np.zeros(
|
||||
shape=(1, self.input_windows, WW_FEATURES),
|
||||
dtype=np.float32,
|
||||
)
|
||||
|
||||
emb_tensor[0, :] = self.embeddings[
|
||||
-self.new_embeddings : len(self.embeddings)
|
||||
- self.new_embeddings
|
||||
+ self.input_windows
|
||||
]
|
||||
self.new_embeddings = max(0, self.new_embeddings - 1)
|
||||
|
||||
# Run inference
|
||||
emb_ptr = emb_tensor.ctypes.data_as(c_void_p)
|
||||
self.lib.TfLiteTensorCopyFromBuffer(
|
||||
self.input_tensor, emb_ptr, c_size_t(emb_tensor.nbytes)
|
||||
)
|
||||
self.lib.TfLiteInterpreterInvoke(self.interpreter)
|
||||
|
||||
output_bytes = self.lib.TfLiteTensorByteSize(self.output_tensor)
|
||||
probs = np.empty(
|
||||
output_bytes // np.dtype(np.float32).itemsize, dtype=np.float32
|
||||
)
|
||||
self.lib.TfLiteTensorCopyToBuffer(
|
||||
self.output_tensor,
|
||||
probs.ctypes.data_as(c_void_p),
|
||||
c_size_t(output_bytes),
|
||||
)
|
||||
|
||||
yield probs.item()
|
||||
|
||||
@staticmethod
|
||||
def from_config(
|
||||
config_path: Union[str, Path],
|
||||
libtensorflowlite_c_path: Union[str, Path],
|
||||
) -> "OpenWakeWord":
|
||||
config_path = Path(config_path)
|
||||
with open(config_path, "r", encoding="utf-8") as config_file:
|
||||
config = json.load(config_file)
|
||||
|
||||
return OpenWakeWord(
|
||||
id=Path(config["model"]).stem,
|
||||
wake_word=config["wake_word"],
|
||||
tflite_model=config_path.parent / config["model"],
|
||||
libtensorflowlite_c_path=libtensorflowlite_c_path,
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OpenWakeWordFeatures(TfLiteWakeWord):
|
||||
def __init__(
|
||||
self,
|
||||
melspectrogram_model: Union[str, Path],
|
||||
embedding_model: Union[str, Path],
|
||||
libtensorflowlite_c_path: Union[str, Path],
|
||||
) -> None:
|
||||
TfLiteWakeWord.__init__(self, libtensorflowlite_c_path)
|
||||
|
||||
# Melspectrogram
|
||||
mel_path = str(Path(melspectrogram_model).resolve()).encode("utf-8")
|
||||
self.mel_model = self.lib.TfLiteModelCreateFromFile(mel_path)
|
||||
self.mel_interpreter = self.lib.TfLiteInterpreterCreate(self.mel_model, None)
|
||||
|
||||
mels_dims = (c_int32 * len(MEL_SHAPE))(*MEL_SHAPE)
|
||||
self.lib.TfLiteInterpreterResizeInputTensor(
|
||||
self.mel_interpreter,
|
||||
c_int32(0),
|
||||
mels_dims,
|
||||
c_int32(len(MEL_SHAPE)),
|
||||
)
|
||||
self.lib.TfLiteInterpreterAllocateTensors(self.mel_interpreter)
|
||||
self.mel_input_tensor = self.lib.TfLiteInterpreterGetInputTensor(
|
||||
self.mel_interpreter, c_int32(0)
|
||||
)
|
||||
self.mel_output_tensor = self.lib.TfLiteInterpreterGetOutputTensor(
|
||||
self.mel_interpreter, c_int32(0)
|
||||
)
|
||||
|
||||
# Embedding
|
||||
emb_path = str(Path(embedding_model).resolve()).encode("utf-8")
|
||||
self.emb_model = self.lib.TfLiteModelCreateFromFile(emb_path)
|
||||
self.emb_interpreter = self.lib.TfLiteInterpreterCreate(self.emb_model, None)
|
||||
emb_dims = (c_int32 * len(EMB_SHAPE))(*EMB_SHAPE)
|
||||
self.lib.TfLiteInterpreterResizeInputTensor(
|
||||
self.emb_interpreter,
|
||||
c_int32(0),
|
||||
emb_dims,
|
||||
c_int32(len(EMB_SHAPE)),
|
||||
)
|
||||
self.lib.TfLiteInterpreterAllocateTensors(self.emb_interpreter)
|
||||
self.emb_input_tensor = self.lib.TfLiteInterpreterGetInputTensor(
|
||||
self.emb_interpreter, c_int32(0)
|
||||
)
|
||||
self.emb_output_tensor = self.lib.TfLiteInterpreterGetOutputTensor(
|
||||
self.emb_interpreter, c_int32(0)
|
||||
)
|
||||
|
||||
# State
|
||||
self.new_audio_samples: int = AUTOFILL_SECONDS * SAMPLE_RATE
|
||||
self.audio: np.ndarray = np.zeros(shape=(_MAX_SAMPLES,), dtype=np.float32)
|
||||
self.new_mels: int = 0
|
||||
self.mels: np.ndarray = np.zeros(shape=(MAX_MELS, NUM_MELS), dtype=np.float32)
|
||||
|
||||
def process_streaming(self, audio_chunk: bytes) -> Iterable[np.ndarray]:
|
||||
"""Generate embeddings from audio."""
|
||||
chunk_array = np.frombuffer(audio_chunk, dtype=np.int16).astype(np.float32)
|
||||
|
||||
# Shift samples left
|
||||
self.audio[: -len(chunk_array)] = self.audio[len(chunk_array) :]
|
||||
|
||||
# Add new samples to end
|
||||
self.audio[-len(chunk_array) :] = chunk_array
|
||||
self.new_audio_samples = min(
|
||||
len(self.audio),
|
||||
self.new_audio_samples + len(chunk_array),
|
||||
)
|
||||
|
||||
while self.new_audio_samples >= MEL_SAMPLES:
|
||||
audio_tensor = np.zeros(shape=(BATCH_SIZE, MEL_SAMPLES), dtype=np.float32)
|
||||
audio_tensor[0, :] = self.audio[
|
||||
-self.new_audio_samples : len(self.audio)
|
||||
- self.new_audio_samples
|
||||
+ MEL_SAMPLES
|
||||
]
|
||||
audio_tensor = np.ascontiguousarray(audio_tensor)
|
||||
self.new_audio_samples = max(0, self.new_audio_samples - SAMPLES_PER_CHUNK)
|
||||
|
||||
# Run inference (mels)
|
||||
audio_ptr = audio_tensor.ctypes.data_as(c_void_p)
|
||||
self.lib.TfLiteTensorCopyFromBuffer(
|
||||
self.mel_input_tensor, audio_ptr, c_size_t(audio_tensor.nbytes)
|
||||
)
|
||||
self.lib.TfLiteInterpreterInvoke(self.mel_interpreter)
|
||||
|
||||
mels_output_bytes = self.lib.TfLiteTensorByteSize(self.mel_output_tensor)
|
||||
mels = np.empty(
|
||||
mels_output_bytes // np.dtype(np.float32).itemsize, dtype=np.float32
|
||||
)
|
||||
self.lib.TfLiteTensorCopyToBuffer(
|
||||
self.mel_output_tensor,
|
||||
mels.ctypes.data_as(c_void_p),
|
||||
c_size_t(mels_output_bytes),
|
||||
)
|
||||
|
||||
mels = (mels / 10) + 2 # transform to fit embedding
|
||||
mels = mels.reshape((1, 1, -1, NUM_MELS))
|
||||
|
||||
# Shift left
|
||||
num_mel_windows = mels.shape[2]
|
||||
self.mels[:-num_mel_windows] = self.mels[num_mel_windows:]
|
||||
|
||||
# Overwrite
|
||||
self.mels[-num_mel_windows:] = mels[0, 0, :, :]
|
||||
self.new_mels = min(len(self.mels), self.new_mels + num_mel_windows)
|
||||
|
||||
while self.new_mels >= EMB_FEATURES:
|
||||
mels_tensor = np.ascontiguousarray(
|
||||
np.zeros(shape=EMB_SHAPE, dtype=np.float32)
|
||||
)
|
||||
mels_tensor[0, :, :, 0] = self.mels[
|
||||
-self.new_mels : len(self.mels) - self.new_mels + EMB_FEATURES, :
|
||||
]
|
||||
self.new_mels = max(0, self.new_mels - EMB_STEP)
|
||||
|
||||
# Run inference (embedding)
|
||||
mels_ptr = mels_tensor.ctypes.data_as(c_void_p)
|
||||
self.lib.TfLiteTensorCopyFromBuffer(
|
||||
self.emb_input_tensor, mels_ptr, c_size_t(mels_tensor.nbytes)
|
||||
)
|
||||
self.lib.TfLiteInterpreterInvoke(self.emb_interpreter)
|
||||
|
||||
emb_output_bytes = self.lib.TfLiteTensorByteSize(self.emb_output_tensor)
|
||||
emb = np.empty(
|
||||
emb_output_bytes // np.dtype(np.float32).itemsize, dtype=np.float32
|
||||
)
|
||||
self.lib.TfLiteTensorCopyToBuffer(
|
||||
self.emb_output_tensor,
|
||||
emb.ctypes.data_as(c_void_p),
|
||||
c_size_t(emb_output_bytes),
|
||||
)
|
||||
emb = emb.reshape((1, 1, -1, WW_FEATURES))
|
||||
yield emb
|
||||
@@ -0,0 +1,25 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from .state import PlayerState
|
||||
|
||||
|
||||
class AudioPlayer(ABC):
|
||||
|
||||
@abstractmethod
|
||||
def play(self, url: str) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def pause(self) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def resume(self) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def stop(self) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def state(self) -> PlayerState:
|
||||
pass
|
||||
@@ -0,0 +1,163 @@
|
||||
import threading
|
||||
from typing import Optional, Callable
|
||||
|
||||
import mpv
|
||||
|
||||
from .base import AudioPlayer
|
||||
from .state import PlayerState
|
||||
|
||||
|
||||
class LibMpvPlayer(AudioPlayer):
|
||||
"""
|
||||
AudioPlayer implementation for Linux Voice Assistant using libmpv.
|
||||
|
||||
Responsibilities:
|
||||
- mpv lifecycle and playback control
|
||||
- thread-safe state management
|
||||
- volume handling with ducking support
|
||||
"""
|
||||
|
||||
def __init__(self, device: Optional[str] = None) -> None:
|
||||
self._state: PlayerState = PlayerState.IDLE
|
||||
self._state_lock = threading.Lock()
|
||||
|
||||
# Volume handling
|
||||
self._user_volume: float = 100.0 # 0.0 – 100.0
|
||||
self._duck_factor: float = 1.0 # 0.0 – 1.0
|
||||
|
||||
# mpv setup
|
||||
self._mpv = mpv.MPV(
|
||||
audio_display=False,
|
||||
log_handler=self._on_mpv_log,
|
||||
loglevel="error",
|
||||
)
|
||||
|
||||
if device:
|
||||
self._mpv["audio-device"] = device
|
||||
|
||||
# Callback Handling
|
||||
self._done_callback: Optional[Callable[[], None]] = None
|
||||
self._suppress_end_event = False
|
||||
self._mpv.event_callback("end-file")(self._on_end_file)
|
||||
|
||||
# -------- Playback control --------
|
||||
|
||||
def play(
|
||||
self,
|
||||
url: str,
|
||||
done_callback: Optional[Callable[[], None]] = None,
|
||||
stop_first: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
Start playback of a media URL.
|
||||
|
||||
Args:
|
||||
url: Media URL or local file path.
|
||||
done_callback: Optional callback invoked when playback finishes.
|
||||
stop_first: If True, start playback in paused state.
|
||||
"""
|
||||
self._done_callback = done_callback
|
||||
self._mpv.pause = stop_first
|
||||
with self._state_lock:
|
||||
self._set_state(PlayerState.LOADING)
|
||||
self._mpv.play(url)
|
||||
|
||||
def pause(self) -> None:
|
||||
"""Pause playback."""
|
||||
with self._state_lock:
|
||||
self._mpv.pause = True
|
||||
self._set_state(PlayerState.PAUSED)
|
||||
|
||||
def resume(self) -> None:
|
||||
"""Resume playback if paused."""
|
||||
with self._state_lock:
|
||||
self._mpv.pause = False
|
||||
self._set_state(PlayerState.PLAYING)
|
||||
|
||||
def stop(self, for_replacement: bool = False) -> None:
|
||||
"""
|
||||
Stop playback.
|
||||
|
||||
If called for track replacement, suppresses end-of-playback handling
|
||||
(state transition to IDLE and done callback) to allow seamless
|
||||
playback transitions.
|
||||
"""
|
||||
with self._state_lock:
|
||||
if for_replacement:
|
||||
self._suppress_end_event = True
|
||||
self._mpv.stop()
|
||||
|
||||
def state(self) -> PlayerState:
|
||||
"""Return the current player state."""
|
||||
with self._state_lock:
|
||||
return self._state
|
||||
|
||||
# -------- Volume / Ducking --------
|
||||
|
||||
def set_volume(self, volume: float) -> None:
|
||||
"""
|
||||
Set user volume.
|
||||
|
||||
Args:
|
||||
volume: Volume level (0.0–100.0).
|
||||
"""
|
||||
with self._state_lock:
|
||||
self._user_volume = max(0.0, min(100.0, float(volume)))
|
||||
self._apply_volume()
|
||||
|
||||
def duck(self, factor: float = 0.5) -> None:
|
||||
"""
|
||||
Reduce volume temporarily by a ducking factor.
|
||||
|
||||
Args:
|
||||
factor: Ducking factor (0.0–1.0).
|
||||
"""
|
||||
with self._state_lock:
|
||||
self._duck_factor = max(0.0, min(1.0, float(factor)))
|
||||
self._apply_volume()
|
||||
|
||||
def unduck(self) -> None:
|
||||
"""Restore volume to the user-defined level."""
|
||||
with self._state_lock:
|
||||
self._duck_factor = 1.0
|
||||
self._apply_volume()
|
||||
|
||||
# -------- Internal helpers --------
|
||||
|
||||
def _apply_volume(self) -> None:
|
||||
"""Apply effective volume (user volume × duck factor) to mpv."""
|
||||
effective = self._user_volume * self._duck_factor
|
||||
self._mpv.volume = max(0.0, min(100.0, effective))
|
||||
|
||||
def _on_end_file(self, event) -> None:
|
||||
callback: Optional[Callable[[], None]] = None
|
||||
|
||||
with self._state_lock:
|
||||
if self._suppress_end_event:
|
||||
self._suppress_end_event = False
|
||||
return
|
||||
|
||||
self._set_state(PlayerState.IDLE)
|
||||
callback = self._done_callback
|
||||
self._done_callback = None
|
||||
|
||||
if callback is not None:
|
||||
try:
|
||||
callback()
|
||||
except RuntimeError:
|
||||
# Callback errors must never break the player
|
||||
pass
|
||||
|
||||
def _on_mpv_log(self, level: str, prefix: str, text: str) -> None:
|
||||
"""
|
||||
Handle mpv log messages.
|
||||
|
||||
Error and fatal messages transition the player into ERROR state.
|
||||
"""
|
||||
if level in ("error", "fatal"):
|
||||
with self._state_lock:
|
||||
self._set_state(PlayerState.ERROR)
|
||||
|
||||
def _set_state(self, new_state: PlayerState) -> None:
|
||||
"""Update internal player state."""
|
||||
self._state = new_state
|
||||
@@ -0,0 +1,10 @@
|
||||
from enum import Enum, auto
|
||||
|
||||
|
||||
class PlayerState(Enum):
|
||||
IDLE = auto()
|
||||
LOADING = auto()
|
||||
PLAYING = auto()
|
||||
PAUSED = auto()
|
||||
STOPPING = auto()
|
||||
ERROR = auto()
|
||||
@@ -1,45 +1,58 @@
|
||||
"""Voice satellite protocol."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import posixpath
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
from collections.abc import Iterable
|
||||
from typing import Dict, Optional, Set, Union
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
from urllib.request import urlopen
|
||||
|
||||
# pylint: disable=no-name-in-module
|
||||
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
|
||||
AuthenticationRequest,
|
||||
DeviceInfoRequest,
|
||||
DeviceInfoResponse,
|
||||
ListEntitiesDoneResponse,
|
||||
ListEntitiesRequest,
|
||||
MediaPlayerCommandRequest,
|
||||
SubscribeHomeAssistantStatesRequest,
|
||||
SwitchCommandRequest,
|
||||
VoiceAssistantAnnounceFinished,
|
||||
VoiceAssistantAnnounceRequest,
|
||||
VoiceAssistantAudio,
|
||||
VoiceAssistantConfigurationRequest,
|
||||
VoiceAssistantConfigurationResponse,
|
||||
VoiceAssistantEventResponse,
|
||||
VoiceAssistantExternalWakeWord,
|
||||
VoiceAssistantRequest,
|
||||
VoiceAssistantSetConfiguration,
|
||||
VoiceAssistantTimerEventResponse,
|
||||
VoiceAssistantWakeWord,
|
||||
)
|
||||
from aioesphomeapi.core import MESSAGE_TYPE_TO_PROTO
|
||||
from aioesphomeapi.model import (
|
||||
VoiceAssistantEventType,
|
||||
VoiceAssistantFeature,
|
||||
VoiceAssistantTimerEventType,
|
||||
)
|
||||
from google.protobuf import message
|
||||
from pymicro_wakeword import MicroWakeWord
|
||||
from pyopen_wakeword import OpenWakeWord
|
||||
|
||||
from .api_server import APIServer
|
||||
from .entity import MediaPlayerEntity
|
||||
from .microwakeword import MicroWakeWord
|
||||
from .models import ServerState
|
||||
from .openwakeword import OpenWakeWord
|
||||
from .entity import MediaPlayerEntity, MuteSwitchEntity, ThinkingSoundEntity
|
||||
from .models import AvailableWakeWord, ServerState, WakeWordType
|
||||
from .util import call_all
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PROTO_TO_MESSAGE_TYPE = {v: k for k, v in MESSAGE_TYPE_TO_PROTO.items()}
|
||||
|
||||
|
||||
class VoiceSatelliteProtocol(APIServer):
|
||||
|
||||
@@ -48,6 +61,28 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
|
||||
self.state = state
|
||||
self.state.satellite = self
|
||||
self.state.connected = False
|
||||
|
||||
existing_media_players = [
|
||||
entity
|
||||
for entity in self.state.entities
|
||||
if isinstance(entity, MediaPlayerEntity)
|
||||
]
|
||||
if existing_media_players:
|
||||
# Keep the first instance and remove any extras.
|
||||
self.state.media_player_entity = existing_media_players[0]
|
||||
for extra in existing_media_players[1:]:
|
||||
self.state.entities.remove(extra)
|
||||
|
||||
existing_mute_switches = [
|
||||
entity
|
||||
for entity in self.state.entities
|
||||
if isinstance(entity, MuteSwitchEntity)
|
||||
]
|
||||
if existing_mute_switches:
|
||||
self.state.mute_switch_entity = existing_mute_switches[0]
|
||||
for extra in existing_mute_switches[1:]:
|
||||
self.state.entities.remove(extra)
|
||||
|
||||
if self.state.media_player_entity is None:
|
||||
self.state.media_player_entity = MediaPlayerEntity(
|
||||
@@ -59,12 +94,117 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
announce_player=state.tts_player,
|
||||
)
|
||||
self.state.entities.append(self.state.media_player_entity)
|
||||
elif self.state.media_player_entity not in self.state.entities:
|
||||
self.state.entities.append(self.state.media_player_entity)
|
||||
|
||||
self.state.media_player_entity.server = self
|
||||
|
||||
# Add/update mute switch entity (like ESPHome Voice PE)
|
||||
mute_switch = self.state.mute_switch_entity
|
||||
if mute_switch is None:
|
||||
mute_switch = MuteSwitchEntity(
|
||||
server=self,
|
||||
key=len(state.entities),
|
||||
name="Mute",
|
||||
object_id="mute",
|
||||
get_muted=lambda: self.state.muted,
|
||||
set_muted=self._set_muted,
|
||||
)
|
||||
self.state.entities.append(mute_switch)
|
||||
self.state.mute_switch_entity = mute_switch
|
||||
elif mute_switch not in self.state.entities:
|
||||
self.state.entities.append(mute_switch)
|
||||
|
||||
mute_switch.server = self
|
||||
mute_switch.update_get_muted(lambda: self.state.muted)
|
||||
mute_switch.update_set_muted(self._set_muted)
|
||||
mute_switch.sync_with_state()
|
||||
|
||||
existing_thinking_sound_switches = [
|
||||
entity
|
||||
for entity in self.state.entities
|
||||
if isinstance(entity, ThinkingSoundEntity)
|
||||
]
|
||||
if existing_thinking_sound_switches:
|
||||
self.state.thinking_sound_entity = existing_thinking_sound_switches[0]
|
||||
for extra in existing_thinking_sound_switches[1:]:
|
||||
self.state.entities.remove(extra)
|
||||
|
||||
# Add/update thinking sound entity
|
||||
thinking_sound_switch = self.state.thinking_sound_entity
|
||||
if thinking_sound_switch is None:
|
||||
thinking_sound_switch = ThinkingSoundEntity(
|
||||
server=self,
|
||||
key=len(state.entities),
|
||||
name="Thinking Sound",
|
||||
object_id="thinking_sound",
|
||||
get_thinking_sound_enabled=lambda: self.state.thinking_sound_enabled,
|
||||
set_thinking_sound_enabled=self._set_thinking_sound_enabled,
|
||||
)
|
||||
self.state.entities.append(thinking_sound_switch)
|
||||
self.state.thinking_sound_entity = thinking_sound_switch
|
||||
elif thinking_sound_switch not in self.state.entities:
|
||||
self.state.entities.append(thinking_sound_switch)
|
||||
|
||||
# Load thinking sound enabled state from preferences (default to False if not set or unknown)
|
||||
if hasattr(
|
||||
self.state.preferences, "thinking_sound"
|
||||
) and self.state.preferences.thinking_sound in (0, 1):
|
||||
self.state.thinking_sound_enabled = bool(
|
||||
self.state.preferences.thinking_sound
|
||||
)
|
||||
else:
|
||||
self.state.thinking_sound_enabled = False
|
||||
|
||||
thinking_sound_switch.server = self
|
||||
thinking_sound_switch.update_get_thinking_sound_enabled(
|
||||
lambda: self.state.thinking_sound_enabled
|
||||
)
|
||||
thinking_sound_switch.update_set_thinking_sound_enabled(
|
||||
self._set_thinking_sound_enabled
|
||||
)
|
||||
thinking_sound_switch.sync_with_state()
|
||||
|
||||
self._is_streaming_audio = False
|
||||
self._tts_url: Optional[str] = None
|
||||
self._tts_played = False
|
||||
self._continue_conversation = False
|
||||
self._timer_finished = False
|
||||
self._processing = False
|
||||
self._pipeline_active = False
|
||||
self._external_wake_words: Dict[str, VoiceAssistantExternalWakeWord] = {}
|
||||
self._disconnect_event = asyncio.Event()
|
||||
|
||||
def _set_thinking_sound_enabled(self, new_state: bool) -> None:
|
||||
self.state.thinking_sound_enabled = bool(new_state)
|
||||
self.state.preferences.thinking_sound = (
|
||||
1 if self.state.thinking_sound_enabled else 0
|
||||
)
|
||||
|
||||
if self.state.thinking_sound_enabled:
|
||||
_LOGGER.debug("Thinking sound enabled")
|
||||
else:
|
||||
_LOGGER.debug("Thinking sound disabled")
|
||||
pass
|
||||
self.state.save_preferences()
|
||||
|
||||
def _set_muted(self, new_state: bool) -> None:
|
||||
self.state.muted = bool(new_state)
|
||||
|
||||
if self.state.muted:
|
||||
# voice_assistant.stop behavior
|
||||
_LOGGER.debug("Muting voice assistant (voice_assistant.stop)")
|
||||
self._is_streaming_audio = False
|
||||
self.state.tts_player.stop()
|
||||
# Stop any ongoing voice processing
|
||||
self.state.stop_word.is_active = False
|
||||
self.state.tts_player.play(self.state.mute_sound)
|
||||
else:
|
||||
# voice_assistant.start_continuous behavior
|
||||
_LOGGER.debug("Unmuting voice assistant (voice_assistant.start_continuous)")
|
||||
self.state.tts_player.play(self.state.unmute_sound)
|
||||
# Resume normal operation - wake word detection will be active again
|
||||
pass
|
||||
|
||||
def handle_voice_event(
|
||||
self, event_type: VoiceAssistantEventType, data: Dict[str, str]
|
||||
@@ -75,6 +215,18 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
self._tts_url = data.get("url")
|
||||
self._tts_played = False
|
||||
self._continue_conversation = False
|
||||
elif (
|
||||
event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_START
|
||||
and self.state.thinking_sound_enabled
|
||||
):
|
||||
# Play short "thinking/processing" sound if configured
|
||||
processing = getattr(self.state, "processing_sound", None)
|
||||
if processing:
|
||||
_LOGGER.debug("Playing processing sound: %s", processing)
|
||||
self.state.stop_word.is_active = True
|
||||
self._processing = True
|
||||
self.duck()
|
||||
self.state.tts_player.play(self.state.processing_sound)
|
||||
elif event_type in (
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_END,
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_STT_END,
|
||||
@@ -107,7 +259,7 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
_LOGGER.debug("Timer event: type=%s", event_type.name)
|
||||
if event_type == VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_FINISHED:
|
||||
if not self._timer_finished:
|
||||
self.state.stop_word.is_active = True
|
||||
self.state.active_wake_words.add(self.state.stop_word.id)
|
||||
self._timer_finished = True
|
||||
self.duck()
|
||||
self._play_timer_finished()
|
||||
@@ -131,20 +283,26 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
|
||||
urls.append(msg.media_id)
|
||||
|
||||
self.state.stop_word.is_active = True
|
||||
self.state.active_wake_words.add(self.state.stop_word.id)
|
||||
self._continue_conversation = msg.start_conversation
|
||||
|
||||
self.duck()
|
||||
yield from self.state.media_player_entity.play(
|
||||
urls, announcement=True, done_callback=self._tts_finished
|
||||
)
|
||||
self.state.tts_player.play(urls, done_callback=self._tts_finished)
|
||||
elif isinstance(msg, VoiceAssistantTimerEventResponse):
|
||||
self.handle_timer_event(VoiceAssistantTimerEventType(msg.event_type), msg)
|
||||
elif isinstance(msg, DeviceInfoRequest):
|
||||
# Compute dynamic device name
|
||||
base_name = re.sub(r"[\s-]+", "-", self.state.name.lower()).strip("-")
|
||||
mac_no_colon = self.state.mac_address.replace(":", "").lower()
|
||||
mac_last6 = mac_no_colon[-6:]
|
||||
device_name = f"{base_name}-{mac_last6}"
|
||||
|
||||
yield DeviceInfoResponse(
|
||||
uses_password=False,
|
||||
name=self.state.name,
|
||||
name=device_name,
|
||||
mac_address=self.state.mac_address,
|
||||
manufacturer="Open Home Foundation",
|
||||
model="Linux Voice Assistant",
|
||||
voice_assistant_feature_flags=(
|
||||
VoiceAssistantFeature.VOICE_ASSISTANT
|
||||
| VoiceAssistantFeature.API_AUDIO
|
||||
@@ -159,6 +317,7 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
ListEntitiesRequest,
|
||||
SubscribeHomeAssistantStatesRequest,
|
||||
MediaPlayerCommandRequest,
|
||||
SwitchCommandRequest,
|
||||
),
|
||||
):
|
||||
for entity in self.state.entities:
|
||||
@@ -167,7 +326,6 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
if isinstance(msg, ListEntitiesRequest):
|
||||
yield ListEntitiesDoneResponse()
|
||||
elif isinstance(msg, VoiceAssistantConfigurationRequest):
|
||||
yield VoiceAssistantConfigurationResponse(
|
||||
available_wake_words = [
|
||||
VoiceAssistantWakeWord(
|
||||
id=ww.id,
|
||||
@@ -175,9 +333,28 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
trained_languages=ww.trained_languages,
|
||||
)
|
||||
for ww in self.state.available_wake_words.values()
|
||||
],
|
||||
]
|
||||
|
||||
for eww in msg.external_wake_words:
|
||||
if eww.model_type != "micro":
|
||||
continue
|
||||
|
||||
available_wake_words.append(
|
||||
VoiceAssistantWakeWord(
|
||||
id=eww.id,
|
||||
wake_word=eww.wake_word,
|
||||
trained_languages=eww.trained_languages,
|
||||
)
|
||||
)
|
||||
|
||||
self._external_wake_words[eww.id] = eww
|
||||
|
||||
yield VoiceAssistantConfigurationResponse(
|
||||
available_wake_words=available_wake_words,
|
||||
active_wake_words=[
|
||||
ww.id for ww in self.state.wake_words.values() if ww.is_active
|
||||
ww.id
|
||||
for ww in self.state.wake_words.values()
|
||||
if ww.id in self.state.active_wake_words
|
||||
],
|
||||
max_active_wake_words=2,
|
||||
)
|
||||
@@ -194,20 +371,25 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
|
||||
model_info = self.state.available_wake_words.get(wake_word_id)
|
||||
if not model_info:
|
||||
# Check external wake words (may require download)
|
||||
external_wake_word = self._external_wake_words.get(wake_word_id)
|
||||
if not external_wake_word:
|
||||
continue
|
||||
|
||||
_LOGGER.debug("Loading wake word: %s", model_info.config_path)
|
||||
self.state.wake_words[wake_word_id] = model_info.load(
|
||||
self.state.libtensorflowlite_c_path
|
||||
)
|
||||
model_info = self._download_external_wake_word(external_wake_word)
|
||||
if not model_info:
|
||||
continue
|
||||
|
||||
self.state.available_wake_words[wake_word_id] = model_info
|
||||
|
||||
_LOGGER.debug("Loading wake word: %s", model_info.wake_word_path)
|
||||
self.state.wake_words[wake_word_id] = model_info.load()
|
||||
|
||||
_LOGGER.info("Wake word set: %s", wake_word_id)
|
||||
active_wake_words.add(wake_word_id)
|
||||
break
|
||||
|
||||
for wake_word in self.state.wake_words.values():
|
||||
wake_word.is_active = wake_word.id in active_wake_words
|
||||
|
||||
self.state.active_wake_words = active_wake_words
|
||||
_LOGGER.debug("Active wake words: %s", active_wake_words)
|
||||
|
||||
self.state.preferences.active_wake_words = list(active_wake_words)
|
||||
@@ -216,7 +398,7 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
|
||||
def handle_audio(self, audio_chunk: bytes) -> None:
|
||||
|
||||
if not self._is_streaming_audio:
|
||||
if not self._is_streaming_audio or self.state.muted:
|
||||
return
|
||||
|
||||
self.send_messages([VoiceAssistantAudio(data=audio_chunk)])
|
||||
@@ -229,6 +411,10 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
_LOGGER.debug("Stopping timer finished sound")
|
||||
return
|
||||
|
||||
if self.state.muted:
|
||||
# Don't respond to wake words when muted (voice_assistant.stop behavior)
|
||||
return
|
||||
|
||||
wake_word_phrase = wake_word.wake_word
|
||||
_LOGGER.debug("Detected wake word: %s", wake_word_phrase)
|
||||
self.send_messages(
|
||||
@@ -239,7 +425,7 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
self.state.tts_player.play(self.state.wakeup_sound)
|
||||
|
||||
def stop(self) -> None:
|
||||
self.state.stop_word.is_active = False
|
||||
self.state.active_wake_words.discard(self.state.stop_word.id)
|
||||
self.state.tts_player.stop()
|
||||
|
||||
if self._timer_finished:
|
||||
@@ -256,7 +442,7 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
self._tts_played = True
|
||||
_LOGGER.debug("Playing TTS response: %s", self._tts_url)
|
||||
|
||||
self.state.stop_word.is_active = True
|
||||
self.state.active_wake_words.add(self.state.stop_word.id)
|
||||
self.state.tts_player.play(self._tts_url, done_callback=self._tts_finished)
|
||||
|
||||
def duck(self) -> None:
|
||||
@@ -268,7 +454,7 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
self.state.music_player.unduck()
|
||||
|
||||
def _tts_finished(self) -> None:
|
||||
self.state.stop_word.is_active = False
|
||||
self.state.active_wake_words.discard(self.state.stop_word.id)
|
||||
self.send_messages([VoiceAssistantAnnounceFinished()])
|
||||
|
||||
if self._continue_conversation:
|
||||
@@ -294,4 +480,112 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
|
||||
def connection_lost(self, exc):
|
||||
super().connection_lost(exc)
|
||||
_LOGGER.info("Disconnected from Home Assistant")
|
||||
|
||||
self._disconnect_event.set()
|
||||
self._is_streaming_audio = False
|
||||
self._tts_url = None
|
||||
self._tts_played = False
|
||||
self._continue_conversation = False
|
||||
self._timer_finished = False
|
||||
|
||||
# Stop any ongoing audio playback and wake/stop word processing.
|
||||
try:
|
||||
self.state.music_player.stop()
|
||||
except Exception: # pragma: no cover - defensive safety net
|
||||
_LOGGER.exception("Failed to stop music player during disconnect")
|
||||
|
||||
try:
|
||||
self.state.tts_player.stop()
|
||||
except Exception: # pragma: no cover - defensive safety net
|
||||
_LOGGER.exception("Failed to stop TTS player during disconnect")
|
||||
|
||||
self.state.stop_word.is_active = False
|
||||
self.state.connected = False
|
||||
if self.state.satellite is self:
|
||||
self.state.satellite = None
|
||||
|
||||
if self.state.mute_switch_entity is not None:
|
||||
self.state.mute_switch_entity.sync_with_state()
|
||||
|
||||
_LOGGER.info("Disconnected from Home Assistant; waiting for reconnection")
|
||||
|
||||
def process_packet(self, msg_type: int, packet_data: bytes) -> None:
|
||||
super().process_packet(msg_type, packet_data)
|
||||
|
||||
if msg_type == PROTO_TO_MESSAGE_TYPE[AuthenticationRequest]:
|
||||
self.state.connected = True
|
||||
# Send states after connect
|
||||
states = []
|
||||
for entity in self.state.entities:
|
||||
states.extend(
|
||||
entity.handle_message(SubscribeHomeAssistantStatesRequest())
|
||||
)
|
||||
self.send_messages(states)
|
||||
_LOGGER.debug("Sent entity states after connect")
|
||||
|
||||
def _download_external_wake_word(
|
||||
self, external_wake_word: VoiceAssistantExternalWakeWord
|
||||
) -> Optional[AvailableWakeWord]:
|
||||
eww_dir = self.state.download_dir / "external_wake_words"
|
||||
eww_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
config_path = eww_dir / f"{external_wake_word.id}.json"
|
||||
should_download_config = not config_path.exists()
|
||||
|
||||
# Check if we need to download the model file
|
||||
model_path = eww_dir / f"{external_wake_word.id}.tflite"
|
||||
should_download_model = True
|
||||
if model_path.exists():
|
||||
model_size = model_path.stat().st_size
|
||||
if model_size == external_wake_word.model_size:
|
||||
with open(model_path, "rb") as model_file:
|
||||
model_hash = hashlib.sha256(model_file.read()).hexdigest()
|
||||
|
||||
if model_hash == external_wake_word.model_hash:
|
||||
should_download_model = False
|
||||
_LOGGER.debug(
|
||||
"Model size and hash match for %s. Skipping download.",
|
||||
external_wake_word.id,
|
||||
)
|
||||
|
||||
if should_download_config or should_download_model:
|
||||
# Download config
|
||||
_LOGGER.debug("Downloading %s to %s", external_wake_word.url, config_path)
|
||||
with urlopen(external_wake_word.url) as request:
|
||||
if request.status != 200:
|
||||
_LOGGER.warning(
|
||||
"Failed to download: %s, status=%s",
|
||||
external_wake_word.url,
|
||||
request.status,
|
||||
)
|
||||
return None
|
||||
|
||||
with open(config_path, "wb") as model_file:
|
||||
shutil.copyfileobj(request, model_file)
|
||||
|
||||
if should_download_model:
|
||||
# Download model file
|
||||
parsed_url = urlparse(external_wake_word.url)
|
||||
parsed_url = parsed_url._replace(
|
||||
path=posixpath.join(posixpath.dirname(parsed_url.path), model_path.name)
|
||||
)
|
||||
model_url = urlunparse(parsed_url)
|
||||
|
||||
_LOGGER.debug("Downloading %s to %s", model_url, model_path)
|
||||
with urlopen(model_url) as request:
|
||||
if request.status != 200:
|
||||
_LOGGER.warning(
|
||||
"Failed to download: %s, status=%s", model_url, request.status
|
||||
)
|
||||
return None
|
||||
|
||||
with open(model_path, "wb") as model_file:
|
||||
shutil.copyfileobj(request, model_file)
|
||||
|
||||
return AvailableWakeWord(
|
||||
id=external_wake_word.id,
|
||||
type=WakeWordType.MICRO_WAKE_WORD,
|
||||
wake_word=external_wake_word.wake_word,
|
||||
trained_languages=external_wake_word.trained_languages,
|
||||
wake_word_path=config_path,
|
||||
)
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Utility methods."""
|
||||
|
||||
import platform
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from typing import Optional
|
||||
@@ -15,8 +14,3 @@ def get_mac() -> str:
|
||||
def call_all(*callables: Optional[Callable[[], None]]) -> None:
|
||||
for item in filter(None, callables):
|
||||
item()
|
||||
|
||||
|
||||
def is_arm() -> bool:
|
||||
machine = platform.machine()
|
||||
return ("arm" in machine) or ("aarch" in machine)
|
||||
@@ -1,89 +0,0 @@
|
||||
"""Base class for wake words."""
|
||||
|
||||
import ctypes as C
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
TfLiteStatus = C.c_int # kTfLiteOk == 0
|
||||
|
||||
|
||||
class TfLiteQuantizationParams(C.Structure):
|
||||
_fields_ = [("scale", C.c_float), ("zero_point", C.c_int32)]
|
||||
|
||||
|
||||
c_void_p = C.c_void_p
|
||||
c_int32 = C.c_int32
|
||||
c_size_t = C.c_size_t
|
||||
|
||||
|
||||
def _not_null(result, func, args):
|
||||
if not result:
|
||||
raise RuntimeError(f"{func.__name__} returned NULL")
|
||||
return result
|
||||
|
||||
|
||||
class TfLiteWakeWord:
|
||||
def __init__(self, libtensorflowlite_c_path: Union[str, Path]):
|
||||
self.libtensorflowlite_c_path = Path(libtensorflowlite_c_path)
|
||||
# Load ONCE with RTLD_GLOBAL
|
||||
self.lib = C.CDLL(
|
||||
str(self.libtensorflowlite_c_path.resolve()), mode=os.RTLD_GLOBAL
|
||||
)
|
||||
|
||||
lib = self.lib
|
||||
|
||||
# Model / Interpreter
|
||||
lib.TfLiteModelCreateFromFile.argtypes = [C.c_char_p]
|
||||
lib.TfLiteModelCreateFromFile.restype = c_void_p
|
||||
|
||||
lib.TfLiteInterpreterCreate.argtypes = [c_void_p, c_void_p]
|
||||
lib.TfLiteInterpreterCreate.restype = c_void_p
|
||||
|
||||
lib.TfLiteInterpreterAllocateTensors.argtypes = [c_void_p]
|
||||
lib.TfLiteInterpreterAllocateTensors.restype = TfLiteStatus
|
||||
|
||||
lib.TfLiteInterpreterInvoke.argtypes = [c_void_p]
|
||||
lib.TfLiteInterpreterInvoke.restype = TfLiteStatus
|
||||
|
||||
# Tensors: get
|
||||
lib.TfLiteInterpreterGetInputTensor.argtypes = [c_void_p, c_int32]
|
||||
lib.TfLiteInterpreterGetInputTensor.restype = c_void_p
|
||||
lib.TfLiteInterpreterGetInputTensor.errcheck = _not_null
|
||||
|
||||
lib.TfLiteInterpreterGetOutputTensor.argtypes = [c_void_p, c_int32]
|
||||
lib.TfLiteInterpreterGetOutputTensor.restype = c_void_p
|
||||
lib.TfLiteInterpreterGetOutputTensor.errcheck = _not_null
|
||||
|
||||
# Sizes / dims
|
||||
lib.TfLiteTensorByteSize.argtypes = [c_void_p]
|
||||
lib.TfLiteTensorByteSize.restype = c_size_t
|
||||
|
||||
lib.TfLiteTensorNumDims.argtypes = [c_void_p]
|
||||
lib.TfLiteTensorNumDims.restype = c_int32
|
||||
|
||||
lib.TfLiteTensorDim.argtypes = [c_void_p, c_int32]
|
||||
lib.TfLiteTensorDim.restype = c_int32
|
||||
|
||||
# Type & quant params
|
||||
lib.TfLiteTensorType.argtypes = [c_void_p]
|
||||
lib.TfLiteTensorType.restype = c_int32
|
||||
|
||||
lib.TfLiteTensorQuantizationParams.argtypes = [c_void_p]
|
||||
lib.TfLiteTensorQuantizationParams.restype = TfLiteQuantizationParams
|
||||
|
||||
# Resize
|
||||
lib.TfLiteInterpreterResizeInputTensor.argtypes = [
|
||||
c_void_p,
|
||||
c_int32,
|
||||
C.POINTER(c_int32),
|
||||
c_int32,
|
||||
]
|
||||
lib.TfLiteInterpreterResizeInputTensor.restype = TfLiteStatus
|
||||
|
||||
# Copy buffers
|
||||
lib.TfLiteTensorCopyFromBuffer.argtypes = [c_void_p, c_void_p, c_size_t]
|
||||
lib.TfLiteTensorCopyFromBuffer.restype = TfLiteStatus
|
||||
|
||||
lib.TfLiteTensorCopyToBuffer.argtypes = [c_void_p, c_void_p, c_size_t]
|
||||
lib.TfLiteTensorCopyToBuffer.restype = TfLiteStatus
|
||||
@@ -10,7 +10,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
try:
|
||||
from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf
|
||||
except ImportError:
|
||||
_LOGGER.fatal("pip install zeroconf")
|
||||
_LOGGER.fatal("zeroconf not installed. Please install it with: pip install zeroconf")
|
||||
raise
|
||||
|
||||
MDNS_TARGET_IP = "224.0.0.251"
|
||||
|
||||
@@ -7,10 +7,13 @@ ignore_missing_imports = True
|
||||
[mypy-aioesphomeapi.api_pb2.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-pymicro_features.*]
|
||||
[mypy-pymicro_wakeword.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-sounddevice.*]
|
||||
[mypy-pyopen_wakeword.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-soundcard.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-mpv.*]
|
||||
|
||||
+10
-9
@@ -25,21 +25,22 @@ classifiers = [
|
||||
]
|
||||
requires-python = ">=3.9.0"
|
||||
dependencies = [
|
||||
"aioesphomeapi==37.2.1",
|
||||
"sounddevice<1",
|
||||
"aioesphomeapi==42.7.0",
|
||||
"soundcard<1",
|
||||
"numpy>=2,<3",
|
||||
"pymicro-features==1.0.0",
|
||||
"pymicro-wakeword>=2,<3",
|
||||
"pyopen-wakeword>=1,<2",
|
||||
"python-mpv>=1,<2",
|
||||
"zeroconf",
|
||||
"zeroconf<1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"black==24.8.0",
|
||||
"flake8==7.2.0",
|
||||
"mypy==1.14.0",
|
||||
"pylint==3.2.7",
|
||||
"pytest==8.3.5",
|
||||
"black",
|
||||
"flake8",
|
||||
"mypy",
|
||||
"pylint",
|
||||
"pytest",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
aioesphomeapi
|
||||
sounddevice
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,37 @@
|
||||
import time
|
||||
import mpv
|
||||
|
||||
"""
|
||||
mpv_smoke_test.py - test playback of media
|
||||
"""
|
||||
|
||||
def handle_event(event):
|
||||
print(f"EVENT: {event['event_id']}")
|
||||
|
||||
|
||||
player = mpv.MPV(
|
||||
audio_display=False,
|
||||
log_handler=print,
|
||||
loglevel="info",
|
||||
)
|
||||
|
||||
player.event_callback = handle_event
|
||||
|
||||
print("Loading media...")
|
||||
player.play("https://icecast.radiofrance.fr/fip-midfi.mp3")
|
||||
|
||||
time.sleep(5)
|
||||
|
||||
print("Pausing...")
|
||||
player.pause = True
|
||||
time.sleep(2)
|
||||
|
||||
print("Resuming...")
|
||||
player.pause = False
|
||||
time.sleep(5)
|
||||
|
||||
print("Stopping...")
|
||||
player.stop()
|
||||
|
||||
time.sleep(2)
|
||||
print("Done.")
|
||||
Binary file not shown.
@@ -0,0 +1,39 @@
|
||||
import time
|
||||
import logging
|
||||
|
||||
from linux_voice_assistant.player.libmpv import LibMpvPlayer
|
||||
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
player = LibMpvPlayer()
|
||||
|
||||
player.play("https://icecast.radiofrance.fr/fip-midfi.mp3")
|
||||
time.sleep(5)
|
||||
|
||||
player.pause()
|
||||
time.sleep(2)
|
||||
|
||||
player.resume()
|
||||
time.sleep(5)
|
||||
|
||||
print("Set volume to 80")
|
||||
player.set_volume(80)
|
||||
time.sleep(2)
|
||||
|
||||
print("Duck")
|
||||
player.duck()
|
||||
time.sleep(2)
|
||||
|
||||
print("Set volume to 40 while ducked")
|
||||
player.set_volume(40)
|
||||
time.sleep(2)
|
||||
|
||||
print("Unduck")
|
||||
player.unduck()
|
||||
time.sleep(2)
|
||||
|
||||
player.stop()
|
||||
time.sleep(2)
|
||||
|
||||
print("Final state:", player.state())
|
||||
@@ -1,43 +0,0 @@
|
||||
"""Tests for microWakeWord."""
|
||||
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
from linux_voice_assistant.microwakeword import MicroWakeWord, MicroWakeWordFeatures
|
||||
from linux_voice_assistant.util import is_arm
|
||||
|
||||
_TESTS_DIR = Path(__file__).parent
|
||||
_REPO_DIR = _TESTS_DIR.parent
|
||||
_MICRO_DIR = _REPO_DIR / "wakewords"
|
||||
|
||||
if is_arm():
|
||||
_LIB_DIR = _REPO_DIR / "lib" / "linux_arm64"
|
||||
else:
|
||||
_LIB_DIR = _REPO_DIR / "lib" / "linux_amd64"
|
||||
|
||||
|
||||
libtensorflowlite_c_path = _LIB_DIR / "libtensorflowlite_c.so"
|
||||
|
||||
|
||||
def test_features() -> None:
|
||||
features = MicroWakeWordFeatures(
|
||||
libtensorflowlite_c_path=libtensorflowlite_c_path,
|
||||
)
|
||||
ww = MicroWakeWord.from_config(
|
||||
config_path=_MICRO_DIR / "okay_nabu.json",
|
||||
libtensorflowlite_c_path=libtensorflowlite_c_path,
|
||||
)
|
||||
|
||||
detected = False
|
||||
with wave.open(str(_TESTS_DIR / "ok_nabu.wav"), "rb") as wav_file:
|
||||
assert wav_file.getframerate() == 16000
|
||||
assert wav_file.getsampwidth() == 2
|
||||
assert wav_file.getnchannels() == 1
|
||||
|
||||
for micro_input in features.process_streaming(
|
||||
wav_file.readframes(wav_file.getnframes())
|
||||
):
|
||||
if ww.process_streaming(micro_input):
|
||||
detected = True
|
||||
|
||||
assert detected
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Tests for openWakeWord."""
|
||||
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
from linux_voice_assistant.openwakeword import OpenWakeWordFeatures, OpenWakeWord
|
||||
from linux_voice_assistant.util import is_arm
|
||||
|
||||
_TESTS_DIR = Path(__file__).parent
|
||||
_REPO_DIR = _TESTS_DIR.parent
|
||||
_OWW_DIR = _REPO_DIR / "wakewords" / "openWakeWord"
|
||||
|
||||
if is_arm():
|
||||
_LIB_DIR = _REPO_DIR / "lib" / "linux_arm64"
|
||||
else:
|
||||
_LIB_DIR = _REPO_DIR / "lib" / "linux_amd64"
|
||||
|
||||
|
||||
libtensorflowlite_c_path = _LIB_DIR / "libtensorflowlite_c.so"
|
||||
|
||||
|
||||
def test_features() -> None:
|
||||
features = OpenWakeWordFeatures(
|
||||
melspectrogram_model=_OWW_DIR / "melspectrogram.tflite",
|
||||
embedding_model=_OWW_DIR / "embedding_model.tflite",
|
||||
libtensorflowlite_c_path=libtensorflowlite_c_path,
|
||||
)
|
||||
ww = OpenWakeWord(
|
||||
id="ok_nabu",
|
||||
wake_word="okay nabu",
|
||||
tflite_model=_OWW_DIR / "ok_nabu_v0.1.tflite",
|
||||
libtensorflowlite_c_path=libtensorflowlite_c_path,
|
||||
)
|
||||
|
||||
max_prob = 0.0
|
||||
with wave.open(str(_TESTS_DIR / "ok_nabu.wav"), "rb") as wav_file:
|
||||
assert wav_file.getframerate() == 16000
|
||||
assert wav_file.getsampwidth() == 2
|
||||
assert wav_file.getnchannels() == 1
|
||||
|
||||
for embeddings in features.process_streaming(
|
||||
wav_file.readframes(wav_file.getnframes())
|
||||
):
|
||||
for prob in ww.process_streaming(embeddings):
|
||||
max_prob = max(max_prob, prob)
|
||||
|
||||
assert max_prob > 0.5
|
||||
@@ -0,0 +1,2 @@
|
||||
def test_placeholder():
|
||||
pass
|
||||
Binary file not shown.
Binary file not shown.
Reference in new issue
Block a user