Compare commits

..
66 Commits
Author SHA1 Message Date
Omar Amin 1d84fa0898 Add delay to continue conversation before listening (#342)
Add a configurable amount of delay (defaulting to 0.5s) after the TTS playback is finished to avoid the mic to capture the end of response during start/continue conversation.

Fixes #313
2026-06-19 22:47:01 +03:00
Omar Amin eb29a00386 Enable dual audio input channel (#334)
This PR adds the ability to choose the microphone stream that works best with a particular STT engine using the new dual audio input channel feature.
2026-06-19 02:46:02 +03:00
Omar Amin 06f7256ebf Upgrade to aiosesphomeapi 45.3.1 2026-06-19 02:29:54 +03:00
Hedda 17e3ee061b Revise Assist Satellite installation instructions (#338)
Updated installation instructions for Assist Satellite app, including manual repository addition and future installation plans.
2026-06-13 02:36:31 +03:00
florian-asche fc2780d92e Update changelog for v1.1.11
This commit was created by changelog-from-release in 'Generate changelog' CI workflow
2026-06-11 23:29:31 +00:00
Omar Amin 32f6de6222 Enhance wake word documentation (#336)
Added more detailed section for both MicoWakeWord&OpenWakeWord.
2026-06-09 01:29:42 +03:00
aryanhasgithub ec70b3b58d Add volume arg to parser (#335)
This PR adds the mic volume to the arg parser and makes the value range from 1-100
2026-06-07 18:15:05 +03:00
Omar Amin b339f76b7d Update formatting of requirements in README
Formatted requirements section for better readability.
2026-06-04 22:48:35 +03:00
Omar Amin 4ba509ab41 Revise README for clarity and hardware compatibility
Updated project description to clarify functionality and improved hardware compatibility notes.
2026-06-04 22:39:24 +03:00
Omar Amin 34edd1e67d App for HAOS (#325)
Added the app link for Home Assistant OS to the documentation.
2026-06-04 22:37:14 +03:00
Omar Amin 957cd205ef Add hey_morgan wakeword 2026-06-01 01:32:04 +03:00
Omar Amin 2a4a08a55b Add webrtc library links in audio options documentation
Updated documentation to include links to the webrtc library for gain and noise suppression features.
2026-05-28 18:24:58 +03:00
Florian Asche 19c4c2337f docs(readme): restructure requirements and hardware section (#321)
Refactor the README to improve clarity by replacing the "Usage" section with a dedicated "Requirements" section. This includes:
- Adding specific hardware requirements (Microphone, CPU, Memory, Storage, OS).
- Updating recommended hardware configurations for better readability.
- Clarifying compatibility notes regarding Raspberry Pi Zero 2W and Respeaker Lite.
2026-05-14 19:34:47 +02:00
Omar Amin b1e36b0cc3 Update hardware compatibility details in README
Added requirement for 16kHz mono audio support for USB microphones to improve voice recognition performance.
2026-05-07 17:35:29 +03:00
florian-asche 0e9f149cc2 Update changelog for v1.1.10
This commit was created by changelog-from-release in 'Generate changelog' CI workflow
2026-04-23 22:36:21 +00:00
Florian Asche 73b494275d docs(readme): add hyperlink for raspberry pi image 2026-04-22 17:47:59 +02:00
Florian Asche 2dd27e27b1 docs(readme): update tested supported python versions 2026-04-22 17:45:38 +02:00
aryanhasgithub f1345e95cf Downgrade to Python 3.12 (#305) 2026-04-22 17:35:54 +02:00
Florian Asche 980ceeef2c fix(docker): update default env file from dev.env to .env 2026-04-22 17:27:40 +02:00
Florian Asche 32c9872f78 fix(docker): standardize env file config for dev compose
Use dev.env as default environment file for development stack,
add ENV_FILE override support to both defined services.
2026-04-22 17:24:18 +02:00
Florian Asche 1ea79aa8bb feat(docker): add custom image and tag env configuration (#306)
- Replace hardcoded image values with overrideable env variables
- Standardize version env var from LVA_VERSION to LVA_IMAGE_TAG
- Add new env options documentation to .env.example
- Add dev.env to gitignore
- Disable default mic noise suppression in dev compose

BREAKING CHANGE: LVA_VERSION env var replaced with LVA_IMAGE_TAG
2026-04-22 17:22:55 +02:00
Daniel Nowak 730f706655 Allow option to have docker use nightly build (#302) 2026-04-22 17:11:30 +02:00
AlecJDavidson 9192d8423b Update README.md (#303)
Fix typo in README.md
2026-04-22 00:33:56 +02:00
Omar Amin 3ea32895ee Wake word sensitivity (#207)
## Summary
- Introduces configurable wake word and stop word sensitivity
- Adds UI-controllable thresholds for wake word detection
- Refactors wake word loading and handling logic
- Improves audio preprocessing and runtime behavior

## Key Features
- Per-wake-word sensitivity configuration (Wake Word 1 & 2)
- Configurable stop word sensitivity
- Fine-grained numeric control (0.0 – 1.0, step 0.001)
- Sensitivity values exposed as Number Entities for Home Assistant
- Persistent storage of user-defined sensitivity values

## Wake Word Handling
- Support for model-specific default thresholds (`probability_cutoff`)
- Automatic fallback to defaults if no user value is set
- Separate thresholds for:
  - Wake Word 1
  - Wake Word 2
  - Stop Word
- Unified handling for MicroWakeWord and OpenWakeWord models

## UI / API Integration
- Adds new Home Assistant entities:
  - `WakeWord1SensitivityNumberEntity`
  - `WakeWord2SensitivityNumberEntity`
  - `StopWordSensitivityNumberEntity`
- Entities support:
  - Live updates
  - Persistence via preferences
  - Synchronization with runtime state
- Pushes updated values to Home Assistant on startup

## Refactoring
- Extracted wake word logic into `wake_word.py`
  - Model discovery
  - Model loading
  - Stop word handling
- Simplified `__main__.py` by delegating wake word responsibilities
- Improved separation between configuration and runtime state

## Additional Changes
- Improved debug logging across initialization and runtime

## Impact
- Enables fine-tuning of wake word detection accuracy
- Reduces false positives and missed triggers
- Improves usability via UI-based configuration
- Provides better flexibility across different environments and microphones
2026-04-21 02:04:11 +02:00
Omar Amin 0de631160e Fix formatting issues in README.md 2026-04-21 00:46:01 +02:00
genericJE 48d9868d1d docs(install): clarify linger is required on headless systems (#299)
* docs(install): clarify linger is required on headless systems
2026-04-20 03:02:36 +02:00
Omar Amin 4c9d92f499 Fix audio cuts and playback issues (#296)
Increase the audio-buffer seconds to initialise the sink stream.
2026-04-17 18:43:32 +02:00
Omar Amin a21d217d45 Fix duplicate MIC_VOLUME entry in install_application.md
Removed duplicate MIC_VOLUME entry from documentation.
2026-04-17 17:40:02 +02:00
Florian Asche d2c2e6fbb9 docs(install): add missing MIC_VOLUME entry in env config table 2026-04-17 15:39:03 +02:00
Omar Amin 0722c40f67 Fix typo in Environment variable for MIC_VOLUME 2026-04-17 02:32:01 +02:00
Omar Amin 72311bf58a Fix spelling and document MIC_VOLUME in installation guide
Corrected spelling of 'Environment' and added documentation for MIC_VOLUME.
2026-04-17 02:30:07 +02:00
Omar Amin 995da72569 Add mic-volume option to README 2026-04-16 23:52:38 +02:00
Omar Amin 79dde095b0 Add mic volume argument to entrypoint script 2026-04-16 23:50:52 +02:00
Omar Amin c284806b73 Add the ability to control microphone volume (#287)
Control microphone volume via UI in device page. Set to the maximum by default and saved in preferences to restore upon restarts.
2026-04-10 01:33:34 +02:00
aryanhasgithub 4ee7bb04bc Add support for WebRTC Auto Gain and Noise Suppression (#250)
## WebRTC Auto Gain & Noise Suppression
 
This PR adds support for WebRTC Auto Gain and Noise Suppression as previously implemented in the Wyoming Satellite project.
 
### What's New
 
Two new entities have been created a number slider for gain and another one for noise suppression. The processing happens inside of the `webrtc.py` file and is implemented in the `__main__.py` file's `process_audio` function. The slider for gain has a limit of 0-31 and noise suppression has a limit of 0-4.
 
The Auto Gain and Noise Suppression persist through restarts and are saved as preferences. The values are dynamically chnged without needing a restart.
 
CLI flags called `--mic-auto-gain` and `--mic-noise-suppression` have also been added.
 
>  When both values are 0, WebRTC processing is bypassed entirely with no performance overhead.

Closes #25 
 
### Testing
 
When saving the audio recorded by LVA there is a noticeable increase. The following two files have been recorded with similar audio being played through them with these results (Gain-31 and NS-4):
 
| Metric | With AGC+NS | Without |
|---|---|---|
| Max amplitude | 0.329 | 0.054 |
| RMS amplitude | 0.008905 | 0.002862 |
| Volume adjustment needed | 3.034x | 13.479x |
| Rough frequency | 916 Hz | 705 Hz |
2026-04-09 00:43:11 +02:00
Florian Asche 341e3dcb9c Output only satellite - part 2 (#278)
Introduce a new `--output-only` command-line flag and corresponding `ENABLE_OUTPUT_ONLY` environment variable. This allows users to run the assistant in output-only mode, where it only supports announcements. The change includes updates to configuration examples, documentation, and the Docker entrypoint script.
2026-03-31 01:57:47 +02:00
JaminandFlorian Asche 7732be0de8 Output only satellite (#182)
Output only satellite for nodes with only speakers and no microphone.
Supports announcements and playing media only.

---------

Co-authored-by: Florian Asche <github@florian-asche.de>
2026-03-31 01:56:16 +02:00
Florian Asche 8b060d7346 docs(config): document timer max ring seconds configuration (#277) 2026-03-31 01:33:07 +02:00
Florian Asche 943c56161f fix(docker): simplify PulseAudio cookie file creation (#276)
Breaking Change: From now on the cookie file will be created in the configured directory/file. There will be no alternative directory where we try to create and touch the file. You need to make sure that the configured directory is writeable from the application.
2026-03-31 01:17:09 +02:00
Omar Amin 88fb2c5a49 Start listening after stopping ringing timer using wake word (#275)
Wake-up after stopping ringing timer using wake words such as "Okay Nabu", "Hey Jarvis" rather than "Stop" to get another command instead of the need to repeat the wake word.
2026-03-31 00:11:25 +02:00
Martin WeineltandFlorian Asche 9067648c87 Expose linux-voice-assistant console script (#268)
Co-authored-by: Florian Asche <github@florian-asche.de>
2026-03-30 21:04:01 +02:00
95323553a5 Fixed PipeWire setup (#258)
* Updated Config and Entrypoint to check for both PA and PW and removed no longer needed additional settings

---------

Co-authored-by: Omar Amin <65174210+omaramin-2000@users.noreply.github.com>
Co-authored-by: Florian Asche <github@florian-asche.de>
2026-03-30 20:57:19 +02:00
Martin WeineltandFlorian Asche 0081f2edee Discover project version from Git tags (#270)
Co-authored-by: Florian Asche <github@florian-asche.de>
2026-03-30 02:16:39 +02:00
b3cd3ec7cf Update package inclusion to include subpackages: Python Module Hotfix (#245)
* Update package inclusion to include subpackages

---------

Co-authored-by: Florian Asche <github@florian-asche.de>
Co-authored-by: Omar Amin <65174210+omaramin-2000@users.noreply.github.com>
2026-03-30 02:13:07 +02:00
Florian Asche 47e617fcab fix PR build pipeline typo 2026-03-30 01:48:11 +02:00
Florian Asche 7a62a75aa6 fix PR build pipeline 2026-03-30 01:30:49 +02:00
Florian Asche c1906a3f20 3 2026-03-30 01:21:07 +02:00
Hedda 0dce320db2 Update README removing colon signs section headers (#265)
Update README to remove the colon sign from all section headers to make formatting consistant with other projects from the Open Home Foundation.
2026-03-26 16:56:44 +01:00
Omar Amin 412035e6b2 Fix lint formatting (#262) 2026-03-26 14:54:40 +02:00
Omar Amin 4194aa42ec Add maximum seconds for ringing timer (#261)
Auto stop ringing timer if the determined time (default 15 mins) has passed without manually stopping. This max time can be modified using the command `--timer-max-ring-seconds`.
2026-03-25 01:46:53 +02:00
Vlad Gaevsky ede3785539 Add debug logging for stop word detection (#257) 2026-03-23 16:35:06 +01:00
Florian Asche 0130d2241f Fix link to AEC enabling documentation 2026-03-23 16:33:39 +01:00
Vlad GaevskyandOmar Amin 96fd54a008 Fix double _tts_finished() call when stop word interrupts TTS (#256)
tts_player.stop() already invokes its done_callback which is
_tts_finished(), so the explicit self._tts_finished() call immediately
after was a duplicate. This caused double 'Unducking music' and double
'TTS response finished' log entries on every stop-word-triggered stop.

Also move _pipeline_active = False inside the 'if not self._tts_played'
branch in VOICE_ASSISTANT_RUN_END, so it is not cleared while TTS is
still playing.

Co-authored-by: Omar Amin <65174210+omaramin-2000@users.noreply.github.com>
2026-03-23 16:24:38 +01:00
aryanhasgithub fbc4e05fbd Add Instructions for enabling AEC (#254)
* Added AEC instructions to documentation
2026-03-23 02:05:59 +01:00
NP v/d Spek 8bb118b411 Cleanup mac address (#249) 2026-03-19 02:27:32 +01:00
Florian Asche abf6cd07b7 changed from review to environment 2026-03-19 01:48:27 +01:00
Florian Asche a5c37de4ec change workflow requirement 2026-03-19 01:41:32 +01:00
Florian Asche 9ddb929423 Hotfix/build pipeline (#253)
* move pr build into its own package
* build code used is from main only for security reasons
2026-03-19 01:30:01 +01:00
Jamin c3885a494b Move examples out of tests directory (#193) 2026-03-15 05:53:49 +01:00
Omar Amin d4f5b549da Update CHANGELOG.md 2026-03-15 05:46:06 +02:00
NP v/d Spek 9d19ffe6da init (#235) 2026-03-14 22:35:54 +01:00
Hedda 496cd90a8c Revise intro to Linux-Voice-Assistant in README (#223)
* Revise intro to Linux-Voice-Assistant in README
2026-03-11 21:41:03 +01:00
Florian Asche 043e8fb1ff Cookie file and other default parameters (#231)
* adding a more robust system around the cookie file and add other default values for parameters
* removed duplicate PulseAudio running message
2026-03-11 21:03:21 +01:00
Omar Amin 735731b63e Fix typo in line 176 2026-03-11 03:01:00 +02:00
Florian Asche a57b13b2d6 Add changelog generator based on release information (#226) 2026-03-11 01:39:32 +01:00
Florian Asche bfabc232c7 add changelog file generator 2026-03-11 01:34:00 +01:00
33 changed files with 1904 additions and 652 deletions

No files matched your search

+27 -2
View File
@@ -1,6 +1,10 @@
# Linux-Voice-Assistant - Docker Environment Configuration # Linux-Voice-Assistant - Docker Environment Configuration
# Copy this file to .env and customize for your setup by 'cp .env.example .env' # 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 mode (optional):
# ENABLE_DEBUG="1" # ENABLE_DEBUG="1"
@@ -14,14 +18,14 @@ LVA_USER_ID="1000"
LVA_USER_GROUP="1000" LVA_USER_GROUP="1000"
### Name for the client (optional): ### Name for the client (optional):
# CLIENT_NAME="speaker_livvingroom" # CLIENT_NAME="speaker_livingroom"
### PulseAudio socket path on the host: ### PulseAudio socket path on the host:
# PulseAudio Server: /run/user/1000/pulse # PulseAudio Server: /run/user/1000/pulse
# Pipewire Server: /run/user/1000/pulse/native # Pipewire Server: /run/user/1000/pulse/native
LVA_PULSE_SERVER="/run/user/${LVA_USER_ID}/pulse/native" LVA_PULSE_SERVER="/run/user/${LVA_USER_ID}/pulse/native"
LVA_XDG_RUNTIME_DIR="/run/user/${LVA_USER_ID}" LVA_XDG_RUNTIME_DIR="/run/user/${LVA_USER_ID}"
LVA_PULSE_COOKIE="/app/configuration/tmp_pulse_cookie" LVA_PULSE_COOKIE="/run/user/${LVA_USER_ID}/pulse/cookie"
### Path to the preferences file (optional): ### Path to the preferences file (optional):
# PREFERENCES_FILE="/app/configuration/preferences.json" # PREFERENCES_FILE="/app/configuration/preferences.json"
@@ -41,6 +45,18 @@ LVA_PULSE_COOKIE="/app/configuration/tmp_pulse_cookie"
### Audio output device (optional): ### Audio output device (optional):
# AUDIO_OUTPUT_DEVICE="default" # AUDIO_OUTPUT_DEVICE="default"
### Microphone volume level (optional):
# MIC_VOLUME="100"
### Mic Gain (optional) passed as an argument to LVA will overide the value in the preferences 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 the preferences file on boot but can be changed at runtime in Home Assistant:
# MIC_NOISE_SUPPRESSION="0"
### Number of mic channels to stream
# AUDIO_INPUT_CHANNELS=2
### Enable thinking sound (optional): ### Enable thinking sound (optional):
# ENABLE_THINKING_SOUND="1" # ENABLE_THINKING_SOUND="1"
@@ -54,9 +70,15 @@ LVA_PULSE_COOKIE="/app/configuration/tmp_pulse_cookie"
### Stop model (optional): ### Stop model (optional):
# STOP_MODEL="stop" # STOP_MODEL="stop"
### Seconds after which the timer stops ringing (optional):
# TIMER_MAX_RING_SECONDS="900"
### Refactory seconds (optional): ### Refactory seconds (optional):
# REFACTORY_SECONDS="2" # REFACTORY_SECONDS="2"
### Delay before mic opens for continued conversation (optional):
# CONTINUE_CONVERSATION_DELAY="0.5"
### Sound files (optional): ### Sound files (optional):
# path for custom files in docker is for example "sounds/custom/your_soundfile.flac" # path for custom files in docker is for example "sounds/custom/your_soundfile.flac"
# WAKEUP_SOUND="sounds/wake_word_triggered.flac" # WAKEUP_SOUND="sounds/wake_word_triggered.flac"
@@ -64,3 +86,6 @@ LVA_PULSE_COOKIE="/app/configuration/tmp_pulse_cookie"
# PROCESSING_SOUND="sounds/processing.wav" # PROCESSING_SOUND="sounds/processing.wav"
# MUTE_SOUND="sounds/mute_switch_on.flac" # MUTE_SOUND="sounds/mute_switch_on.flac"
# UNMUTE_SOUND="sounds/mute_switch_off.flac" # UNMUTE_SOUND="sounds/mute_switch_off.flac"
### Enable output-only mode (optional):
# ENABLE_OUTPUT_ONLY="1"
+16 -17
View File
@@ -1,7 +1,8 @@
---
name: Build PR name: Build PR
on: on:
pull_request: pull_request_target:
types: [opened, synchronize, reopened] types: [opened, synchronize, reopened]
env: env:
@@ -10,6 +11,8 @@ env:
jobs: jobs:
build-and-push-image: build-and-push-image:
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment:
name: pr-build
permissions: permissions:
contents: read contents: read
packages: write packages: write
@@ -17,51 +20,47 @@ jobs:
attestations: write attestations: write
steps: steps:
- name: Checkout repository - name: Checkout PR code
uses: actions/checkout@v4 uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Extract PR information - name: Set variables
id: extract_pr
run: | 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 "REGISTRY_LOWER=$(echo '${{ env.REGISTRY }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
echo "REPOSITORY_LOWER=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV echo "REPOSITORY_LOWER=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
- name: Log in to the Container registry - name: Log in to the Container registry
uses: docker/login-action@v3.1.0 uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 # v3.1.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ github.token }} password: ${{ github.token }}
# Build Docker Images (amd64 and arm64) # Build Docker Images (amd64 and arm64)
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Create version files - name: Create version files
run: | run: |
echo "pr-${{ env.PR_NUMBER }}" > version.txt echo "${{ env.PR_NUMBER }}" > version.txt
echo "${{ github.sha }}" > version_githash.txt echo "${{ github.sha }}" > version_githash.txt
ls -ltr ls -ltr
- name: Build and push Docker image - name: Build and push Docker image
id: push id: push
uses: docker/build-push-action@v5.3.0 uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # v5.3.0
with: with:
context: . context: .
platforms: linux/amd64,linux/aarch64 platforms: linux/amd64,linux/aarch64
push: true push: true
tags: | tags: "${{ env.REGISTRY_LOWER }}/${{ env.REPOSITORY_LOWER }}-pr:${{ env.PR_NUMBER }}"
${{ env.REGISTRY_LOWER }}/${{ env.REPOSITORY_LOWER }}:pr-${{ env.PR_NUMBER }} labels: "${{ env.PR_NUMBER }}"
labels: |
pr=${{ env.PR_NUMBER }}
- name: Generate artifact attestation - name: Generate artifact attestation
uses: actions/attest-build-provenance@v1.4.4 uses: actions/attest-build-provenance@v1.4.4
with: with:
subject-name: ${{ env.REGISTRY }}/${{ github.repository }} subject-name: ${{ env.REGISTRY_LOWER }}/${{ env.REPOSITORY_LOWER }}-pr
subject-digest: ${{ steps.push.outputs.digest }} subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true push-to-registry: true
+19
View File
@@ -0,0 +1,19 @@
name: Generate changelog
on:
release:
types: [published]
jobs:
changelog:
name: Update changelog
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v3
with:
ref: main
- uses: rhysd/changelog-from-release/action@v3
with:
file: CHANGELOG.md
github_token: ${{ secrets.GITHUB_TOKEN }}
+1
View File
@@ -21,5 +21,6 @@ venv/
# Docker # Docker
.env .env
dev.env
version.txt version.txt
version_githash.txt version_githash.txt
+276 -70
View File
@@ -1,92 +1,298 @@
# Changelog <a id="v1.1.11"></a>
# [v1.1.11](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.11) - 2026-06-11
## 1.1.6 <!-- Release notes generated using configuration in .github/release.yml at main -->
### Bug Fixes ## What's Changed
- Start listening after wakeup sound ### Other Changes
- Prevent wake word from interrupting active voice pipeline * docs(readme): restructure requirements and hardware section by [@florian-asche](https://github.com/florian-asche) in [#321](https://github.com/OHF-Voice/linux-voice-assistant/pull/321)
- Ensure wake word state is set after the wake sound * App for HAOS by [@omaramin-2000](https://github.com/omaramin-2000) in [#325](https://github.com/OHF-Voice/linux-voice-assistant/pull/325)
* Add volume arg to parser by [@aryanhasgithub](https://github.com/aryanhasgithub) in [#335](https://github.com/OHF-Voice/linux-voice-assistant/pull/335)
* Enhance wake word documentation by [@omaramin-2000](https://github.com/omaramin-2000) in [#336](https://github.com/OHF-Voice/linux-voice-assistant/pull/336)
### Improvements
- Script/run error handling and return code consistency
- Move port check into Python application
- Help output on application parameters
- Added default sample rate to pulse documentation
## 1.1.5 **Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.10...v1.1.11
### Bug Fixes [Changes][v1.1.11]
- Fix timer finished sound not looping
- Suppress spurious end-file event when starting playback
- Fix tts.speak and announce
- Fix duck/unduck on timers
### Improvements
- Volume persistence across restarts
- Enhance play method for playlist support
- Add version and hash to docker container
- Allow portainer stack to point directly to docker-compose.yml
- Improve documentation
## 1.1.4 <a id="v1.1.10"></a>
# [v1.1.10](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.10) - 2026-04-23
### Improvements <!-- Release notes generated using configuration in .github/release.yml at main -->
- Added custom paths to docker for wakeword and sounds
- Added wake-word-dir variable
- Changed default pipewire configuration
- Updated documentation regarding wakeword and sounds
- Documentation corrections
## 1.1.3 ## What's Changed
### Exciting New Features 🎉
* Add support for WebRTC Auto Gain and Noise Suppression by [@aryanhasgithub](https://github.com/aryanhasgithub) in [#250](https://github.com/OHF-Voice/linux-voice-assistant/pull/250)
* Add Instructions for enabling AEC by [@aryanhasgithub](https://github.com/aryanhasgithub) in [#254](https://github.com/OHF-Voice/linux-voice-assistant/pull/254)
* Discover project version from Git tags by [@mweinelt](https://github.com/mweinelt) in [#270](https://github.com/OHF-Voice/linux-voice-assistant/pull/270)
* Output only satellite by [@jaminh](https://github.com/jaminh) in [#182](https://github.com/OHF-Voice/linux-voice-assistant/pull/182)
* Wake word sensitivity by [@florian-asche](https://github.com/florian-asche) in [#207](https://github.com/OHF-Voice/linux-voice-assistant/pull/207)
### Other Changes
* Revise intro to Linux-Voice-Assistant in README by [@Hedda](https://github.com/Hedda) in [#223](https://github.com/OHF-Voice/linux-voice-assistant/pull/223)
* small typo inside the .env file by [@nielsnl68](https://github.com/nielsnl68) in [#235](https://github.com/OHF-Voice/linux-voice-assistant/pull/235)
* Move examples by [@jaminh](https://github.com/jaminh) in [#193](https://github.com/OHF-Voice/linux-voice-assistant/pull/193)
* Hotfix/build pipeline by [@florian-asche](https://github.com/florian-asche) in [#253](https://github.com/OHF-Voice/linux-voice-assistant/pull/253)
* Cleanup mac address by [@nielsnl68](https://github.com/nielsnl68) in [#249](https://github.com/OHF-Voice/linux-voice-assistant/pull/249)
* Fix double _tts_finished() call when stop word interrupts TTS by [@interkelstar](https://github.com/interkelstar) in [#256](https://github.com/OHF-Voice/linux-voice-assistant/pull/256)
* Add debug logging for stop word detection by [@interkelstar](https://github.com/interkelstar) in [#257](https://github.com/OHF-Voice/linux-voice-assistant/pull/257)
* Add maximum seconds for ringing timer by [@omaramin-2000](https://github.com/omaramin-2000) in [#261](https://github.com/OHF-Voice/linux-voice-assistant/pull/261)
* Fix lint formatting by [@omaramin-2000](https://github.com/omaramin-2000) in [#262](https://github.com/OHF-Voice/linux-voice-assistant/pull/262)
* Update README removing colon signs section headers by [@Hedda](https://github.com/Hedda) in [#265](https://github.com/OHF-Voice/linux-voice-assistant/pull/265)
* Update package inclusion to include subpackages: Python Module Hotfix by [@aryanhasgithub](https://github.com/aryanhasgithub) in [#245](https://github.com/OHF-Voice/linux-voice-assistant/pull/245)
* Fixed PipeWire setup by [@aryanhasgithub](https://github.com/aryanhasgithub) in [#258](https://github.com/OHF-Voice/linux-voice-assistant/pull/258)
* Expose linux-voice-assistant console script by [@mweinelt](https://github.com/mweinelt) in [#268](https://github.com/OHF-Voice/linux-voice-assistant/pull/268)
* Start listening after stopping ringing timer using wake word by [@omaramin-2000](https://github.com/omaramin-2000) in [#275](https://github.com/OHF-Voice/linux-voice-assistant/pull/275)
* fix(docker): simplify PulseAudio cookie file creation by [@florian-asche](https://github.com/florian-asche) in [#276](https://github.com/OHF-Voice/linux-voice-assistant/pull/276)
* docs(config): document timer max ring seconds configuration by [@florian-asche](https://github.com/florian-asche) in [#277](https://github.com/OHF-Voice/linux-voice-assistant/pull/277)
* Output only satellite - part 2 by [@florian-asche](https://github.com/florian-asche) in [#278](https://github.com/OHF-Voice/linux-voice-assistant/pull/278)
* Add the ability to control microphone volume by [@omaramin-2000](https://github.com/omaramin-2000) in [#287](https://github.com/OHF-Voice/linux-voice-assistant/pull/287)
* Fix audio cuts and playback issues by [@omaramin-2000](https://github.com/omaramin-2000) in [#296](https://github.com/OHF-Voice/linux-voice-assistant/pull/296)
* docs(install): clarify linger is required on headless systems by [@genericJE](https://github.com/genericJE) in [#299](https://github.com/OHF-Voice/linux-voice-assistant/pull/299)
* Fix README.md typo by [@AlecJDavidson](https://github.com/AlecJDavidson) in [#303](https://github.com/OHF-Voice/linux-voice-assistant/pull/303)
* Allow option to have docker use nightly build by [@lowlyocean](https://github.com/lowlyocean) in [#302](https://github.com/OHF-Voice/linux-voice-assistant/pull/302)
* feat(docker): add custom image and tag env configuration by [@florian-asche](https://github.com/florian-asche) in [#306](https://github.com/OHF-Voice/linux-voice-assistant/pull/306)
* Downgrade to Python 3.12 for quicker webrtc builds by [@aryanhasgithub](https://github.com/aryanhasgithub) in [#305](https://github.com/OHF-Voice/linux-voice-assistant/pull/305)
### Bug Fixes ## New Contributors
- Hotfix: typo in network-interface output order causing application crash * [@nielsnl68](https://github.com/nielsnl68) made their first contribution in [#235](https://github.com/OHF-Voice/linux-voice-assistant/pull/235)
* [@aryanhasgithub](https://github.com/aryanhasgithub) made their first contribution in [#254](https://github.com/OHF-Voice/linux-voice-assistant/pull/254)
* [@interkelstar](https://github.com/interkelstar) made their first contribution in [#256](https://github.com/OHF-Voice/linux-voice-assistant/pull/256)
* [@mweinelt](https://github.com/mweinelt) made their first contribution in [#270](https://github.com/OHF-Voice/linux-voice-assistant/pull/270)
* [@genericJE](https://github.com/genericJE) made their first contribution in [#299](https://github.com/OHF-Voice/linux-voice-assistant/pull/299)
* [@AlecJDavidson](https://github.com/AlecJDavidson) made their first contribution in [#303](https://github.com/OHF-Voice/linux-voice-assistant/pull/303)
### Documentation **Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.9...v1.1.10
- Correct LVA_USER_ID variable name
## 1.1.2 [Changes][v1.1.10]
### Breaking Changes
- MAC address detection has been fixed. After updating, it may be necessary
to remove and re-add the device in Home Assistant.
- The docker-compose.yml and .env.example files were updated. Please review
and adapt your configuration if required.
### Features <a id="v1.1.9"></a>
- Add variable for network-interface with auto-detection enabled by default # [v1.1.9](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.9) - 2026-03-11
- Add MAC address autodetection based on detected or specified network interface
- Switch from listening on all IP addresses to binding to the IP of the default interface
- Move auto-name creation from shell to Python (name is now optional and auto-generated)
- Add first version of docker-compose for developers
- Changes EXTRA_ARGS variable to bash array for better variable expansion
### Bug Fixes <!-- Release notes generated using configuration in .github/release.yml at main -->
- Fix non-working variables (e.g. sound-files)
- Fix client-name validation (no spaces allowed)
- Fix example configuration
## 1.1.1 ## What's Changed
### Other Changes
* Cookie file and other default parameters by [@florian-asche](https://github.com/florian-asche) in [#231](https://github.com/OHF-Voice/linux-voice-assistant/pull/231)
### Bug Fixes
- Handle STOP command correctly in media_player
## 1.1.0 **Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.8...v1.1.9
### Features [Changes][v1.1.9]
- Support custom/external wake words
- Add an option to play thinking sound
- Add mute/unmute sounds
- Add sound customization
- Handle announcement separately from the media player
- Add Docker build pipeline and compose files
- Don't require name for list commands
### Bug Fixes
- Fix PEP 508 dependency operator
- Fix mpv pause state when loading new media
- Fix issues with audio handling
## 1.0.0 <a id="v1.1.8"></a>
# [v1.1.8](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.8) - 2026-03-11
- Initial release <!-- Release notes generated using configuration in .github/release.yml at main -->
## What's Changed
### Other Changes
* Add changelog generator based on release information by [@florian-asche](https://github.com/florian-asche) in [#226](https://github.com/OHF-Voice/linux-voice-assistant/pull/226)
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.7...v1.1.8
[Changes][v1.1.8]
<a id="v1.1.7"></a>
# [v1.1.7](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.7) - 2026-03-11
<!-- Release notes generated using configuration in .github/release.yml at main -->
## What's Changed
### Exciting New Features 🎉
* Add linting scripts and pipeline by [@florian-asche](https://github.com/florian-asche) in [#201](https://github.com/OHF-Voice/linux-voice-assistant/pull/201)
### Other Changes
* add build flag option for low end devices by [@florian-asche](https://github.com/florian-asche) in [#189](https://github.com/OHF-Voice/linux-voice-assistant/pull/189)
* replaced branch build by PR build only by [@florian-asche](https://github.com/florian-asche) in [#203](https://github.com/OHF-Voice/linux-voice-assistant/pull/203)
* Change when lint pipeline will start by [@florian-asche](https://github.com/florian-asche) in [#204](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](https://github.com/florian-asche) in [#205](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](https://github.com/florian-asche) in [#206](https://github.com/OHF-Voice/linux-voice-assistant/pull/206)
* fix: cookie error ([#127](https://github.com/OHF-Voice/linux-voice-assistant/issues/127)) by [@florian-asche](https://github.com/florian-asche) in [#210](https://github.com/OHF-Voice/linux-voice-assistant/pull/210)
* fix(config): remove unix: prefix from default pulseaudio socket path … by [@florian-asche](https://github.com/florian-asche) in [#221](https://github.com/OHF-Voice/linux-voice-assistant/pull/221)
* Update the changelog file automatically on each release by [@omaramin-2000](https://github.com/omaramin-2000) in [#224](https://github.com/OHF-Voice/linux-voice-assistant/pull/224)
* Enhance hardware compatibility details in README by [@Hedda](https://github.com/Hedda) in [#222](https://github.com/OHF-Voice/linux-voice-assistant/pull/222)
* add changelog based on release notes by [@florian-asche](https://github.com/florian-asche) in [#225](https://github.com/OHF-Voice/linux-voice-assistant/pull/225)
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.6...v1.1.7
[Changes][v1.1.7]
<a id="v1.1.6"></a>
# [v1.1.6](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.6) - 2026-03-04
## What's Changed
* Bugfix: start listening after wakeup sound by [@florian-asche](https://github.com/florian-asche) in [#185](https://github.com/OHF-Voice/linux-voice-assistant/pull/185)
* added default sample rate to pulse documentation by [@florian-asche](https://github.com/florian-asche) in [#190](https://github.com/OHF-Voice/linux-voice-assistant/pull/190)
* Bugfix: Prevent wake word from interrupting active voice pipeline by [@ekiczek](https://github.com/ekiczek) in [#151](https://github.com/OHF-Voice/linux-voice-assistant/pull/151)
* Bugfix: ensure wake word state is set after the wake sound by [@florian-asche](https://github.com/florian-asche) in [#198](https://github.com/OHF-Voice/linux-voice-assistant/pull/198)
* Improvement: script/run error handling and return code consistency by [@florian-asche](https://github.com/florian-asche) in [#186](https://github.com/OHF-Voice/linux-voice-assistant/pull/186)
* Improvement: move portcheck into python application by [@florian-asche](https://github.com/florian-asche) in [#187](https://github.com/OHF-Voice/linux-voice-assistant/pull/187)
* Improvement: help output on application parameters by [@florian-asche](https://github.com/florian-asche) in [#188](https://github.com/OHF-Voice/linux-voice-assistant/pull/188)
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.5...v1.1.6
[Changes][v1.1.6]
<a id="v1.1.5"></a>
# [v1.1.5](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.5) - 2026-02-27
## What's Changed
* Improve documentation by [@florian-asche](https://github.com/florian-asche) in [#166](https://github.com/OHF-Voice/linux-voice-assistant/pull/166)
* Add version and hash to docker container by [@florian-asche](https://github.com/florian-asche) in [#167](https://github.com/OHF-Voice/linux-voice-assistant/pull/167)
* Fix timer finished sound not looping by [@ekiczek](https://github.com/ekiczek) in [#159](https://github.com/OHF-Voice/linux-voice-assistant/pull/159)
* Volume persistence across restarts by [@omaramin-2000](https://github.com/omaramin-2000) in [#168](https://github.com/OHF-Voice/linux-voice-assistant/pull/168)
* docs: add info about reboot by [@florian-asche](https://github.com/florian-asche) in [#175](https://github.com/OHF-Voice/linux-voice-assistant/pull/175)
* Enhance play method for playlist support by [@omaramin-2000](https://github.com/omaramin-2000) in [#172](https://github.com/OHF-Voice/linux-voice-assistant/pull/172)
* Allow portainer stack to point directly to docker-compose.yml by [@lowlyocean](https://github.com/lowlyocean) in [#177](https://github.com/OHF-Voice/linux-voice-assistant/pull/177)
* Fix: Suppress spurious end-file event when starting playback by [@florian-asche](https://github.com/florian-asche) in [#179](https://github.com/OHF-Voice/linux-voice-assistant/pull/179)
* Fix: tts.speak and announce by [@florian-asche](https://github.com/florian-asche) in [#179](https://github.com/OHF-Voice/linux-voice-assistant/pull/179)
* Fix: duck unduck on timers by [@florian-asche](https://github.com/florian-asche) in [#179](https://github.com/OHF-Voice/linux-voice-assistant/pull/179)
## New Contributors
* [@ekiczek](https://github.com/ekiczek) made their first contribution in [#159](https://github.com/OHF-Voice/linux-voice-assistant/pull/159)
* [@lowlyocean](https://github.com/lowlyocean) made their first contribution in [#177](https://github.com/OHF-Voice/linux-voice-assistant/pull/177)
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.4...v1.1.5
[Changes][v1.1.5]
<a id="v1.1.4"></a>
# [v1.1.4](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.4) - 2026-02-20
## What's Changed
* Documentation: Correct LVA_USER_ID variable name where wrong by [@giobber](https://github.com/giobber) in [#155](https://github.com/OHF-Voice/linux-voice-assistant/pull/155)
* Documentation by [@florian-asche](https://github.com/florian-asche) in [#162](https://github.com/OHF-Voice/linux-voice-assistant/pull/162)
* Added custom paths to docker for wakeword and sounds by [@florian-asche](https://github.com/florian-asche) in [#165](https://github.com/OHF-Voice/linux-voice-assistant/pull/165)
* Changed default pipewire configuration by [@florian-asche](https://github.com/florian-asche) in [#165](https://github.com/OHF-Voice/linux-voice-assistant/pull/165)
* Added wake-word-dir variable by [@florian-asche](https://github.com/florian-asche) in [#165](https://github.com/OHF-Voice/linux-voice-assistant/pull/165)
* Updated documentation regarding wakeword and sounds and some other things. by [@florian-asche](https://github.com/florian-asche) in [#165](https://github.com/OHF-Voice/linux-voice-assistant/pull/165)
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.3...v1.1.4
[Changes][v1.1.4]
<a id="v1.1.3"></a>
# [v1.1.3](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.3) - 2026-02-14
## Hotfix:
Fixed a bug where the application crashes when the new network-interface parameter is used.
## What's Changed
* Update documentation, correcting typo by [@giobber](https://github.com/giobber) in [#144](https://github.com/OHF-Voice/linux-voice-assistant/pull/144)
* Hotfix: Typo in network-interface output order by [@florian-asche](https://github.com/florian-asche) in [#146](https://github.com/OHF-Voice/linux-voice-assistant/pull/146)
## New Contributors
* [@giobber](https://github.com/giobber) made their first contribution in [#144](https://github.com/OHF-Voice/linux-voice-assistant/pull/144)
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.2...v1.1.3
[Changes][v1.1.3]
<a id="v1.1.2"></a>
# [v1.1.2](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.2) - 2026-02-13
## Breaking Change
* A previous bug in the MAC address detection could have resulted in a device being created with an incorrect address. After installing the new version, it may be necessary to remove and re-add the device in Home Assistant.
* The docker-compose.yml and .env.example files were updated. Please review and adapt your configuration if required.
## What's Changed
* Add open home foundation badge to readme by [@omaramin-2000](https://github.com/omaramin-2000) in [#138](https://github.com/OHF-Voice/linux-voice-assistant/pull/138)
* Update README.md by [@Hedda](https://github.com/Hedda) in [#31](https://github.com/OHF-Voice/linux-voice-assistant/pull/31)
* Changes EXTRA_ARGS variable to bash array to better handle variable expansion by [@Ziris85](https://github.com/Ziris85) in [#136](https://github.com/OHF-Voice/linux-voice-assistant/pull/136)
* change client-name validation (no spaces allowed) and fix example configuration by [@florian-asche](https://github.com/florian-asche) in [#139](https://github.com/OHF-Voice/linux-voice-assistant/pull/139)
* add first version of docker-compose for developers by [@florian-asche](https://github.com/florian-asche) in [#139](https://github.com/OHF-Voice/linux-voice-assistant/pull/139)
* add variable for network-interface with auto-detection enabled by default by [@florian-asche](https://github.com/florian-asche) in [#139](https://github.com/OHF-Voice/linux-voice-assistant/pull/139)
* add mac-address autodetection based on detected or specified network-interface by [@florian-asche](https://github.com/florian-asche) in [#139](https://github.com/OHF-Voice/linux-voice-assistant/pull/139)
* switch from listening on all ip-addresses to binding to the ip-address of the default interface by [@florian-asche](https://github.com/florian-asche) in [#139](https://github.com/OHF-Voice/linux-voice-assistant/pull/139)
* add missing variables and fix variable names by [@florian-asche](https://github.com/florian-asche) in [#139](https://github.com/OHF-Voice/linux-voice-assistant/pull/139)
* fix non-working variables (e.g. sound-files) by [@florian-asche](https://github.com/florian-asche) in [#139](https://github.com/OHF-Voice/linux-voice-assistant/pull/139)
* move auto-name creation from shell to python (name is now optional and auto-generated if not provided) by [@florian-asche](https://github.com/florian-asche) in [#139](https://github.com/OHF-Voice/linux-voice-assistant/pull/139)
* Change download link for docker-compose setup to latest version by [@florian-asche](https://github.com/florian-asche) in [#141](https://github.com/OHF-Voice/linux-voice-assistant/pull/141)
## New Contributors
* [@Hedda](https://github.com/Hedda) made their first contribution in [#31](https://github.com/OHF-Voice/linux-voice-assistant/pull/31)
* [@Ziris85](https://github.com/Ziris85) made their first contribution in [#136](https://github.com/OHF-Voice/linux-voice-assistant/pull/136)
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.1...v1.1.2
[Changes][v1.1.2]
<a id="v1.1.1"></a>
# [v1.1.1](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.1) - 2026-02-10
## What's Changed
* media_player: handle STOP command correctly (fixes [#128](https://github.com/OHF-Voice/linux-voice-assistant/issues/128)) by [@pgrafe](https://github.com/pgrafe) in [#129](https://github.com/OHF-Voice/linux-voice-assistant/pull/129)
* changed github workflow naming by [@florian-asche](https://github.com/florian-asche) in [#132](https://github.com/OHF-Voice/linux-voice-assistant/pull/132)
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.0...v1.1.1
[Changes][v1.1.1]
<a id="v1.1.0"></a>
# [v1.1.0](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.1.0) - 2026-02-10
## What's Changed
* Fix PEP 508 dependency operator: `=>` -> `>=` by [@ms1design](https://github.com/ms1design) in [#14](https://github.com/OHF-Voice/linux-voice-assistant/pull/14)
* Support custom/external wake words by [@synesthesiam](https://github.com/synesthesiam) in [#55](https://github.com/OHF-Voice/linux-voice-assistant/pull/55)
* Add an option to play thinking sound by [@omaramin-2000](https://github.com/omaramin-2000) in [#30](https://github.com/OHF-Voice/linux-voice-assistant/pull/30)
* Add mute/unmute sounds by [@omaramin-2000](https://github.com/omaramin-2000) in [#82](https://github.com/OHF-Voice/linux-voice-assistant/pull/82)
* Fix issues [#98](https://github.com/OHF-Voice/linux-voice-assistant/issues/98) and [#87](https://github.com/OHF-Voice/linux-voice-assistant/issues/87) by [@omaramin-2000](https://github.com/omaramin-2000) in [#100](https://github.com/OHF-Voice/linux-voice-assistant/pull/100)
* Merge beta changes by [@omaramin-2000](https://github.com/omaramin-2000) in [#112](https://github.com/OHF-Voice/linux-voice-assistant/pull/112)
* Add sound customization by [@omaramin-2000](https://github.com/omaramin-2000) in [#116](https://github.com/OHF-Voice/linux-voice-assistant/pull/116)
* Fix some format and linting issues by [@jaminh](https://github.com/jaminh) in [#120](https://github.com/OHF-Voice/linux-voice-assistant/pull/120)
* removed disconnected log message in process_packet function by [@florian-asche](https://github.com/florian-asche) in [#124](https://github.com/OHF-Voice/linux-voice-assistant/pull/124)
* Handle announcement separately from the media player by [@omaramin-2000](https://github.com/omaramin-2000) in [#125](https://github.com/OHF-Voice/linux-voice-assistant/pull/125)
* Don't require name for list commands by [@jaminh](https://github.com/jaminh) in [#67](https://github.com/OHF-Voice/linux-voice-assistant/pull/67)
* Fix mpv pause state when loading new media by [@pgrafe](https://github.com/pgrafe) in [#123](https://github.com/OHF-Voice/linux-voice-assistant/pull/123)
* Add docker build pipeline and compose files by [@florian-asche](https://github.com/florian-asche) in [#118](https://github.com/OHF-Voice/linux-voice-assistant/pull/118)
## New Contributors
* [@ms1design](https://github.com/ms1design) made their first contribution in [#14](https://github.com/OHF-Voice/linux-voice-assistant/pull/14)
* [@synesthesiam](https://github.com/synesthesiam) made their first contribution in [#55](https://github.com/OHF-Voice/linux-voice-assistant/pull/55)
* [@omaramin-2000](https://github.com/omaramin-2000) made their first contribution in [#30](https://github.com/OHF-Voice/linux-voice-assistant/pull/30)
* [@jaminh](https://github.com/jaminh) made their first contribution in [#120](https://github.com/OHF-Voice/linux-voice-assistant/pull/120)
* [@florian-asche](https://github.com/florian-asche) made their first contribution in [#124](https://github.com/OHF-Voice/linux-voice-assistant/pull/124)
**Full Changelog**: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.0.0...v1.1.0
[Changes][v1.1.0]
<a id="v1.0.0"></a>
# [v1.0.0](https://github.com/OHF-Voice/linux-voice-assistant/releases/tag/v1.0.0) - 2025-09-16
Initial release
[Changes][v1.0.0]
[v1.1.11]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.10...v1.1.11
[v1.1.10]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.9...v1.1.10
[v1.1.9]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.8...v1.1.9
[v1.1.8]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.7...v1.1.8
[v1.1.7]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.6...v1.1.7
[v1.1.6]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.5...v1.1.6
[v1.1.5]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.4...v1.1.5
[v1.1.4]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.3...v1.1.4
[v1.1.3]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.2...v1.1.3
[v1.1.2]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.1...v1.1.2
[v1.1.1]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.1.0...v1.1.1
[v1.1.0]: https://github.com/OHF-Voice/linux-voice-assistant/compare/v1.0.0...v1.1.0
[v1.0.0]: https://github.com/OHF-Voice/linux-voice-assistant/tree/v1.0.0
<!-- Generated by https://github.com/rhysd/changelog-from-release v3.9.1 -->
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.13-slim-trixie FROM python:3.12-slim-trixie
ENV LANG C.UTF-8 ENV LANG C.UTF-8
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
+73 -31
View File
@@ -2,66 +2,99 @@
[![CI](https://github.com/OHF-Voice/linux-voice-assistant/actions/workflows/docker-build-release.yml/badge.svg)](https://github.com/OHF-Voice/linux-voice-assistant/actions/workflows/docker-build-release.yml) [![GitHub Package Version](https://img.shields.io/github/v/tag/OHF-Voice/linux-voice-assistant?label=version)](https://github.com/OHF-Voice/linux-voice-assistant/pkgs/container/linux-voice-assistant) [![GitHub License](https://img.shields.io/github/license/OHF-Voice/linux-voice-assistant)](https://github.com/OHF-Voice/linux-voice-assistant/blob/main/LICENSE.md) [![GitHub last commit](https://img.shields.io/github/last-commit/OHF-Voice/linux-voice-assistant)](https://github.com/OHF-Voice/linux-voice-assistant/commits) [![GitHub Container Registry](https://img.shields.io/badge/Container%20Registry-GHCR-blue)](https://github.com/OHF-Voice/linux-voice-assistant/pkgs/container/linux-voice-assistant) [![CI](https://github.com/OHF-Voice/linux-voice-assistant/actions/workflows/docker-build-release.yml/badge.svg)](https://github.com/OHF-Voice/linux-voice-assistant/actions/workflows/docker-build-release.yml) [![GitHub Package Version](https://img.shields.io/github/v/tag/OHF-Voice/linux-voice-assistant?label=version)](https://github.com/OHF-Voice/linux-voice-assistant/pkgs/container/linux-voice-assistant) [![GitHub License](https://img.shields.io/github/license/OHF-Voice/linux-voice-assistant)](https://github.com/OHF-Voice/linux-voice-assistant/blob/main/LICENSE.md) [![GitHub last commit](https://img.shields.io/github/last-commit/OHF-Voice/linux-voice-assistant)](https://github.com/OHF-Voice/linux-voice-assistant/commits) [![GitHub Container Registry](https://img.shields.io/badge/Container%20Registry-GHCR-blue)](https://github.com/OHF-Voice/linux-voice-assistant/pkgs/container/linux-voice-assistant)
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 voice satellite 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).
[![A project from the Open Home Foundation](https://www.openhomefoundation.org/badges/ohf-project.png)](https://www.openhomefoundation.org/) [![A project from the Open Home Foundation](https://www.openhomefoundation.org/badges/ohf-project.png)](https://www.openhomefoundation.org/)
## Features: ## Features
- Works with [Home Assistant](https://www.home-assistant.io/integrations/esphome/) - 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))
- Local wake word detection using integrated [OpenWakeWord](https://github.com/dscripka/openWakeWord) or [MicroWakeWord](https://github.com/kahrendt/microWakeWord) - 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) - Supports multiple architectures (linux/amd64 and linux/aarch64)
- Automated builds with artifact attestation for security - Automated builds with artifact attestation for security
- Supports multiple wake words and languages
- Supports announcments, start/continue conversation, and timers - 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 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)
## Requirements
## Usage: - **Microphone:** Device must support 16kHz mono audio
- **CPU:** 1Ghz
### Hardware: - **Memory:** min. 512MB
- **Storage:** The OS and software is around 4GB
- **OS:** linux/amd64 or linux/aarch64
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. 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). Two solutions recommended for setups today is:
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). - use a Raspberry Pi Zero 2W (Single Board Computer with built-in WiFi) in combination with the [Satellite1 Hat Board](https://futureproofhomes.net/products/satellite1-top-microphone-board)
- use at least a Raspberry Pi 3 with the [Respeaker Lite](https://wiki.seeedstudio.com/reSpeaker_usb_v3/). The Respeaker Lite currently has a problem with the Zero 2W.
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. Those mic-boards 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).
But you can also install LVA on AMD64 devices, for example on your Linux desktop computer. Alternatively if on a lower budget then suggest could use other microphone-array boards like for 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).
### Software: ## Usage
#### Installation: ### Installation
**Assist Satellite app for Home Assistant OS**
For HA OS, we provide a finished [Assist Satellite](https://github.com/OHF-Voice/apps/tree/main/assist_satellite) app (formerly add-on), which uses the Linux Voice Assistant runtime to turn your HA host into a voice satellite.
> [!NOTE]
> For now you first have to add the [OHF-Voice apps](https://github.com/OHF-Voice/apps) repo manually to the App Store repositroy inside Home Assistant before you can install it.
Later you will be able to install it directly from the official add-on repository (but it is not yet published publicly there):
[![Add repository to your Home Assistant instance.](https://my.home-assistant.io/badges/supervisor_addon.svg)](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https://github.com/home-assistant/addons)
Once installed, the satellite is automatically discovered by Home Assistant via the ESPHome integration.
**Raspberry Pi prebuilt image**
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 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). **Docker / bare metal**
#### Parameter overview: 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).
💡 **Note:** There is a [environment variable](docs/install_application.md#environment-variables-reference) for each parameter if you use docker or systemd based setup. ### Parameter overview
💡 **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 ```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] [--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] [--mute-sound MUTE_SOUND] [--unmute-sound UNMUTE_SOUND] [--preferences-file PREFERENCES_FILE] [--host HOST] [--network-interface NETWORK_INTERFACE] [--port PORT] [--enable-thinking-sound] [--debug]
``` ```
| Parameter | Description | Default | | Parameter | Description | Default |
|-----------|-------------|---------| | ---------------------------- | --------------------------------------------------------------- | ----------------------------------- |
| `--name` | Name of the voice assistant device (required) | Autogenerated (`lva-MAC-ADDRESS`) | | `--name` | Name of the voice assistant device (required) | Autogenerated (`lva-MAC-ADDRESS`) |
| `--audio-input-device` | Soundcard name for input device | Autodetected | | `--audio-input-device` | Soundcard name for input device | Autodetected |
| `--audio-input-block-size` | Audio input block size in samples | 1024 | | `--audio-input-block-size` | Audio input block size in samples | 1024 |
| `--audio-output-device` | mpv name for output device | Autodetected | | `--audio-output-device` | mpv name for output device | Autodetected |
| `--mic-volume` | Control microphone volume | 100 |
| `--mic-auto-gain` | Add WebRTC Gain to Mic | 0 |
| `--mic-noise-suppression` | Add WebRTC Noise Suppression to Mic | 0 |
| `--audio-input-channels` | Number of microphone audio channels to stream | 2 |
| `--wake-word-dir` | Directory with wake word models (.tflite) and configs (.json) | `wakewords/` | | `--wake-word-dir` | Directory with wake word models (.tflite) and configs (.json) | `wakewords/` |
| `--wake-model` | ID of active wake word model | `okay_nabu` | | `--wake-model` | ID of active wake word model | `okay_nabu` |
| `--stop-model` | ID of stop model | `stop` | | `--stop-model` | ID of stop model | `stop` |
| `--download-dir` | Directory to download custom wake word models, etc. | `local/` | | `--download-dir` | Directory to download custom wake word models, etc. | `local/` |
| `--refractory-seconds` | Seconds before wake word can be activated again | 2.0 | | `--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` | | `--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` | | `--timer-finished-sound` | Sound file played when timer finishes | `sounds/timer_finished.flac` |
| `--processing-sound` | Sound played while assistant is processing | `sounds/processing.wav` | | `--processing-sound` | Sound played while assistant is processing | `sounds/processing.wav` |
@@ -73,8 +106,11 @@ usage: __main__.py [-h] [--name NAME] [--audio-input-device AUDIO_INPUT_DEVICE]
| `--port` | Port for ESPHome server | 6053 | | `--port` | Port for ESPHome server | 6053 |
| `--enable-thinking-sound` | Enable thinking sound on startup | False | | `--enable-thinking-sound` | Enable thinking sound on startup | False |
| `--debug` | Print DEBUG messages to console | 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. 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,18 +122,19 @@ 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. 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: The project uses the following tools to ensure code quality:
- **Black**: Code formatting (88 characters per line, PEP 8 compliant) - **Black**: Code formatting (88 characters per line, PEP 8 compliant)
- **isort**: Import sorting compatible with Black - **isort**: Import sorting compatible with Black
- **flake8**: Style and syntax checks - **flake8**: Style and syntax checks
- **pylint**: Code quality checks - **pylint**: Code quality checks
- **mypy**: Static type analysis - **mypy**: Static type analysis
### Setup: ### Setup
To use the development tools (linting, testing, etc.), you need to install the required dependencies: To use the development tools (linting, testing, etc.), you need to install the required dependencies:
@@ -106,39 +143,44 @@ To use the development tools (linting, testing, etc.), you need to install the r
source .venv/bin/activate source .venv/bin/activate
``` ```
### Linting Commands: ### Linting Commands
#### Run all linting checks
#### Run all linting checks:
```sh ```sh
./script/lint... ./script/lint...
``` ```
#### Individual linting commands (with auto-fix support): #### Individual linting commands (with auto-fix support)
| Script | Description | Auto-fix Available? | | Script | Description | Auto-fix Available? |
|--------|-------------|---------------------| | ------------------------ | ------------------------------------------ | ----------------------- |
| `./script/lint_black` | Checks Python code formatting with Black | Yes, use`--auto` flag | | `./script/lint_black` | Checks Python code formatting with Black | Yes, use`--auto` flag |
| `./script/lint_flake8` | Runs style and syntax checks with flake8 | No | | `./script/lint_flake8` | Runs style and syntax checks with flake8 | No |
| `./script/lint_isort` | Checks import sorting with isort | Yes, use`--auto` flag | | `./script/lint_isort` | Checks import sorting with isort | Yes, use`--auto` flag |
| `./script/lint_mypy` | Runs static type analysis with mypy | No | | `./script/lint_mypy` | Runs static type analysis with mypy | No |
| `./script/lint_pylint` | Runs code quality checks with pylint | Yes, use`--auto` flag | | `./script/lint_pylint` | Runs code quality checks with pylint | Yes, use`--auto` flag |
#### Examples: #### Examples
Run a specific lint check: Run a specific lint check:
```sh ```sh
./script/lint_black ./script/lint_black
``` ```
Auto-fix formatting issues (Black + isort): Auto-fix formatting issues (Black + isort):
```sh ```sh
./script/lint_black --auto ./script/lint_black --auto
./script/lint_isort --auto ./script/lint_isort --auto
``` ```
### Testing: ### Testing
Run the test suite: Run the test suite:
```sh ```sh
./script/test ./script/test
``` ```
+15 -13
View File
@@ -2,14 +2,14 @@
services: services:
# Fix permissions for wakeword_data and configuration volume # Fix permissions for wakeword_data and configuration volume
fix-permissions: 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: [] entrypoint: []
command: "chown -R ${LVA_USER_ID}:${LVA_USER_GROUP} /app/local /app/configuration /app/wakewords/custom /app/sounds/custom" command: "chown -R ${LVA_USER_ID:-1000}:${LVA_USER_GROUP:-1000} /app/local /app/configuration /app/wakewords/custom /app/sounds/custom"
environment: environment:
# Pulseaudio # Pulseaudio
- XDG_RUNTIME_DIR=${LVA_XDG_RUNTIME_DIR} - XDG_RUNTIME_DIR=${LVA_XDG_RUNTIME_DIR:-/run/user/1000}
- PULSE_SERVER=${LVA_PULSE_SERVER} - PULSE_SERVER=${LVA_PULSE_SERVER:-/run/user/1000/pulse/native}
- PULSE_COOKIE=${LVA_PULSE_COOKIE} - PULSE_COOKIE=${LVA_PULSE_COOKIE:-/run/user/1000/pulse/cookie}
env_file: ${ENV_FILE:-.env} env_file: ${ENV_FILE:-.env}
group_add: group_add:
- audio - audio
@@ -24,16 +24,16 @@ services:
# Linux Voice Assistant # Linux Voice Assistant
linux-voice-assistant: linux-voice-assistant:
container_name: 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 restart: unless-stopped
network_mode: "host" network_mode: "host"
user: "${LVA_USER_ID}:${LVA_USER_GROUP}" user: "${LVA_USER_ID:-1000}:${LVA_USER_GROUP:-1000}"
environment: environment:
# Pulseaudio # Pulseaudio
- XDG_RUNTIME_DIR=${LVA_XDG_RUNTIME_DIR} - XDG_RUNTIME_DIR=${LVA_XDG_RUNTIME_DIR:-/run/user/1000}
- PULSE_SERVER=${LVA_PULSE_SERVER} - PULSE_SERVER=${LVA_PULSE_SERVER:-/run/user/1000/pulse/native}
- PULSE_COOKIE=${LVA_PULSE_COOKIE} - PULSE_COOKIE=${LVA_PULSE_COOKIE:-/run/user/1000/pulse/cookie}
env_file: .env env_file: ${ENV_FILE:-.env}
group_add: group_add:
- audio - audio
# Priority for audio process # Priority for audio process
@@ -50,7 +50,7 @@ services:
- /etc/timezone:/etc/timezone:ro - /etc/timezone:/etc/timezone:ro
# Pulseaudio # Pulseaudio
# /run/user/1000:/run/user/1000 # /run/user/1000:/run/user/1000
- ${LVA_XDG_RUNTIME_DIR}:${LVA_XDG_RUNTIME_DIR} - ${LVA_XDG_RUNTIME_DIR:-/run/user/1000}:${LVA_XDG_RUNTIME_DIR:-/run/user/1000}
# Mount source code for development # Mount source code for development
- ./linux_voice_assistant:/app/linux_voice_assistant - ./linux_voice_assistant:/app/linux_voice_assistant
- ./docker-entrypoint.sh:/app/docker-entrypoint.sh - ./docker-entrypoint.sh:/app/docker-entrypoint.sh
@@ -66,7 +66,9 @@ services:
timeout: 5s timeout: 5s
retries: 3 retries: 3
start_period: 90s start_period: 90s
#command: command:
# - "--mic-noise-suppression"
# - "1"
# - --additional-parameters-here # - --additional-parameters-here
# - --but-use-env-if-possible # - --but-use-env-if-possible
+11 -11
View File
@@ -2,14 +2,14 @@
services: services:
# Fix permissions for wakeword_data and configuration volume # Fix permissions for wakeword_data and configuration volume
fix-permissions: 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: [] entrypoint: []
command: "chown -R ${LVA_USER_ID}:${LVA_USER_GROUP} /app/local /app/configuration /app/wakewords/custom /app/sounds/custom" command: "chown -R ${LVA_USER_ID:-1000}:${LVA_USER_GROUP:-1000} /app/local /app/configuration /app/wakewords/custom /app/sounds/custom"
environment: environment:
# Pulseaudio # Pulseaudio
- XDG_RUNTIME_DIR=${LVA_XDG_RUNTIME_DIR} - XDG_RUNTIME_DIR=${LVA_XDG_RUNTIME_DIR:-/run/user/1000}
- PULSE_SERVER=${LVA_PULSE_SERVER} - PULSE_SERVER=${LVA_PULSE_SERVER:-/run/user/1000/pulse/native}
- PULSE_COOKIE=${LVA_PULSE_COOKIE} - PULSE_COOKIE=${LVA_PULSE_COOKIE:-/run/user/1000/pulse/cookie}
env_file: ${ENV_FILE:-.env} env_file: ${ENV_FILE:-.env}
group_add: group_add:
- audio - audio
@@ -24,15 +24,15 @@ services:
# Linux Voice Assistant # Linux Voice Assistant
linux-voice-assistant: linux-voice-assistant:
container_name: 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 restart: unless-stopped
network_mode: "host" network_mode: "host"
user: "${LVA_USER_ID}:${LVA_USER_GROUP}" user: "${LVA_USER_ID:-1000}:${LVA_USER_GROUP:-1000}"
environment: environment:
# Pulseaudio # Pulseaudio
- XDG_RUNTIME_DIR=${LVA_XDG_RUNTIME_DIR} - XDG_RUNTIME_DIR=${LVA_XDG_RUNTIME_DIR:-/run/user/1000}
- PULSE_SERVER=${LVA_PULSE_SERVER} - PULSE_SERVER=${LVA_PULSE_SERVER:-/run/user/1000/pulse/native}
- PULSE_COOKIE=${LVA_PULSE_COOKIE} - PULSE_COOKIE=${LVA_PULSE_COOKIE:-/run/user/1000/pulse/cookie}
env_file: ${ENV_FILE:-.env} env_file: ${ENV_FILE:-.env}
group_add: group_add:
- audio - audio
@@ -50,7 +50,7 @@ services:
- /etc/timezone:/etc/timezone:ro - /etc/timezone:/etc/timezone:ro
# Pulseaudio # Pulseaudio
# /run/user/1000:/run/user/1000 # /run/user/1000:/run/user/1000
- ${LVA_XDG_RUNTIME_DIR}:${LVA_XDG_RUNTIME_DIR} - ${LVA_XDG_RUNTIME_DIR:-/run/user/1000}:${LVA_XDG_RUNTIME_DIR:-/run/user/1000}
depends_on: depends_on:
- fix-permissions - fix-permissions
# Health check for process # Health check for process
+32 -2
View File
@@ -40,6 +40,22 @@ if [ -n "${AUDIO_OUTPUT_DEVICE}" ]; then
EXTRA_ARGS+=( "--audio-output-device" "$AUDIO_OUTPUT_DEVICE" ) EXTRA_ARGS+=( "--audio-output-device" "$AUDIO_OUTPUT_DEVICE" )
fi 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 [ -n "${AUDIO_INPUT_CHANNELS}" ]; then
EXTRA_ARGS+=( "--audio-input-channels" "$AUDIO_INPUT_CHANNELS" )
fi
if [ "$ENABLE_THINKING_SOUND" = "1" ]; then if [ "$ENABLE_THINKING_SOUND" = "1" ]; then
EXTRA_ARGS+=( "--enable-thinking-sound" ) EXTRA_ARGS+=( "--enable-thinking-sound" )
fi fi
@@ -60,6 +76,10 @@ if [ -n "${REFACTORY_SECONDS}" ]; then
EXTRA_ARGS+=( "--refractory-seconds" "$REFACTORY_SECONDS" ) EXTRA_ARGS+=( "--refractory-seconds" "$REFACTORY_SECONDS" )
fi fi
if [ -n "${CONTINUE_CONVERSATION_DELAY}" ]; then
EXTRA_ARGS+=( "--continue-conversation-delay" "$CONTINUE_CONVERSATION_DELAY" )
fi
if [ -n "${WAKEUP_SOUND}" ]; then if [ -n "${WAKEUP_SOUND}" ]; then
EXTRA_ARGS+=( "--wakeup-sound" "$WAKEUP_SOUND" ) EXTRA_ARGS+=( "--wakeup-sound" "$WAKEUP_SOUND" )
fi fi
@@ -80,13 +100,24 @@ if [ -n "${UNMUTE_SOUND}" ]; then
EXTRA_ARGS+=( "--unmute-sound" "$UNMUTE_SOUND" ) EXTRA_ARGS+=( "--unmute-sound" "$UNMUTE_SOUND" )
fi 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 # 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 if [ ! -f "$PULSE_COOKIE" ]; then
echo "Creating PulseAudio cookie file" echo "Creating PulseAudio cookie file at $PULSE_COOKIE"
touch "$PULSE_COOKIE" touch "$PULSE_COOKIE"
chmod 600 "$PULSE_COOKIE" chmod 600 "$PULSE_COOKIE"
fi fi
fi
### Wait for PulseAudio ### Wait for PulseAudio
@@ -110,7 +141,6 @@ for i in $(seq 1 $CP_MAX_RETRIES); do
echo "⏳ PulseAudio not running yet, retrying in $CP_RETRY_DELAY s..." echo "⏳ PulseAudio not running yet, retrying in $CP_RETRY_DELAY s..."
sleep $CP_RETRY_DELAY sleep $CP_RETRY_DELAY
done done
echo "✅ PulseAudio is running"
### Start application ### Start application
-36
View File
@@ -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'
```
+73
View File
@@ -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](https://github.com/OHF-Voice/webrtc-noise-gain) 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](https://github.com/OHF-Voice/webrtc-noise-gain) 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
+83
View File
@@ -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>
}
}
}
```
+26 -2
View File
@@ -176,14 +176,21 @@ Environment=PREFERENCES_FILE="/home/pi/linux-voice-assistant/preferences.json"
# Environment=PORT="6053" # Environment=PORT="6053"
# Environment=AUDIO_INPUT_DEVICE="default" # Environment=AUDIO_INPUT_DEVICE="default"
# Environment=AUDIO_OUTPUT_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=ENABLE_THINKING_SOUND="1"
# Environment=WAKE_WORD_DIR="app/wakewords"
# Environment=WAKE-MODEL="okay_nabu" # Environment=WAKE-MODEL="okay_nabu"
# Environment=STOP_MODEL="stop"
# Environment=TIMER_MAX_RING_SECONDS="900"
# Environment=REFACTORY_SECONDS="2" # Environment=REFACTORY_SECONDS="2"
# Environment=WAKEUP_SOUND="sounds/wake_word_triggered.flac" # Environment=WAKEUP_SOUND="sounds/wake_word_triggered.flac"
# Environment=TIMER_FINISHED_SOUND="sounds/timer_finished.flac" # Environment=TIMER_FINISHED_SOUND="sounds/timer_finished.flac"
# Environment=PROCESSING_SOUND="sounds/processing.wav" # Environment=PROCESSING_SOUND="sounds/processing.wav"
# Environment=MUTE_SOUND="sounds/mute_switch_on.flac" # Environment=MUTE_SOUND="sounds/mute_switch_on.flac"
# Environment=UNMUTE_SOUND="sounds/mute_switch_off.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
# ExecStart=/home/pi/linux-voice-assistant/docker-entrypoint.sh --additional-parameter-if-you-want # ExecStart=/home/pi/linux-voice-assistant/docker-entrypoint.sh --additional-parameter-if-you-want
Restart=always Restart=always
@@ -237,29 +244,36 @@ The following variables can be configured in the `.env` or in the service file:
| Variable | Default | Description | | 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_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) | | `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 | | `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_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_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 | | `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 | | `PREFERENCES_FILE` | (optional) | Path to a custom preferences JSON file |
| `NETWORK_INTERFACE` | Autodetected | network card for server | | `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 | | `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 | | `PORT` | `6053` | API server port |
| `AUDIO_INPUT_DEVICE` | Autodetected | Audio input device name | | `AUDIO_INPUT_DEVICE` | Autodetected | Audio input device name |
| `AUDIO_OUTPUT_DEVICE` | Autodetected | Audio output device name | | `AUDIO_OUTPUT_DEVICE` | Autodetected | Audio output device name |
| `MIC_VOLUME` | Control microphone volume | 100 |
| `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 | | `ENABLE_THINKING_SOUND` | false | Set to "1" to enable thinking sound |
| `WAKE_WORD_DIR` | `app/wakewords` | Path to the wake word directory | | `WAKE_WORD_DIR` | `app/wakewords` | Path to the wake word directory |
| `WAKE_MODEL` | `okay_nabu` | Wake word model to use | | `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 | | `REFACTORY_SECONDS` | `2` | Refractory period in seconds after wake word |
| `WAKEUP_SOUND` | `sounds/wake_word_triggered.flac` | Sound file for wake word triggered | | `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 | | `TIMER_FINISHED_SOUND` | `sounds/timer_finished.flac` | Sound file for timer finished |
| `PROCESSING_SOUND` | `sounds/processing.wav` | Sound file for processing state | | `PROCESSING_SOUND` | `sounds/processing.wav` | Sound file for processing state |
| `MUTE_SOUND` | `sounds/mute_switch_on.flac` | Sound file for mute on | | `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 | `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. 💡 **Note:** For the systemd installation some variables set in the service need to be without `LVA_` prefix.
@@ -273,16 +287,26 @@ If you want to use your own sounds, you can add them to the `sounds/custom` aka
#### Available Wake Word Models: #### Available Wake Word Models:
The following wake word models are available: The following wake word models are available:
**microWakeWord:**
- `okay_nabu` - Default wake word - `okay_nabu` - Default wake word
- `alexa` - Alexa wake word - `alexa` - Alexa wake word
- `hey_jarvis` - Jarvis wake word - `hey_jarvis` - Jarvis wake word
- `hey_mycroft` - Mycroft wake word - `hey_mycroft` - Mycroft wake word
- `hey_morgan` - Morgan wake word
- `hey_luna` - Luna wake word - `hey_luna` - Luna wake word
- `hey_home_assistant` - Home Assistant wake word - `hey_home_assistant` - Home Assistant wake word
- `stop` - Stop wake word - `stop` - Stop wake word
- `okay_computer` - Okay Computer wake word - `okay_computer` - Okay Computer wake word
- `choo_choo_homie` - Choo Choo Homie wake word - `choo_choo_homie` - Choo Choo Homie wake word
**openWakeWord:**
- `ok_nabu` - Default wake word
- `alexa` - Alexa wake word
- `hey_jarvis` - Jarvis wake word
- `hey_mycroft` - Mycroft wake word
- `hey_rhasspy` - Rhasspy wake word
### Custom Wake Word: ### Custom Wake Word:
+32 -355
View File
@@ -1,6 +1,6 @@
# Install Audioservice # 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?** **How does the communication work?**
@@ -15,7 +15,7 @@ PipeWire is a multimedia server that provides low-latency audio/video handling.
sudo apt update sudo apt update
# Install PipeWire and related packages # 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: 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/ 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 ```sh
sudo mkdir -p /var/lib/systemd/linger 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. 💡 **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): ### Configure PipeWire (optional):
💡 **Note:** If you are not on a desktop system, which is already configured for PipeWire, you can configure it manually. 💡 **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 ```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): Add the following content:
```
```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.
#
context.properties = { 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.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
} }
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: ## B) PulseAudio:
@@ -422,7 +94,7 @@ sudo vi /etc/pulse/daemon.conf
Change the following lines in the file: Change the following lines in the file:
```sh ```sh
default-sample-rate = 48000 default-sample-rate = 16000
``` ```
Enable and start Pulseaudio Enable and start Pulseaudio
@@ -439,9 +111,10 @@ pulseaudio --check
pactl info 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: ## 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. 💡 **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.
+229 -76
View File
@@ -9,15 +9,16 @@ import threading
import time import time
from pathlib import Path from pathlib import Path
from queue import Queue from queue import Queue
from typing import Dict, List, Optional, Set, Union from typing import List, Optional, Union
import numpy as np import numpy as np
import soundcard as sc 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 getmac import get_mac_address # type: ignore
from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures
from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures
from .models import AvailableWakeWord, Preferences, ServerState, WakeWordType from .models import Preferences, ServerState
from .mpv_player import MpvMediaPlayer from .mpv_player import MpvMediaPlayer
from .satellite import VoiceSatelliteProtocol from .satellite import VoiceSatelliteProtocol
from .util import ( from .util import (
@@ -26,6 +27,8 @@ from .util import (
get_esphome_version, get_esphome_version,
get_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 from .zeroconf import HomeAssistantZeroconf
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -68,6 +71,16 @@ async def main() -> None:
action="store_true", action="store_true",
help="List audio output devices and exit", help="List audio output devices and exit",
) )
parser.add_argument("--mic-volume", type=int, default=100, choices=list(range(1, 101)), help="Microphone volume level (1 to 100)")
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(
"--audio-input-channels",
type=int,
default=1,
choices=(1, 2),
help="Number of mic channels to capture and stream (1=mono, 2=dual-channel voice)",
)
parser.add_argument( parser.add_argument(
"--wake-word-dir", "--wake-word-dir",
default=[_WAKEWORDS_DIR], default=[_WAKEWORDS_DIR],
@@ -95,6 +108,12 @@ async def main() -> None:
type=float, type=float,
help="Seconds before wake word can be activated again", help="Seconds before wake word can be activated again",
) )
parser.add_argument(
"--continue-conversation-delay",
type=float,
default=0.5,
help="Seconds to wait after TTS finishes before opening the mic for continued conversation (default: 0.5)",
)
parser.add_argument( parser.add_argument(
"--wakeup-sound", "--wakeup-sound",
default=str(_SOUNDS_DIR / "wake_word_triggered.flac"), default=str(_SOUNDS_DIR / "wake_word_triggered.flac"),
@@ -145,11 +164,22 @@ async def main() -> None:
action="store_true", action="store_true",
help="Enable thinking finish sound, when the assistant is done thinking and needed more time to process", 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( parser.add_argument(
"--debug", "--debug",
action="store_true", action="store_true",
help="Add this to enable debug logging", help="Add this to enable debug logging",
) )
parser.add_argument(
"--output-only",
action="store_true",
help="Enable output only mode",
)
args = parser.parse_args() args = parser.parse_args()
if args.list_input_devices: if args.list_input_devices:
@@ -173,7 +203,7 @@ async def main() -> None:
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO) logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
_LOGGER.debug(args) _LOGGER.debug(args)
# Resolve network interface for mac-adress detection # Resolve network interface for mac-address detection
if not args.network_interface: if not args.network_interface:
print("No network interface specified, try to detect default interface") print("No network interface specified, try to detect default interface")
network_interface = get_default_interface() network_interface = get_default_interface()
@@ -194,8 +224,10 @@ async def main() -> None:
host_ip_address = args.host host_ip_address = args.host
# Resolve mac # Resolve mac
mac_address = get_mac_address(interface=network_interface) if not (mac_address := get_mac_address(interface=network_interface)):
mac_address_clean = mac_address.replace(":", "") print("No Mac address was found, app stopped.")
sys.exit(1)
mac_address_clean = mac_address.replace(":", "").lower()
# Resolve name # Resolve name
if not args.name: if not args.name:
@@ -207,8 +239,8 @@ async def main() -> None:
print(f"Using friendly name: {args.name}") print(f"Using friendly name: {args.name}")
friendly_name = args.name friendly_name = args.name
mac_no_colon = mac_address.replace(":", "").lower() device_name = f"lva-{mac_address_clean}"
device_name = f"lva-{mac_no_colon}"
print(f"Device name: {device_name}") print(f"Device name: {device_name}")
# Resolve version # Resolve version
@@ -237,32 +269,7 @@ async def main() -> None:
# Load available wake words # Load available wake words
wake_word_dirs = [Path(ww_dir) for ww_dir in args.wake_word_dir] wake_word_dirs = [Path(ww_dir) for ww_dir in args.wake_word_dir]
wake_word_dirs.append(args.download_dir / "external_wake_words") wake_word_dirs.append(args.download_dir / "external_wake_words")
available_wake_words: Dict[str, AvailableWakeWord] = {} available_wake_words = find_available_wake_words(wake_word_dirs, args.stop_model)
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())))
# Load preferences # Load preferences
preferences_path = Path(args.preferences_file) preferences_path = Path(args.preferences_file)
@@ -282,48 +289,33 @@ async def main() -> None:
if args.enable_thinking_sound: if args.enable_thinking_sound:
preferences.thinking_sound = 1 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_volume > 0.0:
preferences.mic_volume = args.mic_volume
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 # Load wake/stop models
active_wake_words: Set[str] = set() 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)
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)
# TODO: allow openWakeWord for "stop" # TODO: allow openWakeWord for "stop"
stop_model: Optional[MicroWakeWord] = None stop_model = load_stop_model(wake_word_dirs, args.stop_model)
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
assert stop_model is not None assert stop_model is not None
state = ServerState( state = ServerState(
name=device_name, name=device_name,
friendly_name=friendly_name, friendly_name=friendly_name,
network_interface=network_interface, network_interface=network_interface,
mac_address=get_mac_address(interface=network_interface), mac_address=mac_address,
ip_address=host_ip_address, ip_address=host_ip_address,
version=version, version=version,
esphome_version=esphome_version, esphome_version=esphome_version,
@@ -343,11 +335,27 @@ async def main() -> None:
preferences=preferences, preferences=preferences,
preferences_path=preferences_path, preferences_path=preferences_path,
refractory_seconds=args.refractory_seconds, refractory_seconds=args.refractory_seconds,
continue_conversation_delay=args.continue_conversation_delay,
output_only=args.output_only,
download_dir=args.download_dir, download_dir=args.download_dir,
volume=initial_volume, volume=initial_volume,
mic_volume=preferences.mic_volume,
mic_auto_gain=preferences.mic_auto_gain,
mic_noise_suppression=preferences.mic_noise_suppression,
audio_input_channels=args.audio_input_channels,
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() state.save_preferences()
initial_volume_percent = int(round(initial_volume * 100)) initial_volume_percent = int(round(initial_volume * 100))
@@ -359,6 +367,22 @@ async def main() -> None:
attempt = 1 attempt = 1
server = None 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: while attempt <= max_attempts:
try: try:
server = await loop.create_server(lambda: VoiceSatelliteProtocol(state), host=host_ip_address, port=args.port) server = await loop.create_server(lambda: VoiceSatelliteProtocol(state), host=host_ip_address, port=args.port)
@@ -404,6 +428,7 @@ async def main() -> None:
def process_audio(state: ServerState, mic, block_size: int): def process_audio(state: ServerState, mic, block_size: int):
"""Process audio chunks from the microphone.""" """Process audio chunks from the microphone."""
n_channels = state.audio_input_channels
wake_words: List[Union[MicroWakeWord, OpenWakeWord]] = [] wake_words: List[Union[MicroWakeWord, OpenWakeWord]] = []
micro_features: Optional[MicroWakeWordFeatures] = None micro_features: Optional[MicroWakeWordFeatures] = None
@@ -414,27 +439,119 @@ def process_audio(state: ServerState, mic, block_size: int):
has_oww = False has_oww = False
last_active: Optional[float] = None last_active: Optional[float] = None
webrtc: Optional[WebRTCProcessor] = None
try: try:
_LOGGER.debug("Opening audio input device: %s", mic.name) _LOGGER.debug("Opening audio input device: %s", mic.name)
with mic.recorder(samplerate=16000, channels=1, blocksize=block_size) as mic_in: with mic.recorder(samplerate=16000, channels=n_channels, blocksize=block_size) as mic_in:
while True: while True:
audio_chunk_array = mic_in.record(block_size).reshape(-1) # Shape: (block_size, n_channels) for stereo, (block_size, 1) for mono.
audio_chunk = (np.clip(audio_chunk_array, -1.0, 1.0) * 32767.0).astype("<i2").tobytes() # little-endian 16-bit signed raw = mic_in.record(block_size) # float32, range [-1, 1]
mic_vol_scalar = max(0.1, min(1.0, state.mic_volume / 100.0))
if state.satellite is None: # Build per-channel byte arrays. Channel 0 is the primary
# microphone; channel 1 (when present) is the reference/speaker
# feed used for server-side AEC.
channel_chunks: list[bytes] = []
for ch in range(n_channels):
col = raw[:, ch] if n_channels > 1 else raw.reshape(-1)
chunk = (np.clip(col * mic_vol_scalar, -1.0, 1.0) * 32767.0).astype("<i2").tobytes()
channel_chunks.append(chunk)
# Primary channel drives WebRTC and wake-word detection.
audio_chunk = channel_chunks[0]
agc = state.preferences.mic_auto_gain or 0
ns = state.preferences.mic_noise_suppression or 0
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 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): if (not wake_words) or (state.wake_words_changed and state.wake_words):
# Update list of wake word models to process # Update list of wake word models to process
state.wake_words_changed = False state.wake_words_changed = False
wake_words = [ww for ww in state.wake_words.values() if ww.id in state.active_wake_words] 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 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): if isinstance(wake_word, OpenWakeWord):
has_oww = True 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: if micro_features is None:
micro_features = MicroWakeWordFeatures() micro_features = MicroWakeWordFeatures()
@@ -442,7 +559,9 @@ def process_audio(state: ServerState, mic, block_size: int):
oww_features = OpenWakeWordFeatures.from_builtin() oww_features = OpenWakeWordFeatures.from_builtin()
try: try:
state.satellite.handle_audio(audio_chunk) # Both channels travel in one message: data=ch0 (enhanced), data2=ch1 (raw reference)
audio_chunk_2 = channel_chunks[1] if n_channels >= 2 else None
state.satellite.handle_audio(audio_chunk, audio_chunk_2)
assert micro_features is not None assert micro_features is not None
micro_inputs.clear() micro_inputs.clear()
@@ -453,16 +572,36 @@ def process_audio(state: ServerState, mic, block_size: int):
oww_inputs.clear() oww_inputs.clear()
oww_inputs.extend(oww_features.process_streaming(audio_chunk)) 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 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): 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: for micro_input in micro_inputs:
if wake_word.process_streaming(micro_input): if wake_word.process_streaming(micro_input):
wake_word.debug_probabilities = True
activated = True activated = True
elif isinstance(wake_word, OpenWakeWord): elif isinstance(wake_word, OpenWakeWord):
for oww_input in oww_inputs: for oww_input in oww_inputs:
for prob in wake_word.process_streaming(oww_input): 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 activated = True
if activated and not state.muted: if activated and not state.muted:
@@ -474,11 +613,20 @@ def process_audio(state: ServerState, mic, block_size: int):
# Always process to keep state correct # Always process to keep state correct
stopped = False 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: for micro_input in micro_inputs:
if state.stop_word.process_streaming(micro_input): if state.stop_word.process_streaming(micro_input):
state.stop_word.debug_probabilities = True
stopped = True stopped = True
if stopped and (state.stop_word.id in state.active_wake_words) and not state.muted: if stopped and (state.stop_word.id in state.active_wake_words) and not state.muted:
_LOGGER.debug("Stop word detected")
state.satellite.stop() state.satellite.stop()
except Exception: except Exception:
_LOGGER.exception("Unexpected error handling audio") _LOGGER.exception("Unexpected error handling audio")
@@ -489,5 +637,10 @@ def process_audio(state: ServerState, mic, block_size: int):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
if __name__ == "__main__":
def run():
asyncio.run(main()) asyncio.run(main())
if __name__ == "__main__":
run()
+284
View File
@@ -6,10 +6,16 @@ from typing import Callable, List, Optional, Union
# pylint: disable=no-name-in-module # pylint: disable=no-name-in-module
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined] from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
ListEntitiesMediaPlayerResponse, ListEntitiesMediaPlayerResponse,
ListEntitiesNumberResponse,
ListEntitiesRequest, ListEntitiesRequest,
ListEntitiesSelectResponse,
ListEntitiesSwitchResponse, ListEntitiesSwitchResponse,
MediaPlayerCommandRequest, MediaPlayerCommandRequest,
MediaPlayerStateResponse, MediaPlayerStateResponse,
NumberCommandRequest,
NumberStateResponse,
SelectCommandRequest,
SelectStateResponse,
SubscribeHomeAssistantStatesRequest, SubscribeHomeAssistantStatesRequest,
SwitchCommandRequest, SwitchCommandRequest,
SwitchStateResponse, SwitchStateResponse,
@@ -19,6 +25,7 @@ from aioesphomeapi.model import (
MediaPlayerCommand, MediaPlayerCommand,
MediaPlayerEntityFeature, MediaPlayerEntityFeature,
MediaPlayerState, MediaPlayerState,
NumberMode,
) )
from google.protobuf import message from google.protobuf import message
@@ -116,6 +123,10 @@ class MediaPlayerEntity(ESPHomeEntity):
def handle_message(self, msg: message.Message) -> Iterable[message.Message]: def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
self._log.debug("handle_message called with msg: %s", msg) 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): if isinstance(msg, MediaPlayerCommandRequest) and (msg.key == self.key):
self._log.debug("MediaPlayerCommandRequest matched for this key") self._log.debug("MediaPlayerCommandRequest matched for this key")
@@ -347,3 +358,276 @@ class ThinkingSoundEntity(ESPHomeEntity):
# Always return our internal switch state # Always return our internal switch state
self.sync_with_state() self.sync_with_state()
yield SwitchStateResponse(key=self.key, state=self._switch_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
# -----------------------------------------------------------------------------
+63 -1
View File
@@ -15,8 +15,12 @@ if TYPE_CHECKING:
from .entity import ( from .entity import (
ESPHomeEntity, ESPHomeEntity,
MediaPlayerEntity, MediaPlayerEntity,
MicSettingEntity,
MuteSwitchEntity, MuteSwitchEntity,
StopWordSensitivityNumberEntity,
ThinkingSoundEntity, ThinkingSoundEntity,
WakeWord1SensitivityNumberEntity,
WakeWord2SensitivityNumberEntity,
) )
from .mpv_player import MpvMediaPlayer from .mpv_player import MpvMediaPlayer
from .satellite import VoiceSatelliteProtocol from .satellite import VoiceSatelliteProtocol
@@ -36,6 +40,7 @@ class AvailableWakeWord:
wake_word: str wake_word: str
trained_languages: List[str] trained_languages: List[str]
wake_word_path: Path wake_word_path: Path
probability_cutoff: float = 0.7
def load(self) -> "Union[MicroWakeWord, OpenWakeWord]": def load(self) -> "Union[MicroWakeWord, OpenWakeWord]":
if self.type == WakeWordType.MICRO_WAKE_WORD: if self.type == WakeWordType.MICRO_WAKE_WORD:
@@ -56,9 +61,16 @@ class AvailableWakeWord:
@dataclass @dataclass
class Preferences: 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 volume: Optional[float] = None
thinking_sound: int = 0 # 0 = disabled, 1 = enabled 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 @dataclass
@@ -86,17 +98,36 @@ class ServerState:
preferences: Preferences preferences: Preferences
preferences_path: Path preferences_path: Path
download_dir: Path download_dir: Path
continue_conversation_delay: float = 0.5 # seconds to wait after TTS before opening mic
media_player_entity: "Optional[MediaPlayerEntity]" = None media_player_entity: "Optional[MediaPlayerEntity]" = None
satellite: "Optional[VoiceSatelliteProtocol]" = None satellite: "Optional[VoiceSatelliteProtocol]" = None
mute_switch_entity: "Optional[MuteSwitchEntity]" = None mute_switch_entity: "Optional[MuteSwitchEntity]" = None
thinking_sound_entity: "Optional[ThinkingSoundEntity]" = 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 wake_words_changed: bool = False
refractory_seconds: float = 2.0 refractory_seconds: float = 2.0
thinking_sound_enabled: bool = False thinking_sound_enabled: bool = False
output_only: bool = False
muted: bool = False muted: bool = False
connected: bool = False connected: bool = False
volume: float = 1.0 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
audio_input_channels: int = 2 # number of mic channels to stream
timer_max_ring_seconds: float = 900.0
def save_preferences(self) -> None: def save_preferences(self) -> None:
"""Save preferences as JSON.""" """Save preferences as JSON."""
@@ -129,3 +160,34 @@ class ServerState:
_LOGGER.info("Saving volume %s to %s", clamped_volume, self.preferences_path) _LOGGER.info("Saving volume %s to %s", clamped_volume, self.preferences_path)
self.save_preferences() self.save_preferences()
_LOGGER.info("Volume saved successfully") _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()
+15
View File
@@ -37,6 +37,21 @@ class LibMpvPlayer(AudioPlayer):
if device: if device:
self._mpv["audio-device"] = 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 # Callback Handling
self._done_callback: Optional[Callable[[], None]] = None self._done_callback: Optional[Callable[[], None]] = None
self._mpv.event_callback("end-file")(self._on_end_file) self._mpv.event_callback("end-file")(self._on_end_file)
+333 -25
View File
@@ -5,6 +5,7 @@ import hashlib
import logging import logging
import posixpath import posixpath
import shutil import shutil
import threading
import time import time
from collections.abc import Iterable from collections.abc import Iterable
from typing import Dict, List, Optional, Set, Union from typing import Dict, List, Optional, Set, Union
@@ -19,6 +20,8 @@ from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
ListEntitiesDoneResponse, ListEntitiesDoneResponse,
ListEntitiesRequest, ListEntitiesRequest,
MediaPlayerCommandRequest, MediaPlayerCommandRequest,
NumberCommandRequest,
SelectCommandRequest,
SubscribeHomeAssistantStatesRequest, SubscribeHomeAssistantStatesRequest,
SwitchCommandRequest, SwitchCommandRequest,
VoiceAssistantAnnounceFinished, VoiceAssistantAnnounceFinished,
@@ -44,7 +47,15 @@ from pymicro_wakeword import MicroWakeWord
from pyopen_wakeword import OpenWakeWord from pyopen_wakeword import OpenWakeWord
from .api_server import APIServer 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 .models import AvailableWakeWord, ServerState, WakeWordType
from .util import call_all from .util import call_all
@@ -52,6 +63,8 @@ _LOGGER = logging.getLogger(__name__)
PROTO_TO_MESSAGE_TYPE = {v: k for k, v in MESSAGE_TYPE_TO_PROTO.items()} PROTO_TO_MESSAGE_TYPE = {v: k for k, v in MESSAGE_TYPE_TO_PROTO.items()}
_HAS_AUDIO_DATA2 = "data2" in {f.name for f in VoiceAssistantAudio.DESCRIPTOR.fields}
class VoiceSatelliteProtocol(APIServer): class VoiceSatelliteProtocol(APIServer):
@@ -62,7 +75,21 @@ class VoiceSatelliteProtocol(APIServer):
self.state.satellite = self self.state.satellite = self
self.state.connected = False 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
)
# Channel 1 carries echo-reference audio; advertise SPEAKER so HA knows to use it for server-side AEC.
if state.audio_input_channels >= 2:
self.supported_features |= VoiceAssistantFeature.MULTI_CHANNEL_AUDIO # pylint: disable=no-member
existing_media_players = [entity for entity in self.state.entities if isinstance(entity, MediaPlayerEntity)] existing_media_players = [entity for entity in self.state.entities if isinstance(entity, MediaPlayerEntity)]
if existing_media_players: if existing_media_players:
# Keep the first instance and remove any extras. # Keep the first instance and remove any extras.
self.state.media_player_entity = existing_media_players[0] self.state.media_player_entity = existing_media_players[0]
@@ -147,11 +174,157 @@ class VoiceSatelliteProtocol(APIServer):
thinking_sound_switch.update_set_thinking_sound_enabled(self._set_thinking_sound_enabled) thinking_sound_switch.update_set_thinking_sound_enabled(self._set_thinking_sound_enabled)
thinking_sound_switch.sync_with_state() 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._is_streaming_audio = False
self._tts_url: Optional[str] = None self._tts_url: Optional[str] = None
self._tts_played = False self._tts_played = False
self._continue_conversation = False self._continue_conversation = False
self._timer_finished = False self._timer_finished = False
self._timer_ring_start: Optional[float] = None
self._processing = False self._processing = False
self._pipeline_active = False self._pipeline_active = False
self._external_wake_words: Dict[str, VoiceAssistantExternalWakeWord] = {} self._external_wake_words: Dict[str, VoiceAssistantExternalWakeWord] = {}
@@ -168,6 +341,33 @@ class VoiceSatelliteProtocol(APIServer):
pass pass
self.state.save_preferences() 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: def _set_muted(self, new_state: bool) -> None:
self.state.muted = bool(new_state) self.state.muted = bool(new_state)
@@ -220,9 +420,12 @@ class VoiceSatelliteProtocol(APIServer):
self.play_tts() self.play_tts()
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END: elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END:
self._is_streaming_audio = False self._is_streaming_audio = False
self._pipeline_active = False
if not self._tts_played: if not self._tts_played:
self._pipeline_active = False
self._tts_finished() 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 self._tts_played = False
@@ -238,6 +441,7 @@ class VoiceSatelliteProtocol(APIServer):
if not self._timer_finished: if not self._timer_finished:
self.state.active_wake_words.add(self.state.stop_word.id) self.state.active_wake_words.add(self.state.stop_word.id)
self._timer_finished = True self._timer_finished = True
self._timer_ring_start = time.monotonic()
self.duck() self.duck()
self._play_timer_finished() self._play_timer_finished()
@@ -281,18 +485,11 @@ class VoiceSatelliteProtocol(APIServer):
mac_address=self.state.mac_address, mac_address=self.state.mac_address,
manufacturer="Open Home Foundation", manufacturer="Open Home Foundation",
model="Linux Voice Assistant", model="Linux Voice Assistant",
voice_assistant_feature_flags=( voice_assistant_feature_flags=self.supported_features,
VoiceAssistantFeature.VOICE_ASSISTANT | VoiceAssistantFeature.API_AUDIO | VoiceAssistantFeature.ANNOUNCE | VoiceAssistantFeature.START_CONVERSATION | VoiceAssistantFeature.TIMERS
),
) )
elif isinstance( elif isinstance(
msg, msg,
( (ListEntitiesRequest, SubscribeHomeAssistantStatesRequest, MediaPlayerCommandRequest, SwitchCommandRequest, NumberCommandRequest, SelectCommandRequest),
ListEntitiesRequest,
SubscribeHomeAssistantStatesRequest,
MediaPlayerCommandRequest,
SwitchCommandRequest,
),
): ):
for entity in self.state.entities: for entity in self.state.entities:
yield from entity.handle_message(msg) yield from entity.handle_message(msg)
@@ -300,6 +497,9 @@ class VoiceSatelliteProtocol(APIServer):
if isinstance(msg, ListEntitiesRequest): if isinstance(msg, ListEntitiesRequest):
yield ListEntitiesDoneResponse() yield ListEntitiesDoneResponse()
elif isinstance(msg, VoiceAssistantConfigurationRequest): 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 = [ available_wake_words = [
VoiceAssistantWakeWord( VoiceAssistantWakeWord(
id=ww.id, id=ww.id,
@@ -309,10 +509,20 @@ class VoiceSatelliteProtocol(APIServer):
for ww in self.state.available_wake_words.values() 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: 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": if eww.model_type != "micro":
_LOGGER.debug(" → Skipping: not micro model type")
continue continue
_LOGGER.debug(" → Adding to available wake words")
available_wake_words.append( available_wake_words.append(
VoiceAssistantWakeWord( VoiceAssistantWakeWord(
id=eww.id, id=eww.id,
@@ -322,23 +532,36 @@ class VoiceSatelliteProtocol(APIServer):
) )
self._external_wake_words[eww.id] = eww 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( yield VoiceAssistantConfigurationResponse(
available_wake_words=available_wake_words, 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, 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): elif isinstance(msg, VoiceAssistantSetConfiguration):
# Change active wake words # Change active wake words
active_wake_words: Set[str] = set() 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: for wake_word_id in msg.active_wake_words:
if wake_word_id in self.state.wake_words: if wake_word_id in self.state.wake_words:
# Already active # Already active
active_wake_words.add(wake_word_id) active_wake_words.add(wake_word_id)
continue else:
model_info = self.state.available_wake_words.get(wake_word_id) model_info = self.state.available_wake_words.get(wake_word_id)
if not model_info: if not model_info:
# Check external wake words (may require download) # Check external wake words (may require download)
@@ -357,29 +580,73 @@ class VoiceSatelliteProtocol(APIServer):
_LOGGER.info("Wake word set: %s", wake_word_id) _LOGGER.info("Wake word set: %s", wake_word_id)
active_wake_words.add(wake_word_id) active_wake_words.add(wake_word_id)
break
# 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 self.state.active_wake_words = active_wake_words
_LOGGER.debug("Active wake words: %s", 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.save_preferences()
self.state.wake_words_changed = True self.state.wake_words_changed = True
def handle_audio(self, audio_chunk: bytes) -> None: # handle_audio — both channels in ONE message
def handle_audio(self, audio_chunk: bytes, audio_chunk_2: Optional[bytes] = None) -> None:
if not self._is_streaming_audio or self.state.muted: if not self._is_streaming_audio or self.state.muted:
return return
if _HAS_AUDIO_DATA2 and audio_chunk_2 is not None:
self.send_messages([VoiceAssistantAudio(data=audio_chunk, data2=audio_chunk_2)])
else:
self.send_messages([VoiceAssistantAudio(data=audio_chunk)]) self.send_messages([VoiceAssistantAudio(data=audio_chunk)])
def wakeup(self, wake_word: Union[MicroWakeWord, OpenWakeWord]) -> None: def wakeup(self, wake_word: Union[MicroWakeWord, OpenWakeWord]) -> None:
if self._timer_finished: 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_finished = False
self._timer_ring_start = None
self.state.active_wake_words.discard(self.state.stop_word.id)
self.unduck() self.unduck()
self.state.tts_player.stop() 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 return
if self.state.muted: if self.state.muted:
@@ -413,13 +680,15 @@ class VoiceSatelliteProtocol(APIServer):
if self._timer_finished: if self._timer_finished:
self._timer_finished = False self._timer_finished = False
self._timer_ring_start = None
self.unduck() self.unduck()
self.state.tts_player.stop() self.state.tts_player.stop()
_LOGGER.debug("Stopping timer finished sound") _LOGGER.debug("Stopping timer finished sound")
else: else:
# tts_player.stop() invokes the done_callback (_tts_finished),
# so we don't call _tts_finished() again explicitly.
self.state.tts_player.stop() self.state.tts_player.stop()
_LOGGER.debug("TTS response stopped manually") _LOGGER.debug("TTS response stopped manually")
self._tts_finished()
def play_tts(self) -> None: def play_tts(self) -> None:
if (not self._tts_url) or self._tts_played: if (not self._tts_url) or self._tts_played:
@@ -445,11 +714,25 @@ class VoiceSatelliteProtocol(APIServer):
self.send_messages([VoiceAssistantAnnounceFinished()]) self.send_messages([VoiceAssistantAnnounceFinished()])
if self._continue_conversation: if self._continue_conversation:
self._continue_conversation = False
# Keep pipeline active during the settle delay so the mic stays closed
# and does not capture the tail end of the TTS audio from the speaker.
self._pipeline_active = True
_LOGGER.debug("Continuing conversation after %.2fs settle delay", self.state.continue_conversation_delay)
def _start_continued_conversation() -> None:
if self.state.muted:
_LOGGER.debug("Skipping continued conversation: muted")
self._pipeline_active = False
self.unduck()
return
self.send_messages([VoiceAssistantRequest(start=True)]) self.send_messages([VoiceAssistantRequest(start=True)])
self._is_streaming_audio = True self._is_streaming_audio = True
self._pipeline_active = True _LOGGER.debug("Continued conversation started")
_LOGGER.debug("Continuing conversation")
threading.Timer(self.state.continue_conversation_delay, _start_continued_conversation).start()
else: else:
self._continue_conversation = False
self.unduck() self.unduck()
_LOGGER.debug("TTS response finished") _LOGGER.debug("TTS response finished")
@@ -458,6 +741,22 @@ class VoiceSatelliteProtocol(APIServer):
if not self._timer_finished: if not self._timer_finished:
_LOGGER.debug("Timer finished sound stopped") _LOGGER.debug("Timer finished sound stopped")
self.unduck() 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 return
self.state.tts_player.play( self.state.tts_player.play(
@@ -498,6 +797,15 @@ class VoiceSatelliteProtocol(APIServer):
if self.state.mute_switch_entity is not None: if self.state.mute_switch_entity is not None:
self.state.mute_switch_entity.sync_with_state() 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") _LOGGER.info("Disconnected from Home Assistant; waiting for reconnection")
def process_packet(self, msg_type: int, packet_data: bytes) -> None: def process_packet(self, msg_type: int, packet_data: bytes) -> None:
+202
View File
@@ -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
+41
View File
@@ -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)
+5
View File
@@ -9,3 +9,8 @@ ignore_missing_imports = True
[mypy-soundcard.*] [mypy-soundcard.*]
ignore_missing_imports = True ignore_missing_imports = True
[mypy-aioesphomeapi.api_pb2]
ignore_missing_imports = True
allow_subclassing_any = True
disable_error_code = attr-defined
+8 -4
View File
@@ -1,10 +1,9 @@
[build-system] [build-system]
requires = ["setuptools>=62.3"] requires = ["setuptools>=62.3", "setuptools-scm>=8"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
name = "linux-voice-assistant" name = "linux-voice-assistant"
version = "1.0.0"
license = {text = "Apache-2.0"} license = {text = "Apache-2.0"}
description = "Linux voice assistant for Home Assistant using the ESPHome protocol" description = "Linux voice assistant for Home Assistant using the ESPHome protocol"
readme = "README.md" readme = "README.md"
@@ -23,7 +22,7 @@ classifiers = [
] ]
requires-python = ">=3.11.0" requires-python = ">=3.11.0"
dependencies = [ dependencies = [
"aioesphomeapi==42.7.0", "aioesphomeapi==45.3.1",
"netifaces2==0.0.22", "netifaces2==0.0.22",
"soundcard<1", "soundcard<1",
"numpy>=2,<3", "numpy>=2,<3",
@@ -33,7 +32,9 @@ dependencies = [
"zeroconf<1", "zeroconf<1",
"getmac<1", "getmac<1",
"types-protobuf", "types-protobuf",
"webrtc_noise_gain",
] ]
dynamic = ["version"]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
@@ -46,6 +47,9 @@ dev = [
"autopep8", "autopep8",
] ]
[project.scripts]
linux-voice-assistant = "linux_voice_assistant.__main__:run"
[project.urls] [project.urls]
"Source Code" = "http://github.com/OHF-Voice/linux-voice-assistant" "Source Code" = "http://github.com/OHF-Voice/linux-voice-assistant"
@@ -55,7 +59,7 @@ zip-safe = true
include-package-data = true include-package-data = true
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
include = ["linux_voice_assistant"] include = ["linux_voice_assistant", "linux_voice_assistant.*"]
exclude = ["tests", "tests.*"] exclude = ["tests", "tests.*"]
[tool.black] [tool.black]
+18
View File
@@ -0,0 +1,18 @@
{
"type": "micro",
"wake_word": "Hey Morgan",
"author": "John Karabudak",
"website": "https://johnthenerd.com",
"model": "hey_morgan.tflite",
"trained_languages": [
"en"
],
"version": 2,
"micro": {
"probability_cutoff": 0.9,
"sliding_window_size": 8,
"feature_step_size": 10,
"tensor_arena_size": 30000,
"minimum_esphome_version": "2024.7.0"
}
}
Binary file not shown.
+4 -1
View File
@@ -1,5 +1,8 @@
{ {
"type": "openWakeWord", "type": "openWakeWord",
"wake_word": "Alexa", "wake_word": "Alexa",
"model": "alexa_v0.1.tflite" "model": "alexa_v0.1.tflite",
"openWakeWord": {
"probability_cutoff": 0.7
}
} }
+4 -1
View File
@@ -1,5 +1,8 @@
{ {
"type": "openWakeWord", "type": "openWakeWord",
"wake_word": "Hey Jarvis", "wake_word": "Hey Jarvis",
"model": "hey_jarvis_v0.1.tflite" "model": "hey_jarvis_v0.1.tflite",
"openWakeWord": {
"probability_cutoff": 0.7
}
} }
+4 -1
View File
@@ -1,5 +1,8 @@
{ {
"type": "openWakeWord", "type": "openWakeWord",
"wake_word": "Hey Mycroft", "wake_word": "Hey Mycroft",
"model": "hey_mycroft_v0.1.tflite" "model": "hey_mycroft_v0.1.tflite",
"openWakeWord": {
"probability_cutoff": 0.7
}
} }
+4 -1
View File
@@ -1,5 +1,8 @@
{ {
"type": "openWakeWord", "type": "openWakeWord",
"wake_word": "Hey Rhasspy", "wake_word": "Hey Rhasspy",
"model": "hey_rhasspy_v0.1.tflite" "model": "hey_rhasspy_v0.1.tflite",
"openWakeWord": {
"probability_cutoff": 0.7
}
} }
+5 -2
View File
@@ -1,5 +1,8 @@
{ {
"type": "openWakeWord", "type": "openWakeWord",
"wake_word": "Okay Nabu", "wake_word": "Ok Nabu",
"model": "ok_nabu_v0.1.tflite" "model": "ok_nabu_v0.1.tflite",
"openWakeWord": {
"probability_cutoff": 0.7
}
} }