Merge branch 'main' into patch-1

This commit is contained in:
Omar Amin authored and GitHub committed 2026-02-12 14:48:09 +02:00
commit 9e491a9e51
50 files changed
+2476 -922

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
*
!/pyproject.toml
!/setup.cfg
!/script/*
!/docker-entrypoint.sh
!/linux_voice_assistant/*
!/sounds/*
!/wakewords/*
!/wakewords/openWakeWord/*
+52
View File
@@ -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"
+77
View File
@@ -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`
+64
View File
@@ -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
View File
@@ -15,5 +15,9 @@ venv/
.mypy_cache/
*.egg-info/
dist/
preferences.json
/dist/
/preferences.json
/local/
# Docker
.env
+6 -1
View File
@@ -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
View File
@@ -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"]
+62 -41
View File
@@ -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)).
[![CI](https://github.com/OHF-Voice/linux-voice-assistant/actions/workflows/docker-build-release.yml/badge.svg)](https://github.com/OHF-Voice/linux-voice-assistant/actions/workflows/docker-build-release.yml) [![GitHub Package Version](https://img.shields.io/github/v/tag/OHF-Voice/linux-voice-assistant?label=version)](https://github.com/OHF-Voice/linux-voice-assistant/pkgs/container/linux-voice-assistant) [![GitHub License](https://img.shields.io/github/license/OHF-Voice/linux-voice-assistant)](https://github.com/OHF-Voice/linux-voice-assistant/blob/main/LICENSE.md) [![GitHub last commit](https://img.shields.io/github/last-commit/OHF-Voice/linux-voice-assistant)](https://github.com/OHF-Voice/linux-voice-assistant/commits) [![GitHub Container Registry](https://img.shields.io/badge/Container%20Registry-GHCR-blue)](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.
+60
View 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:
+119
View File
@@ -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
+36
View File
@@ -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'
```
+165
View File
@@ -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
View File
@@ -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)
+335
View File
@@ -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.
+135
View File
@@ -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
View File
@@ -1 +0,0 @@
https://github.com/tphakala/tflite_c/
Binary file not shown.
Binary file not shown.
+133 -67
View File
@@ -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)
# -----------------------------------------------------------------------------
+24 -5
View File
@@ -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."""
+181 -1
View File
@@ -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)
-189
View File
@@ -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:]
+30 -23
View File
@@ -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."""
+88 -74
View File
@@ -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()
-293
View File
@@ -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
+25
View File
@@ -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
+163
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
from enum import Enum, auto
class PlayerState(Enum):
IDLE = auto()
LOADING = auto()
PLAYING = auto()
PAUSED = auto()
STOPPING = auto()
ERROR = auto()
+320 -26
View File
@@ -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,17 +326,35 @@ class VoiceSatelliteProtocol(APIServer):
if isinstance(msg, ListEntitiesRequest):
yield ListEntitiesDoneResponse()
elif isinstance(msg, VoiceAssistantConfigurationRequest):
yield VoiceAssistantConfigurationResponse(
available_wake_words=[
available_wake_words = [
VoiceAssistantWakeWord(
id=ww.id,
wake_word=ww.wake_word,
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,
)
-6
View File
@@ -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)
-89
View File
@@ -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
+1 -1
View File
@@ -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"
+5 -2
View File
@@ -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
View File
@@ -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]
-2
View File
@@ -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.
+37
View File
@@ -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.")
BIN
View File
Binary file not shown.
+39
View File
@@ -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())
-43
View File
@@ -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
-47
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
def test_placeholder():
pass
Binary file not shown.
Binary file not shown.