Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73b494275d | ||
|
|
2dd27e27b1 | ||
|
|
f1345e95cf | ||
|
|
980ceeef2c | ||
|
|
32c9872f78 | ||
|
|
1ea79aa8bb | ||
|
|
730f706655 | ||
|
|
9192d8423b | ||
|
|
3ea32895ee | ||
|
|
0de631160e | ||
|
|
48d9868d1d | ||
|
|
4c9d92f499 | ||
|
|
a21d217d45 | ||
|
|
d2c2e6fbb9 | ||
|
|
0722c40f67 | ||
|
|
72311bf58a | ||
|
|
995da72569 | ||
|
|
79dde095b0 | ||
|
|
c284806b73 | ||
|
|
4ee7bb04bc | ||
|
|
341e3dcb9c | ||
|
|
7732be0de8 | ||
|
|
8b060d7346 | ||
|
|
943c56161f | ||
|
|
88fb2c5a49 | ||
|
|
9067648c87 | ||
|
|
95323553a5 | ||
|
|
0081f2edee | ||
|
|
b3cd3ec7cf | ||
|
|
47e617fcab | ||
|
|
7a62a75aa6 | ||
|
|
c1906a3f20 | ||
|
|
0dce320db2 | ||
|
|
412035e6b2 | ||
|
|
4194aa42ec | ||
|
|
ede3785539 | ||
|
|
0130d2241f | ||
|
|
96fd54a008 | ||
|
|
fbc4e05fbd | ||
|
|
8bb118b411 | ||
|
|
abf6cd07b7 | ||
|
|
a5c37de4ec | ||
|
|
9ddb929423 | ||
|
|
c3885a494b | ||
|
|
d4f5b549da | ||
|
|
9d19ffe6da | ||
|
|
496cd90a8c |
No files matched your search
+17
-1
@@ -1,6 +1,10 @@
|
||||
# Linux-Voice-Assistant - Docker Environment Configuration
|
||||
# Copy this file to .env and customize for your setup by 'cp .env.example .env'
|
||||
|
||||
### Specify another docker image (optional):
|
||||
# LVA_IMAGE="ghcr.io/ohf-voice/linux-voice-assistant"
|
||||
# LVA_IMAGE_TAG="latest"
|
||||
|
||||
### Enable debug mode (optional):
|
||||
# ENABLE_DEBUG="1"
|
||||
|
||||
@@ -14,7 +18,7 @@ LVA_USER_ID="1000"
|
||||
LVA_USER_GROUP="1000"
|
||||
|
||||
### Name for the client (optional):
|
||||
# CLIENT_NAME="speaker_livvingroom"
|
||||
# CLIENT_NAME="speaker_livingroom"
|
||||
|
||||
### PulseAudio socket path on the host:
|
||||
# PulseAudio Server: /run/user/1000/pulse
|
||||
@@ -41,6 +45,12 @@ LVA_PULSE_COOKIE="/run/user/${LVA_USER_ID}/pulse/cookie"
|
||||
### Audio output device (optional):
|
||||
# AUDIO_OUTPUT_DEVICE="default"
|
||||
|
||||
### Mic Gain (optional) passed as an argument to LVA will overide the value in thepreferences file on boot but can be changed at runtime in Home Assistant:
|
||||
# MIC_AUTO_GAIN="0"
|
||||
|
||||
### Mic Noise Suppression (optional) passed as an argument to LVA will overide the value in thepreferences file on boot but can be changed at runtime in Home Assistant:
|
||||
# MIC_NOISE_SUPPRESSION="0"
|
||||
|
||||
### Enable thinking sound (optional):
|
||||
# ENABLE_THINKING_SOUND="1"
|
||||
|
||||
@@ -54,6 +64,9 @@ LVA_PULSE_COOKIE="/run/user/${LVA_USER_ID}/pulse/cookie"
|
||||
### Stop model (optional):
|
||||
# STOP_MODEL="stop"
|
||||
|
||||
### Seconds after which the timer stops ringing (optional):
|
||||
# TIMER_MAX_RING_SECONDS="900"
|
||||
|
||||
### Refactory seconds (optional):
|
||||
# REFACTORY_SECONDS="2"
|
||||
|
||||
@@ -64,3 +77,6 @@ LVA_PULSE_COOKIE="/run/user/${LVA_USER_ID}/pulse/cookie"
|
||||
# PROCESSING_SOUND="sounds/processing.wav"
|
||||
# MUTE_SOUND="sounds/mute_switch_on.flac"
|
||||
# UNMUTE_SOUND="sounds/mute_switch_off.flac"
|
||||
|
||||
### Enable output-only mode (optional):
|
||||
# ENABLE_OUTPUT_ONLY="1"
|
||||
@@ -1,7 +1,8 @@
|
||||
---
|
||||
name: Build PR
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
env:
|
||||
@@ -10,6 +11,8 @@ env:
|
||||
jobs:
|
||||
build-and-push-image:
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: pr-build
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -17,51 +20,47 @@ jobs:
|
||||
attestations: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Checkout PR code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Extract PR information
|
||||
id: extract_pr
|
||||
- name: Set variables
|
||||
run: |
|
||||
echo "PR_NUMBER=${{ github.event.number }}" >> $GITHUB_ENV
|
||||
echo "PR_NUMBER=${{ github.event.pull_request.number || github.run_number }}" >> $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
|
||||
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: Create version files
|
||||
run: |
|
||||
echo "pr-${{ env.PR_NUMBER }}" > version.txt
|
||||
echo "${{ env.PR_NUMBER }}" > version.txt
|
||||
echo "${{ github.sha }}" > version_githash.txt
|
||||
ls -ltr
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@v5.3.0
|
||||
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 }}:pr-${{ env.PR_NUMBER }}
|
||||
labels: |
|
||||
pr=${{ env.PR_NUMBER }}
|
||||
|
||||
tags: "${{ env.REGISTRY_LOWER }}/${{ env.REPOSITORY_LOWER }}-pr:${{ env.PR_NUMBER }}"
|
||||
labels: "${{ env.PR_NUMBER }}"
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v1.4.4
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY }}/${{ github.repository }}
|
||||
subject-name: ${{ env.REGISTRY_LOWER }}/${{ env.REPOSITORY_LOWER }}-pr
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
@@ -7,6 +7,8 @@ jobs:
|
||||
changelog:
|
||||
name: Update changelog
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
|
||||
@@ -21,5 +21,6 @@ venv/
|
||||
|
||||
# Docker
|
||||
.env
|
||||
dev.env
|
||||
version.txt
|
||||
version_githash.txt
|
||||
@@ -1,5 +1,32 @@
|
||||
# Changelog
|
||||
|
||||
## 1.1.9
|
||||
|
||||
### Improvements
|
||||
- Cookie file and other default parameters by @florian-asche in https://github.com/OHF-Voice/linux-voice-assistant/pull/231
|
||||
|
||||
## 1.1.8
|
||||
|
||||
### Features
|
||||
- Add changelog generator based on release information by @florian-asche in https://github.com/OHF-Voice/linux-voice-assistant/pull/226
|
||||
|
||||
## 1.1.7
|
||||
|
||||
### Exciting New Features 🎉
|
||||
- Add linting scripts and pipeline by @florian-asche in https://github.com/OHF-Voice/linux-voice-assistant/pull/201
|
||||
|
||||
### Other Changes
|
||||
- add build flag option for low end devices by @florian-asche in https://github.com/OHF-Voice/linux-voice-assistant/pull/189
|
||||
- replaced branch build by PR build only by @florian-asche in https://github.com/OHF-Voice/linux-voice-assistant/pull/203
|
||||
- Change when lint pipeline will start by @florian-asche in https://github.com/OHF-Voice/linux-voice-assistant/pull/204
|
||||
- fix(script): use os.environ instead of subprocess.environ in setup script by @florian-asche in https://github.com/OHF-Voice/linux-voice-assistant/pull/205
|
||||
- docs: add documentation for low power device setup and improve setup script by @florian-asche in https://github.com/OHF-Voice/linux-voice-assistant/pull/206
|
||||
- fix: cookie error (#127) by @florian-asche in https://github.com/OHF-Voice/linux-voice-assistant/pull/210
|
||||
- fix(config): remove unix: prefix from default pulseaudio socket path … by @florian-asche in https://github.com/OHF-Voice/linux-voice-assistant/pull/221
|
||||
- Update the changelog file automatically on each release by @omaramin-2000 in https://github.com/OHF-Voice/linux-voice-assistant/pull/224
|
||||
- Enhance hardware compatibility details in README by @Hedda in https://github.com/OHF-Voice/linux-voice-assistant/pull/222
|
||||
- add changelog based on release notes by @florian-asche in https://github.com/OHF-Voice/linux-voice-assistant/pull/225
|
||||
|
||||
## 1.1.6
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM python:3.13-slim-trixie
|
||||
FROM python:3.12-slim-trixie
|
||||
|
||||
ENV LANG C.UTF-8
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
@@ -2,51 +2,56 @@
|
||||
|
||||
[](https://github.com/OHF-Voice/linux-voice-assistant/actions/workflows/docker-build-release.yml) [](https://github.com/OHF-Voice/linux-voice-assistant/pkgs/container/linux-voice-assistant) [](https://github.com/OHF-Voice/linux-voice-assistant/blob/main/LICENSE.md) [](https://github.com/OHF-Voice/linux-voice-assistant/commits) [](https://github.com/OHF-Voice/linux-voice-assistant/pkgs/container/linux-voice-assistant)
|
||||
|
||||
Experimental Linux-Voice-Assistant for [Home Assistant](https://www.home-assistant.io/) that uses the [ESPHome](https://esphome.io/) protocol/API (via [aioesphomeapi](https://github.com/esphome/aioesphomeapi)).
|
||||
An experimental Linux-Voice-Assistant software for [Home Assistant](https://www.home-assistant.io/) remote voice control and interaction.
|
||||
|
||||
This project enables you to build a Linux-based voice assistant designed to use [Assist](https://www.home-assistant.io/voice_control/) for Home Assistant. It allows you to create your own smart speaker that runs on any x64 or ARM64 hardware capable of handling local audio processing (using PulseAudio).
|
||||
|
||||
Unlike simpler voice satellites that run on microcontrollers with very limited compute power, this setup can perform local wake word detection (OWW/MWW) and process some data on-device.
|
||||
|
||||
Because it runs on a full Linux system and offers access significantly more local computing resources for additional features and other integrations on the same satellite, this approach also provides greater flexibility for customization (such as for example experiment with using PipeWire).
|
||||
|
||||
[](https://www.openhomefoundation.org/)
|
||||
|
||||
## Features:
|
||||
## Features
|
||||
|
||||
- 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)
|
||||
- Works with [Home Assistant](https://www.home-assistant.io/integrations/esphome/) using the [ESPHome](https://esphome.io/) protocol/API (via [aioesphomeapi](https://github.com/esphome/aioesphomeapi))
|
||||
- Feature local on-device wake word detection using integrated [OpenWakeWord](https://github.com/dscripka/openWakeWord) or [MicroWakeWord](https://github.com/kahrendt/microWakeWord)
|
||||
- Supports multiple wake words and languages
|
||||
- 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.
|
||||
- Tested and works with Python 3.11 and Python 3.12.
|
||||
- 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
|
||||
- Prebuild [Raspberry Pi image](https://github.com/florian-asche/PiCompose)
|
||||
|
||||
## Usage
|
||||
|
||||
## Usage:
|
||||
|
||||
### Hardware:
|
||||
### Hardware
|
||||
|
||||
A more extensive 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 (multimedia framework for Linux)](https://pipewire.org/) can in theory be used for voice input with the prebuild image from there, you should however preferably use a far-field microphone-array solution if want better result.
|
||||
|
||||
Two solutions recommended for test setups today is to use a Raspberry Pi Zero 2 W SBC (Single Board Computer with built-in WiFi) in combination with the [Satellite1 Hat Board](https://futureproofhomes.net/products/satellite1-top-microphone-board) or the [Respeaker Lite](https://wiki.seeedstudio.com/reSpeaker_usb_v3/). Those have microphone-array designed for far-field voice capture with the added benefit of using an onboard XMOS DSP microcontroller with custom firmware which does advanced audio pre-processing for microphone cleanup that result in very good voice recognition capabilities (as it runs algorithms for Noise Suppression, Acoustic Echo Cancellation, Interference Cancellation, and Automatic Gain Control).
|
||||
|
||||
Alternativly if on a lower budget then suggest could try other untested microphone-array boards like example the [reSpeaker 2-Mics Pi HAT V2.0](https://wiki.seeedstudio.com/ReSpeaker_2_Mics_Pi_HAT/) (which uses a much more basic audio codec chip).
|
||||
Alternatively if on a lower budget then suggest could try other untested microphone-array boards like example the [reSpeaker 2-Mics Pi HAT V2.0](https://wiki.seeedstudio.com/ReSpeaker_2_Mics_Pi_HAT/) (which uses a much more basic audio codec chip).
|
||||
|
||||
As for the minimum required compute performance on these satellites the target reference hardware for testing is currently a 64-bit ARM-based SBC based on Raspberry Pi RP3A0 SiP (System-in-Package); which means the Raspberry Pi Zero 2 W, Raspberry Pi Compute Module 3E (Raspberry Pi CM3E), or other development boards that uses the Compute Module Zero" (Raspberry Pi CM0), as all of which have similar specifications to the Raspberry Pi 3 B/B+ but with a CPU running at a lower frequency.
|
||||
|
||||
But you can also install LVA on AMD64 devices, for example on your Linux desktop computer.
|
||||
|
||||
### Software:
|
||||
### Software
|
||||
|
||||
#### Installation:
|
||||
#### Installation
|
||||
|
||||
For Raspberry Pi users, we provide a prebuild image that can be flashed to a SD card. See [PiCompose](https://github.com/florian-asche/PiCompose).
|
||||
|
||||
For all other users we have different installation methods available (Docker, systemd), each with its own dedicated instructions. See [Linux-Voice-Assistant - Installation](docs/install.md).
|
||||
For all other users, we have different installation methods available (Docker, systemd), each with its own dedicated instructions. See [Linux-Voice-Assistant - Installation](docs/install.md).
|
||||
|
||||
#### Parameter overview:
|
||||
#### Parameter overview
|
||||
|
||||
💡 **Note:** There is a [environment variable](docs/install_application.md#environment-variables-reference) for each parameter if you use docker or systemd based setup.
|
||||
💡 **Note:** There is an [environment variable](docs/install_application.md#environment-variables-reference) for each parameter if you use docker or systemd based setup.
|
||||
|
||||
``` sh
|
||||
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]
|
||||
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] [--mic-auto-gain] [--mic-noise-suppression]
|
||||
[--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] [--network-interface NETWORK_INTERFACE] [--port PORT] [--enable-thinking-sound] [--debug]
|
||||
```
|
||||
@@ -57,11 +62,15 @@ usage: __main__.py [-h] [--name NAME] [--audio-input-device AUDIO_INPUT_DEVICE]
|
||||
| `--audio-input-device` | Soundcard name for input device | Autodetected |
|
||||
| `--audio-input-block-size` | Audio input block size in samples | 1024 |
|
||||
| `--audio-output-device` | mpv name for output device | Autodetected |
|
||||
| `--mic-volume` | Control microphone volume | 1.0 |
|
||||
| `--mic-auto-gain` | Add WebRTC Gain to Mic | 0 |
|
||||
| `--mic-noise-suppression` | Add WebRTC Noise Suppression to Mic | 0 |
|
||||
| `--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 |
|
||||
| `--timer-max-ring-seconds` | Seconds after which the timer stops ringing | 900.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` |
|
||||
@@ -73,8 +82,12 @@ usage: __main__.py [-h] [--name NAME] [--audio-input-device AUDIO_INPUT_DEVICE]
|
||||
| `--port` | Port for ESPHome server | 6053 |
|
||||
| `--enable-thinking-sound` | Enable thinking sound on startup | False |
|
||||
| `--debug` | Print DEBUG messages to console | False |
|
||||
| `--output-only` | Enable output only mode | False |
|
||||
|
||||
## Build Information:
|
||||
💡 **Note:** There is a detailed explanation on the gain, noise suppression, and wake word sensitivity flags in the [audio options](docs/audio_options.md) file.
|
||||
|
||||
|
||||
## Build Information
|
||||
|
||||
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.
|
||||
|
||||
@@ -86,9 +99,9 @@ The Docker images are built using GitHub Actions, which provides:
|
||||
|
||||
The documentation for the build process can be found in the [GitHub Actions Workflows](.github/workflow.md) file.
|
||||
|
||||
## Development:
|
||||
## Development
|
||||
|
||||
### Code Quality Checks:
|
||||
### Code Quality Checks
|
||||
|
||||
The project uses the following tools to ensure code quality:
|
||||
- **Black**: Code formatting (88 characters per line, PEP 8 compliant)
|
||||
@@ -97,7 +110,7 @@ The project uses the following tools to ensure code quality:
|
||||
- **pylint**: Code quality checks
|
||||
- **mypy**: Static type analysis
|
||||
|
||||
### Setup:
|
||||
### Setup
|
||||
|
||||
To use the development tools (linting, testing, etc.), you need to install the required dependencies:
|
||||
|
||||
@@ -106,14 +119,14 @@ To use the development tools (linting, testing, etc.), you need to install the r
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
### Linting Commands:
|
||||
### Linting Commands
|
||||
|
||||
#### Run all linting checks:
|
||||
#### Run all linting checks
|
||||
``` sh
|
||||
./script/lint...
|
||||
```
|
||||
|
||||
#### Individual linting commands (with auto-fix support):
|
||||
#### Individual linting commands (with auto-fix support)
|
||||
|
||||
| Script | Description | Auto-fix Available? |
|
||||
|--------|-------------|---------------------|
|
||||
@@ -123,7 +136,7 @@ source .venv/bin/activate
|
||||
| `./script/lint_mypy` | Runs static type analysis with mypy | No |
|
||||
| `./script/lint_pylint` | Runs code quality checks with pylint | Yes, use `--auto` flag |
|
||||
|
||||
#### Examples:
|
||||
#### Examples
|
||||
|
||||
Run a specific lint check:
|
||||
``` sh
|
||||
@@ -136,7 +149,7 @@ Auto-fix formatting issues (Black + isort):
|
||||
./script/lint_isort --auto
|
||||
```
|
||||
|
||||
### Testing:
|
||||
### Testing
|
||||
|
||||
Run the test suite:
|
||||
``` sh
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
services:
|
||||
# Fix permissions for wakeword_data and configuration volume
|
||||
fix-permissions:
|
||||
image: "ghcr.io/ohf-voice/linux-voice-assistant:latest"
|
||||
image: "${LVA_IMAGE:-ghcr.io/ohf-voice/linux-voice-assistant-pr}:${LVA_IMAGE_TAG:-nightly}"
|
||||
entrypoint: []
|
||||
command: "chown -R ${LVA_USER_ID:-1000}:${LVA_USER_GROUP:-1000} /app/local /app/configuration /app/wakewords/custom /app/sounds/custom"
|
||||
environment:
|
||||
@@ -24,7 +24,7 @@ services:
|
||||
# Linux Voice Assistant
|
||||
linux-voice-assistant:
|
||||
container_name: linux-voice-assistant
|
||||
image: "ghcr.io/ohf-voice/linux-voice-assistant:latest"
|
||||
image: "${LVA_IMAGE:-ghcr.io/ohf-voice/linux-voice-assistant-pr}:${LVA_IMAGE_TAG:-nightly}"
|
||||
restart: unless-stopped
|
||||
network_mode: "host"
|
||||
user: "${LVA_USER_ID:-1000}:${LVA_USER_GROUP:-1000}"
|
||||
@@ -33,7 +33,7 @@ services:
|
||||
- XDG_RUNTIME_DIR=${LVA_XDG_RUNTIME_DIR:-/run/user/1000}
|
||||
- PULSE_SERVER=${LVA_PULSE_SERVER:-/run/user/1000/pulse/native}
|
||||
- PULSE_COOKIE=${LVA_PULSE_COOKIE:-/run/user/1000/pulse/cookie}
|
||||
env_file: .env
|
||||
env_file: ${ENV_FILE:-.env}
|
||||
group_add:
|
||||
- audio
|
||||
# Priority for audio process
|
||||
@@ -66,7 +66,9 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 90s
|
||||
#command:
|
||||
command:
|
||||
# - "--mic-noise-suppression"
|
||||
# - "1"
|
||||
# - --additional-parameters-here
|
||||
# - --but-use-env-if-possible
|
||||
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
services:
|
||||
# Fix permissions for wakeword_data and configuration volume
|
||||
fix-permissions:
|
||||
image: "ghcr.io/ohf-voice/linux-voice-assistant:latest"
|
||||
image: "${LVA_IMAGE:-ghcr.io/ohf-voice/linux-voice-assistant}:${LVA_IMAGE_TAG:-latest}"
|
||||
entrypoint: []
|
||||
command: "chown -R ${LVA_USER_ID:-1000}:${LVA_USER_GROUP:-1000} /app/local /app/configuration /app/wakewords/custom /app/sounds/custom"
|
||||
environment:
|
||||
@@ -24,7 +24,7 @@ services:
|
||||
# Linux Voice Assistant
|
||||
linux-voice-assistant:
|
||||
container_name: linux-voice-assistant
|
||||
image: "ghcr.io/ohf-voice/linux-voice-assistant:latest"
|
||||
image: "${LVA_IMAGE:-ghcr.io/ohf-voice/linux-voice-assistant}:${LVA_IMAGE_TAG:-latest}"
|
||||
restart: unless-stopped
|
||||
network_mode: "host"
|
||||
user: "${LVA_USER_ID:-1000}:${LVA_USER_GROUP:-1000}"
|
||||
|
||||
+23
-8
@@ -40,6 +40,18 @@ if [ -n "${AUDIO_OUTPUT_DEVICE}" ]; then
|
||||
EXTRA_ARGS+=( "--audio-output-device" "$AUDIO_OUTPUT_DEVICE" )
|
||||
fi
|
||||
|
||||
if [ -n "${MIC_VOLUME}" ]; then
|
||||
EXTRA_ARGS+=( "--mic-volume" "$MIC_VOLUME" )
|
||||
fi
|
||||
|
||||
if [ -n "${MIC_AUTO_GAIN}" ]; then
|
||||
EXTRA_ARGS+=( "--mic-auto-gain" "$MIC_AUTO_GAIN" )
|
||||
fi
|
||||
|
||||
if [ -n "${MIC_NOISE_SUPPRESSION}" ]; then
|
||||
EXTRA_ARGS+=( "--mic-noise-suppression" "$MIC_NOISE_SUPPRESSION" )
|
||||
fi
|
||||
|
||||
if [ "$ENABLE_THINKING_SOUND" = "1" ]; then
|
||||
EXTRA_ARGS+=( "--enable-thinking-sound" )
|
||||
fi
|
||||
@@ -80,19 +92,22 @@ if [ -n "${UNMUTE_SOUND}" ]; then
|
||||
EXTRA_ARGS+=( "--unmute-sound" "$UNMUTE_SOUND" )
|
||||
fi
|
||||
|
||||
if [ -n "${TIMER_MAX_RING_SECONDS}" ]; then
|
||||
EXTRA_ARGS+=( "--timer-max-ring-seconds" "$TIMER_MAX_RING_SECONDS" )
|
||||
fi
|
||||
|
||||
if [ "$ENABLE_OUTPUT_ONLY" = "1" ]; then
|
||||
EXTRA_ARGS+=( "--output-only" )
|
||||
fi
|
||||
|
||||
|
||||
# Add cookie file for pulseaudio to prevent errors
|
||||
PULSE_COOKIE=${PULSE_COOKIE:-"/run/user/1000/pulse/cookie"}
|
||||
if [[ "$PULSE_COOKIE" != "DISABLED" ]]; then
|
||||
if [ ! -f "$PULSE_COOKIE" ]; then
|
||||
echo "PulseAudio cookie file not found at $PULSE_COOKIE"
|
||||
PULSE_COOKIE="/app/configuration/tmp_pulse_cookie"
|
||||
echo "changed PULSE_COOKIE to $PULSE_COOKIE"
|
||||
if [ ! -f "$PULSE_COOKIE" ]; then
|
||||
echo "Creating PulseAudio cookie file at $PULSE_COOKIE"
|
||||
touch "$PULSE_COOKIE"
|
||||
chmod 600 "$PULSE_COOKIE"
|
||||
fi
|
||||
echo "Creating PulseAudio cookie file at $PULSE_COOKIE"
|
||||
touch "$PULSE_COOKIE"
|
||||
chmod 600 "$PULSE_COOKIE"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
# Additional Audio Settings
|
||||
|
||||
This document is still in an early stage and will be updated soon.
|
||||
|
||||
## Pipewire:
|
||||
|
||||
tbd
|
||||
|
||||
## Pulseaudio:
|
||||
|
||||
### Acoustic Echo Cancellation:
|
||||
|
||||
Enable the echo cancel PulseAudio module:
|
||||
|
||||
``` sh
|
||||
pactl load-module module-echo-cancel \
|
||||
aec_method=webrtc \
|
||||
aec_args="analog_gain_control=0 digital_gain_control=1 noise_suppression=1"
|
||||
```
|
||||
|
||||
Verify that the `echo-cancel-source` and `echo-cancel-sink` devices are present:
|
||||
|
||||
``` sh
|
||||
pactl list short sources
|
||||
pactl list short sinks
|
||||
```
|
||||
|
||||
Use the new devices:
|
||||
|
||||
``` sh
|
||||
# The device names may be different on your system.
|
||||
# Double check with --list-input-devices and --list-output-devices
|
||||
python3 -m linux_voice_assistant ... \
|
||||
--audio-input-device 'Echo-Cancel Source' \
|
||||
--audio-output-device 'pipewire/echo-cancel-sink'
|
||||
```
|
||||
@@ -0,0 +1,73 @@
|
||||
# Audio Options: Gain, Noise Suppression, and Wake Word Sensitivity
|
||||
|
||||
## What do Gain and Noise Suppression do
|
||||
|
||||
### Gain
|
||||
|
||||
When you change gain, LVA(using the webrtc library) automatically adjusts the microphone input volume to keep it at a consistent level. If you're speaking quietly it boosts the signal, if you're speaking loudly it reduces it. Useful in the following ways:-
|
||||
|
||||
- Microphones that are too quiet by default
|
||||
- Environments where you move around relative to the mic
|
||||
- Keeping wake word detection consistent regardless of speaking volume
|
||||
|
||||
### Noise Suppression
|
||||
|
||||
When you change noise suppression, LVA(using the webrtc library) filters out constant background noise from the audio signal. It works by learning what "silence" sounds like in your environment and subtracting that from the audio. Useful in the following ways:
|
||||
|
||||
- Noisy environments
|
||||
- Improving STT accuracy by sending cleaner audio to Home Assistant
|
||||
- Reducing false wake word triggers from background noise
|
||||
|
||||
## Using Gain and Noise Suppression with LVA
|
||||
|
||||
LVA implements gain and Noise Suppression in two ways:
|
||||
|
||||
- [CLI Argument/ENV file](#cli-argumentenv-file)
|
||||
- [Home Assistant Entity](#home-assistant-entity)
|
||||
|
||||
### CLI Argument/ENV file
|
||||
|
||||
#### CLI Argument
|
||||
|
||||
Using docker-entrypoint.sh the following flags can be used to set gain and noise suppression:
|
||||
- `--mic-auto-gain`(ranges from 0-31)
|
||||
- `--mic-noise-suppression`(ranges from 0-4)
|
||||
|
||||
#### ENV file
|
||||
|
||||
You can add/edit these variables in the .env file to set gain and noise suppression:
|
||||
- `MIC_AUTO_GAIN="0"`
|
||||
- `MIC_NOISE_SUPPRESSION="0"`
|
||||
|
||||
|
||||
### Home Assistant Entity
|
||||
|
||||
LVA exposes two slider entities to change these gain and noise suppression from which you can edit the gain and noise suppression at runtime.
|
||||
|
||||
💡 **Note:** Setting the flag and ENV values to 0 turns them off and are not used.
|
||||
|
||||
💡 **Note:** Keep in mind that when the flags are set they will overwrite the previous value in the preferences file and also they will be overridden if the value is changed in Home Assistant(Also applies to the ENV file but the ENV file will always overwrite the last value on startup).
|
||||
|
||||
|
||||
## Wake Word Sensitivity
|
||||
|
||||
LVA exposes three numeric controls in the Home Assistant device page for fine-grained sensitivity tuning. These let you dial in the exact probability threshold that best matches your microphone quality, room acoustics, and false-activation tolerance.
|
||||
|
||||
| Entity | Description | Default |
|
||||
|--------|-------------|---------|
|
||||
| **Wake Word 1 Sensitivity** | Probability cutoff for the primary wake word | From model manifest |
|
||||
| **Wake Word 2 Sensitivity** | Probability cutoff for the secondary wake word (if active) | From model manifest |
|
||||
| **Stop Word Sensitivity** | Probability cutoff for the stop word | From model manifest |
|
||||
|
||||
Values range from `0.0` to `1.0`:
|
||||
|
||||
- **Higher value** (e.g. `0.95`) → more selective, fewer false activations, may miss quieter or accented speech
|
||||
- **Lower value** (e.g. `0.50`) → more responsive, but more likely to trigger on similar-sounding words
|
||||
The defaults are read directly from each model's `.json` manifest file (the `probability_cutoff` field), so bundled models like `okay_nabu` (0.85), `hey_jarvis` (0.97), and custom downloaded models all start at their author-tested baseline. Changes made in the Home Assistant UI are persisted in `preferences.json` and survive restarts.
|
||||
|
||||
**Tuning guide:**
|
||||
|
||||
- If the wake word **rarely activates** (misses your voice): lower the value slightly, e.g. by `0.05` steps
|
||||
- If the wake word **activates too often** (false triggers from TV, music, or similar words): raise the value slightly
|
||||
- Start from the model's default and make small adjustments — a change of `0.05–0.10` is usually enough to notice a difference
|
||||
- Far-field microphones with noise cancellation (e.g. ReSpeaker, Satellite1) generally work well at the default values; basic USB microphones may need a lower threshold
|
||||
@@ -0,0 +1,83 @@
|
||||
# Enabling Acoustic Echo Cancellation
|
||||
|
||||
Acoustic Echo Cancellation (AEC) is a type of sound processing used to cancel out the noise coming out of your speaker and going into your mic. In LVA this functionality can be useful to allow LVA to listen to wake words even when audio is playing, particularly when a timer is playing. PulseAudio and PipeWire already provide built-in modules for AEC.
|
||||
|
||||
## Enabling AEC
|
||||
|
||||
### PulseAudio
|
||||
```sh
|
||||
pactl load-module module-echo-cancel source_name=aec_mic aec_method=webrtc
|
||||
```
|
||||
|
||||
To make permanent, add to `/etc/pulse/default.pa`:
|
||||
```
|
||||
load-module module-echo-cancel source_name=aec_mic aec_method=webrtc
|
||||
```
|
||||
|
||||
### PipeWire
|
||||
```sh
|
||||
pw-cli load-module libpipewire-module-echo-cancel '{ aec.method=webrtc source.props={ node.name=aec_mic } }'
|
||||
```
|
||||
|
||||
To make permanent, add the following to the `context.modules` section of `/etc/pipewire/pipewire.conf`:
|
||||
```
|
||||
{ name = libpipewire-module-echo-cancel
|
||||
args = {
|
||||
aec.method = webrtc
|
||||
source.props = { node.name = aec_mic }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or if you are using a standard PipeWire installation without a custom `pipewire.conf`, create a new file inside `pipewire.conf.d/`:
|
||||
```
|
||||
context.modules = [
|
||||
{ name = libpipewire-module-echo-cancel
|
||||
args = {
|
||||
aec.method = webrtc
|
||||
source.props = { node.name = aec_mic }
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Using the AEC Input
|
||||
|
||||
In the `.env` file:
|
||||
```
|
||||
AUDIO_INPUT_DEVICE="aec_mic"
|
||||
```
|
||||
|
||||
## Changing Source and Sink for AEC
|
||||
|
||||
### PulseAudio
|
||||
```sh
|
||||
pactl load-module module-echo-cancel source_name=aec_mic aec_method=webrtc source_master=<mic-source> sink_master=<sink>
|
||||
```
|
||||
|
||||
### PipeWire
|
||||
```sh
|
||||
pw-cli load-module libpipewire-module-echo-cancel '{ aec.method=webrtc source.props={ node.name=aec_mic node.description="Echo Cancelled Mic" } sink.props={ node.name=aec_sink } capture.props={ node.name=<your-mic-node-name> } playback.props={ node.name=<your-speaker-node-name> } }'
|
||||
```
|
||||
|
||||
To make permanent, add to `context.modules` in `/etc/pipewire/pipewire.conf` or a file in `pipewire.conf.d/`:
|
||||
```
|
||||
{ name = libpipewire-module-echo-cancel
|
||||
args = {
|
||||
aec.method = webrtc
|
||||
source.props = {
|
||||
node.name = aec_mic
|
||||
node.description = "Echo Cancelled Mic"
|
||||
}
|
||||
sink.props = {
|
||||
node.name = aec_sink
|
||||
}
|
||||
capture.props = {
|
||||
node.name = <your-mic-node-name>
|
||||
}
|
||||
playback.props = {
|
||||
node.name = <your-speaker-node-name>
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -176,14 +176,21 @@ Environment=PREFERENCES_FILE="/home/pi/linux-voice-assistant/preferences.json"
|
||||
# Environment=PORT="6053"
|
||||
# Environment=AUDIO_INPUT_DEVICE="default"
|
||||
# Environment=AUDIO_OUTPUT_DEVICE="default"
|
||||
# Environment=MIC_VOLUME="1.0"
|
||||
# Environment=MIC_AUTO_GAIN="0"
|
||||
# Environment=MIC_NOISE_SUPPRESSION="0"
|
||||
# Environment=ENABLE_THINKING_SOUND="1"
|
||||
# Environment=WAKE_WORD_DIR="app/wakewords"
|
||||
# Environment=WAKE-MODEL="okay_nabu"
|
||||
# Environment=STOP_MODEL="stop"
|
||||
# Environment=TIMER_MAX_RING_SECONDS="900"
|
||||
# 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"
|
||||
# Environment=ENABLE_OUTPUT_ONLY="1"
|
||||
ExecStart=/home/pi/linux-voice-assistant/docker-entrypoint.sh
|
||||
# ExecStart=/home/pi/linux-voice-assistant/docker-entrypoint.sh --additional-parameter-if-you-want
|
||||
Restart=always
|
||||
@@ -237,29 +244,36 @@ The following variables can be configured in the `.env` or in the service file:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `ENABLE_DEBUG` | (optional) | Set to "1" to enable debug mode |
|
||||
| `LIST_DEVICES` | (optional) | Set to "1" to list audio devices instead of starting |
|
||||
| `LVA_USER_ID` | `1000` | User ID for the container (usually 1000 for the first user) |
|
||||
| `LVA_USER_GROUP` | `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` | `/run/user/${LVA_USER_ID}/pulse/native` | Path to the PulseAudio/PipeWire socket (In some cases a `:unix`infront of the path is needed) |
|
||||
| `LVA_XDG_RUNTIME_DIR` | `/run/user/${LVA_USER_ID}` | XDG runtime directory |
|
||||
| `LVA_PULSE_COOKIE` | `/app/configuration/tmp_pulse_cookie` | Cookie file for PulseAudio if you use encryption. By default disabled. We use a tmp file to avoid errors if the file is not found |
|
||||
| `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 |
|
||||
| `NETWORK_INTERFACE` | Autodetected | network card for server |
|
||||
| `HOST` | Autodetected | API server IP-Address, can be 0.0.0.0 for all interfaces, but only one network card works for MAC-ADDRESS and ESP protocol |
|
||||
| `PORT` | `6053` | API server port |
|
||||
| `AUDIO_INPUT_DEVICE` | Autodetected | Audio input device name |
|
||||
| `AUDIO_OUTPUT_DEVICE` | Autodetected | Audio output device name |
|
||||
| `MIC_VOLUME` | Control microphone volume | 1.0 |
|
||||
| `MIC_AUTO_GAIN` | Add WebRTC Gain to Mic | 0 |
|
||||
| `MIC_NOISE_SUPPRESSION` | Add WebRTC Noise Suppresion to Mic | 0 |
|
||||
| `ENABLE_THINKING_SOUND` | false | Set to "1" to enable thinking sound |
|
||||
| `WAKE_WORD_DIR` | `app/wakewords` | Path to the wake word directory |
|
||||
| `WAKE_MODEL` | `okay_nabu` | Wake word model to use |
|
||||
| `STOP_MODEL` | `stop` | Stop model to use |
|
||||
| `TIMER_MAX_RING_SECONDS` | `900` | Seconds after which the timer stops ringing |
|
||||
| `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
|
||||
| `ENABLE_OUTPUT_ONLY` | (optional) | Set to "1" to enable output-only mode |
|
||||
|
||||
|
||||
💡 **Note:** For the systemd installation some variables set in the service need to be without `LVA_` prefix.
|
||||
|
||||
|
||||
+33
-356
@@ -1,6 +1,6 @@
|
||||
# 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 with pipewire-pulse. You can choose either variant A or B from this installation documentation.
|
||||
For Linux-Voice-Assistant a Pulseaudio connection to the soundcard is required. Since PulseAudio is no longer the default audio-server we recommend to use and install pipewire (pipewire-pulse) by default. We also support PulseAudio. You can choose either variant A or B from this installation documentation.
|
||||
|
||||
**How does the communication work?**
|
||||
|
||||
@@ -15,7 +15,7 @@ PipeWire is a multimedia server that provides low-latency audio/video handling.
|
||||
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
|
||||
sudo apt install -y pipewire wireplumber pipewire-audio-client-libraries libspa-0.2-bluetooth pipewire-audio pipewire-pulse dfu-util pulseaudio-utils
|
||||
```
|
||||
|
||||
Link the PipeWire configuration for ALSA applications:
|
||||
@@ -24,7 +24,7 @@ Link the PipeWire configuration for ALSA applications:
|
||||
sudo 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):
|
||||
Allow services to run without an active user session. This step is **required on headless systems** (e.g. Raspberry Pi OS Lite), since PipeWire runs under the user's systemd session and only starts while someone is logged in. Without it, LVA will silently stop working after you close your SSH session.
|
||||
|
||||
```sh
|
||||
sudo mkdir -p /var/lib/systemd/linger
|
||||
@@ -33,370 +33,42 @@ sudo touch /var/lib/systemd/linger/$USER
|
||||
|
||||
💡 **Note:** Replace `$USER` with your actual username that you want to run the voice assistant.
|
||||
|
||||
You can verify it worked with:
|
||||
|
||||
```sh
|
||||
loginctl show-user $USER | grep Linger
|
||||
# Expected: Linger=yes
|
||||
```
|
||||
|
||||
### Configure PipeWire (optional):
|
||||
|
||||
💡 **Note:** If you are not on a desktop system, which is already configured for PipeWire, you can configure it manually.
|
||||
|
||||
Create the PipeWire configuration directory:
|
||||
💡 **Note:** LVA records audio at 16kHz. By default PipeWire may run at a different sample rate (typically 48kHz) and will resample automatically. Setting `default.clock.rate = 16000` avoids this resampling overhead, which is particularly beneficial on low-power hardware such as a Raspberry Pi.
|
||||
|
||||
LVA is meant to be configured system wide. So only that method has been documented.
|
||||
|
||||
#### System-wide Configuration
|
||||
```sh
|
||||
sudo mkdir -p "/etc/pipewire"
|
||||
sudo mkdir -p /etc/pipewire/pipewire.conf.d
|
||||
sudo vi /etc/pipewire/pipewire.conf.d/linux-voice-assistant.conf
|
||||
```
|
||||
|
||||
Create the file `/etc/pipewire/pipewire.conf` with the following content (minimal configuration for voice assistant use):
|
||||
|
||||
```ini
|
||||
# Daemon config file for PipeWire version "1.2.7" #
|
||||
#
|
||||
# Copy and edit this file in /etc/pipewire for system-wide changes
|
||||
# or in ~/.config/pipewire for local changes.
|
||||
#
|
||||
# It is also possible to place a file with an updated section in
|
||||
# /etc/pipewire/pipewire.conf.d/ for system-wide changes or in
|
||||
# ~/.config/pipewire/pipewire.conf.d/ for local changes.
|
||||
#
|
||||
|
||||
Add the following content:
|
||||
```
|
||||
context.properties = {
|
||||
## Configure properties in the system.
|
||||
#library.name.system = support/libspa-support
|
||||
#context.data-loop.library.name.system = support/libspa-support
|
||||
#support.dbus = true
|
||||
#link.max-buffers = 64
|
||||
link.max-buffers = 16 # version < 3 clients can't handle more
|
||||
mem.warn-mlock = true
|
||||
#mem.allow-mlock = true
|
||||
#mem.mlock-all = false
|
||||
#clock.power-of-two-quantum = true
|
||||
log.level = 3
|
||||
#cpu.zero.denormals = false
|
||||
|
||||
#loop.rt-prio = -1 # -1 = use module-rt prio, 0 disable rt
|
||||
#loop.class = data.rt
|
||||
#thread.affinity = [ 0 1 ] # optional array of CPUs
|
||||
context.num-data-loops = 1 # -1 = num-cpus, 0 = no data loops
|
||||
#
|
||||
#context.data-loops = [
|
||||
# { loop.rt-prio = -1
|
||||
# loop.class = [ data.rt audio.rt ]
|
||||
# #library.name.system = support/libspa-support
|
||||
# thread.name = data-loop.0
|
||||
# #thread.affinity = [ 0 1 ] # optional array of CPUs
|
||||
# }
|
||||
#]
|
||||
|
||||
core.daemon = true # listening for socket connections
|
||||
core.name = pipewire-0 # core name and socket name
|
||||
|
||||
## Properties for the DSP configuration.
|
||||
default.clock.rate = 16000
|
||||
#default.clock.allowed-rates = [ 48000 ]
|
||||
#default.clock.quantum = 1024
|
||||
#default.clock.min-quantum = 32
|
||||
#default.clock.max-quantum = 2048
|
||||
#default.clock.quantum-limit = 8192
|
||||
#default.clock.quantum-floor = 4
|
||||
#default.video.width = 640
|
||||
#default.video.height = 480
|
||||
#default.video.rate.num = 25
|
||||
#default.video.rate.denom = 1
|
||||
#
|
||||
#settings.check-quantum = false
|
||||
#settings.check-rate = false
|
||||
|
||||
# keys checked below to disable module loading
|
||||
module.x11.bell = true
|
||||
# enables autoloading of access module, when disabled an alternative
|
||||
# access module needs to be loaded.
|
||||
module.access = true
|
||||
# enables autoloading of module-jackdbus-detect
|
||||
module.jackdbus-detect = true
|
||||
default.clock.rate = 16000
|
||||
}
|
||||
|
||||
context.properties.rules = [
|
||||
{ matches = [ { cpu.vm.name = !null } ]
|
||||
actions = {
|
||||
update-props = {
|
||||
# These overrides are only applied when running in a vm.
|
||||
default.clock.min-quantum = 1024
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
context.spa-libs = {
|
||||
#<factory-name regex> = <library-name>
|
||||
#
|
||||
# Used to find spa factory names. It maps an spa factory name
|
||||
# regular expression to a library name that should contain
|
||||
# that factory.
|
||||
#
|
||||
audio.convert.* = audioconvert/libspa-audioconvert
|
||||
avb.* = avb/libspa-avb
|
||||
api.alsa.* = alsa/libspa-alsa
|
||||
#api.v4l2.* = v4l2/libspa-v4l2
|
||||
#api.libcamera.* = libcamera/libspa-libcamera
|
||||
api.bluez5.* = bluez5/libspa-bluez5
|
||||
api.vulkan.* = vulkan/libspa-vulkan
|
||||
api.jack.* = jack/libspa-jack
|
||||
support.* = support/libspa-support
|
||||
#video.convert.* = videoconvert/libspa-videoconvert
|
||||
#videotestsrc = videotestsrc/libspa-videotestsrc
|
||||
#audiotestsrc = audiotestsrc/libspa-audiotestsrc
|
||||
}
|
||||
|
||||
context.modules = [
|
||||
#{ name = <module-name>
|
||||
# ( args = { <key> = <value> ... } )
|
||||
# ( flags = [ ( ifexists ) ( nofail ) ] )
|
||||
# ( condition = [ { <key> = <value> ... } ... ] )
|
||||
#}
|
||||
#
|
||||
# Loads a module with the given parameters.
|
||||
# If ifexists is given, the module is ignored when it is not found.
|
||||
# If nofail is given, module initialization failures are ignored.
|
||||
# If condition is given, the module is loaded only when the context
|
||||
# properties all match the match rules.
|
||||
#
|
||||
|
||||
# Uses realtime scheduling to boost the audio thread priorities. This uses
|
||||
# RTKit if the user doesn't have permission to use regular realtime
|
||||
# scheduling. You can also clamp utilisation values to improve scheduling
|
||||
# on embedded and heterogeneous systems, e.g. Arm big.LITTLE devices.
|
||||
{ name = libpipewire-module-rt
|
||||
args = {
|
||||
nice.level = -11
|
||||
rt.prio = 88
|
||||
#rt.time.soft = -1
|
||||
#rt.time.hard = -1
|
||||
#uclamp.min = 0
|
||||
#uclamp.max = 1024
|
||||
}
|
||||
flags = [ ifexists nofail ]
|
||||
}
|
||||
|
||||
# The native communication protocol.
|
||||
{ name = libpipewire-module-protocol-native
|
||||
args = {
|
||||
# List of server Unix sockets, and optionally permissions
|
||||
#sockets = [ { name = "pipewire-0" }, { name = "pipewire-0-manager" } ]
|
||||
}
|
||||
}
|
||||
|
||||
# The profile module. Allows application to access profiler
|
||||
# and performance data. It provides an interface that is used
|
||||
# by pw-top and pw-profiler.
|
||||
{ name = libpipewire-module-profiler }
|
||||
|
||||
# Allows applications to create metadata objects. It creates
|
||||
# a factory for Metadata objects.
|
||||
{ name = libpipewire-module-metadata }
|
||||
|
||||
# Creates a factory for making devices that run in the
|
||||
# context of the PipeWire server.
|
||||
{ name = libpipewire-module-spa-device-factory }
|
||||
|
||||
# Creates a factory for making nodes that run in the
|
||||
# context of the PipeWire server.
|
||||
{ name = libpipewire-module-spa-node-factory }
|
||||
|
||||
# Allows creating nodes that run in the context of the
|
||||
# client. Is used by all clients that want to provide
|
||||
# data to PipeWire.
|
||||
{ name = libpipewire-module-client-node }
|
||||
|
||||
# Allows creating devices that run in the context of the
|
||||
# client. Is used by the session manager.
|
||||
{ name = libpipewire-module-client-device }
|
||||
|
||||
# The portal module monitors the PID of the portal process
|
||||
# and tags connections with the same PID as portal
|
||||
# connections.
|
||||
{ name = libpipewire-module-portal
|
||||
flags = [ ifexists nofail ]
|
||||
}
|
||||
|
||||
# The access module can perform access checks and block
|
||||
# new clients.
|
||||
{ name = libpipewire-module-access
|
||||
args = {
|
||||
# Socket-specific access permissions
|
||||
#access.socket = { pipewire-0 = "default", pipewire-0-manager = "unrestricted" }
|
||||
|
||||
# Deprecated legacy mode (not socket-based),
|
||||
# for now enabled by default if access.socket is not specified
|
||||
#access.legacy = true
|
||||
}
|
||||
condition = [ { module.access = true } ]
|
||||
}
|
||||
|
||||
# Makes a factory for wrapping nodes in an adapter with a
|
||||
# converter and resampler.
|
||||
{ name = libpipewire-module-adapter }
|
||||
|
||||
# Makes a factory for creating links between ports.
|
||||
{ name = libpipewire-module-link-factory }
|
||||
|
||||
# Provides factories to make session manager objects.
|
||||
{ name = libpipewire-module-session-manager }
|
||||
|
||||
# Use libcanberra to play X11 Bell
|
||||
{ name = libpipewire-module-x11-bell
|
||||
args = {
|
||||
#sink.name = "@DEFAULT_SINK@"
|
||||
#sample.name = "bell-window-system"
|
||||
#x11.display = null
|
||||
#x11.xauthority = null
|
||||
}
|
||||
flags = [ ifexists nofail ]
|
||||
condition = [ { module.x11.bell = true } ]
|
||||
}
|
||||
{ name = libpipewire-module-jackdbus-detect
|
||||
args = {
|
||||
#jack.library = libjack.so.0
|
||||
#jack.server = null
|
||||
#jack.client-name = PipeWire
|
||||
#jack.connect = true
|
||||
#tunnel.mode = duplex # source|sink|duplex
|
||||
source.props = {
|
||||
#audio.channels = 2
|
||||
#midi.ports = 1
|
||||
#audio.position = [ FL FR ]
|
||||
# extra sink properties
|
||||
}
|
||||
sink.props = {
|
||||
#audio.channels = 2
|
||||
#midi.ports = 1
|
||||
#audio.position = [ FL FR ]
|
||||
# extra sink properties
|
||||
}
|
||||
}
|
||||
flags = [ ifexists nofail ]
|
||||
condition = [ { module.jackdbus-detect = true } ]
|
||||
}
|
||||
]
|
||||
|
||||
context.objects = [
|
||||
#{ factory = <factory-name>
|
||||
# ( args = { <key> = <value> ... } )
|
||||
# ( flags = [ ( nofail ) ] )
|
||||
# ( condition = [ { <key> = <value> ... } ... ] )
|
||||
#}
|
||||
#
|
||||
# Creates an object from a PipeWire factory with the given parameters.
|
||||
# If nofail is given, errors are ignored (and no object is created).
|
||||
# If condition is given, the object is created only when the context properties
|
||||
# all match the match rules.
|
||||
#
|
||||
#{ factory = spa-node-factory args = { factory.name = videotestsrc node.name = videotestsrc node.description = videotestsrc "Spa:Pod:Object:Param:Props:patternType" = 1 } }
|
||||
#{ factory = spa-device-factory args = { factory.name = api.jack.device foo=bar } flags = [ nofail ] }
|
||||
#{ factory = spa-device-factory args = { factory.name = api.alsa.enum.udev } }
|
||||
#{ factory = spa-node-factory args = { factory.name = api.alsa.seq.bridge node.name = Internal-MIDI-Bridge } }
|
||||
#{ factory = adapter args = { factory.name = audiotestsrc node.name = my-test node.description = audiotestsrc } }
|
||||
#{ factory = spa-node-factory args = { factory.name = api.vulkan.compute.source node.name = my-compute-source } }
|
||||
|
||||
# A default dummy driver. This handles nodes marked with the "node.always-process"
|
||||
# property when no other driver is currently active. JACK clients need this.
|
||||
{ factory = spa-node-factory
|
||||
args = {
|
||||
factory.name = support.node.driver
|
||||
node.name = Dummy-Driver
|
||||
node.group = pipewire.dummy
|
||||
node.sync-group = sync.dummy
|
||||
priority.driver = 200000
|
||||
#clock.id = monotonic # realtime | tai | monotonic-raw | boottime
|
||||
#clock.name = "clock.system.monotonic"
|
||||
}
|
||||
}
|
||||
{ factory = spa-node-factory
|
||||
args = {
|
||||
factory.name = support.node.driver
|
||||
node.name = Freewheel-Driver
|
||||
priority.driver = 190000
|
||||
node.group = pipewire.freewheel
|
||||
node.sync-group = sync.dummy
|
||||
node.freewheel = true
|
||||
#freewheel.wait = 10
|
||||
}
|
||||
}
|
||||
|
||||
# This creates a new Source node. It will have input ports
|
||||
# that you can link, to provide audio for this source.
|
||||
#{ factory = adapter
|
||||
# args = {
|
||||
# factory.name = support.null-audio-sink
|
||||
# node.name = "my-mic"
|
||||
# node.description = "Microphone"
|
||||
# media.class = "Audio/Source/Virtual"
|
||||
# audio.position = "FL,FR"
|
||||
# monitor.passthrough = true
|
||||
# }
|
||||
#}
|
||||
|
||||
# This creates a single PCM source device for the given
|
||||
# alsa device path hw:0. You can change source to sink
|
||||
# to make a sink in the same way.
|
||||
#{ factory = adapter
|
||||
# args = {
|
||||
# factory.name = api.alsa.pcm.source
|
||||
# node.name = "alsa-source"
|
||||
# node.description = "PCM Source"
|
||||
# media.class = "Audio/Source"
|
||||
# api.alsa.path = "hw:0"
|
||||
# api.alsa.period-size = 1024
|
||||
# api.alsa.headroom = 0
|
||||
# api.alsa.disable-mmap = false
|
||||
# api.alsa.disable-batch = false
|
||||
# audio.format = "S16LE"
|
||||
# audio.rate = 48000
|
||||
# audio.channels = 2
|
||||
# audio.position = "FL,FR"
|
||||
# }
|
||||
#}
|
||||
|
||||
# Use the metadata factory to create metadata and some default values.
|
||||
#{ factory = metadata
|
||||
# args = {
|
||||
# metadata.name = my-metadata
|
||||
# metadata.values = [
|
||||
# { key = default.audio.sink value = { name = somesink } }
|
||||
# { key = default.audio.source value = { name = somesource } }
|
||||
# ]
|
||||
# }
|
||||
#}
|
||||
]
|
||||
|
||||
context.exec = [
|
||||
#{ path = <program-name>
|
||||
# ( args = "<arguments>" | [ <arg1> <arg2> ... ] )
|
||||
# ( condition = [ { <key> = <value> ... } ... ] )
|
||||
#}
|
||||
#
|
||||
# Execute the given program with arguments.
|
||||
# If condition is given, the program is executed only when the context
|
||||
# properties all match the match rules.
|
||||
#
|
||||
# You can optionally start the session manager here,
|
||||
# but it is better to start it as a systemd service.
|
||||
# Run the session manager with -h for options.
|
||||
#
|
||||
#{ path = "/usr/bin/pipewire-media-session" args = ""
|
||||
# condition = [ { exec.session-manager = null } { exec.session-manager = true } ] }
|
||||
#
|
||||
# You can optionally start the pulseaudio-server here as well
|
||||
# but it is better to start it as a systemd service.
|
||||
# It can be interesting to start another daemon here that listens
|
||||
# on another address with the -a option (eg. -a tcp:4713).
|
||||
#
|
||||
#{ path = "/usr/bin/pipewire" args = [ "-c" "pipewire-pulse.conf" ]
|
||||
# condition = [ { exec.pipewire-pulse = null } { exec.pipewire-pulse = true } ] }
|
||||
]
|
||||
```
|
||||
|
||||
### Reboot
|
||||
#### Applying the Changes
|
||||
PipeWire runs as a systemd user service under the user session (e.g., UID 1000). When executing commands as root, systemctl --user cannot connect to the user's DBus session unless the required environment variables are set. But as recommend at the end of this documentation part it is better and we recommend to restart the whole system.
|
||||
```sh
|
||||
sudo -u pi XDG_RUNTIME_DIR=/run/user/1000 systemctl --user restart pipewire pipewire-pulse wireplumber
|
||||
|
||||
After you have made the changes, reboot your system to apply the changes.
|
||||
```
|
||||
|
||||
💡 **Note:** Offtopic but for systems with hardware like the Seeed 2-Mic Voice Card, you even have to reboot the system multiple times to make sure that the driver is installed and everything is loaded correctly.
|
||||
💡 **Note:** In certain cases where hardware drivers need to be installed, a system reboot may be required. For hardware such as the Seeed 2-Mic Voice Card, multiple reboots may be needed to ensure the driver is installed and loaded and installed correctly. More about that in the PiCompose documentation within the Ready-to-use Image.
|
||||
|
||||
## B) PulseAudio:
|
||||
|
||||
@@ -422,7 +94,7 @@ sudo vi /etc/pulse/daemon.conf
|
||||
Change the following lines in the file:
|
||||
|
||||
```sh
|
||||
default-sample-rate = 48000
|
||||
default-sample-rate = 16000
|
||||
```
|
||||
|
||||
Enable and start Pulseaudio
|
||||
@@ -439,9 +111,10 @@ pulseaudio --check
|
||||
pactl info
|
||||
```
|
||||
|
||||
### Reboot
|
||||
Reboot the system:
|
||||
|
||||
In some cases to get the audio hardware working you may need to restart your system.
|
||||
|
||||
After you have made the changes, reboot your system to apply the changes.
|
||||
|
||||
|
||||
## Additional Information:
|
||||
@@ -484,3 +157,7 @@ sudo alsamixer
|
||||
```
|
||||
|
||||
💡 **Note:** Replace `$LVA_USER_ID` with your actual user id that you want to run the voice assistant.
|
||||
|
||||
## Adding Acoustic Echo Cancellation
|
||||
|
||||
Acoustic Echo Cancellation (AEC) is a type of sound processing used to cancel out the noise coming out of your speaker and going into your mic. In LVA this functionality can be useful to allow LVA to listen to wake words even when audio is playing, particularly when a timer is playing. PulseAudio and PipeWire already provide built-in modules for AEC. To enable AEC, see [Linux-Voice-Assistant - Enabling AEC](enabling_aec.md).
|
||||
File renamed without changes.
File renamed without changes.
@@ -9,15 +9,16 @@ import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from queue import Queue
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from typing import List, Optional, Union
|
||||
|
||||
import numpy as np
|
||||
import soundcard as sc
|
||||
from aioesphomeapi.api_pb2 import NumberStateResponse # type: ignore # pylint: disable=no-name-in-module
|
||||
from getmac import get_mac_address # type: ignore
|
||||
from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures
|
||||
from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures
|
||||
|
||||
from .models import AvailableWakeWord, Preferences, ServerState, WakeWordType
|
||||
from .models import Preferences, ServerState
|
||||
from .mpv_player import MpvMediaPlayer
|
||||
from .satellite import VoiceSatelliteProtocol
|
||||
from .util import (
|
||||
@@ -26,6 +27,8 @@ from .util import (
|
||||
get_esphome_version,
|
||||
get_version,
|
||||
)
|
||||
from .wake_word import find_available_wake_words, load_stop_model, load_wake_models
|
||||
from .webrtc import WebRTCProcessor
|
||||
from .zeroconf import HomeAssistantZeroconf
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -68,6 +71,8 @@ async def main() -> None:
|
||||
action="store_true",
|
||||
help="List audio output devices and exit",
|
||||
)
|
||||
parser.add_argument("--mic-auto-gain", type=int, default=0, choices=list(range(32)))
|
||||
parser.add_argument("--mic-noise-suppression", type=int, default=0, choices=(0, 1, 2, 3, 4))
|
||||
parser.add_argument(
|
||||
"--wake-word-dir",
|
||||
default=[_WAKEWORDS_DIR],
|
||||
@@ -145,11 +150,22 @@ async def main() -> None:
|
||||
action="store_true",
|
||||
help="Enable thinking finish sound, when the assistant is done thinking and needed more time to process",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timer-max-ring-seconds",
|
||||
type=float,
|
||||
default=900.0, # 15 minutes
|
||||
help="Seconds before a ringing timer auto-stops (default: 900)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--debug",
|
||||
action="store_true",
|
||||
help="Add this to enable debug logging",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-only",
|
||||
action="store_true",
|
||||
help="Enable output only mode",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.list_input_devices:
|
||||
@@ -194,8 +210,10 @@ async def main() -> None:
|
||||
host_ip_address = args.host
|
||||
|
||||
# Resolve mac
|
||||
mac_address = get_mac_address(interface=network_interface)
|
||||
mac_address_clean = mac_address.replace(":", "")
|
||||
if not (mac_address := get_mac_address(interface=network_interface)):
|
||||
print("No Mac address was found, app stopped.")
|
||||
sys.exit(1)
|
||||
mac_address_clean = mac_address.replace(":", "").lower()
|
||||
|
||||
# Resolve name
|
||||
if not args.name:
|
||||
@@ -207,8 +225,8 @@ async def main() -> None:
|
||||
print(f"Using friendly name: {args.name}")
|
||||
friendly_name = args.name
|
||||
|
||||
mac_no_colon = mac_address.replace(":", "").lower()
|
||||
device_name = f"lva-{mac_no_colon}"
|
||||
device_name = f"lva-{mac_address_clean}"
|
||||
|
||||
print(f"Device name: {device_name}")
|
||||
|
||||
# Resolve version
|
||||
@@ -237,32 +255,7 @@ async def main() -> None:
|
||||
# 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:
|
||||
for model_config_path in wake_word_dir.glob("*.json"):
|
||||
model_id = model_config_path.stem
|
||||
if model_id == args.stop_model:
|
||||
# Don't show stop model as an available wake word
|
||||
continue
|
||||
|
||||
with open(model_config_path, "r", encoding="utf-8") as model_config_file:
|
||||
model_config = json.load(model_config_file)
|
||||
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", []),
|
||||
wake_word_path=wake_word_path,
|
||||
)
|
||||
|
||||
_LOGGER.debug("Available wake words: %s", list(sorted(available_wake_words.keys())))
|
||||
available_wake_words = find_available_wake_words(wake_word_dirs, args.stop_model)
|
||||
|
||||
# Load preferences
|
||||
preferences_path = Path(args.preferences_file)
|
||||
@@ -282,48 +275,31 @@ async def main() -> None:
|
||||
if args.enable_thinking_sound:
|
||||
preferences.thinking_sound = 1
|
||||
|
||||
if args.mic_auto_gain or args.mic_noise_suppression:
|
||||
try:
|
||||
import webrtc_noise_gain # type: ignore[import-untyped] # noqa: F401
|
||||
except ImportError:
|
||||
_LOGGER.exception("Extras for webrtc are not installed")
|
||||
sys.exit(1)
|
||||
|
||||
if args.mic_auto_gain > 0:
|
||||
preferences.mic_auto_gain = args.mic_auto_gain
|
||||
|
||||
if args.mic_noise_suppression > 0:
|
||||
preferences.mic_noise_suppression = args.mic_noise_suppression
|
||||
|
||||
# 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
|
||||
for wake_word_id in preferences.active_wake_words:
|
||||
wake_word = available_wake_words.get(wake_word_id)
|
||||
if wake_word is None:
|
||||
_LOGGER.warning("Unrecognized wake word id: %s", wake_word_id)
|
||||
continue
|
||||
|
||||
_LOGGER.debug("Loading wake model: %s", wake_word_id)
|
||||
wake_models[wake_word_id] = wake_word.load()
|
||||
active_wake_words.add(wake_word_id)
|
||||
|
||||
if not wake_models:
|
||||
# Load default model
|
||||
wake_word_id = args.wake_model
|
||||
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()
|
||||
active_wake_words.add(wake_word_id)
|
||||
wake_models, active_wake_words, fallback_used = load_wake_models(available_wake_words, [word for word in preferences.active_wake_words if word is not None], args.wake_model)
|
||||
|
||||
# TODO: allow openWakeWord for "stop"
|
||||
stop_model: Optional[MicroWakeWord] = None
|
||||
for wake_word_dir in wake_word_dirs:
|
||||
stop_config_path = wake_word_dir / f"{args.stop_model}.json"
|
||||
if not stop_config_path.exists():
|
||||
continue
|
||||
|
||||
_LOGGER.debug("Loading stop model: %s", stop_config_path)
|
||||
stop_model = MicroWakeWord.from_config(stop_config_path)
|
||||
break
|
||||
|
||||
stop_model = load_stop_model(wake_word_dirs, args.stop_model)
|
||||
assert stop_model is not None
|
||||
|
||||
state = ServerState(
|
||||
name=device_name,
|
||||
friendly_name=friendly_name,
|
||||
network_interface=network_interface,
|
||||
mac_address=get_mac_address(interface=network_interface),
|
||||
mac_address=mac_address,
|
||||
ip_address=host_ip_address,
|
||||
version=version,
|
||||
esphome_version=esphome_version,
|
||||
@@ -343,11 +319,25 @@ async def main() -> None:
|
||||
preferences=preferences,
|
||||
preferences_path=preferences_path,
|
||||
refractory_seconds=args.refractory_seconds,
|
||||
output_only=args.output_only,
|
||||
download_dir=args.download_dir,
|
||||
volume=initial_volume,
|
||||
mic_volume=preferences.mic_volume,
|
||||
mic_auto_gain=preferences.mic_auto_gain,
|
||||
mic_noise_suppression=preferences.mic_noise_suppression,
|
||||
timer_max_ring_seconds=args.timer_max_ring_seconds,
|
||||
)
|
||||
|
||||
if args.enable_thinking_sound:
|
||||
if fallback_used:
|
||||
# Fallback to the default model was used, save as active wake words
|
||||
_LOGGER.debug("Fallback was used, save default wake words in Preferences.")
|
||||
state.preferences.active_wake_words = list(active_wake_words)
|
||||
state.active_wake_words = active_wake_words
|
||||
state.wake_words = wake_models
|
||||
state.save_preferences()
|
||||
state.wake_words_changed = True
|
||||
|
||||
if args.enable_thinking_sound or args.mic_auto_gain or args.mic_noise_suppression:
|
||||
state.save_preferences()
|
||||
|
||||
initial_volume_percent = int(round(initial_volume * 100))
|
||||
@@ -359,6 +349,22 @@ async def main() -> None:
|
||||
attempt = 1
|
||||
server = None
|
||||
|
||||
# Validate VoiceSatelliteProtocol initialization BEFORE starting server
|
||||
# This catches errors like missing imports or broken initialization immediately
|
||||
# instead of failing silently only when first client connects
|
||||
_LOGGER.debug("Validating VoiceSatelliteProtocol initialization...")
|
||||
try:
|
||||
# Create test instance to run complete __init__ code path
|
||||
test_protocol = VoiceSatelliteProtocol(state)
|
||||
# Cleanup state reference
|
||||
test_protocol.state.satellite = None
|
||||
del test_protocol
|
||||
_LOGGER.debug("✅ VoiceSatelliteProtocol validation successful")
|
||||
except Exception:
|
||||
_LOGGER.critical("❌ FATAL ERROR in VoiceSatelliteProtocol initialization!", exc_info=True)
|
||||
_LOGGER.critical("Program will exit immediately - fix the error above first!")
|
||||
sys.exit(1)
|
||||
|
||||
while attempt <= max_attempts:
|
||||
try:
|
||||
server = await loop.create_server(lambda: VoiceSatelliteProtocol(state), host=host_ip_address, port=args.port)
|
||||
@@ -414,27 +420,108 @@ def process_audio(state: ServerState, mic, block_size: int):
|
||||
has_oww = False
|
||||
|
||||
last_active: Optional[float] = None
|
||||
webrtc: Optional[WebRTCProcessor] = 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_array = mic_in.record(block_size).reshape(-1)
|
||||
audio_chunk = (np.clip(audio_chunk_array, -1.0, 1.0) * 32767.0).astype("<i2").tobytes() # little-endian 16-bit signed
|
||||
# little-endian 16-bit signed
|
||||
mic_vol_scalar = max(0.1, min(1.0, state.mic_volume / 100.0))
|
||||
audio_chunk = (np.clip(audio_chunk_array * mic_vol_scalar, -1.0, 1.0) * 32767.0).astype("<i2").tobytes()
|
||||
agc = state.preferences.mic_auto_gain or 0
|
||||
ns = state.preferences.mic_noise_suppression or 0
|
||||
|
||||
if state.satellite is None:
|
||||
if agc > 0 or ns > 0:
|
||||
if webrtc is None:
|
||||
webrtc = WebRTCProcessor(agc_level=agc, ns_level=ns)
|
||||
else:
|
||||
webrtc.update_settings(agc, ns)
|
||||
audio_chunk = webrtc.process(audio_chunk)
|
||||
if not audio_chunk:
|
||||
continue
|
||||
|
||||
if state.satellite is None or not hasattr(state.satellite, "_is_streaming_audio"):
|
||||
continue
|
||||
|
||||
# WAKE WORD
|
||||
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.id in state.active_wake_words]
|
||||
|
||||
# TODO: Load default stop word value from json into state and preferences missing.
|
||||
|
||||
has_oww = False
|
||||
for wake_word in wake_words:
|
||||
for idx, wake_word in enumerate(wake_words):
|
||||
|
||||
# Load default threshold from model json
|
||||
wake_word_id = wake_word.id if hasattr(wake_word, "id") else next(iter(state.wake_words.keys()))
|
||||
available_word = state.available_wake_words.get(wake_word_id)
|
||||
# _LOGGER.debug("word= %s", state.available_wake_words.get(wake_word_id))
|
||||
default_threshold = available_word.probability_cutoff if available_word else 0.7
|
||||
_LOGGER.debug("Using default threshold %.3f for wake word '%s' from model config", default_threshold, wake_word_id)
|
||||
# Check preferences override
|
||||
if idx == 0:
|
||||
old_val = state.wake_word_1_threshold
|
||||
if state.preferences.wake_word_1_sensitivity is not None:
|
||||
state.wake_word_1_threshold = state.preferences.wake_word_1_sensitivity
|
||||
else:
|
||||
state.wake_word_1_threshold = default_threshold
|
||||
_LOGGER.debug("Wake Word 1 threshold set to %.3f (was %.3f, preferences: %s)", state.wake_word_1_threshold, old_val, state.preferences.wake_word_1_sensitivity)
|
||||
elif idx == 1:
|
||||
old_val = state.wake_word_2_threshold
|
||||
if state.preferences.wake_word_2_sensitivity is not None:
|
||||
state.wake_word_2_threshold = state.preferences.wake_word_2_sensitivity
|
||||
else:
|
||||
state.wake_word_2_threshold = default_threshold
|
||||
_LOGGER.debug("Wake Word 2 threshold set to %.3f (was %.3f, preferences: %s)", state.wake_word_2_threshold, old_val, state.preferences.wake_word_2_sensitivity)
|
||||
|
||||
if isinstance(wake_word, OpenWakeWord):
|
||||
has_oww = True
|
||||
|
||||
# Sync entity states after threshold values were updated
|
||||
if state.satellite is not None:
|
||||
_LOGGER.debug("Updating WebUI entities with new threshold values")
|
||||
|
||||
# Wake Word 1
|
||||
if state.satellite.state.sensitivity_1_number_entity is not None:
|
||||
_LOGGER.debug(" → Syncing Wake Word 1 entity to value %.3f", state.wake_word_1_threshold)
|
||||
state.satellite.state.sensitivity_1_number_entity.sync_with_state()
|
||||
_LOGGER.debug(" ✅ Wake Word 1 entity now has value %.3f", state.satellite.state.sensitivity_1_number_entity.value)
|
||||
|
||||
# Wake Word 2
|
||||
if state.satellite.state.sensitivity_2_number_entity is not None:
|
||||
_LOGGER.debug(" → Syncing Wake Word 2 entity to value %.3f", state.wake_word_2_threshold)
|
||||
state.satellite.state.sensitivity_2_number_entity.sync_with_state()
|
||||
_LOGGER.debug(" ✅ Wake Word 2 entity now has value %.3f", state.satellite.state.sensitivity_2_number_entity.value)
|
||||
|
||||
# Stop Word
|
||||
if state.satellite.state.stop_sensitivity_number_entity is not None:
|
||||
_LOGGER.debug(" → Syncing Stop Word entity to value %.3f", state.stop_word_threshold)
|
||||
state.satellite.state.stop_sensitivity_number_entity.sync_with_state()
|
||||
_LOGGER.debug(" ✅ Stop Word entity now has value %.3f", state.satellite.state.stop_sensitivity_number_entity.value)
|
||||
|
||||
_LOGGER.debug("All sensitivity entities synced successfully")
|
||||
|
||||
# Force push new state to connected Home Assistant instance
|
||||
if state.satellite is not None:
|
||||
try:
|
||||
_LOGGER.debug("Pushing updated state values to Home Assistant")
|
||||
for entity in [
|
||||
state.satellite.state.sensitivity_1_number_entity,
|
||||
state.satellite.state.sensitivity_2_number_entity,
|
||||
state.satellite.state.stop_sensitivity_number_entity,
|
||||
]:
|
||||
if entity is not None:
|
||||
state.satellite.send_messages([NumberStateResponse(key=entity.key, state=entity.value)]) # type: ignore[attr-defined]
|
||||
_LOGGER.debug(" → Pushed value %.3f for entity %d", entity.value, entity.key)
|
||||
except Exception as e:
|
||||
_LOGGER.debug("Could not push state (no client connected yet): %s", e)
|
||||
|
||||
# TODO: Save settings: At this moment settings are only saved when changed in the UI. Means that the default value can change while updating since its not saved in preferences.
|
||||
|
||||
if micro_features is None:
|
||||
micro_features = MicroWakeWordFeatures()
|
||||
|
||||
@@ -453,16 +540,36 @@ def process_audio(state: ServerState, mic, block_size: int):
|
||||
oww_inputs.clear()
|
||||
oww_inputs.extend(oww_features.process_streaming(audio_chunk))
|
||||
|
||||
for wake_word in wake_words:
|
||||
for wake_word_index, wake_word in enumerate(wake_words):
|
||||
activated = False
|
||||
|
||||
# Set dynamic threshold depending on wake word index
|
||||
if wake_word_index == 0:
|
||||
threshold = state.wake_word_1_threshold
|
||||
# _LOGGER.debug("Set wake word %d probability cutoff to %.3f", wake_word_index+1, state.wake_word_1_threshold)
|
||||
elif wake_word_index == 1:
|
||||
threshold = state.wake_word_2_threshold
|
||||
# _LOGGER.debug("Set wake word %d probability cutoff to %.3f", wake_word_index+1, state.wake_word_2_threshold)
|
||||
else:
|
||||
threshold = 0.7
|
||||
# _LOGGER.debug("Set wake word %d probability cutoff to fallback value 0.7", wake_word_index+1)
|
||||
|
||||
if isinstance(wake_word, MicroWakeWord):
|
||||
# No debugging when no detection
|
||||
wake_word.debug_probabilities = False
|
||||
|
||||
# set microWakeWord cutoff
|
||||
wake_word.probability_cutoff = threshold
|
||||
|
||||
for micro_input in micro_inputs:
|
||||
if wake_word.process_streaming(micro_input):
|
||||
wake_word.debug_probabilities = True
|
||||
activated = True
|
||||
elif isinstance(wake_word, OpenWakeWord):
|
||||
for oww_input in oww_inputs:
|
||||
for prob in wake_word.process_streaming(oww_input):
|
||||
if prob > 0.5:
|
||||
if prob > threshold:
|
||||
_LOGGER.debug("Wake word '%s' activated (probability %.3f exceeded threshold %.3f)", wake_word.wake_word, prob, threshold) # type: ignore[attr-defined]
|
||||
activated = True
|
||||
|
||||
if activated and not state.muted:
|
||||
@@ -474,11 +581,20 @@ def process_audio(state: ServerState, mic, block_size: int):
|
||||
|
||||
# Always process to keep state correct
|
||||
stopped = False
|
||||
|
||||
# No debugging when no detection
|
||||
state.stop_word.debug_probabilities = False
|
||||
|
||||
# Apply stop word sensitivity threshold
|
||||
state.stop_word.probability_cutoff = state.stop_word_threshold
|
||||
# _LOGGER.debug("Set stop word probability cutoff to %.3f", state.stop_word_threshold)
|
||||
for micro_input in micro_inputs:
|
||||
if state.stop_word.process_streaming(micro_input):
|
||||
state.stop_word.debug_probabilities = True
|
||||
stopped = True
|
||||
|
||||
if stopped and (state.stop_word.id in state.active_wake_words) and not state.muted:
|
||||
_LOGGER.debug("Stop word detected")
|
||||
state.satellite.stop()
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error handling audio")
|
||||
@@ -489,5 +605,10 @@ def process_audio(state: ServerState, mic, block_size: int):
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
def run():
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -6,10 +6,16 @@ from typing import Callable, List, Optional, Union
|
||||
# pylint: disable=no-name-in-module
|
||||
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
|
||||
ListEntitiesMediaPlayerResponse,
|
||||
ListEntitiesNumberResponse,
|
||||
ListEntitiesRequest,
|
||||
ListEntitiesSelectResponse,
|
||||
ListEntitiesSwitchResponse,
|
||||
MediaPlayerCommandRequest,
|
||||
MediaPlayerStateResponse,
|
||||
NumberCommandRequest,
|
||||
NumberStateResponse,
|
||||
SelectCommandRequest,
|
||||
SelectStateResponse,
|
||||
SubscribeHomeAssistantStatesRequest,
|
||||
SwitchCommandRequest,
|
||||
SwitchStateResponse,
|
||||
@@ -19,6 +25,7 @@ from aioesphomeapi.model import (
|
||||
MediaPlayerCommand,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
NumberMode,
|
||||
)
|
||||
from google.protobuf import message
|
||||
|
||||
@@ -116,6 +123,10 @@ class MediaPlayerEntity(ESPHomeEntity):
|
||||
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
||||
self._log.debug("handle_message called with msg: %s", msg)
|
||||
|
||||
# Suppress warning for irrelevant NumberCommandRequest
|
||||
if isinstance(msg, (NumberCommandRequest, SelectCommandRequest)):
|
||||
return
|
||||
|
||||
if isinstance(msg, MediaPlayerCommandRequest) and (msg.key == self.key):
|
||||
self._log.debug("MediaPlayerCommandRequest matched for this key")
|
||||
|
||||
@@ -347,3 +358,276 @@ class ThinkingSoundEntity(ESPHomeEntity):
|
||||
# Always return our internal switch state
|
||||
self.sync_with_state()
|
||||
yield SwitchStateResponse(key=self.key, state=self._switch_state)
|
||||
|
||||
|
||||
class MicSettingEntity(ESPHomeEntity):
|
||||
def __init__(
|
||||
self,
|
||||
server: APIServer,
|
||||
key: int,
|
||||
name: str,
|
||||
object_id: str,
|
||||
get_value: Callable[[], Union[float, str]],
|
||||
set_value: Callable[[Union[float, str]], None],
|
||||
min_value: float = 0.0,
|
||||
max_value: float = 1.0,
|
||||
options: Optional[List[str]] = None,
|
||||
icon: str = "mdi:microphone",
|
||||
) -> None:
|
||||
ESPHomeEntity.__init__(self, server)
|
||||
self.key = key
|
||||
self.name = name
|
||||
self.object_id = object_id
|
||||
self.options = options # If present, this behaves as a Dropdown
|
||||
self.min_value = min_value
|
||||
self.max_value = max_value
|
||||
self._get_value = get_value
|
||||
self._set_value = set_value
|
||||
self._state = self._get_value()
|
||||
self.icon = icon
|
||||
|
||||
def sync_with_state(self) -> None:
|
||||
"""Sync internal state with the actual value."""
|
||||
self._state = self._get_value()
|
||||
|
||||
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
||||
# --- 1. HANDLE COMMANDS FROM HOME ASSISTANT ---
|
||||
if self.options:
|
||||
if isinstance(msg, SelectCommandRequest) and (msg.key == self.key):
|
||||
new_val = msg.state
|
||||
self._state = new_val
|
||||
self._set_value(new_val)
|
||||
yield SelectStateResponse(key=self.key, state=new_val)
|
||||
else:
|
||||
if isinstance(msg, NumberCommandRequest) and (msg.key == self.key):
|
||||
new_val = msg.state
|
||||
self._state = new_val
|
||||
self._set_value(new_val)
|
||||
yield NumberStateResponse(key=self.key, state=new_val)
|
||||
|
||||
# --- 2. DISCOVERY (TELL HA WHAT TYPE TO SHOW) ---
|
||||
if isinstance(msg, ListEntitiesRequest):
|
||||
if self.options:
|
||||
yield ListEntitiesSelectResponse(
|
||||
object_id=self.object_id,
|
||||
key=self.key,
|
||||
name=self.name,
|
||||
options=self.options,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
icon=self.icon,
|
||||
)
|
||||
else:
|
||||
yield ListEntitiesNumberResponse(
|
||||
object_id=self.object_id,
|
||||
key=self.key,
|
||||
name=self.name,
|
||||
min_value=self.min_value,
|
||||
max_value=self.max_value,
|
||||
step=1.0,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
icon=self.icon,
|
||||
)
|
||||
|
||||
# --- 3. INITIAL SYNC / STATE UPDATES ---
|
||||
elif isinstance(msg, SubscribeHomeAssistantStatesRequest):
|
||||
self.sync_with_state()
|
||||
if self.options:
|
||||
yield SelectStateResponse(key=self.key, state=str(self._state))
|
||||
else:
|
||||
yield NumberStateResponse(key=self.key, state=float(self._state))
|
||||
|
||||
def update_get_value(self, get_value: Callable[[], Union[float, str]]) -> None:
|
||||
self._get_value = get_value
|
||||
|
||||
def update_set_value(self, set_value: Callable[[Union[float, str]], None]) -> None:
|
||||
self._set_value = set_value
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class WakeWord1SensitivityNumberEntity(ESPHomeEntity):
|
||||
def __init__(
|
||||
self,
|
||||
server: APIServer,
|
||||
key: int,
|
||||
name: str,
|
||||
object_id: str,
|
||||
get_sensitivity: Callable[[], float],
|
||||
set_sensitivity: Callable[[float], None],
|
||||
initial_value: float = 0.5,
|
||||
) -> None:
|
||||
ESPHomeEntity.__init__(self, server)
|
||||
|
||||
self.key = key
|
||||
self.name = name
|
||||
self.object_id = object_id
|
||||
self._get_sensitivity = get_sensitivity
|
||||
self._set_sensitivity = set_sensitivity
|
||||
self.value = initial_value
|
||||
self._log = logging.getLogger(f"{self.__class__.__name__}[{self.key}]")
|
||||
|
||||
def update_get_sensitivity(self, get_sensitivity: Callable[[], float]) -> None:
|
||||
self._get_sensitivity = get_sensitivity
|
||||
|
||||
def update_set_sensitivity(self, set_sensitivity: Callable[[float], None]) -> None:
|
||||
self._set_sensitivity = set_sensitivity
|
||||
|
||||
def sync_with_state(self) -> None:
|
||||
old_value = self.value
|
||||
self.value = self._get_sensitivity()
|
||||
self._log.debug("Entity synchronized: old=%.3f new=%.3f", old_value, self.value)
|
||||
|
||||
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
||||
if isinstance(msg, NumberCommandRequest) and (msg.key == self.key):
|
||||
new_value = float(msg.state)
|
||||
self._log.debug("Sensitivity value changed: %s => %s", self.value, new_value)
|
||||
self.value = new_value
|
||||
self._set_sensitivity(new_value)
|
||||
yield NumberStateResponse(key=self.key, state=self.value)
|
||||
elif isinstance(msg, ListEntitiesRequest):
|
||||
yield ListEntitiesNumberResponse(
|
||||
object_id=self.object_id,
|
||||
key=self.key,
|
||||
name=self.name,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
min_value=0.0,
|
||||
max_value=1.0,
|
||||
step=0.001,
|
||||
mode=NumberMode.BOX,
|
||||
)
|
||||
elif isinstance(msg, SubscribeHomeAssistantStatesRequest):
|
||||
self.sync_with_state()
|
||||
yield NumberStateResponse(key=self.key, state=self.value)
|
||||
|
||||
|
||||
class WakeWord2SensitivityNumberEntity(ESPHomeEntity):
|
||||
def __init__(
|
||||
self,
|
||||
server: APIServer,
|
||||
key: int,
|
||||
name: str,
|
||||
object_id: str,
|
||||
get_sensitivity: Callable[[], float],
|
||||
set_sensitivity: Callable[[float], None],
|
||||
initial_value: float = 0.5,
|
||||
) -> None:
|
||||
ESPHomeEntity.__init__(self, server)
|
||||
|
||||
self.key = key
|
||||
self.name = name
|
||||
self.object_id = object_id
|
||||
self._get_sensitivity = get_sensitivity
|
||||
self._set_sensitivity = set_sensitivity
|
||||
self.value = initial_value
|
||||
self._log = logging.getLogger(f"{self.__class__.__name__}[{self.key}]")
|
||||
|
||||
def update_get_sensitivity(self, get_sensitivity: Callable[[], float]) -> None:
|
||||
self._get_sensitivity = get_sensitivity
|
||||
|
||||
def update_set_sensitivity(self, set_sensitivity: Callable[[float], None]) -> None:
|
||||
self._set_sensitivity = set_sensitivity
|
||||
|
||||
def sync_with_state(self) -> None:
|
||||
old_value = self.value
|
||||
self.value = self._get_sensitivity()
|
||||
self._log.debug("Entity synchronized: old=%.3f new=%.3f", old_value, self.value)
|
||||
|
||||
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
||||
if isinstance(msg, NumberCommandRequest) and (msg.key == self.key):
|
||||
new_value = float(msg.state)
|
||||
self._log.debug("Second wake word sensitivity value changed: %s => %s", self.value, new_value)
|
||||
self.value = new_value
|
||||
self._set_sensitivity(new_value)
|
||||
yield NumberStateResponse(key=self.key, state=self.value)
|
||||
elif isinstance(msg, ListEntitiesRequest):
|
||||
yield ListEntitiesNumberResponse(
|
||||
object_id=self.object_id,
|
||||
key=self.key,
|
||||
name=self.name,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
min_value=0.0,
|
||||
max_value=1.0,
|
||||
step=0.001,
|
||||
mode=NumberMode.BOX,
|
||||
)
|
||||
elif isinstance(msg, SubscribeHomeAssistantStatesRequest):
|
||||
self.sync_with_state()
|
||||
yield NumberStateResponse(key=self.key, state=self.value)
|
||||
|
||||
|
||||
class StopWordSensitivityNumberEntity(ESPHomeEntity):
|
||||
def __init__(
|
||||
self,
|
||||
server: APIServer,
|
||||
key: int,
|
||||
name: str,
|
||||
object_id: str,
|
||||
get_sensitivity: Callable[[], float],
|
||||
set_sensitivity: Callable[[float], None],
|
||||
initial_value: float = 0.5,
|
||||
) -> None:
|
||||
ESPHomeEntity.__init__(self, server)
|
||||
|
||||
self.key = key
|
||||
self.name = name
|
||||
self.object_id = object_id
|
||||
self._get_sensitivity = get_sensitivity
|
||||
self._set_sensitivity = set_sensitivity
|
||||
self.value = initial_value
|
||||
self._log = logging.getLogger(f"{self.__class__.__name__}[{self.key}]")
|
||||
|
||||
def update_get_sensitivity(self, get_sensitivity: Callable[[], float]) -> None:
|
||||
self._get_sensitivity = get_sensitivity
|
||||
|
||||
def update_set_sensitivity(self, set_sensitivity: Callable[[float], None]) -> None:
|
||||
self._set_sensitivity = set_sensitivity
|
||||
|
||||
def sync_with_state(self) -> None:
|
||||
old_value = self.value
|
||||
self.value = self._get_sensitivity()
|
||||
self._log.debug("Entity synchronized: old=%.3f new=%.3f", old_value, self.value)
|
||||
|
||||
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
||||
if isinstance(msg, NumberCommandRequest) and (msg.key == self.key):
|
||||
new_value = float(msg.state)
|
||||
self._log.debug("Stop word sensitivity value changed: %s => %s", self.value, new_value)
|
||||
self.value = new_value
|
||||
self._set_sensitivity(new_value)
|
||||
yield NumberStateResponse(key=self.key, state=self.value)
|
||||
elif isinstance(msg, ListEntitiesRequest):
|
||||
yield ListEntitiesNumberResponse(
|
||||
object_id=self.object_id,
|
||||
key=self.key,
|
||||
name=self.name,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
icon="mdi:hand-back-left",
|
||||
min_value=0.0,
|
||||
max_value=1.0,
|
||||
step=0.001,
|
||||
mode=NumberMode.BOX,
|
||||
)
|
||||
elif isinstance(msg, SubscribeHomeAssistantStatesRequest):
|
||||
self.sync_with_state()
|
||||
yield NumberStateResponse(key=self.key, state=self.value)
|
||||
|
||||
|
||||
# Backward compatibility export aliases
|
||||
__all__ = [
|
||||
"ESPHomeEntity",
|
||||
"MediaPlayerEntity",
|
||||
"MuteSwitchEntity",
|
||||
"ThinkingSoundEntity",
|
||||
"WakeWord1SensitivityNumberEntity",
|
||||
"WakeWord2SensitivityNumberEntity",
|
||||
"StopWordSensitivityNumberEntity",
|
||||
# Old class names for backward compatibility
|
||||
"WakeWordSensitivityNumberEntity",
|
||||
"SecondWakeWordSensitivityNumberEntity",
|
||||
]
|
||||
|
||||
WakeWordSensitivityNumberEntity = WakeWord1SensitivityNumberEntity
|
||||
SecondWakeWordSensitivityNumberEntity = WakeWord2SensitivityNumberEntity
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -15,8 +15,12 @@ if TYPE_CHECKING:
|
||||
from .entity import (
|
||||
ESPHomeEntity,
|
||||
MediaPlayerEntity,
|
||||
MicSettingEntity,
|
||||
MuteSwitchEntity,
|
||||
StopWordSensitivityNumberEntity,
|
||||
ThinkingSoundEntity,
|
||||
WakeWord1SensitivityNumberEntity,
|
||||
WakeWord2SensitivityNumberEntity,
|
||||
)
|
||||
from .mpv_player import MpvMediaPlayer
|
||||
from .satellite import VoiceSatelliteProtocol
|
||||
@@ -36,6 +40,7 @@ class AvailableWakeWord:
|
||||
wake_word: str
|
||||
trained_languages: List[str]
|
||||
wake_word_path: Path
|
||||
probability_cutoff: float = 0.7
|
||||
|
||||
def load(self) -> "Union[MicroWakeWord, OpenWakeWord]":
|
||||
if self.type == WakeWordType.MICRO_WAKE_WORD:
|
||||
@@ -56,9 +61,16 @@ class AvailableWakeWord:
|
||||
|
||||
@dataclass
|
||||
class Preferences:
|
||||
active_wake_words: List[str] = field(default_factory=list)
|
||||
active_wake_words: List[Optional[str]] = field(default_factory=list)
|
||||
volume: Optional[float] = None
|
||||
thinking_sound: int = 0 # 0 = disabled, 1 = enabled
|
||||
wake_word_1_sensitivity: Optional[float] = None
|
||||
wake_word_2_sensitivity: Optional[float] = None
|
||||
stop_word_sensitivity: Optional[float] = None
|
||||
|
||||
mic_auto_gain: int = 0
|
||||
mic_noise_suppression: int = 0
|
||||
mic_volume: int = 100 # 1–100, default maximum
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -91,12 +103,29 @@ class ServerState:
|
||||
satellite: "Optional[VoiceSatelliteProtocol]" = None
|
||||
mute_switch_entity: "Optional[MuteSwitchEntity]" = None
|
||||
thinking_sound_entity: "Optional[ThinkingSoundEntity]" = None
|
||||
sensitivity_1_number_entity: "Optional[WakeWord1SensitivityNumberEntity]" = None
|
||||
sensitivity_2_number_entity: "Optional[WakeWord2SensitivityNumberEntity]" = None
|
||||
stop_sensitivity_number_entity: "Optional[StopWordSensitivityNumberEntity]" = None
|
||||
mic_gain_entity: "Optional[MicSettingEntity]" = None
|
||||
mic_noise_suppression_entity: "Optional[MicSettingEntity]" = None
|
||||
mic_volume_entity: "Optional[MicSettingEntity]" = None
|
||||
wake_words_changed: bool = False
|
||||
refractory_seconds: float = 2.0
|
||||
thinking_sound_enabled: bool = False
|
||||
output_only: bool = False
|
||||
muted: bool = False
|
||||
connected: bool = False
|
||||
volume: float = 1.0
|
||||
oww_probability_cutoff: float = 0.7 # Dynamic threshold for OpenWakeWord
|
||||
oww_second_probability_cutoff: float = 0.7 # Dynamic threshold for second OpenWakeWord
|
||||
oww_stop_probability_cutoff: float = 0.5 # Dynamic threshold for Stop word
|
||||
wake_word_1_threshold: float = 0.7
|
||||
wake_word_2_threshold: float = 0.7
|
||||
stop_word_threshold: float = 0.5
|
||||
mic_auto_gain: int = 0
|
||||
mic_noise_suppression: int = 0
|
||||
mic_volume: int = 100 # 1–100, default maximum
|
||||
timer_max_ring_seconds: float = 900.0
|
||||
|
||||
def save_preferences(self) -> None:
|
||||
"""Save preferences as JSON."""
|
||||
@@ -129,3 +158,34 @@ class ServerState:
|
||||
_LOGGER.info("Saving volume %s to %s", clamped_volume, self.preferences_path)
|
||||
self.save_preferences()
|
||||
_LOGGER.info("Volume saved successfully")
|
||||
|
||||
def persist_mic_gain(self, gain: float) -> None:
|
||||
"""Persist the microphone auto gain value."""
|
||||
gain_int = int(gain)
|
||||
if self.mic_auto_gain == gain_int and self.preferences.mic_auto_gain == gain_int:
|
||||
return
|
||||
|
||||
self.mic_auto_gain = gain_int
|
||||
self.preferences.mic_auto_gain = gain_int
|
||||
self.save_preferences()
|
||||
|
||||
def persist_mic_noise(self, noise: float) -> None:
|
||||
"""Persist the microphone noise suppression value."""
|
||||
noise_int = int(noise)
|
||||
if self.mic_noise_suppression == noise_int and self.preferences.mic_noise_suppression == noise_int:
|
||||
return
|
||||
|
||||
self.mic_noise_suppression = noise_int
|
||||
self.preferences.mic_noise_suppression = noise_int
|
||||
self.save_preferences()
|
||||
|
||||
def persist_mic_volume(self, volume: float) -> None:
|
||||
"""Persist the microphone input volume (0–100)."""
|
||||
volume_int = max(1, min(100, int(round(volume))))
|
||||
if self.mic_volume == volume_int and self.preferences.mic_volume == volume_int:
|
||||
return
|
||||
|
||||
self.mic_volume = volume_int
|
||||
self.preferences.mic_volume = volume_int
|
||||
_LOGGER.info("Saving mic_volume %s to %s", volume_int, self.preferences_path)
|
||||
self.save_preferences()
|
||||
@@ -37,6 +37,21 @@ class LibMpvPlayer(AudioPlayer):
|
||||
if device:
|
||||
self._mpv["audio-device"] = device
|
||||
|
||||
# Pre-buffer audio before the sink starts clocking samples out.
|
||||
# The default (0.2 s) is too tight for short notification sounds on
|
||||
# PulseAudio/PipeWire: the sink stream takes a few ms to initialise
|
||||
# and the very first samples are dropped before it is ready, making
|
||||
# short files (<1 s) appear to start mid-way through.
|
||||
# 0.8 s gives the output pipeline enough headroom without adding any
|
||||
# noticeable latency for a user-facing notification sound.
|
||||
self._mpv["audio-buffer"] = 0.8
|
||||
|
||||
# Keep the PulseAudio/PipeWire stream open between files by outputting
|
||||
# silence when idle. This eliminates the per-play sink re-initialisation
|
||||
# penalty entirely, so back-to-back short sounds (wakeup → TTS, mute →
|
||||
# unmute) never lose their first samples regardless of system load.
|
||||
self._mpv["audio-stream-silence"] = True
|
||||
|
||||
# Callback Handling
|
||||
self._done_callback: Optional[Callable[[], None]] = None
|
||||
self._mpv.event_callback("end-file")(self._on_end_file)
|
||||
|
||||
@@ -5,6 +5,7 @@ import hashlib
|
||||
import logging
|
||||
import posixpath
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Iterable
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
@@ -19,6 +20,8 @@ from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
|
||||
ListEntitiesDoneResponse,
|
||||
ListEntitiesRequest,
|
||||
MediaPlayerCommandRequest,
|
||||
NumberCommandRequest,
|
||||
SelectCommandRequest,
|
||||
SubscribeHomeAssistantStatesRequest,
|
||||
SwitchCommandRequest,
|
||||
VoiceAssistantAnnounceFinished,
|
||||
@@ -44,7 +47,15 @@ from pymicro_wakeword import MicroWakeWord
|
||||
from pyopen_wakeword import OpenWakeWord
|
||||
|
||||
from .api_server import APIServer
|
||||
from .entity import MediaPlayerEntity, MuteSwitchEntity, ThinkingSoundEntity
|
||||
from .entity import (
|
||||
MediaPlayerEntity,
|
||||
MicSettingEntity,
|
||||
MuteSwitchEntity,
|
||||
StopWordSensitivityNumberEntity,
|
||||
ThinkingSoundEntity,
|
||||
WakeWord1SensitivityNumberEntity,
|
||||
WakeWord2SensitivityNumberEntity,
|
||||
)
|
||||
from .models import AvailableWakeWord, ServerState, WakeWordType
|
||||
from .util import call_all
|
||||
|
||||
@@ -62,7 +73,18 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
self.state.satellite = self
|
||||
self.state.connected = False
|
||||
|
||||
# Report capabilities appropriately
|
||||
if state.output_only:
|
||||
_LOGGER.debug("Output only features")
|
||||
self.supported_features = VoiceAssistantFeature.API_AUDIO | VoiceAssistantFeature.ANNOUNCE
|
||||
else:
|
||||
_LOGGER.debug("Voice assistant features")
|
||||
self.supported_features = (
|
||||
VoiceAssistantFeature.VOICE_ASSISTANT | VoiceAssistantFeature.API_AUDIO | VoiceAssistantFeature.ANNOUNCE | VoiceAssistantFeature.START_CONVERSATION | VoiceAssistantFeature.TIMERS
|
||||
)
|
||||
|
||||
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]
|
||||
@@ -147,11 +169,157 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
thinking_sound_switch.update_set_thinking_sound_enabled(self._set_thinking_sound_enabled)
|
||||
thinking_sound_switch.sync_with_state()
|
||||
|
||||
# Add/update Wake Word 1 sensitivity number entity
|
||||
sensitivity_1_entity = self.state.sensitivity_1_number_entity
|
||||
if sensitivity_1_entity is None:
|
||||
sensitivity_1_entity = WakeWord1SensitivityNumberEntity(
|
||||
server=self,
|
||||
key=len(state.entities),
|
||||
name="Wake Word 1 Sensitivity",
|
||||
object_id="wake_word_1_sensitivity",
|
||||
get_sensitivity=lambda: self.state.wake_word_1_threshold,
|
||||
set_sensitivity=self._set_sensitivity_1,
|
||||
initial_value=self.state.wake_word_1_threshold,
|
||||
)
|
||||
self.state.entities.append(sensitivity_1_entity)
|
||||
self.state.sensitivity_1_number_entity = sensitivity_1_entity
|
||||
elif sensitivity_1_entity not in self.state.entities:
|
||||
self.state.entities.append(sensitivity_1_entity)
|
||||
|
||||
sensitivity_1_entity.server = self
|
||||
sensitivity_1_entity.update_get_sensitivity(lambda: self.state.wake_word_1_threshold)
|
||||
sensitivity_1_entity.update_set_sensitivity(self._set_sensitivity_1)
|
||||
|
||||
sensitivity_1_entity.sync_with_state()
|
||||
_LOGGER.debug("INIT: Wake Word 1 entity initialized with value %.3f", sensitivity_1_entity.value)
|
||||
|
||||
# Add/update Wake Word 2 sensitivity number entity
|
||||
sensitivity_2_entity = self.state.sensitivity_2_number_entity
|
||||
if sensitivity_2_entity is None:
|
||||
sensitivity_2_entity = WakeWord2SensitivityNumberEntity(
|
||||
server=self,
|
||||
key=len(state.entities),
|
||||
name="Wake Word 2 Sensitivity",
|
||||
object_id="wake_word_2_sensitivity",
|
||||
get_sensitivity=lambda: self.state.wake_word_2_threshold,
|
||||
set_sensitivity=self._set_sensitivity_2,
|
||||
initial_value=self.state.wake_word_2_threshold,
|
||||
)
|
||||
self.state.entities.append(sensitivity_2_entity)
|
||||
self.state.sensitivity_2_number_entity = sensitivity_2_entity
|
||||
elif sensitivity_2_entity not in self.state.entities:
|
||||
self.state.entities.append(sensitivity_2_entity)
|
||||
|
||||
sensitivity_2_entity.server = self
|
||||
sensitivity_2_entity.update_get_sensitivity(lambda: self.state.wake_word_2_threshold)
|
||||
sensitivity_2_entity.update_set_sensitivity(self._set_sensitivity_2)
|
||||
|
||||
sensitivity_2_entity.sync_with_state()
|
||||
|
||||
# Add/update Stop Word sensitivity number entity
|
||||
stop_sensitivity_entity = self.state.stop_sensitivity_number_entity
|
||||
if stop_sensitivity_entity is None:
|
||||
stop_sensitivity_entity = StopWordSensitivityNumberEntity(
|
||||
server=self,
|
||||
key=len(state.entities),
|
||||
name="Stop Word Sensitivity",
|
||||
object_id="stop_word_sensitivity",
|
||||
get_sensitivity=lambda: self.state.stop_word_threshold,
|
||||
set_sensitivity=self._set_stop_sensitivity,
|
||||
initial_value=self.state.stop_word_threshold,
|
||||
)
|
||||
self.state.entities.append(stop_sensitivity_entity)
|
||||
self.state.stop_sensitivity_number_entity = stop_sensitivity_entity
|
||||
elif stop_sensitivity_entity not in self.state.entities:
|
||||
self.state.entities.append(stop_sensitivity_entity)
|
||||
|
||||
stop_sensitivity_entity.server = self
|
||||
stop_sensitivity_entity.update_get_sensitivity(lambda: self.state.stop_word_threshold)
|
||||
stop_sensitivity_entity.update_set_sensitivity(self._set_stop_sensitivity)
|
||||
|
||||
stop_sensitivity_entity.sync_with_state()
|
||||
|
||||
# Mic Gain
|
||||
if self.state.mic_gain_entity is None:
|
||||
self.state.mic_gain_entity = MicSettingEntity(
|
||||
server=self,
|
||||
key=len(self.state.entities),
|
||||
name="Mic Auto Gain",
|
||||
object_id="mic_gain",
|
||||
min_value=0.0,
|
||||
max_value=31.0,
|
||||
get_value=lambda: float(self.state.mic_auto_gain),
|
||||
set_value=lambda val: self.state.persist_mic_gain(float(val)),
|
||||
icon="mdi:microphone-plus",
|
||||
)
|
||||
self.state.entities.append(self.state.mic_gain_entity)
|
||||
elif self.state.mic_gain_entity not in self.state.entities:
|
||||
self.state.entities.append(self.state.mic_gain_entity)
|
||||
|
||||
self.state.mic_gain_entity.server = self
|
||||
self.state.mic_gain_entity.update_get_value(lambda: float(self.state.mic_auto_gain))
|
||||
self.state.mic_gain_entity.update_set_value(lambda val: self.state.persist_mic_gain(float(val))) # type: ignore[arg-type]
|
||||
self.state.mic_gain_entity.sync_with_state()
|
||||
|
||||
# Mic Noise Suppression
|
||||
_NOISE_OPTIONS = ["Off", "Low", "Medium", "High", "Max"]
|
||||
_NOISE_TO_INT = {label: i for i, label in enumerate(_NOISE_OPTIONS)}
|
||||
|
||||
def _get_noise_label() -> str:
|
||||
return _NOISE_OPTIONS[max(0, min(4, self.state.mic_noise_suppression))]
|
||||
|
||||
def _set_noise_label(label: Union[float, str]) -> None:
|
||||
self.state.persist_mic_noise(float(_NOISE_TO_INT.get(str(label), 0)))
|
||||
|
||||
if self.state.mic_noise_suppression_entity is None:
|
||||
self.state.mic_noise_suppression_entity = MicSettingEntity(
|
||||
server=self,
|
||||
key=len(self.state.entities),
|
||||
name="Mic Noise Suppression",
|
||||
object_id="mic_noise",
|
||||
options=_NOISE_OPTIONS,
|
||||
get_value=_get_noise_label,
|
||||
set_value=_set_noise_label,
|
||||
icon="mdi:waveform",
|
||||
)
|
||||
self.state.entities.append(self.state.mic_noise_suppression_entity)
|
||||
elif self.state.mic_noise_suppression_entity not in self.state.entities:
|
||||
self.state.entities.append(self.state.mic_noise_suppression_entity)
|
||||
|
||||
self.state.mic_noise_suppression_entity.server = self
|
||||
self.state.mic_noise_suppression_entity.update_get_value(_get_noise_label)
|
||||
self.state.mic_noise_suppression_entity.update_set_value(_set_noise_label)
|
||||
self.state.mic_noise_suppression_entity.sync_with_state()
|
||||
|
||||
# Mic Volume
|
||||
if self.state.mic_volume_entity is None:
|
||||
self.state.mic_volume_entity = MicSettingEntity(
|
||||
server=self,
|
||||
key=len(self.state.entities),
|
||||
name="Mic Volume",
|
||||
object_id="mic_volume",
|
||||
min_value=1.0,
|
||||
max_value=100.0,
|
||||
get_value=lambda: float(self.state.mic_volume),
|
||||
set_value=lambda val: self.state.persist_mic_volume(float(val)),
|
||||
icon="mdi:microphone-settings",
|
||||
)
|
||||
self.state.entities.append(self.state.mic_volume_entity)
|
||||
elif self.state.mic_volume_entity not in self.state.entities:
|
||||
self.state.entities.append(self.state.mic_volume_entity)
|
||||
|
||||
self.state.mic_volume_entity.server = self
|
||||
self.state.mic_volume_entity.update_get_value(lambda: float(self.state.mic_volume))
|
||||
self.state.mic_volume_entity.update_set_value(lambda val: self.state.persist_mic_volume(float(val)))
|
||||
|
||||
# ---- Instance variables ----
|
||||
|
||||
self._is_streaming_audio = False
|
||||
self._tts_url: Optional[str] = None
|
||||
self._tts_played = False
|
||||
self._continue_conversation = False
|
||||
self._timer_finished = False
|
||||
self._timer_ring_start: Optional[float] = None
|
||||
self._processing = False
|
||||
self._pipeline_active = False
|
||||
self._external_wake_words: Dict[str, VoiceAssistantExternalWakeWord] = {}
|
||||
@@ -168,6 +336,33 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
pass
|
||||
self.state.save_preferences()
|
||||
|
||||
def _set_sensitivity_1(self, new_value: float) -> None:
|
||||
self.state.wake_word_1_threshold = float(new_value)
|
||||
self.state.preferences.wake_word_1_sensitivity = float(new_value)
|
||||
self.state.save_preferences()
|
||||
_LOGGER.debug("Wake Word 1 Sensitivity value set to: %s", new_value)
|
||||
# Sync entity state
|
||||
if self.state.sensitivity_1_number_entity is not None:
|
||||
self.state.sensitivity_1_number_entity.sync_with_state()
|
||||
|
||||
def _set_sensitivity_2(self, new_value: float) -> None:
|
||||
self.state.wake_word_2_threshold = float(new_value)
|
||||
self.state.preferences.wake_word_2_sensitivity = float(new_value)
|
||||
self.state.save_preferences()
|
||||
_LOGGER.debug("Wake Word 2 Sensitivity value set to: %s", new_value)
|
||||
# Sync entity state
|
||||
if self.state.sensitivity_2_number_entity is not None:
|
||||
self.state.sensitivity_2_number_entity.sync_with_state()
|
||||
|
||||
def _set_stop_sensitivity(self, new_value: float) -> None:
|
||||
self.state.stop_word_threshold = float(new_value)
|
||||
self.state.preferences.stop_word_sensitivity = float(new_value)
|
||||
self.state.save_preferences()
|
||||
_LOGGER.debug("Stop Word Sensitivity value set to: %s", new_value)
|
||||
# Sync entity state
|
||||
if self.state.stop_sensitivity_number_entity is not None:
|
||||
self.state.stop_sensitivity_number_entity.sync_with_state()
|
||||
|
||||
def _set_muted(self, new_state: bool) -> None:
|
||||
self.state.muted = bool(new_state)
|
||||
|
||||
@@ -220,9 +415,12 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
self.play_tts()
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END:
|
||||
self._is_streaming_audio = False
|
||||
self._pipeline_active = False
|
||||
if not self._tts_played:
|
||||
self._pipeline_active = False
|
||||
self._tts_finished()
|
||||
# When TTS is playing, keep _pipeline_active = True to block
|
||||
# false wake word detections from speaker audio feedback.
|
||||
# _tts_finished() callback will clear it when playback ends.
|
||||
|
||||
self._tts_played = False
|
||||
|
||||
@@ -238,6 +436,7 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
if not self._timer_finished:
|
||||
self.state.active_wake_words.add(self.state.stop_word.id)
|
||||
self._timer_finished = True
|
||||
self._timer_ring_start = time.monotonic()
|
||||
self.duck()
|
||||
self._play_timer_finished()
|
||||
|
||||
@@ -281,18 +480,11 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
mac_address=self.state.mac_address,
|
||||
manufacturer="Open Home Foundation",
|
||||
model="Linux Voice Assistant",
|
||||
voice_assistant_feature_flags=(
|
||||
VoiceAssistantFeature.VOICE_ASSISTANT | VoiceAssistantFeature.API_AUDIO | VoiceAssistantFeature.ANNOUNCE | VoiceAssistantFeature.START_CONVERSATION | VoiceAssistantFeature.TIMERS
|
||||
),
|
||||
voice_assistant_feature_flags=self.supported_features,
|
||||
)
|
||||
elif isinstance(
|
||||
msg,
|
||||
(
|
||||
ListEntitiesRequest,
|
||||
SubscribeHomeAssistantStatesRequest,
|
||||
MediaPlayerCommandRequest,
|
||||
SwitchCommandRequest,
|
||||
),
|
||||
(ListEntitiesRequest, SubscribeHomeAssistantStatesRequest, MediaPlayerCommandRequest, SwitchCommandRequest, NumberCommandRequest, SelectCommandRequest),
|
||||
):
|
||||
for entity in self.state.entities:
|
||||
yield from entity.handle_message(msg)
|
||||
@@ -300,6 +492,9 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
if isinstance(msg, ListEntitiesRequest):
|
||||
yield ListEntitiesDoneResponse()
|
||||
elif isinstance(msg, VoiceAssistantConfigurationRequest):
|
||||
_LOGGER.debug("✅ Received VoiceAssistantConfigurationRequest from Home Assistant")
|
||||
_LOGGER.debug(" -> Request contains %d external wake words", len(msg.external_wake_words))
|
||||
|
||||
available_wake_words = [
|
||||
VoiceAssistantWakeWord(
|
||||
id=ww.id,
|
||||
@@ -309,10 +504,20 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
for ww in self.state.available_wake_words.values()
|
||||
]
|
||||
|
||||
# Log available internal wake words first
|
||||
internal_ww_count = len(self.state.available_wake_words)
|
||||
_LOGGER.debug(" -> Found %d internal available wake words", internal_ww_count)
|
||||
for ww in available_wake_words:
|
||||
_LOGGER.debug(" - %s: '%s' (langs: %s)", ww.id, ww.wake_word, ww.trained_languages)
|
||||
|
||||
for eww in msg.external_wake_words:
|
||||
_LOGGER.debug(" -> Processing external wake word: id=%s, word='%s', type=%s", eww.id, eww.wake_word, eww.model_type)
|
||||
|
||||
if eww.model_type != "micro":
|
||||
_LOGGER.debug(" → Skipping: not micro model type")
|
||||
continue
|
||||
|
||||
_LOGGER.debug(" → Adding to available wake words")
|
||||
available_wake_words.append(
|
||||
VoiceAssistantWakeWord(
|
||||
id=eww.id,
|
||||
@@ -322,47 +527,83 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
)
|
||||
|
||||
self._external_wake_words[eww.id] = eww
|
||||
_LOGGER.debug(" → Stored in external wake words cache")
|
||||
|
||||
active_ww_ids = [ww.id for ww in self.state.wake_words.values() if ww.id in self.state.active_wake_words]
|
||||
_LOGGER.debug(" -> Active wake word IDs: %s", active_ww_ids)
|
||||
|
||||
yield VoiceAssistantConfigurationResponse(
|
||||
available_wake_words=available_wake_words,
|
||||
active_wake_words=[ww.id for ww in self.state.wake_words.values() if ww.id in self.state.active_wake_words],
|
||||
active_wake_words=active_ww_ids,
|
||||
max_active_wake_words=2,
|
||||
)
|
||||
_LOGGER.info("Connected to Home Assistant")
|
||||
|
||||
_LOGGER.info("✅ Connected to Home Assistant - Configuration handshake completed")
|
||||
_LOGGER.debug("✅ VoiceAssistantConfigurationResponse sent successfully")
|
||||
elif isinstance(msg, VoiceAssistantSetConfiguration):
|
||||
# Change active wake words
|
||||
active_wake_words: Set[str] = set()
|
||||
new_wake_words: List[Optional[str]] = [None, None]
|
||||
|
||||
# Get old positions before modification
|
||||
old_positions: Dict[str, int] = {}
|
||||
for idx, ww_id in enumerate(self.state.preferences.active_wake_words):
|
||||
if ww_id is not None and idx < 2:
|
||||
old_positions[ww_id] = idx
|
||||
|
||||
# Process new active wake words
|
||||
for wake_word_id in msg.active_wake_words:
|
||||
if wake_word_id in self.state.wake_words:
|
||||
# Already active
|
||||
active_wake_words.add(wake_word_id)
|
||||
continue
|
||||
|
||||
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
|
||||
|
||||
model_info = self._download_external_wake_word(external_wake_word)
|
||||
else:
|
||||
model_info = self.state.available_wake_words.get(wake_word_id)
|
||||
if not model_info:
|
||||
continue
|
||||
# Check external wake words (may require download)
|
||||
external_wake_word = self._external_wake_words.get(wake_word_id)
|
||||
if not external_wake_word:
|
||||
continue
|
||||
|
||||
self.state.available_wake_words[wake_word_id] = model_info
|
||||
model_info = self._download_external_wake_word(external_wake_word)
|
||||
if not model_info:
|
||||
continue
|
||||
|
||||
_LOGGER.debug("Loading wake word: %s", model_info.wake_word_path)
|
||||
self.state.wake_words[wake_word_id] = model_info.load()
|
||||
self.state.available_wake_words[wake_word_id] = model_info
|
||||
|
||||
_LOGGER.info("Wake word set: %s", wake_word_id)
|
||||
active_wake_words.add(wake_word_id)
|
||||
break
|
||||
_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)
|
||||
|
||||
# Keep old positions
|
||||
remaining_ww = list(active_wake_words)
|
||||
placed = set()
|
||||
|
||||
# First, place Wake Words in their old positions.
|
||||
for ww_id in remaining_ww:
|
||||
if ww_id in old_positions:
|
||||
pos = old_positions[ww_id]
|
||||
if pos < 2:
|
||||
new_wake_words[pos] = ww_id
|
||||
placed.add(ww_id)
|
||||
|
||||
# Add remaining wake words to free slots
|
||||
free_slots = [i for i in range(2) if new_wake_words[i] is None]
|
||||
for ww_id in remaining_ww:
|
||||
if ww_id not in placed and free_slots:
|
||||
pos = free_slots.pop(0)
|
||||
new_wake_words[pos] = ww_id
|
||||
placed.add(ww_id)
|
||||
|
||||
# If only one wake word is left and it was at position 1, position 0 remains None
|
||||
# Position 2 automatically stays None if not occupied
|
||||
|
||||
self.state.active_wake_words = active_wake_words
|
||||
_LOGGER.debug("Active wake words: %s", active_wake_words)
|
||||
_LOGGER.debug("Wake word positions: [0]=%s, [1]=%s", new_wake_words[0], new_wake_words[1])
|
||||
|
||||
self.state.preferences.active_wake_words = list(active_wake_words)
|
||||
self.state.preferences.active_wake_words = new_wake_words
|
||||
self.state.save_preferences()
|
||||
self.state.wake_words_changed = True
|
||||
|
||||
@@ -375,11 +616,30 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
|
||||
def wakeup(self, wake_word: Union[MicroWakeWord, OpenWakeWord]) -> None:
|
||||
if self._timer_finished:
|
||||
# Stop timer instead
|
||||
# Stop the ringing timer, then start a normal wake-up after a short
|
||||
# delay so the transition doesn't feel abrupt.
|
||||
self._timer_finished = False
|
||||
self._timer_ring_start = None
|
||||
self.state.active_wake_words.discard(self.state.stop_word.id)
|
||||
self.unduck()
|
||||
self.state.tts_player.stop()
|
||||
_LOGGER.debug("Stopping timer finished sound")
|
||||
_LOGGER.debug("Stopping timer finished sound; will wake up in 1 s")
|
||||
|
||||
wake_word_phrase = wake_word.wake_word # type: ignore
|
||||
|
||||
def _delayed_wakeup() -> None:
|
||||
if self.state.muted or self._pipeline_active:
|
||||
_LOGGER.debug("Delayed wakeup skipped (muted=%s, pipeline_active=%s)", self.state.muted, self._pipeline_active)
|
||||
return
|
||||
_LOGGER.debug("Delayed wakeup: playing wakeup sound for %s", wake_word_phrase)
|
||||
self._pipeline_active = True
|
||||
self.duck()
|
||||
self.state.tts_player.play(
|
||||
self.state.wakeup_sound,
|
||||
done_callback=lambda: self._on_wakeup_sound_finished(wake_word_phrase),
|
||||
)
|
||||
|
||||
threading.Timer(0.1, _delayed_wakeup).start()
|
||||
return
|
||||
|
||||
if self.state.muted:
|
||||
@@ -413,13 +673,15 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
|
||||
if self._timer_finished:
|
||||
self._timer_finished = False
|
||||
self._timer_ring_start = None
|
||||
self.unduck()
|
||||
self.state.tts_player.stop()
|
||||
_LOGGER.debug("Stopping timer finished sound")
|
||||
else:
|
||||
# tts_player.stop() invokes the done_callback (_tts_finished),
|
||||
# so we don't call _tts_finished() again explicitly.
|
||||
self.state.tts_player.stop()
|
||||
_LOGGER.debug("TTS response stopped manually")
|
||||
self._tts_finished()
|
||||
|
||||
def play_tts(self) -> None:
|
||||
if (not self._tts_url) or self._tts_played:
|
||||
@@ -458,8 +720,24 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
if not self._timer_finished:
|
||||
_LOGGER.debug("Timer finished sound stopped")
|
||||
self.unduck()
|
||||
self._timer_ring_start = None
|
||||
return
|
||||
|
||||
# Auto-stop after timer_max_ring_seconds
|
||||
if self._timer_ring_start is not None:
|
||||
elapsed = time.monotonic() - self._timer_ring_start
|
||||
if elapsed >= self.state.timer_max_ring_seconds:
|
||||
_LOGGER.info(
|
||||
"Timer auto-stopped after %.0f seconds (max=%.0f)",
|
||||
elapsed,
|
||||
self.state.timer_max_ring_seconds,
|
||||
)
|
||||
self._timer_finished = False
|
||||
self._timer_ring_start = None
|
||||
self.state.active_wake_words.discard(self.state.stop_word.id)
|
||||
self.unduck()
|
||||
return
|
||||
|
||||
self.state.tts_player.play(
|
||||
self.state.timer_finished_sound,
|
||||
done_callback=lambda: call_all(
|
||||
@@ -498,6 +776,15 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
if self.state.mute_switch_entity is not None:
|
||||
self.state.mute_switch_entity.sync_with_state()
|
||||
|
||||
if self.state.mic_gain_entity is not None:
|
||||
self.state.mic_gain_entity.sync_with_state()
|
||||
|
||||
if self.state.mic_noise_suppression_entity is not None:
|
||||
self.state.mic_noise_suppression_entity.sync_with_state()
|
||||
|
||||
if self.state.mic_volume_entity is not None:
|
||||
self.state.mic_volume_entity.sync_with_state()
|
||||
|
||||
_LOGGER.info("Disconnected from Home Assistant; waiting for reconnection")
|
||||
|
||||
def process_packet(self, msg_type: int, packet_data: bytes) -> None:
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
|
||||
from pymicro_wakeword import MicroWakeWord
|
||||
from pyopen_wakeword import OpenWakeWord
|
||||
|
||||
from .models import AvailableWakeWord, WakeWordType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def find_available_wake_words(wake_word_dirs: List[Path], stop_model_id: str) -> Dict[str, AvailableWakeWord]:
|
||||
"""
|
||||
Searches all available wake words in the specified directories.
|
||||
Loads configurations and creates AvailableWakeWord objects.
|
||||
|
||||
Args:
|
||||
wake_word_dirs: List of directories to search for wake words
|
||||
stop_model_id: ID of the stop model which will not be listed as available wake word
|
||||
|
||||
Returns:
|
||||
Dictionary with wake word ID as key and AvailableWakeWord object as value
|
||||
"""
|
||||
available_wake_words: Dict[str, AvailableWakeWord] = {}
|
||||
|
||||
_LOGGER.debug("Searching for wake words in directories: %s", [str(d) for d in wake_word_dirs])
|
||||
|
||||
for wake_word_dir in wake_word_dirs:
|
||||
_LOGGER.debug("Checking directory: %s (exists: %s)", wake_word_dir, wake_word_dir.exists())
|
||||
|
||||
config_files = list(wake_word_dir.glob("*.json"))
|
||||
_LOGGER.debug("Found %d JSON configuration files in %s", len(config_files), wake_word_dir)
|
||||
|
||||
for model_config_path in config_files:
|
||||
_LOGGER.debug("Processing configuration file: %s", model_config_path)
|
||||
|
||||
model_id = model_config_path.stem
|
||||
if model_id == stop_model_id:
|
||||
# Skip stop model, do not show as available wake word
|
||||
_LOGGER.debug("Skipping stop model: %s", model_id)
|
||||
continue
|
||||
|
||||
with open(model_config_path, "r", encoding="utf-8") as model_config_file:
|
||||
model_config = json.load(model_config_file)
|
||||
model_type = WakeWordType(model_config["type"])
|
||||
|
||||
_LOGGER.debug("Model %s is of type: %s", model_id, model_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
|
||||
|
||||
_LOGGER.debug("Model path resolved to: %s (exists: %s)", wake_word_path, wake_word_path.exists())
|
||||
|
||||
# Get type specific configuration
|
||||
type_config = model_config.get(model_type.value, {})
|
||||
|
||||
_LOGGER.debug("Type specific config for %s (%s): %s", model_id, model_type.value, type_config)
|
||||
|
||||
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", []),
|
||||
wake_word_path=wake_word_path,
|
||||
probability_cutoff=type_config.get("probability_cutoff", 0.7),
|
||||
)
|
||||
_LOGGER.debug("Successfully registered wake word: %s", model_id)
|
||||
|
||||
_LOGGER.debug("Total available wake words found: %d", len(available_wake_words))
|
||||
_LOGGER.debug("Available wake words: %s", list(sorted(available_wake_words.keys())))
|
||||
return available_wake_words
|
||||
|
||||
|
||||
def load_wake_models(
|
||||
available_wake_words: Dict[str, AvailableWakeWord], active_wake_word_ids: Optional[List[str]], default_wake_word_id: str
|
||||
) -> tuple[Dict[str, Union[MicroWakeWord, OpenWakeWord]], Set[str], bool]:
|
||||
"""
|
||||
Loads the specified wake word models.
|
||||
|
||||
If no active wake words are provided, the default model will be loaded.
|
||||
|
||||
Args:
|
||||
available_wake_words: Dictionary with all available wake words
|
||||
active_wake_word_ids: List of IDs of wake words to load (may be None)
|
||||
default_wake_word_id: ID of the default model which is loaded if no others are specified
|
||||
|
||||
Returns:
|
||||
Tuple with (Dictionary of loaded models, Set of active wake word IDs)
|
||||
"""
|
||||
active_wake_words: Set[str] = set()
|
||||
wake_models: Dict[str, Union[MicroWakeWord, OpenWakeWord]] = {}
|
||||
|
||||
_LOGGER.debug("Requested active wake word ids: %s", active_wake_word_ids)
|
||||
_LOGGER.debug("Default wake word id: %s", default_wake_word_id)
|
||||
|
||||
if active_wake_word_ids:
|
||||
# Load preferred models
|
||||
_LOGGER.debug("Loading requested wake word models, count: %d", len(active_wake_word_ids))
|
||||
for index, wake_word_id in enumerate(active_wake_word_ids):
|
||||
_LOGGER.debug("Processing wake word %d/%d: %s", index + 1, len(active_wake_word_ids), wake_word_id)
|
||||
wake_word = available_wake_words.get(wake_word_id)
|
||||
if wake_word is None:
|
||||
_LOGGER.warning("Unknown wake word ID: %s - skipping", wake_word_id)
|
||||
continue
|
||||
|
||||
_LOGGER.debug("Loading wake model: %s (%s)", wake_word_id, wake_word.wake_word)
|
||||
try:
|
||||
wake_models[wake_word_id] = wake_word.load()
|
||||
active_wake_words.add(wake_word_id)
|
||||
_LOGGER.debug("✅ Successfully loaded wake model: %s", wake_word_id)
|
||||
except Exception as ex:
|
||||
_LOGGER.error("❌ Failed to load wake model %s: %s", wake_word_id, ex, exc_info=True)
|
||||
|
||||
if not wake_models:
|
||||
# No models loaded, fall back to default model
|
||||
_LOGGER.debug("No wake models loaded, falling back to default model")
|
||||
wake_word_id = default_wake_word_id
|
||||
|
||||
# Check if default wake word exists
|
||||
wake_word = available_wake_words.get(wake_word_id)
|
||||
if wake_word is None:
|
||||
_LOGGER.error("❌ Default wake word '%s' not found!", wake_word_id)
|
||||
|
||||
# Try fallback to 'okay_nabu'
|
||||
wake_word_id = "okay_nabu"
|
||||
wake_word = available_wake_words.get(wake_word_id)
|
||||
|
||||
if wake_word is None:
|
||||
_LOGGER.error("❌ Fallback wake word 'okay_nabu' also not found!")
|
||||
|
||||
# If absolutely nothing works, take first available wake word
|
||||
if available_wake_words:
|
||||
wake_word_id = next(iter(available_wake_words.keys()))
|
||||
wake_word = available_wake_words[wake_word_id]
|
||||
_LOGGER.warning("⚠️ Using first available wake word as last resort: %s", wake_word_id)
|
||||
else:
|
||||
_LOGGER.critical("❌ NO WAKE WORDS FOUND AT ALL! Cannot proceed.")
|
||||
raise RuntimeError("No wake word models available in any search directory")
|
||||
|
||||
# wake_word_id2 = "hey_home_assistant"
|
||||
# wake_word2 = available_wake_words.get(wake_word_id2)
|
||||
|
||||
_LOGGER.debug("Loading default wake model 1: %s", wake_word_id)
|
||||
# _LOGGER.debug("Loading default wake model 2: %s", wake_word_id2)
|
||||
try:
|
||||
wake_models[wake_word_id] = wake_word.load()
|
||||
# if wake_word2 is not None:
|
||||
# wake_models[wake_word_id2] = wake_word2.load()
|
||||
# active_wake_words.add(wake_word_id2)
|
||||
|
||||
active_wake_words.add(wake_word_id)
|
||||
_LOGGER.debug("✅ Successfully loaded default wake model 1: %s", wake_word_id)
|
||||
# _LOGGER.debug("✅ Successfully loaded default wake model 2: %s", wake_word_id2)
|
||||
except Exception as ex:
|
||||
_LOGGER.critical("❌ Failed to load even fallback wake word %s: %s", wake_word_id, ex, exc_info=True)
|
||||
raise
|
||||
|
||||
_LOGGER.debug("Loaded %d wake models successfully", len(wake_models))
|
||||
_LOGGER.debug("Active wake words: %s", sorted(active_wake_words))
|
||||
|
||||
fallback_used = not active_wake_word_ids or not wake_models
|
||||
|
||||
return wake_models, active_wake_words, fallback_used
|
||||
|
||||
|
||||
def load_stop_model(wake_word_dirs: List[Path], stop_model_id: str) -> Optional[MicroWakeWord]:
|
||||
"""
|
||||
Loads the stop word model.
|
||||
|
||||
Args:
|
||||
wake_word_dirs: List of directories to search for the stop model
|
||||
stop_model_id: ID of the stop model
|
||||
|
||||
Returns:
|
||||
Loaded MicroWakeWord object or None if not found
|
||||
"""
|
||||
_LOGGER.debug("Searching for stop model '%s' in directories: %s", stop_model_id, [str(d) for d in wake_word_dirs])
|
||||
|
||||
for wake_word_dir in wake_word_dirs:
|
||||
stop_config_path = wake_word_dir / f"{stop_model_id}.json"
|
||||
_LOGGER.debug("Checking stop model path: %s (exists: %s)", stop_config_path, stop_config_path.exists())
|
||||
|
||||
if not stop_config_path.exists():
|
||||
continue
|
||||
|
||||
_LOGGER.debug("Found stop model configuration at: %s", stop_config_path)
|
||||
_LOGGER.debug("Loading stop model: %s", stop_config_path)
|
||||
|
||||
try:
|
||||
model = MicroWakeWord.from_config(stop_config_path)
|
||||
_LOGGER.debug("Successfully loaded stop model")
|
||||
return model
|
||||
except Exception as ex:
|
||||
_LOGGER.error("Failed to load stop model from %s: %s", stop_config_path, ex, exc_info=True)
|
||||
|
||||
_LOGGER.warning("Stop model '%s' could not be found in any search directory", stop_model_id)
|
||||
return None
|
||||
@@ -0,0 +1,41 @@
|
||||
import logging
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebRTCProcessor:
|
||||
def __init__(self, agc_level: int = 0, ns_level: int = 0):
|
||||
from webrtc_noise_gain import AudioProcessor # type: ignore[import-untyped]
|
||||
|
||||
self.apm = AudioProcessor(agc_level, ns_level)
|
||||
self.agc_level = agc_level
|
||||
self.ns_level = ns_level
|
||||
self._buffer = bytearray()
|
||||
self.FRAME_SIZE_BYTES = 320 # 160 samples * 2 bytes (16-bit PCM)
|
||||
|
||||
def update_settings(self, agc_level: int, ns_level: int):
|
||||
"""Re-initialize processor if settings changed."""
|
||||
if self.agc_level != agc_level or self.ns_level != ns_level:
|
||||
from webrtc_noise_gain import AudioProcessor
|
||||
|
||||
_LOGGER.debug("Updating WebRTC settings: Gain=%s, NS=%s", agc_level, ns_level)
|
||||
self.apm = AudioProcessor(agc_level, ns_level)
|
||||
self.agc_level = agc_level
|
||||
self.ns_level = ns_level
|
||||
|
||||
def process(self, raw_bytes: bytes) -> bytes:
|
||||
"""
|
||||
Buffer and process audio.
|
||||
Returns processed bytes (may be shorter than input if buffering).
|
||||
"""
|
||||
self._buffer.extend(raw_bytes)
|
||||
processed_chunks: list[bytes] = []
|
||||
|
||||
while len(self._buffer) >= self.FRAME_SIZE_BYTES:
|
||||
frame = bytes(self._buffer[: self.FRAME_SIZE_BYTES])
|
||||
del self._buffer[: self.FRAME_SIZE_BYTES] # drain in-place
|
||||
|
||||
result = self.apm.Process10ms(frame)
|
||||
processed_chunks.append(result.audio)
|
||||
|
||||
return b"".join(processed_chunks)
|
||||
@@ -9,3 +9,8 @@ ignore_missing_imports = True
|
||||
|
||||
[mypy-soundcard.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-aioesphomeapi.api_pb2]
|
||||
ignore_missing_imports = True
|
||||
allow_subclassing_any = True
|
||||
disable_error_code = attr-defined
|
||||
+7
-3
@@ -1,10 +1,9 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=62.3"]
|
||||
requires = ["setuptools>=62.3", "setuptools-scm>=8"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "linux-voice-assistant"
|
||||
version = "1.0.0"
|
||||
license = {text = "Apache-2.0"}
|
||||
description = "Linux voice assistant for Home Assistant using the ESPHome protocol"
|
||||
readme = "README.md"
|
||||
@@ -33,7 +32,9 @@ dependencies = [
|
||||
"zeroconf<1",
|
||||
"getmac<1",
|
||||
"types-protobuf",
|
||||
"webrtc_noise_gain",
|
||||
]
|
||||
dynamic = ["version"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
@@ -46,6 +47,9 @@ dev = [
|
||||
"autopep8",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
linux-voice-assistant = "linux_voice_assistant.__main__:run"
|
||||
|
||||
[project.urls]
|
||||
"Source Code" = "http://github.com/OHF-Voice/linux-voice-assistant"
|
||||
|
||||
@@ -55,7 +59,7 @@ zip-safe = true
|
||||
include-package-data = true
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["linux_voice_assistant"]
|
||||
include = ["linux_voice_assistant", "linux_voice_assistant.*"]
|
||||
exclude = ["tests", "tests.*"]
|
||||
|
||||
[tool.black]
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"type": "openWakeWord",
|
||||
"wake_word": "Alexa",
|
||||
"model": "alexa_v0.1.tflite"
|
||||
"model": "alexa_v0.1.tflite",
|
||||
"openWakeWord": {
|
||||
"probability_cutoff": 0.7
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"type": "openWakeWord",
|
||||
"wake_word": "Hey Jarvis",
|
||||
"model": "hey_jarvis_v0.1.tflite"
|
||||
"model": "hey_jarvis_v0.1.tflite",
|
||||
"openWakeWord": {
|
||||
"probability_cutoff": 0.7
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"type": "openWakeWord",
|
||||
"wake_word": "Hey Mycroft",
|
||||
"model": "hey_mycroft_v0.1.tflite"
|
||||
"model": "hey_mycroft_v0.1.tflite",
|
||||
"openWakeWord": {
|
||||
"probability_cutoff": 0.7
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"type": "openWakeWord",
|
||||
"wake_word": "Hey Rhasspy",
|
||||
"model": "hey_rhasspy_v0.1.tflite"
|
||||
"model": "hey_rhasspy_v0.1.tflite",
|
||||
"openWakeWord": {
|
||||
"probability_cutoff": 0.7
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"type": "openWakeWord",
|
||||
"wake_word": "Okay Nabu",
|
||||
"model": "ok_nabu_v0.1.tflite"
|
||||
"wake_word": "Ok Nabu",
|
||||
"model": "ok_nabu_v0.1.tflite",
|
||||
"openWakeWord": {
|
||||
"probability_cutoff": 0.7
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user