Compare commits

..
111 Commits
Author SHA1 Message Date
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
Florian Asche 3027b33f51 remove cliff.toml 2026-03-11 01:29:25 +01:00
Florian Asche cddec7709c add changelog based on release notes (#225) 2026-03-11 01:27:17 +01:00
Hedda be9c1220cd Enhance hardware compatibility details in README (#222)
* Enhance hardware compatibility details in README
2026-03-11 01:15:25 +01:00
Omar Amin 893476ebf2 Merge pull request #224 from omaramin-2000/main
Update the changelog file automatically on each release
2026-03-11 01:42:25 +02:00
Omar Amin 50f6fef860 Create cliff.toml 2026-03-11 01:11:49 +02:00
Omar Amin cd453b685a Merge branch 'main' into main 2026-03-11 01:08:49 +02:00
Omar Amin 79c34cf0b3 Update CHANGELOG.md 2026-03-11 01:06:29 +02:00
Omar Amin fc8ee9b691 Update CHANGELOG.md 2026-03-11 01:05:53 +02:00
Omar Amin 7868f8fc8b Merge branch 'main' into main 2026-03-11 01:03:14 +02:00
Omar Amin 5771fb9e1e Update CHANGELOG.md 2026-03-11 01:02:23 +02:00
Omar Amin 25cd705d88 Delete release-please-config.json 2026-03-11 00:48:21 +02:00
Omar Amin f5b07381c6 Delete release-please-manifest.json 2026-03-11 00:48:09 +02:00
Omar Amin 50ac5f8783 Rename release-please.yml to release.yml 2026-03-11 00:47:46 +02:00
Omar Amin f81d21e37b Update release-please.yml 2026-03-11 00:44:09 +02:00
Omar Amin 53ae51cf52 Create release-please-manifest.json 2026-03-11 00:37:10 +02:00
Omar Amin 454a1ea281 Create release-please-config.json 2026-03-11 00:33:28 +02:00
Omar Amin 18d0a19cb4 Create release-please.yml 2026-03-11 00:29:18 +02:00
Florian Asche d6b803e6d6 fix(config): remove unix: prefix from default pulseaudio socket path … (#221)
* fix(config): remove unix: prefix from default pulseaudio socket path in .env.example and docs
* docs: update install documentation
2026-03-10 02:03:07 +01:00
Florian Asche b53678e0f3 fix: cookie error (#127) (#210)
* Add support for custom PulseAudio cookie configuration
2026-03-07 17:33:48 +01:00
Florian Asche efbad18275 docs: add documentation for low power device setup and improve setup script (#206) 2026-03-05 23:59:41 +01:00
Florian Asche 1104614608 fix(script): use os.environ instead of subprocess.environ in setup script (#205) 2026-03-05 02:14:09 +01:00
Florian Asche 54c6b010f3 Change when lint pipeline will start (#204) 2026-03-05 02:04:08 +01:00
Florian Asche 1fc29241b4 Add linting scripts and pipeline (#201)
* Add linting scripts and pipeline
2026-03-05 01:58:39 +01:00
Florian Asche 5b1d3b75e2 replaced branch build by PR build only (#203) 2026-03-05 01:35:06 +01:00
Florian Asche 06d30ac95e add build flag option for low end devices (#189)
* add build flag option for low-end devices
2026-03-04 20:58:53 +01:00
Florian Asche a62800f5ad fix typo (#200) 2026-03-04 01:47:18 +01:00
Florian Asche 29f11c7dbc improved help output on application parameters (#188) 2026-03-04 01:09:10 +01:00
Florian Asche f2e3d8e7d3 Feature: move portcheck into python application (#187)
* moved port check from shell script into the python application
* fixed pulseaudio check console output
* portcheck has now 15 chances to get the port before abort
2026-03-04 01:08:45 +01:00
Florian Asche b800f93f50 Improve script/run error handling and return code consistency (#186) 2026-03-04 01:07:45 +01:00
Florian Asche 68d2890fdd fix: ensure wake word state is set after the wake sound (#198) 2026-03-04 01:07:19 +01:00
Ethan Kiczek a30a10c95a Prevent wake word from interrupting active voice pipeline (#151)
* Prevent wake word from interrupting active voice pipeline
2026-03-04 00:54:19 +01:00
Florian Asche 43f7cd3e7d added default sample rate to pulse documentation (#190) 2026-03-02 16:15:45 +01:00
Florian Asche ecace7011f bugfix: start listening after wakeup sound (#185) 2026-03-01 21:01:35 +01:00
Florian Asche 80e0d6254e Fix: Suppress spurious end-file event when starting playback and tts.speak (#179)
* bugfix: supress first end of file event and only callback on second one
* add some notes on testing
* fixed tts.speak
* fix unduck after timer end
* use custom docker-entrypoint on dev container
* change mv to copy in the docs when using .env file
2026-02-26 01:31:18 +01:00
Daniel Nowak 85312d3f55 Allow portainer stack to point directly to docker-compose.yml (#177)
Portainer stacks can point to a docker-compose.yml directly in a github repo.
2026-02-24 02:06:37 +01:00
Omar Amin 9dcc4542a4 Enhance play method for playlist support (#172)
Refactor play method to support playlists and sequential playback.
2026-02-24 00:24:14 +01:00
Florian Asche 5a732f8421 docs: add info about reboot (#175) 2026-02-22 21:18:34 +01:00
Omar Amin 020b1f4ecf Merge pull request #168 from omaramin-2000/Volume-Persistence-Across-Restarts
Volume persistence across restarts
2026-02-22 22:06:29 +02:00
Omar Amin 15b87942c5 Merge branch 'main' into Volume-Persistence-Across-Restarts 2026-02-22 05:34:57 +02:00
Omar Amin 614bce4c55 Merge pull request #159 from ekiczek/fix/timer-loop-suppress
Fix timer finished sound not looping
2026-02-22 02:43:47 +02:00
Omar Amin c4e444f8ca Refactor volume setting and add persistence
Refactor volume handling to use _apply_volume method and persist volume settings if server state is available.
2026-02-22 00:50:17 +02:00
Omar Amin c81a707c6a Enhance volume persistence with logging
Add logging for volume persistence and conditionally skip saving if unchanged.
2026-02-22 00:43:32 +02:00
Omar Amin 981a786a46 Merge branch 'main' into Volume-Persistence-Across-Restarts 2026-02-21 22:19:05 +02:00
Florian Asche 1ae6b18475 Merge branch 'main' into fix/timer-loop-suppress 2026-02-21 21:01:13 +01:00
Florian Asche 7de510cb1f Add version and hash to docker container (#167)
* Add version and git hash to image
* Add version reporting to HA via api
2026-02-21 20:38:46 +01:00
Omar Amin 38c064fc68 Update media player entity volume handling
Set the volume and previous volume for the media player entity based on the current state.
2026-02-21 16:27:06 +02:00
Omar Amin 0d34e67823 Enhance volume management in entity.py
Added initial volume parameter and volume control methods.
2026-02-21 16:26:13 +02:00
Omar Amin ac69618d29 Add volume handling to Preferences and persistence method
Added volume attribute to Preferences and implemented persist_volume method to save normalized media volume.
2026-02-21 16:25:16 +02:00
Omar Amin c91511c93e Initialize volume from preferences on startup
Load initial volume from preferences and constrain it between 0.0 and 1.0. Set volume for music and TTS players based on initial volume.
2026-02-21 16:24:40 +02:00
Florian Asche 76308cc209 add more clear documentation on the start page (#166) 2026-02-20 19:21:53 +01:00
Florian AscheandFlorian Asche 70fab99c9f Feature/custom paths (#165)
* add possibility to add own wake words and sounds
* improve documentation

---------

Co-authored-by: Florian Asche <git@florian-asche.de>
2026-02-20 19:05:38 +01:00
Omar Amin 7074dfb278 Merge branch 'main' into fix/timer-loop-suppress 2026-02-20 13:17:57 +02:00
Florian AscheandFlorian Asche 033ff45baf Documentation (#162)
* docs: add info about permission and fix variable name
* docs(install): simplify systemd service user setup
* docs: Remove LVA_USER_ID and LVA_USER_GROUP environment variables from the systemd unit example. The User directive is sufficient for defining the service user. Consolidated installation notes regarding user permissions.

---------

Co-authored-by: Florian Asche <git@florian-asche.de>
2026-02-19 23:00:03 +01:00
Ethan Kiczek 3834f51179 Fix timer finished sound not looping
Check if player is actually playing before calling stop(for_replacement=True)
in MpvMediaPlayer.play(). When the player was already idle,
stop(for_replacement=True) would set _suppress_end_event=True with no
end-file event to consume it. The next track's end-file callback would
then be incorrectly suppressed, breaking the timer loop.

- Import PlayerState to check player state
- Only call stop(for_replacement=True) when player is not IDLE
2026-02-18 18:13:51 -05:00
Omar Amin cf5494642e Merge pull request #155 from giobber/patch-1
Correct LVA_USER_ID variable name where wrong
2026-02-17 23:20:52 +02:00
Elia Giacobazzi e1670ba5cf Correct LVA_USER_ID variable name where wrong 2026-02-17 17:26:26 +01:00
Florian AscheandFlorian Asche 05356acb3e fix bug in network-interface parameter usage
Co-authored-by: Florian Asche <git@florian-asche.de>
2026-02-14 22:14:53 +01:00
Omar Amin d6dbf83158 Merge pull request #144 from giobber/patch
Update documentation, correcting typo
2026-02-14 17:20:26 +02:00
Elia Giacobazzi 358e02afd5 Add missing code block characters 2026-02-14 01:13:32 +01:00
Elia Giacobazzi ffabc7d9fd Update documentation, correcting typo
- Fix typo where variable LVA_USER_ID was written as USERLVA_
- Remove "sudo ln -sf" from descriptive text
- Add missing sudo command from two code blocks
- Add characters probably missign due to typos
2026-02-13 23:55:52 +01:00
56 changed files with 2623 additions and 653 deletions

No files matched your search

+2
View File
@@ -8,3 +8,5 @@
!/sounds/* !/sounds/*
!/wakewords/* !/wakewords/*
!/wakewords/openWakeWord/* !/wakewords/openWakeWord/*
!/version.txt
!/version_githash.txt
+29 -7
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,13 +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="unix:/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="/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"
@@ -40,21 +45,38 @@ LVA_XDG_RUNTIME_DIR="/run/user/${LVA_USER_ID}"
### Audio output device (optional): ### Audio output device (optional):
# AUDIO_OUTPUT_DEVICE="default" # AUDIO_OUTPUT_DEVICE="default"
# Enable thinking sound (optional): ### Mic Gain (optional) passed as an argument to LVA will overide the value in thepreferences file on boot but can be changed at runtime in Home Assistant:
# MIC_AUTO_GAIN="0"
### Mic Noise Suppression (optional) passed as an argument to LVA will overide the value in thepreferences file on boot but can be changed at runtime in Home Assistant:
# MIC_NOISE_SUPPRESSION="0"
### Enable thinking sound (optional):
# ENABLE_THINKING_SOUND="1" # ENABLE_THINKING_SOUND="1"
# Wake model (optional): ### Wake word directory (optional):
# path for custom files in docker is for example "app/wakewords/custom"
# WAKE_WORD_DIR="app/wakewords"
### Wake model (optional):
# WAKE_MODEL="okay_nabu" # WAKE_MODEL="okay_nabu"
# Stop model (optional): ### Stop model (optional):
# STOP_MODEL="stop" # STOP_MODEL="stop"
# Refactory seconds (optional): ### Seconds after which the timer stops ringing (optional):
# TIMER_MAX_RING_SECONDS="900"
### Refactory seconds (optional):
# REFACTORY_SECONDS="2" # REFACTORY_SECONDS="2"
# Sound files (optional): ### Sound files (optional):
# 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"
# TIMER_FINISHED_SOUND="sounds/timer_finished.flac" # TIMER_FINISHED_SOUND="sounds/timer_finished.flac"
# 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"
+18
View File
@@ -0,0 +1,18 @@
# .github/release.yml
changelog:
exclude:
labels:
- ignore-for-release
categories:
- title: Breaking Changes 🛠
labels:
- Semver-Major
- breaking-change
- title: Exciting New Features 🎉
labels:
- Semver-Minor
- enhancement
- title: Other Changes
labels:
- "*"
+5 -6
View File
@@ -8,7 +8,7 @@ This project uses three separate Docker build workflows that automatically build
|----------|---------|--------------| |----------|---------|--------------|
| [docker-build-release.yml](.github/workflows/docker-build-release.yml) | Tags: `v1.2.0`, `v1.2.0-alpha1` | `1.2.0`, `1.2`, `latest` | | [docker-build-release.yml](.github/workflows/docker-build-release.yml) | Tags: `v1.2.0`, `v1.2.0-alpha1` | `1.2.0`, `1.2`, `latest` |
| [docker-build-nightly.yml](.github/workflows/docker-build-nightly.yml) | Push to `main` | `nightly` | | [docker-build-nightly.yml](.github/workflows/docker-build-nightly.yml) | Push to `main` | `nightly` |
| [docker-build-branch.yml](.github/workflows/docker-build-branch.yml) | All other branches | Branch name | | [docker-build-pr.yml](.github/workflows/docker-build-pr.yml) | Pull requests | `pr-<pr-number>` |
## Detailed Description ## Detailed Description
@@ -40,17 +40,16 @@ Automatically builds nightly images from the main branch.
ghcr.io/ohf-voice/linux-voice-assistant:nightly ghcr.io/ohf-voice/linux-voice-assistant:nightly
``` ```
### Branch Workflow (`docker-build-branch.yml`) ### PR Workflow (`docker-build-pr.yml`)
Builds images for all other branches. Builds images for pull requests using the PR number as tag.
**Triggers:** **Triggers:**
- Push to all branches except `main` - Pull request opened, synchronized, or reopened
- No tags
**Image Tag:** **Image Tag:**
``` ```
ghcr.io/ohf-voice/linux-voice-assistant:<branch-name> ghcr.io/ohf-voice/linux-voice-assistant:pr-<pr-number>
``` ```
## Multi-Architecture Support ## Multi-Architecture Support
@@ -25,6 +25,7 @@ jobs:
run: | run: |
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@e92390c5fb421da1463c202d546fed0ec5c39f20 # v3.1.0 uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 # v3.1.0
with: with:
@@ -36,6 +37,13 @@ jobs:
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
run: |
echo "nightly" > version.txt
echo "${{ github.sha }}" > version_githash.txt
ls -ltr
- name: Build and push Docker image - name: Build and push Docker image
id: push id: push
uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # v5.3.0 uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # v5.3.0
@@ -1,11 +1,9 @@
name: Build branch ---
name: Build PR
on: on:
push: pull_request_target:
branches-ignore: types: [opened, synchronize, reopened]
- "main"
tags-ignore:
- "**"
env: env:
REGISTRY: ghcr.io REGISTRY: ghcr.io
@@ -13,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
@@ -20,45 +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 clean branch name - name: Set variables
id: extract_branch
run: | run: |
echo "CLEAN_BRANCH_NAME=$(echo ${{ github.head_ref || github.ref_name }} | sed 's|.*/||')" >> $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
run: |
echo "${{ env.PR_NUMBER }}" > version.txt
echo "${{ github.sha }}" > version_githash.txt
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 }}:${{ env.CLEAN_BRANCH_NAME }} labels: "${{ env.PR_NUMBER }}"
labels: |
branch=${{ env.CLEAN_BRANCH_NAME }}
- 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
@@ -45,6 +45,13 @@ jobs:
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}} type=semver,pattern={{major}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
- name: Create version files
run: |
echo "${{ steps.meta.outputs.version }}" > version.txt
echo "${{ github.sha }}" > version_githash.txt
ls -ltr
- name: Build and push Docker image - name: Build and push Docker image
id: push id: push
uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # v5.3.0 uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # v5.3.0
+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 }}
+89
View File
@@ -0,0 +1,89 @@
name: Lint Checks
on:
push:
branches:
- "main"
tags:
- "v[0-9]+.[0-9]+.[0-9]+" # For releases, e.g. v1.2.0
- "v[0-9]+.[0-9]+.[0-9]+-[a-zA-Z0-9]+" # For prereleases, e.g. v1.3.0-alpha1
pull_request:
types: [opened, synchronize, reopened]
jobs:
lint_black:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.13
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
script/setup --dev
- name: Run black
run: ./script/lint_black
lint_flake8:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.13
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
script/setup --dev
- name: Run flake8
run: ./script/lint_flake8
lint_isort:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.13
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
script/setup --dev
- name: Run isort
run: ./script/lint_isort
lint_mypy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.13
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
script/setup --dev
- name: Run mypy
run: |
./script/lint_mypy
lint_pylint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.13
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
script/setup --dev
- name: Run pylint
run: ./script/lint_pylint
+3
View File
@@ -21,3 +21,6 @@ venv/
# Docker # Docker
.env .env
dev.env
version.txt
version_githash.txt
-6
View File
@@ -1,6 +0,0 @@
[settings]
multi_line_output=3
include_trailing_comma=True
force_grid_wrap=0
use_parentheses=True
line_length=88
+112 -11
View File
@@ -1,17 +1,118 @@
# Changelog # Changelog
## Unreleased ## 1.1.9
- Add support for custom/external wake words ### Improvements
- Add `--download-dir <DIR>` to store downloaded wake word models/configs - Cookie file and other default parameters by @florian-asche in https://github.com/OHF-Voice/linux-voice-assistant/pull/231
- Switch to `soundcard` instead of `sounddevice`
- Add `--list-input-devices` and `--list-output-devices` ## 1.1.8
- Use `pymicro-wakeword` for microWakeWord
- Add zeroconf/mDNS discovery ### Features
- Support openWakeWord with `pyopen-wakeword` - Add changelog generator based on release information by @florian-asche in https://github.com/OHF-Voice/linux-voice-assistant/pull/226
- Support multiple wake words
- Save active wake words to preferences JSON file ## 1.1.7
- Refactor main into separate files
### Exciting New Features 🎉
- Add linting scripts and pipeline by @florian-asche in https://github.com/OHF-Voice/linux-voice-assistant/pull/201
### Other Changes
- add build flag option for low end devices by @florian-asche in https://github.com/OHF-Voice/linux-voice-assistant/pull/189
- replaced branch build by PR build only by @florian-asche in https://github.com/OHF-Voice/linux-voice-assistant/pull/203
- Change when lint pipeline will start by @florian-asche in https://github.com/OHF-Voice/linux-voice-assistant/pull/204
- fix(script): use os.environ instead of subprocess.environ in setup script by @florian-asche in https://github.com/OHF-Voice/linux-voice-assistant/pull/205
- docs: add documentation for low power device setup and improve setup script by @florian-asche in https://github.com/OHF-Voice/linux-voice-assistant/pull/206
- fix: cookie error (#127) by @florian-asche in https://github.com/OHF-Voice/linux-voice-assistant/pull/210
- fix(config): remove unix: prefix from default pulseaudio socket path … by @florian-asche in https://github.com/OHF-Voice/linux-voice-assistant/pull/221
- Update the changelog file automatically on each release by @omaramin-2000 in https://github.com/OHF-Voice/linux-voice-assistant/pull/224
- Enhance hardware compatibility details in README by @Hedda in https://github.com/OHF-Voice/linux-voice-assistant/pull/222
- add changelog based on release notes by @florian-asche in https://github.com/OHF-Voice/linux-voice-assistant/pull/225
## 1.1.6
### Bug Fixes
- Start listening after wakeup sound
- Prevent wake word from interrupting active voice pipeline
- Ensure wake word state is set after the wake sound
### 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
### Bug Fixes
- 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
### Improvements
- 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
### Bug Fixes
- Hotfix: typo in network-interface output order causing application crash
### Documentation
- Correct LVA_USER_ID variable name
## 1.1.2
### 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
- Add variable for network-interface with auto-detection enabled by default
- 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
- Fix non-working variables (e.g. sound-files)
- Fix client-name validation (no spaces allowed)
- Fix example configuration
## 1.1.1
### Bug Fixes
- Handle STOP command correctly in media_player
## 1.1.0
### Features
- 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 ## 1.0.0
+3 -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
@@ -54,6 +54,8 @@ COPY sounds/ ./sounds/
COPY ../wakewords/ ./wakewords/ COPY ../wakewords/ ./wakewords/
COPY linux_voice_assistant/ ./linux_voice_assistant/ COPY linux_voice_assistant/ ./linux_voice_assistant/
COPY docker-entrypoint.sh ./ COPY docker-entrypoint.sh ./
COPY version.txt ./
COPY version_githash.txt ./
### Run installation: ### Run installation:
RUN chmod +x docker-entrypoint.sh RUN chmod +x docker-entrypoint.sh
+103 -19
View File
@@ -2,41 +2,56 @@
[![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 Linux-Voice-Assistant software for [Home Assistant](https://www.home-assistant.io/) remote voice control and interaction.
This project enables you to build a Linux-based voice assistant designed to use [Assist](https://www.home-assistant.io/voice_control/) for Home Assistant. It allows you to create your own smart speaker that runs on any x64 or ARM64 hardware capable of handling local audio processing (using PulseAudio).
Unlike simpler voice satellites that run on microcontrollers with very limited compute power, this setup can perform local wake word detection (OWW/MWW) and process some data on-device.
Because it runs on a full Linux system and offers access significantly more local computing resources for additional features and other integrations on the same satellite, this approach also provides greater flexibility for customization (such as for example experiment with using PipeWire).
[![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)
## Usage
## Usage: ### Hardware
### Hardware: A more extensive list for possible compatible hardware can be found in the [PiCompose documentation](https://github.com/florian-asche/PiCompose) but basically any microphone that works with [PipeWire (multimedia framework for Linux)](https://pipewire.org/) can in theory be used for voice input with the prebuild image from there, you should however preferably use a far-field microphone-array solution if want better result.
You can for example use the Raspberry Pi Zero 2W with the [Satellite1 Hat Board](https://futureproofhomes.net/products/satellite1-top-microphone-board), the [Respeaker Lite](https://wiki.seeedstudio.com/reSpeaker_usb_v3/) or the [Respeaker 2Mic_Hat](https://wiki.seeedstudio.com/ReSpeaker_2_Mics_Pi_HAT/). A list for possible compatible hardware can be found in the [PiCompose documentation](https://github.com/florian-asche/PiCompose) but basically any microphone that works with [Pipewire](https://pipewire.org/) can be used with the prebuild image. 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).
### Software: Alternatively if on a lower budget then suggest could try other untested microphone-array boards like example the [reSpeaker 2-Mics Pi HAT V2.0](https://wiki.seeedstudio.com/ReSpeaker_2_Mics_Pi_HAT/) (which uses a much more basic audio codec chip).
#### Installation: 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.
We have different installation methods available (Docker, systemd), each with its own dedicated instructions. But you can also install LVA on AMD64 devices, for example on your Linux desktop computer.
See [Linux-Voice-Assistant - Installation](docs/install.md). ### Software
#### Parameter overview: #### Installation
For Raspberry Pi users, we provide a prebuild image that can be flashed to a SD card. See [PiCompose](https://github.com/florian-asche/PiCompose).
For all other users, we have different installation methods available (Docker, systemd), each with its own dedicated instructions. See [Linux-Voice-Assistant - Installation](docs/install.md).
#### 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]
``` ```
@@ -44,14 +59,18 @@ usage: __main__.py [-h] [--name NAME] [--audio-input-device AUDIO_INPUT_DEVICE]
| 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 | System default microphone | | `--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 | System default speaker | | `--audio-output-device` | mpv name for output device | Autodetected |
| `--mic-volume` | Control microphone volume | 1.0 |
| `--mic-auto-gain` | Add WebRTC Gain to Mic | 0 |
| `--mic-noise-suppression` | Add WebRTC Noise Suppression to Mic | 0 |
| `--wake-word-dir` | Directory with wake word models (.tflite) and configs (.json) | `wakewords/` | | `--wake-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` |
@@ -63,8 +82,12 @@ 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.
@@ -75,3 +98,64 @@ The Docker images are built using GitHub Actions, which provides:
- Regular updates and maintenance - Regular updates and maintenance
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
### Code Quality Checks
The project uses the following tools to ensure code quality:
- **Black**: Code formatting (88 characters per line, PEP 8 compliant)
- **isort**: Import sorting compatible with Black
- **flake8**: Style and syntax checks
- **pylint**: Code quality checks
- **mypy**: Static type analysis
### Setup
To use the development tools (linting, testing, etc.), you need to install the required dependencies:
``` sh
./script/setup --dev
source .venv/bin/activate
```
### Linting Commands
#### Run all linting checks
``` sh
./script/lint...
```
#### Individual linting commands (with auto-fix support)
| Script | Description | Auto-fix Available? |
|--------|-------------|---------------------|
| `./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_isort` | Checks import sorting with isort | Yes, use `--auto` flag |
| `./script/lint_mypy` | Runs static type analysis with mypy | No |
| `./script/lint_pylint` | Runs code quality checks with pylint | Yes, use `--auto` flag |
#### Examples
Run a specific lint check:
``` sh
./script/lint_black
```
Auto-fix formatting issues (Black + isort):
``` sh
./script/lint_black --auto
./script/lint_isort --auto
```
### Testing
Run the test suite:
``` sh
./script/test
```
## License
This project is licensed under the Apache 2.0 License - see the [LICENSE](LICENSE) file for details.
+28 -10
View File
@@ -2,30 +2,38 @@
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" command: "chown -R ${LVA_USER_ID:-1000}:${LVA_USER_GROUP:-1000} /app/local /app/configuration /app/wakewords/custom /app/sounds/custom"
env_file: .env environment:
# Pulseaudio
- XDG_RUNTIME_DIR=${LVA_XDG_RUNTIME_DIR:-/run/user/1000}
- PULSE_SERVER=${LVA_PULSE_SERVER:-/run/user/1000/pulse/native}
- PULSE_COOKIE=${LVA_PULSE_COOKIE:-/run/user/1000/pulse/cookie}
env_file: ${ENV_FILE:-.env}
group_add: group_add:
- audio - audio
volumes: volumes:
# Wakeword data and configuration # Wakeword data and configuration
- wakeword_data:/app/local - wakeword_data:/app/local
- wakeword_custom:/app/wakewords/custom
- configuration:/app/configuration - configuration:/app/configuration
- sounds_custom:/app/sounds/custom
restart: "no" restart: "no"
# 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}
env_file: .env - PULSE_COOKIE=${LVA_PULSE_COOKIE:-/run/user/1000/pulse/cookie}
env_file: ${ENV_FILE:-.env}
group_add: group_add:
- audio - audio
# Priority for audio process # Priority for audio process
@@ -34,15 +42,21 @@ services:
volumes: volumes:
# Wakeword data and configuration # Wakeword data and configuration
- wakeword_data:/app/local - wakeword_data:/app/local
- wakeword_custom:/app/wakewords/custom
- configuration:/app/configuration - configuration:/app/configuration
- sounds_custom:/app/sounds/custom
# Time and zone # Time and zone
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
- /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
- ./script:/app/script
- ./version.txt:/app/version.txt
- ./version_githash.txt:/app/version_githash.txt
depends_on: depends_on:
- fix-permissions - fix-permissions
# Health check for process # Health check for process
@@ -52,11 +66,15 @@ 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
volumes: volumes:
wakeword_data: wakeword_data:
wakeword_custom:
configuration: configuration:
sounds_custom:
+21 -9
View File
@@ -2,30 +2,38 @@
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" command: "chown -R ${LVA_USER_ID:-1000}:${LVA_USER_GROUP:-1000} /app/local /app/configuration /app/wakewords/custom /app/sounds/custom"
env_file: .env environment:
# Pulseaudio
- XDG_RUNTIME_DIR=${LVA_XDG_RUNTIME_DIR:-/run/user/1000}
- PULSE_SERVER=${LVA_PULSE_SERVER:-/run/user/1000/pulse/native}
- PULSE_COOKIE=${LVA_PULSE_COOKIE:-/run/user/1000/pulse/cookie}
env_file: ${ENV_FILE:-.env}
group_add: group_add:
- audio - audio
volumes: volumes:
# Wakeword data and configuration # Wakeword data and configuration
- wakeword_data:/app/local - wakeword_data:/app/local
- wakeword_custom:/app/wakewords/custom
- configuration:/app/configuration - configuration:/app/configuration
- sounds_custom:/app/sounds/custom
restart: "no" restart: "no"
# 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}
env_file: .env - PULSE_COOKIE=${LVA_PULSE_COOKIE:-/run/user/1000/pulse/cookie}
env_file: ${ENV_FILE:-.env}
group_add: group_add:
- audio - audio
# Priority for audio process # Priority for audio process
@@ -34,13 +42,15 @@ services:
volumes: volumes:
# Wakeword data and configuration # Wakeword data and configuration
- wakeword_data:/app/local - wakeword_data:/app/local
- wakeword_custom:/app/wakewords/custom
- configuration:/app/configuration - configuration:/app/configuration
- sounds_custom:/app/sounds/custom
# Time and zone # Time and zone
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
- /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
@@ -57,4 +67,6 @@ services:
volumes: volumes:
wakeword_data: wakeword_data:
wakeword_custom:
configuration: configuration:
sounds_custom:
+36 -23
View File
@@ -40,10 +40,26 @@ 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 [ "$ENABLE_THINKING_SOUND" = "1" ]; then if [ "$ENABLE_THINKING_SOUND" = "1" ]; then
EXTRA_ARGS+=( "--enable-thinking-sound" ) EXTRA_ARGS+=( "--enable-thinking-sound" )
fi fi
if [ -n "${WAKE_WORD_DIR}" ]; then
EXTRA_ARGS+=( "--wake-word-dir" "$WAKE_WORD_DIR" )
fi
if [ -n "${WAKE_MODEL}" ]; then if [ -n "${WAKE_MODEL}" ]; then
EXTRA_ARGS+=( "--wake-model" "$WAKE_MODEL" ) EXTRA_ARGS+=( "--wake-model" "$WAKE_MODEL" )
fi fi
@@ -76,13 +92,32 @@ 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
PULSE_COOKIE=${PULSE_COOKIE:-"/run/user/1000/pulse/cookie"}
if [[ "$PULSE_COOKIE" != "DISABLED" ]]; then
if [ ! -f "$PULSE_COOKIE" ]; then
echo "Creating PulseAudio cookie file at $PULSE_COOKIE"
touch "$PULSE_COOKIE"
chmod 600 "$PULSE_COOKIE"
fi
fi
### Wait for PulseAudio ### Wait for PulseAudio
# Wait for PulseAudio to be available before starting the application # Wait for PulseAudio to be available before starting the application
CP_MAX_RETRIES=30 CP_MAX_RETRIES=30
CP_RETRY_DELAY=1 CP_RETRY_DELAY=1
### while maybe besser? ### while maybe besser?
echo "Checking port $PORT..." echo "Checking PulseAudio service status..."
for i in $(seq 1 $CP_MAX_RETRIES); do for i in $(seq 1 $CP_MAX_RETRIES); do
# Check if PulseAudio is running # Check if PulseAudio is running
if pactl info >/dev/null 2>&1; then if pactl info >/dev/null 2>&1; then
@@ -100,28 +135,6 @@ for i in $(seq 1 $CP_MAX_RETRIES); do
done done
### Check port availability
# PORT variable is used from env
PA_MAX_RETRIES=30
PA_RETRY_DELAY=2
echo "Checking port $PORT..."
for i in $(seq 1 $PA_MAX_RETRIES); do
# Wait for port to be free (in case of rapid restarts)
if ! ss -tln | grep -q ":${PORT} "; then
echo "Port $PORT is available"
break
fi
if [ $i -eq $PA_MAX_RETRIES ]; then
echo "ERROR: Port $PORT still in use after $((PA_MAX_RETRIES * PA_RETRY_DELAY)) seconds"
exit 2
fi
echo "Attempt $i/$PA_MAX_RETRIES: Port $PORT in use, waiting ${PA_RETRY_DELAY}s..."
sleep $PA_RETRY_DELAY
done
### Start application ### Start application
if [ "$LIST_DEVICES" = "1" ]; then if [ "$LIST_DEVICES" = "1" ]; then
echo "list input devices" echo "list input devices"
-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 library) automatically adjusts the microphone input volume to keep it at a consistent level. If you're speaking quietly it boosts the signal, if you're speaking loudly it reduces it. Useful in the following ways:-
- Microphones that are too quiet by default
- Environments where you move around relative to the mic
- Keeping wake word detection consistent regardless of speaking volume
### Noise Suppression
When you change noise suppression, LVA(using the webrtc library) filters out constant background noise from the audio signal. It works by learning what "silence" sounds like in your environment and subtracting that from the audio. Useful in the following ways:
- Noisy environments
- Improving STT accuracy by sending cleaner audio to Home Assistant
- Reducing false wake word triggers from background noise
## Using Gain and Noise Suppression with LVA
LVA implements gain and Noise Suppression in two ways:
- [CLI Argument/ENV file](#cli-argumentenv-file)
- [Home Assistant Entity](#home-assistant-entity)
### CLI Argument/ENV file
#### CLI Argument
Using docker-entrypoint.sh the following flags can be used to set gain and noise suppression:
- `--mic-auto-gain`(ranges from 0-31)
- `--mic-noise-suppression`(ranges from 0-4)
#### ENV file
You can add/edit these variables in the .env file to set gain and noise suppression:
- `MIC_AUTO_GAIN="0"`
- `MIC_NOISE_SUPPRESSION="0"`
### Home Assistant Entity
LVA exposes two slider entities to change these gain and noise suppression from which you can edit the gain and noise suppression at runtime.
💡 **Note:** Setting the flag and ENV values to 0 turns them off and are not used.
💡 **Note:** Keep in mind that when the flags are set they will overwrite the previous value in the preferences file and also they will be overridden if the value is changed in Home Assistant(Also applies to the ENV file but the ENV file will always overwrite the last value on startup).
## Wake Word Sensitivity
LVA exposes three numeric controls in the Home Assistant device page for fine-grained sensitivity tuning. These let you dial in the exact probability threshold that best matches your microphone quality, room acoustics, and false-activation tolerance.
| Entity | Description | Default |
|--------|-------------|---------|
| **Wake Word 1 Sensitivity** | Probability cutoff for the primary wake word | From model manifest |
| **Wake Word 2 Sensitivity** | Probability cutoff for the secondary wake word (if active) | From model manifest |
| **Stop Word Sensitivity** | Probability cutoff for the stop word | From model manifest |
Values range from `0.0` to `1.0`:
- **Higher value** (e.g. `0.95`) → more selective, fewer false activations, may miss quieter or accented speech
- **Lower value** (e.g. `0.50`) → more responsive, but more likely to trigger on similar-sounding words
The defaults are read directly from each model's `.json` manifest file (the `probability_cutoff` field), so bundled models like `okay_nabu` (0.85), `hey_jarvis` (0.97), and custom downloaded models all start at their author-tested baseline. Changes made in the Home Assistant UI are persisted in `preferences.json` and survive restarts.
**Tuning guide:**
- If the wake word **rarely activates** (misses your voice): lower the value slightly, e.g. by `0.05` steps
- If the wake word **activates too often** (false triggers from TV, music, or similar words): raise the value slightly
- Start from the model's default and make small adjustments — a change of `0.05–0.10` is usually enough to notice a difference
- Far-field microphones with noise cancellation (e.g. ReSpeaker, Satellite1) generally work well at the default values; basic USB microphones may need a lower threshold
+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>
}
}
}
```
+55 -57
View File
@@ -39,11 +39,13 @@ LVA_VERSION=$(curl -s https://api.github.com/repos/ohf-voice/linux-voice-assista
echo "Installing version: " $LVA_VERSION echo "Installing version: " $LVA_VERSION
wget https://raw.githubusercontent.com/ohf-voice/linux-voice-assistant/refs/tags/$LVA_VERSION/docker-compose.yml wget https://raw.githubusercontent.com/ohf-voice/linux-voice-assistant/refs/tags/$LVA_VERSION/docker-compose.yml
wget https://raw.githubusercontent.com/ohf-voice/linux-voice-assistant/refs/tags/$LVA_VERSION/.env.example wget https://raw.githubusercontent.com/ohf-voice/linux-voice-assistant/refs/tags/$LVA_VERSION/.env.example
mv .env.example .env cp .env.example .env
``` ```
💡 **Note:** The LVA_VERSION variable downloads the latest available version of LVA. If you want another version, you can specify it with the LVA_VERSION variable. 💡 **Note:** The LVA_VERSION variable downloads the latest available version of LVA. If you want another version, you can specify it with the LVA_VERSION variable.
You can change the used version in the docker-compose.yml.
### Docker Image Tags ### Docker Image Tags
| Tag | Description | Example | | Tag | Description | Example |
@@ -59,44 +61,7 @@ Edit the .env file and change the values to your needs:
vim .env vim .env
``` ```
```ini You can change various settings, for example the audio sounds which are played when the wake word is detected or when the timer is finished. You also need to set the correct user and group id, and the pulseaudio server in the `.env`file.
# Linux-Voice-Assistant - Docker Environment Configuration
# Copy this file to .env and customize for your setup by 'cp .env.example .env'
### Enable debug mode (optional):
# ENABLE_DEBUG="1"
### List audio devices (optional):
# if enabled normal startup is disabled
# LIST_DEVICES="1"
### User ID:
# This is used to set the correct permissions for the accessing the audio device and accessing the PulseAudio socket
USERLVA_USER_ID00"
LVA_USER_GROUP="1000"
### Name for the client (optional):
# by default it uses the HOSTNAME variable from the piCompose environment which includes the MAC from the network card
# CLIENT_NAME="My Voice Assistant Speaker"
### PulseAudio socket path on the host:
# PulseAudio Server: /run/user/1000/pulse
# Pipewire Server: /run/user/1000/pulse/native
LVA_PULSE_SERVER="unix:/run/user/${USERLVA_USER_IDlse/native"
LVA_XDG_RUNTIME_DIR="/run/user/${USERLVA_USER_ID
### Path to the preferences file (optional):
# PREFERENCES_FILE="/app/configuration/preferences.json"
### Port for the api (optional):
# PORT="6053"
### Audio input device (optional):
[...]
```
Please use the original file from the repository. You can change various settings, for example the audio sounds which are played when the wake word is detected or when the timer is finished.
💡 **Note:** You can exit vim with `:wq` or `:q!` if you dont want to save the changes. 💡 **Note:** You can exit vim with `:wq` or `:q!` if you dont want to save the changes.
@@ -170,6 +135,16 @@ Install the application:
script/setup script/setup
``` ```
💡 **Note:** For hardware with limited performance (like Raspberry Pi Zero 2W or other SBCs), you may need to adjust compilation parameters to ensure the build process completes successfully. The script supports custom `CXXFLAGS` and `MAKEFLAGS`:
- `--cxxflags`: Custom C++ compilation flags (default: `-O1 -g0`)
- `--makeflags`: Custom make flags (default: `-j1`)
For example, to optimize for low-performance hardware:
``` sh
script/setup --cxxflags="-O1 -g0" --makeflags="-j1"
```
Create a systemd service for the application: Create a systemd service for the application:
``` sh ``` sh
@@ -188,29 +163,34 @@ Wants=network-online.target
Type=simple Type=simple
User=pi User=pi
WorkingDirectory=/home/pi/linux-voice-assistant WorkingDirectory=/home/pi/linux-voice-assistant
Environment=PATH=/home/pi/linux-voice-assistant/.venv/bin:/usr/bin:/bin Environment=PATH=/home/pi/linux-voice-assistant/.venv/bin:/usr/bin:/bin
# Environment=ENABLE_DEBUG="1" # Environment=ENABLE_DEBUG="1"
# Environment=LIST_DEVICES="1" # Environment=LIST_DEVICES="1"
Environment=LVA_USER_ID="1000"
Environment=LVA_USER_GROUP="1000"
# Environment=CLIENT_NAME="My Voice Assistant Speaker" # Environment=CLIENT_NAME="My Voice Assistant Speaker"
Environment=LVA_PULSE_SERVER="unix:/run/user/${LVA_USER_ID}/pulse/native" Environment=PULSE_SERVER="/run/user/<replace_with_your_user_id>/pulse/native"
Environment=LVA_XDG_RUNTIME_DIR="/run/user/${LVA_USER_ID}" Environment=XDG_RUNTIME_DIR="/run/user/<replace_with_your_user_id>"
Environment=PULSE_COOKIE="/home/pi/linux-voice-assistant/tmp_pulse_cookie"
Environment=PREFERENCES_FILE="/home/pi/linux-voice-assistant/preferences.json" Environment=PREFERENCES_FILE="/home/pi/linux-voice-assistant/preferences.json"
# Environment=NETWORK_INTERFACE="eth0" # Environment=NETWORK_INTERFACE="eth0"
# Environment=HOST="0.0.0.0" # Environment=HOST="0.0.0.0"
# 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
@@ -222,7 +202,7 @@ WantedBy=multi-user.target
💡 **Note:** We are using the `docker-entrypoint.sh` script to start the application. This script is located in the root of the repository. But there is no docker used. Only the start script is used. 💡 **Note:** We are using the `docker-entrypoint.sh` script to start the application. This script is located in the root of the repository. But there is no docker used. Only the start script is used.
💡 **Note:** Replace `USERLVA_USER_IDth your actual user id that you want to run the voice assistant. Do the same for `LVA_USER_GROUP`. 💡 **Note:** Replace `pi` with your actual user that you want to run the voice assistant. You need to run Pipewire and LVA with the same user in order to provide access to the audio socket. You can also add the group audio to the user which LVA is running on. `sudo usermod -a -G audio pi`
Reload the systemd daemon and start the service: Reload the systemd daemon and start the service:
@@ -264,27 +244,45 @@ The following variables can be configured in the `.env` or in the service file:
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `USERLVA_USER_ID` | `1000` | User ID for the container (usually 1000 for the first user) |
| `GROUP_ID` | `1000` | GROUP ID for the container (usually 1000 for the first users group) |
| `CLIENT_NAME` | (optional) | Custom name for this voice assistant instance |
| `LVA_PULSE_SERVER` | `unix:/run/user/${LVA_USER_ID}/pulse/native` | Path to the PulseAudio/PipeWire socket |
| `LVA_XDG_RUNTIME_DIR` | `/run/user/${LVA_USER_ID}` | XDG runtime directory |
| `ENABLE_DEBUG` | (optional) | Set to "1" to enable debug mode | | `ENABLE_DEBUG` | (optional) | Set to "1" to enable debug mode |
| `LIST_DEVICES` | (optional) | Set to "1" to list audio devices instead of starting | | `LIST_DEVICES` | (optional) | Set to "1" to list audio devices instead of starting |
| `LVA_USER_ID` | `1000` | User ID for the container (usually 1000 for the first user) |
| `LVA_USER_GROUP` | `1000` | GROUP ID for the container (usually 1000 for the first users group) |
| `CLIENT_NAME` | (optional) | Custom name for this voice assistant instance |
| `LVA_PULSE_SERVER` | `/run/user/${LVA_USER_ID}/pulse/native` | Path to the PulseAudio/PipeWire socket (In some cases a `:unix`infront of the path is needed) |
| `LVA_XDG_RUNTIME_DIR` | `/run/user/${LVA_USER_ID}` | XDG runtime directory |
| `LVA_PULSE_COOKIE` | `/app/configuration/tmp_pulse_cookie` | Cookie file for PulseAudio if you use encryption. By default disabled. We use a tmp file to avoid errors if the file is not found |
| `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` | `default` | Audio input device name | | `AUDIO_INPUT_DEVICE` | Autodetected | Audio input device name |
| `AUDIO_OUTPUT_DEVICE` | `default` | Audio output device name | | `AUDIO_OUTPUT_DEVICE` | Autodetected | Audio output device name |
| `MIC_VOLUME` | Control microphone volume | 1.0 |
| `MIC_AUTO_GAIN` | Add WebRTC Gain to Mic | 0 |
| `MIC_NOISE_SUPPRESSION` | Add WebRTC Noise Suppresion to Mic | 0 |
| `ENABLE_THINKING_SOUND` | false | Set to "1" to enable thinking sound | | `ENABLE_THINKING_SOUND` | false | Set to "1" to enable thinking sound |
| `WAKE_WORD_DIR` | `app/wakewords` | Path to the wake word directory |
| `WAKE_MODEL` | `okay_nabu` | Wake word model to use | | `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.
### Use own soundfiles:
If you want to use your own sounds, you can add them to the `sounds/custom` aka `/var/lib/docker/volumes/lva_sounds_custom/_data` directory and reference them in the `.env` file.
### Wake Word:
#### Available Wake Word Models: #### Available Wake Word Models:
@@ -300,13 +298,13 @@ The following wake word models are available:
- `choo_choo_homie` - Choo Choo Homie wake word - `choo_choo_homie` - Choo Choo Homie wake word
### Wake Word: ### Custom Wake Word:
💡 **Note:** The default value for this parameter is the `wakewords` directory in the repository. If you want to use your own wakewords, you can add them to the `wakewords/custom` aka `/var/lib/docker/volumes/lva_wakeword_custom/_data` directory and reference them in the `.env` file.
Change the default wake word with `--wake-model <id>` where `<id>` is the name of a model in the `wakewords` directory. For example, `--wake-model hey_jarvis` will load `wakewords/hey_jarvis.tflite` by default. Change the default wake word with `WAKE_MODEL <id>` where `<id>` is the name of a model in the `wakewords` directory. For example, `WAKE_MODEL hey_jarvis` will load `app/wakewords/hey_jarvis.tflite` by default.
You can include more wakeword directories by adding `--wake-word-dir <DIR>` where `<DIR>` contains either [microWakeWord][] or [openWakeWord][] config files and `.tflite` models. For example, `--wake-word-dir wakewords/openWakeWord` will include the default wake words for openWakeWord. You can include more wakeword directories by adding `WAKE_WORD_DIR <DIR>` where `<DIR>` contains either [microWakeWord][] or [openWakeWord][] config files and `.tflite` models. For example, `WAKE_WORD_DIR app/wakewords/openWakeWord` will include the default wake words for openWakeWord.
If you want to add [other wakewords][wakewords-collection], make sure to create a small JSON config file to identify it as an openWakeWord model. For example, download the [GLaDOS][glados] model to `glados.tflite` and create `glados.json` with: If you want to add [other wakewords][wakewords-collection], make sure to create a small JSON config file to identify it as an openWakeWord model. For example, download the [GLaDOS][glados] model to `glados.tflite` and create `glados.json` with:
@@ -318,7 +316,7 @@ If you want to add [other wakewords][wakewords-collection], make sure to create
} }
``` ```
Add `--wake-word-dir <DIR>` with the directory containing `glados.tflite` and `glados.json` to your command-line. Add `WAKE_WORD_DIR <DIR>` with the directory containing `glados.tflite` and `glados.json` to your command-line.
#### Additional Wakewords: #### Additional Wakewords:
+72 -44
View File
@@ -1,6 +1,10 @@
# 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. You can choose either one (A or B) of them. 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?**
The Linux-Voice-Assistant container connects to the PulseAudio(PipeWire-Pulse) server running on the host system. The container does not have direct access to the sound card hardware. Instead, it communicates with the PulseAudio running on the host system, which then handles the actual audio processing and output.
## A) Pipewire (recommended): ## A) Pipewire (recommended):
@@ -11,16 +15,16 @@ 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 applicationssudo ln -sf: Link the PipeWire configuration for ALSA applications:
```sh ```sh
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
@@ -29,62 +33,51 @@ 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.
### Configure PipeWire (optional): You can verify it worked with:
Create the PipeWire configuration directory:
```sh ```sh
mkdir -p "/etc/pipewire" loginctl show-user $USER | grep Linger
# Expected: Linger=yes
``` ```
Create the file `/etc/pipewire/pipewire.conf` with the following content (minimal configuration for voice assistant use): ### Configure PipeWire (optional):
``` ini 💡 **Note:** If you are not on a desktop system, which is already configured for PipeWire, you can configure it manually.
# Daemon config file for PipeWire
💡 **Note:** LVA records audio at 16kHz. By default PipeWire may run at a different sample rate (typically 48kHz) and will resample automatically. Setting `default.clock.rate = 16000` avoids this resampling overhead, which is particularly beneficial on low-power hardware such as a Raspberry Pi.
LVA is meant to be configured system wide. So only that method has been documented.
#### System-wide Configuration
```sh
sudo mkdir -p /etc/pipewire/pipewire.conf.d
sudo vi /etc/pipewire/pipewire.conf.d/linux-voice-assistant.conf
```
Add the following content:
```
context.properties = { context.properties = {
link.max-buffers = 16
mem.warn-mlock = true
log.level = 3
context.num-data-loops = 1
core.daemon = true
core.name = pipewire-0
default.clock.rate = 16000 default.clock.rate = 16000
} }
context.modules = [
{ name = libpipewire-module-rt
args = {
nice.level = -11
rt.prio = 88
}
flags = [ ifexists nofail ]
}
{ name = libpipewire-module-protocol-native }
{ name = libpipewire-module-profiler }
{ name = libpipewire-module-metadata }
{ name = libpipewire-module-spa-device-factory }
{ name = libpipewire-module-spa-node-factory }
{ name = libpipewire-module-client-node }
{ name = libpipewire-module-client-device }
{ name = libpipewire-module-portal
flags = [ ifexists nofail ]
}
{ name = libpipewire-module-access
condition = [ { module.access = true } ]
}
{ name = libpipewire-module-adapter }
{ name = libpipewire-module-link-factory }
{ name = libpipewire-module-session-manager }
]
``` ```
#### 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
```
💡 **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:
Make sure that you only run Pulseaudio and there is no Pipewire installed. Make sure that you only run Pulseaudio and there is no Pipewire installed.
```sh
sudo apt remove --purge pipewire pipewire-pulse wireplumber sudo apt remove --purge pipewire pipewire-pulse wireplumber
sudo apt autoremove sudo apt autoremove
```
Install Pulseaudio Install Pulseaudio
@@ -92,6 +85,18 @@ Install Pulseaudio
sudo apt install pulseaudio pulseaudio-utils dfu-util sudo apt install pulseaudio pulseaudio-utils dfu-util
``` ```
Configure pulse
```sh
sudo vi /etc/pulse/daemon.conf
```
Change the following lines in the file:
```sh
default-sample-rate = 16000
```
Enable and start Pulseaudio Enable and start Pulseaudio
```sh ```sh
@@ -106,9 +111,28 @@ pulseaudio --check
pactl info pactl info
``` ```
Reboot the system:
In some cases to get the audio hardware working you may need to restart your system.
## Additional Information: ## Additional Information:
### Debug audio output:
You can debug the audio output with the following command:
```bash
export LVA_XDG_RUNTIME_DIR=/run/user/${LVA_USER_ID}
sudo aplay -L
speaker-test -D pulse -c2 -twav
```
💡 **Note:** Replace `$LVA_USER_ID` with your actual user id that you want to run the voice assistant.
💡 **Note:** You can replace `pulse` with `default` or `alsa_output.pci-0000_00_1f.3.analog-stereo` or any other audio device.
### Set audio volume: ### Set audio volume:
If your driver or audiodevice is loaded and you can see the device with `aplay -L` then If your driver or audiodevice is loaded and you can see the device with `aplay -L` then
@@ -133,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.
+291 -127
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import argparse import argparse
import asyncio import asyncio
import errno
import json import json
import logging import logging
import sys import sys
@@ -8,20 +9,27 @@ 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 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 getmac import get_mac_address from .util import (
get_default_interface,
get_default_ipv4,
get_esphome_version,
get_version,
)
from .wake_word import find_available_wake_words, load_stop_model, load_wake_models
from .webrtc import WebRTCProcessor
from .zeroconf import HomeAssistantZeroconf from .zeroconf import HomeAssistantZeroconf
from .util import get_default_interface
from .util import get_default_ipv4
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
_MODULE_DIR = Path(__file__).parent _MODULE_DIR = Path(__file__).parent
@@ -36,11 +44,12 @@ _SOUNDS_DIR = _REPO_DIR / "sounds"
async def main() -> None: async def main() -> None:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument( parser.add_argument(
"--name" "--name",
help="Real name for the device",
) )
parser.add_argument( parser.add_argument(
"--audio-input-device", "--audio-input-device",
help="soundcard name for input device (see --list-input-devices)", help="Name for the audio input device (see --list-input-devices)",
) )
parser.add_argument( parser.add_argument(
"--list-input-devices", "--list-input-devices",
@@ -50,37 +59,40 @@ async def main() -> None:
parser.add_argument( parser.add_argument(
"--audio-input-block-size", "--audio-input-block-size",
type=int, type=int,
default=1024 default=1024,
# todo
) )
parser.add_argument( parser.add_argument(
"--audio-output-device", "--audio-output-device",
help="mpv name for output device (see --list-output-devices)", help="Name for the audio output device (see --list-output-devices)",
) )
parser.add_argument( parser.add_argument(
"--list-output-devices", "--list-output-devices",
action="store_true", action="store_true",
help="List audio output devices and exit", help="List audio output devices and exit",
) )
parser.add_argument("--mic-auto-gain", type=int, default=0, choices=list(range(32)))
parser.add_argument("--mic-noise-suppression", type=int, default=0, choices=(0, 1, 2, 3, 4))
parser.add_argument( parser.add_argument(
"--wake-word-dir", "--wake-word-dir",
default=[_WAKEWORDS_DIR], default=[_WAKEWORDS_DIR],
action="append", action="append",
help="Directory with wake word models (.tflite) and configs (.json)", help="Directory with wake word models (.tflite) and configuration (.json)",
) )
parser.add_argument( parser.add_argument(
"--wake-model", "--wake-model",
default="okay_nabu", default="okay_nabu",
help="Id of active wake model" help="File name of the first active wake model",
) )
parser.add_argument( parser.add_argument(
"--stop-model", "--stop-model",
default="stop", default="stop",
help="Id of stop model" help="File name of the stop model",
) )
parser.add_argument( parser.add_argument(
"--download-dir", "--download-dir",
default=_REPO_DIR / "local", default=_REPO_DIR / "local",
help="Directory to download custom wake word models, etc.", help="Directory to download custom wake word models to",
) )
parser.add_argument( parser.add_argument(
"--refractory-seconds", "--refractory-seconds",
@@ -90,11 +102,13 @@ async def main() -> None:
) )
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"),
help="Directory and file name for wake sound (when you say the wake word)",
) )
parser.add_argument( parser.add_argument(
"--timer-finished-sound", "--timer-finished-sound",
default=str(_SOUNDS_DIR / "timer_finished.flac") default=str(_SOUNDS_DIR / "timer_finished.flac"),
help="Directory and file name for timer finished sound",
) )
parser.add_argument( parser.add_argument(
"--processing-sound", "--processing-sound",
@@ -111,33 +125,51 @@ async def main() -> None:
default=str(_SOUNDS_DIR / "mute_switch_off.flac"), default=str(_SOUNDS_DIR / "mute_switch_off.flac"),
help="Sound to play when unmuting the assistant", help="Sound to play when unmuting the assistant",
) )
parser.add_argument("--preferences-file", default=_REPO_DIR / "preferences.json") parser.add_argument(
"--preferences-file",
default=_REPO_DIR / "preferences.json",
help="Directory and file name for the file where the preferences are stored in JSON format",
)
parser.add_argument( parser.add_argument(
"--host", "--host",
help="IP-Address for ESPHome server", # 0.0.0.0 is IPv4, None is all interfaces help="Optional host IP address to bind to (default: Autodetected by network interface)", # 0.0.0.0 is IPv4, None is all interfaces
) )
parser.add_argument( parser.add_argument(
"--network-interface", "--network-interface",
help="Network interface to use for ESPHome server (default: will be automatically detected)", help="Network interface the application will be listening on (default: will be automatically detected by gateway)",
) )
# Note that default port is also set in docker-entrypoint.sh # Note that default port is also set in docker-entrypoint.sh
parser.add_argument( parser.add_argument(
"--port", type=int, "--port",
type=int,
default=6053, default=6053,
help="Port for ESPHome server (default: 6053)" help="Port the application is listenening on (default: 6053)",
) )
parser.add_argument( parser.add_argument(
"--enable-thinking-sound", "--enable-thinking-sound",
action="store_true", action="store_true",
help="Enable thinking sound on startup", help="Enable thinking finish sound, when the assistant is done thinking and needed more time to process",
) )
parser.add_argument( parser.add_argument(
"--debug", action="store_true", help="Print DEBUG messages to console" "--timer-max-ring-seconds",
type=float,
default=900.0, # 15 minutes
help="Seconds before a ringing timer auto-stops (default: 900)",
)
parser.add_argument(
"--debug",
action="store_true",
help="Add this to enable debug logging",
)
parser.add_argument(
"--output-only",
action="store_true",
help="Enable output only mode",
) )
args = parser.parse_args() args = parser.parse_args()
if args.list_input_devices: if args.list_input_devices:
print("Input devices") print("Audio Input devices:")
print("=" * 13) print("=" * 13)
for idx, mic in enumerate(sc.all_microphones()): for idx, mic in enumerate(sc.all_microphones()):
print(f"[{idx}]", mic.name) print(f"[{idx}]", mic.name)
@@ -147,7 +179,7 @@ async def main() -> None:
from mpv import MPV from mpv import MPV
player = MPV() player = MPV()
print("Output devices") print("Audio output devices:")
print("=" * 14) print("=" * 14)
for speaker in player.audio_device_list: # type: ignore for speaker in player.audio_device_list: # type: ignore
@@ -157,39 +189,53 @@ 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()
print(f"Default interface detected:", network_interface) print(f"Default interface detected: {network_interface}")
else: else:
print("Network interface specified") print("Network interface specified")
print(f"Using network interface: ", network_interface)
network_interface = args.network_interface network_interface = args.network_interface
print(f"Using network interface: {network_interface}")
# Resolve ip_address where the application will be listening # Resolve ip_address where the application will be listening
if not args.host: if not args.host:
print("No host (ip-address) specified, try to detect IP-Address") print("No host (ip-address) specified, try to detect IP-Address")
host_ip_address = get_default_ipv4(network_interface) host_ip_address = get_default_ipv4(network_interface)
print(f"IP-Address detected: ", host_ip_address) print(f"IP-Address detected: {host_ip_address}")
else: else:
print("Host specified") print("Host specified")
print(f"Using host: ", args.host) print(f"Using host: {args.host}")
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:
print("No name specified, try to autogenerate name") print("No friendly name specified, try to autogenerate name")
device_name = f"lva-{mac_address_clean}" friendly_name = f"LVA - {mac_address_clean}"
print(f"Name autogenerated: {device_name}") print(f"Friendly name autogenerated: {friendly_name}")
else: else:
print("Name specified") print("Friendly name specified")
print(f"Using name: {args.name}") print(f"Using friendly name: {args.name}")
device_name = args.name friendly_name = args.name
device_name = f"lva-{mac_address_clean}"
print(f"Device name: {device_name}")
# Resolve version
version = get_version()
print(f"Version: {version}")
# Resolve esphome version
esphome_version = get_esphome_version()
print(f"ESPHome api version: {esphome_version}")
# Resolve download dir # Resolve download dir
args.download_dir = Path(args.download_dir) args.download_dir = Path(args.download_dir)
@@ -209,32 +255,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)
@@ -246,51 +267,42 @@ async def main() -> None:
else: else:
preferences = Preferences() preferences = Preferences()
# Load volume from preferences on startup, and ensure it's between 0.0 and 1.0
initial_volume = preferences.volume if preferences.volume is not None else 1.0
initial_volume = max(0.0, min(1.0, float(initial_volume)))
preferences.volume = initial_volume
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_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,
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,
esphome_version=esphome_version,
audio_queue=Queue(), audio_queue=Queue(),
entities=[], entities=[],
available_wake_words=available_wake_words, available_wake_words=available_wake_words,
@@ -307,11 +319,67 @@ 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,
output_only=args.output_only,
download_dir=args.download_dir, download_dir=args.download_dir,
volume=initial_volume,
mic_volume=preferences.mic_volume,
mic_auto_gain=preferences.mic_auto_gain,
mic_noise_suppression=preferences.mic_noise_suppression,
timer_max_ring_seconds=args.timer_max_ring_seconds,
) )
if args.enable_thinking_sound: if fallback_used:
# Fallback to the default model was used, save as active wake words
_LOGGER.debug("Fallback was used, save default wake words in Preferences.")
state.preferences.active_wake_words = list(active_wake_words)
state.active_wake_words = active_wake_words
state.wake_words = wake_models
state.save_preferences() state.save_preferences()
state.wake_words_changed = True
if args.enable_thinking_sound or args.mic_auto_gain or args.mic_noise_suppression:
state.save_preferences()
initial_volume_percent = int(round(initial_volume * 100))
state.music_player.set_volume(initial_volume_percent)
state.tts_player.set_volume(initial_volume_percent)
loop = asyncio.get_running_loop()
max_attempts = 15
attempt = 1
server = None
# Validate VoiceSatelliteProtocol initialization BEFORE starting server
# This catches errors like missing imports or broken initialization immediately
# instead of failing silently only when first client connects
_LOGGER.debug("Validating VoiceSatelliteProtocol initialization...")
try:
# Create test instance to run complete __init__ code path
test_protocol = VoiceSatelliteProtocol(state)
# Cleanup state reference
test_protocol.state.satellite = None
del test_protocol
_LOGGER.debug("✅ VoiceSatelliteProtocol validation successful")
except Exception:
_LOGGER.critical("❌ FATAL ERROR in VoiceSatelliteProtocol initialization!", exc_info=True)
_LOGGER.critical("Program will exit immediately - fix the error above first!")
sys.exit(1)
while attempt <= max_attempts:
try:
server = await loop.create_server(lambda: VoiceSatelliteProtocol(state), host=host_ip_address, port=args.port)
break # connect successful, exit the loop
except OSError as err:
message = err.strerror or str(err)
if err.errno == errno.EADDRINUSE:
message = "address already in use"
if attempt < max_attempts:
_LOGGER.warning("Attempt %d/%d failed to bind on address (%s, %s): %s. Retrying in 1 second...", attempt, max_attempts, host_ip_address, args.port, message)
await asyncio.sleep(1)
attempt += 1
else:
_LOGGER.exception("All %d attempts failed to bind on address (%s, %s): %s", max_attempts, host_ip_address, args.port, message)
sys.exit(1)
process_audio_thread = threading.Thread( process_audio_thread = threading.Thread(
target=process_audio, target=process_audio,
@@ -320,19 +388,14 @@ async def main() -> None:
) )
process_audio_thread.start() process_audio_thread.start()
loop = asyncio.get_running_loop()
server = await loop.create_server(
lambda: VoiceSatelliteProtocol(state), host=host_ip_address, port=args.port
)
# Auto discovery (zeroconf, mDNS) # Auto discovery (zeroconf, mDNS)
discovery = HomeAssistantZeroconf(port=args.port, name=state.name, mac_address=state.mac_address, host_ip_address=host_ip_address) discovery = HomeAssistantZeroconf(port=args.port, name=state.name, mac_address=state.mac_address, host_ip_address=host_ip_address)
await discovery.register_server() await discovery.register_server()
try: try:
async with server: async with server: # type: ignore
_LOGGER.info("Server started (host=%s, port=%s)", host_ip_address, args.port) _LOGGER.info("Server started (host=%s, port=%s)", host_ip_address, args.port)
await server.serve_forever() await server.serve_forever() # type: ignore
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
finally: finally:
@@ -357,35 +420,108 @@ 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=1, blocksize=block_size) as mic_in:
while True: while True:
audio_chunk_array = mic_in.record(block_size).reshape(-1) audio_chunk_array = mic_in.record(block_size).reshape(-1)
audio_chunk = ( # little-endian 16-bit signed
(np.clip(audio_chunk_array, -1.0, 1.0) * 32767.0) mic_vol_scalar = max(0.1, min(1.0, state.mic_volume / 100.0))
.astype("<i2") # little-endian 16-bit signed audio_chunk = (np.clip(audio_chunk_array * mic_vol_scalar, -1.0, 1.0) * 32767.0).astype("<i2").tobytes()
.tobytes() agc = state.preferences.mic_auto_gain or 0
) ns = state.preferences.mic_noise_suppression or 0
if state.satellite is None: if agc > 0 or ns > 0:
if webrtc is None:
webrtc = WebRTCProcessor(agc_level=agc, ns_level=ns)
else:
webrtc.update_settings(agc, ns)
audio_chunk = webrtc.process(audio_chunk)
if not audio_chunk:
continue 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 = [ wake_words = [ww for ww in state.wake_words.values() if ww.id in state.active_wake_words]
ww
for ww in state.wake_words.values() # TODO: Load default stop word value from json into state and preferences missing.
if ww.id in state.active_wake_words
]
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()
@@ -404,38 +540,61 @@ 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:
# Check refractory # Check refractory
now = time.monotonic() now = time.monotonic()
if (last_active is None) or ( if (last_active is None) or ((now - last_active) > state.refractory_seconds):
(now - last_active) > state.refractory_seconds
):
state.satellite.wakeup(wake_word) state.satellite.wakeup(wake_word)
last_active = now last_active = now
# 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 ( if stopped and (state.stop_word.id in state.active_wake_words) and not state.muted:
stopped _LOGGER.debug("Stop word detected")
and (state.stop_word.id in state.active_wake_words)
and not state.muted
):
state.satellite.stop() state.satellite.stop()
except Exception: except Exception:
_LOGGER.exception("Unexpected error handling audio") _LOGGER.exception("Unexpected error handling audio")
@@ -446,5 +605,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()
+4 -11
View File
@@ -5,7 +5,7 @@ import logging
import threading import threading
from abc import abstractmethod from abc import abstractmethod
from collections.abc import Iterable from collections.abc import Iterable
from typing import TYPE_CHECKING, List, Optional from typing import TYPE_CHECKING, Optional
# pylint: disable=no-name-in-module # pylint: disable=no-name-in-module
from aioesphomeapi._frame_helper.packets import make_plain_text_packets from aioesphomeapi._frame_helper.packets import make_plain_text_packets
@@ -77,20 +77,13 @@ class APIServer(asyncio.Protocol):
self.send_messages(msgs) self.send_messages(msgs)
def send_messages(self, msgs: List[message.Message]): def send_messages(self, msgs: Iterable[message.Message]):
if self._writelines is None or not msgs: if self._writelines is None or not msgs:
return return
packets = [ packets = [(PROTO_TO_MESSAGE_TYPE[msg.__class__], msg.SerializeToString()) for msg in msgs]
(PROTO_TO_MESSAGE_TYPE[msg.__class__], msg.SerializeToString())
for msg in msgs
]
packet_bytes = make_plain_text_packets(packets) packet_bytes = make_plain_text_packets(packets)
if ( if self._loop is not None and self._loop_thread_id is not None and threading.get_ident() != self._loop_thread_id:
self._loop is not None
and self._loop_thread_id is not None
and threading.get_ident() != self._loop_thread_id
):
self._loop.call_soon_threadsafe(self._writelines, packet_bytes) self._loop.call_soon_threadsafe(self._writelines, packet_bytes)
return return
+344 -24
View File
@@ -1,15 +1,21 @@
import logging
from abc import abstractmethod from abc import abstractmethod
from collections.abc import Iterable from collections.abc import Iterable
from typing import Callable, List, Optional, Union from typing import Callable, List, Optional, Union
import logging
# 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
@@ -58,6 +65,8 @@ class MediaPlayerEntity(ESPHomeEntity):
object_id: str, object_id: str,
music_player: MpvMediaPlayer, music_player: MpvMediaPlayer,
announce_player: MpvMediaPlayer, announce_player: MpvMediaPlayer,
initial_volume: float = 1.0,
on_volume_changed: Optional[Callable[[float], None]] = None,
) -> None: ) -> None:
ESPHomeEntity.__init__(self, server) ESPHomeEntity.__init__(self, server)
@@ -65,11 +74,13 @@ class MediaPlayerEntity(ESPHomeEntity):
self.name = name self.name = name
self.object_id = object_id self.object_id = object_id
self.state = MediaPlayerState.IDLE self.state = MediaPlayerState.IDLE
self.volume = 1.0 self.volume = max(0.0, min(1.0, initial_volume))
self.muted = False self.muted = False
self.previous_volume = 1.0 self.previous_volume = 1.0
self.music_player = music_player self.music_player = music_player
self.announce_player = announce_player self.announce_player = announce_player
self._on_volume_changed = on_volume_changed
self.apply_volume_from_state(initial_volume)
self._log = logging.getLogger(f"{self.__class__.__name__}[{self.key}]") self._log = logging.getLogger(f"{self.__class__.__name__}[{self.key}]")
def play( def play(
@@ -79,34 +90,30 @@ class MediaPlayerEntity(ESPHomeEntity):
done_callback: Optional[Callable[[], None]] = None, done_callback: Optional[Callable[[], None]] = None,
) -> Iterable[message.Message]: ) -> Iterable[message.Message]:
if announcement: if announcement:
self._log.debug("PLAY: announcement true")
if self.music_player.is_playing: if self.music_player.is_playing:
# Announce, resume music # Announce, resume music
self.music_player.pause() self.music_player.pause()
self.announce_player.play( self.announce_player.play(
url, url,
done_callback=lambda: call_all( done_callback=lambda: call_all(self.music_player.resume, done_callback),
self.music_player.resume, done_callback
),
) )
else: else:
# Announce, idle # Announce, idle
self.announce_player.play( self.announce_player.play(
url, url,
done_callback=lambda: call_all( done_callback=lambda: call_all(
self.server.send_messages( self.server.send_messages([self._update_state(MediaPlayerState.IDLE)]),
[self._update_state(MediaPlayerState.IDLE)]
),
done_callback, done_callback,
), ),
) )
else: else:
self._log.debug("PLAY: announcement false")
# Music # Music
self.music_player.play( self.music_player.play(
url, url,
done_callback=lambda: call_all( done_callback=lambda: call_all(
self.server.send_messages( self.server.send_messages([self._update_state(MediaPlayerState.IDLE)]),
[self._update_state(MediaPlayerState.IDLE)]
),
done_callback, done_callback,
), ),
) )
@@ -116,10 +123,15 @@ 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")
if msg.has_media_url: if msg.has_media_url:
self._log.debug("Executing PLAY")
self._log.debug("Message has media URL: %s", msg.media_url) self._log.debug("Message has media URL: %s", msg.media_url)
announcement = msg.has_announcement and msg.announcement announcement = msg.has_announcement and msg.announcement
yield from self.play(msg.media_url, announcement=announcement) yield from self.play(msg.media_url, announcement=announcement)
@@ -164,10 +176,12 @@ class MediaPlayerEntity(ESPHomeEntity):
elif msg.has_volume: elif msg.has_volume:
self._log.debug("Message has volume: %.2f", msg.volume) self._log.debug("Message has volume: %.2f", msg.volume)
volume = int(msg.volume * 100) self._apply_volume(msg.volume, persist=True)
self.music_player.set_volume(volume) if hasattr(self.server, "state") and getattr(self.server, "state", None) is not None:
self.announce_player.set_volume(volume) self._log.debug("Persisting volume to preferences")
self.volume = msg.volume self.server.state.persist_volume(self.volume)
else:
self._log.warning("Cannot persist volume - server.state not available")
yield self._update_state(self.state) yield self._update_state(self.state)
elif isinstance(msg, ListEntitiesRequest): elif isinstance(msg, ListEntitiesRequest):
@@ -186,6 +200,8 @@ class MediaPlayerEntity(ESPHomeEntity):
self._log.warning("Unknown message type received: %s", type(msg)) self._log.warning("Unknown message type received: %s", type(msg))
def _update_state(self, new_state: MediaPlayerState) -> MediaPlayerStateResponse: def _update_state(self, new_state: MediaPlayerState) -> MediaPlayerStateResponse:
self._log.debug("SET NEW STATE: %s => %s", self.state, new_state)
self._log.debug("SET NEW STATE: %s => %s", self.state.name, new_state.name)
self.state = new_state self.state = new_state
return self._get_state_message() return self._get_state_message()
@@ -197,6 +213,43 @@ class MediaPlayerEntity(ESPHomeEntity):
muted=self.muted, muted=self.muted,
) )
def apply_volume_from_state(self, volume: float) -> None:
"""Synchronize the local volume with the stored state without persisting."""
clamped = max(0.0, min(1.0, float(volume)))
if self.muted:
self.previous_volume = clamped
return
self._apply_volume(clamped, persist=False)
def set_volume_callback(self, callback: Optional[Callable[[float], None]]) -> None:
"""Update the callback invoked when the volume changes."""
self._on_volume_changed = callback
def _apply_volume(
self,
volume: float,
*,
persist: bool,
remember: bool = True,
) -> None:
normalized = max(0.0, min(1.0, float(volume)))
volume_percent = int(round(normalized * 100))
self.music_player.set_volume(volume_percent)
self.announce_player.set_volume(volume_percent)
self.volume = normalized
if remember:
self.previous_volume = normalized
if self._on_volume_changed and persist:
self._on_volume_changed(normalized)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -218,9 +271,7 @@ class MuteSwitchEntity(ESPHomeEntity):
self.object_id = object_id self.object_id = object_id
self._get_muted = get_muted self._get_muted = get_muted
self._set_muted = set_muted self._set_muted = set_muted
self._switch_state = ( self._switch_state = self._get_muted() # Sync internal state with actual muted value on init
self._get_muted()
) # Sync internal state with actual muted value on init
def update_set_muted(self, set_muted: Callable[[bool], None]) -> None: def update_set_muted(self, set_muted: Callable[[bool], None]) -> None:
# Update the callback used to change the mute state. # Update the callback used to change the mute state.
@@ -275,15 +326,11 @@ class ThinkingSoundEntity(ESPHomeEntity):
self._set_thinking_sound_enabled = set_thinking_sound_enabled self._set_thinking_sound_enabled = set_thinking_sound_enabled
self._switch_state = self._get_thinking_sound_enabled() # Sync internal state self._switch_state = self._get_thinking_sound_enabled() # Sync internal state
def update_get_thinking_sound_enabled( def update_get_thinking_sound_enabled(self, get_thinking_sound_enabled: Callable[[], bool]) -> None:
self, get_thinking_sound_enabled: Callable[[], bool]
) -> None:
# Update the callback used to read the thinking sound enabled state. # Update the callback used to read the thinking sound enabled state.
self._get_thinking_sound_enabled = get_thinking_sound_enabled self._get_thinking_sound_enabled = get_thinking_sound_enabled
def update_set_thinking_sound_enabled( def update_set_thinking_sound_enabled(self, set_thinking_sound_enabled: Callable[[bool], None]) -> None:
self, set_thinking_sound_enabled: Callable[[bool], None]
) -> None:
# Update the callback used to change the thinking sound enabled state. # Update the callback used to change the thinking sound enabled state.
self._set_thinking_sound_enabled = set_thinking_sound_enabled self._set_thinking_sound_enabled = set_thinking_sound_enabled
@@ -311,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
# -----------------------------------------------------------------------------
+90 -2
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,16 +61,27 @@ 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
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
class ServerState: class ServerState:
name: str name: str
friendly_name: str
mac_address: str mac_address: str
ip_address: str ip_address: str
network_interface: str network_interface: str
version: str
esphome_version: str
audio_queue: "Queue[Optional[bytes]]" audio_queue: "Queue[Optional[bytes]]"
entities: "List[ESPHomeEntity]" entities: "List[ESPHomeEntity]"
available_wake_words: "Dict[str, AvailableWakeWord]" available_wake_words: "Dict[str, AvailableWakeWord]"
@@ -87,11 +103,29 @@ class ServerState:
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
oww_probability_cutoff: float = 0.7 # Dynamic threshold for OpenWakeWord
oww_second_probability_cutoff: float = 0.7 # Dynamic threshold for second OpenWakeWord
oww_stop_probability_cutoff: float = 0.5 # Dynamic threshold for Stop word
wake_word_1_threshold: float = 0.7
wake_word_2_threshold: float = 0.7
stop_word_threshold: float = 0.5
mic_auto_gain: int = 0
mic_noise_suppression: int = 0
mic_volume: int = 100 # 1–100, default maximum
timer_max_ring_seconds: float = 900.0
def save_preferences(self) -> None: def save_preferences(self) -> None:
"""Save preferences as JSON.""" """Save preferences as JSON."""
@@ -99,5 +133,59 @@ class ServerState:
self.preferences_path.parent.mkdir(parents=True, exist_ok=True) self.preferences_path.parent.mkdir(parents=True, exist_ok=True)
with open(self.preferences_path, "w", encoding="utf-8") as preferences_file: with open(self.preferences_path, "w", encoding="utf-8") as preferences_file:
json.dump( json.dump(
asdict(self.preferences), preferences_file, ensure_ascii=False, indent=4 asdict(self.preferences),
preferences_file,
ensure_ascii=False,
indent=4,
) )
def persist_volume(self, volume: float) -> None:
"""Persist the normalized media volume (0.0 - 1.0)."""
clamped_volume = max(0.0, min(1.0, volume))
_LOGGER.debug(
"persist_volume called: new=%s, current=%s, prefs=%s",
clamped_volume,
self.volume,
self.preferences.volume,
)
if abs(self.volume - clamped_volume) < 0.0001 and self.preferences.volume is not None and abs(self.preferences.volume - clamped_volume) < 0.0001:
_LOGGER.debug("Skipping save - volume unchanged")
return
self.volume = clamped_volume
self.preferences.volume = clamped_volume
_LOGGER.info("Saving volume %s to %s", clamped_volume, self.preferences_path)
self.save_preferences()
_LOGGER.info("Volume saved successfully")
def persist_mic_gain(self, gain: float) -> None:
"""Persist the microphone auto gain value."""
gain_int = int(gain)
if self.mic_auto_gain == gain_int and self.preferences.mic_auto_gain == gain_int:
return
self.mic_auto_gain = gain_int
self.preferences.mic_auto_gain = gain_int
self.save_preferences()
def persist_mic_noise(self, noise: float) -> None:
"""Persist the microphone noise suppression value."""
noise_int = int(noise)
if self.mic_noise_suppression == noise_int and self.preferences.mic_noise_suppression == noise_int:
return
self.mic_noise_suppression = noise_int
self.preferences.mic_noise_suppression = noise_int
self.save_preferences()
def persist_mic_volume(self, volume: float) -> None:
"""Persist the microphone input volume (0–100)."""
volume_int = max(1, min(100, int(round(volume))))
if self.mic_volume == volume_int and self.preferences.mic_volume == volume_int:
return
self.mic_volume = volume_int
self.preferences.mic_volume = volume_int
_LOGGER.info("Saving mic_volume %s to %s", volume_int, self.preferences_path)
self.save_preferences()
+50 -21
View File
@@ -1,8 +1,9 @@
# mpv_player.py # mpv_player.py
import logging import logging
from typing import Union, List, Callable, Optional from typing import Callable, List, Optional, Union
from .player.libmpv import LibMpvPlayer from .player.libmpv import LibMpvPlayer
from .player.state import PlayerState
class MpvMediaPlayer: class MpvMediaPlayer:
@@ -17,6 +18,7 @@ class MpvMediaPlayer:
self._log = logging.getLogger(self.__class__.__name__) self._log = logging.getLogger(self.__class__.__name__)
self._player = LibMpvPlayer(device=device) self._player = LibMpvPlayer(device=device)
self._done_callback: Optional[Callable[[], None]] = None self._done_callback: Optional[Callable[[], None]] = None
self._playlist: List[str] = []
self._log.debug("MpvMediaPlayer initialized (device=%s)", device) self._log.debug("MpvMediaPlayer initialized (device=%s)", device)
@@ -30,34 +32,55 @@ class MpvMediaPlayer:
Play a media URL. Play a media URL.
Args: Args:
url: Media URL or list of URLs (LVA currently uses a single URL). url: Media URL or list of URLs for sequential playback.
done_callback: Optional callback invoked when playback finishes. done_callback: Optional callback invoked when playback finishes.
stop_first: Kept for API compatibility. stop_first: Kept for API compatibility.
""" """
# LVA currently only uses single URLs # Handle single URL vs list
if isinstance(url, list): if isinstance(url, str):
self._log.debug("Received URL list, using first entry") urls = [url]
url = url[0] else:
urls = list(url) # Copy the list
# Track is changing if not urls:
self._log.warning("play() called with empty URL list")
return
# Track is changing - stop if needed
if self._done_callback is not None: if self._done_callback is not None:
self._log.debug( if self._player.state() != PlayerState.IDLE:
"Stopping active playback before starting new media" self._log.debug("Stopping active playback before starting new media")
)
# Not self.stop() → this would call done_callback
self._player.stop(for_replacement=True) self._player.stop(for_replacement=True)
self._done_callback = None self._done_callback = None
self._log.info("Playing media: %s", url) self._log.info("Playing %d URL(s): %s", len(urls), urls[0])
self._log.debug(
"play(url=%s, stop_first=%s, done_callback=%s)",
url,
stop_first,
bool(done_callback),
)
# Store playlist and callback
self._playlist = urls
self._done_callback = done_callback self._done_callback = done_callback
self._player.play(url, done_callback=done_callback, stop_first=stop_first)
# Start playing first URL
next_url = self._playlist.pop(0)
self._player.play(next_url, done_callback=self._on_track_finished, stop_first=stop_first)
def _on_track_finished(self) -> None:
"""Called when a track finishes - plays next or invokes done callback."""
if self._playlist:
# More tracks to play
next_url = self._playlist.pop(0)
self._log.debug("Playing next URL from playlist: %s", next_url)
self._player.play(next_url, done_callback=self._on_track_finished, stop_first=False)
else:
# Playlist finished
callback = self._done_callback
self._done_callback = None
if callback:
self._log.debug("Playlist finished, invoking done_callback")
try:
callback()
except Exception as e:
self._log.exception("Error in done_callback: %s", e)
def pause(self) -> None: def pause(self) -> None:
"""Pause playback.""" """Pause playback."""
@@ -82,12 +105,18 @@ class MpvMediaPlayer:
finally: finally:
self._done_callback = None self._done_callback = None
@property
def is_playing(self) -> bool:
"""Check if the player is currently playing or paused."""
state = self._player.state()
return state in (PlayerState.PLAYING, PlayerState.PAUSED, PlayerState.LOADING)
def set_volume(self, volume: float) -> None: def set_volume(self, volume: float) -> None:
""" """
Set playback volume. Set playback volume.
Args: Args:
volume: Volume in percent (0.0–100.0). volume: Volume in percent (0.0-100.0).
""" """
self._log.debug("set_volume(volume=%.2f)", volume) self._log.debug("set_volume(volume=%.2f)", volume)
self._player.set_volume(volume) self._player.set_volume(volume)
@@ -97,7 +126,7 @@ class MpvMediaPlayer:
Temporarily reduce volume. Temporarily reduce volume.
Args: Args:
factor: Volume multiplier (0.0–1.0). factor: Volume multiplier (0.0-1.0).
""" """
self._log.debug("duck(factor=%.2f)", factor) self._log.debug("duck(factor=%.2f)", factor)
self._player.duck(factor) self._player.duck(factor)
+2 -1
View File
@@ -1,5 +1,6 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from .state import PlayerState
from linux_voice_assistant.player.state import PlayerState
class AudioPlayer(ABC): class AudioPlayer(ABC):
+63 -12
View File
@@ -1,10 +1,11 @@
import logging
import threading import threading
from typing import Optional, Callable from typing import Callable, Optional
import mpv import mpv
from .base import AudioPlayer from linux_voice_assistant.player.base import AudioPlayer
from .state import PlayerState from linux_voice_assistant.player.state import PlayerState
class LibMpvPlayer(AudioPlayer): class LibMpvPlayer(AudioPlayer):
@@ -18,6 +19,7 @@ class LibMpvPlayer(AudioPlayer):
""" """
def __init__(self, device: Optional[str] = None) -> None: def __init__(self, device: Optional[str] = None) -> None:
self._log = logging.getLogger(self.__class__.__name__)
self._state: PlayerState = PlayerState.IDLE self._state: PlayerState = PlayerState.IDLE
self._state_lock = threading.Lock() self._state_lock = threading.Lock()
@@ -35,10 +37,25 @@ 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._suppress_end_event = False
self._mpv.event_callback("end-file")(self._on_end_file) self._mpv.event_callback("end-file")(self._on_end_file)
self._mpv.event_callback("start-file")(self._on_start_file)
# -------- Playback control -------- # -------- Playback control --------
@@ -56,10 +73,11 @@ class LibMpvPlayer(AudioPlayer):
done_callback: Optional callback invoked when playback finishes. done_callback: Optional callback invoked when playback finishes.
stop_first: If True, start playback in paused state. stop_first: If True, start playback in paused state.
""" """
self._done_callback = done_callback
self._mpv.pause = stop_first
with self._state_lock: with self._state_lock:
self._log.debug("play: current_state=%s", self._state)
self._done_callback = done_callback
self._set_state(PlayerState.LOADING) self._set_state(PlayerState.LOADING)
self._mpv.pause = stop_first
self._mpv.play(url) self._mpv.play(url)
def pause(self) -> None: def pause(self) -> None:
@@ -70,6 +88,7 @@ class LibMpvPlayer(AudioPlayer):
def resume(self) -> None: def resume(self) -> None:
"""Resume playback if paused.""" """Resume playback if paused."""
self._log.debug("unduck() called")
with self._state_lock: with self._state_lock:
self._mpv.pause = False self._mpv.pause = False
self._set_state(PlayerState.PLAYING) self._set_state(PlayerState.PLAYING)
@@ -78,13 +97,14 @@ class LibMpvPlayer(AudioPlayer):
""" """
Stop playback. Stop playback.
If called for track replacement, suppresses end-of-playback handling If called for track replacement, clears the callback to prevent
(state transition to IDLE and done callback) to allow seamless it from being invoked during the transition.
playback transitions.
""" """
self._log.debug("unduck() called")
with self._state_lock: with self._state_lock:
if for_replacement: if for_replacement:
self._suppress_end_event = True # Clear callback to prevent invocation during track transition
self._done_callback = None
self._mpv.stop() self._mpv.stop()
def state(self) -> PlayerState: def state(self) -> PlayerState:
@@ -101,6 +121,7 @@ class LibMpvPlayer(AudioPlayer):
Args: Args:
volume: Volume level (0.0–100.0). volume: Volume level (0.0–100.0).
""" """
self._log.debug("unduck() called")
with self._state_lock: with self._state_lock:
self._user_volume = max(0.0, min(100.0, float(volume))) self._user_volume = max(0.0, min(100.0, float(volume)))
self._apply_volume() self._apply_volume()
@@ -112,12 +133,14 @@ class LibMpvPlayer(AudioPlayer):
Args: Args:
factor: Ducking factor (0.0–1.0). factor: Ducking factor (0.0–1.0).
""" """
self._log.debug("unduck() called")
with self._state_lock: with self._state_lock:
self._duck_factor = max(0.0, min(1.0, float(factor))) self._duck_factor = max(0.0, min(1.0, float(factor)))
self._apply_volume() self._apply_volume()
def unduck(self) -> None: def unduck(self) -> None:
"""Restore volume to the user-defined level.""" """Restore volume to the user-defined level."""
self._log.debug("unduck() called")
with self._state_lock: with self._state_lock:
self._duck_factor = 1.0 self._duck_factor = 1.0
self._apply_volume() self._apply_volume()
@@ -126,6 +149,7 @@ class LibMpvPlayer(AudioPlayer):
def _apply_volume(self) -> None: def _apply_volume(self) -> None:
"""Apply effective volume (user volume × duck factor) to mpv.""" """Apply effective volume (user volume × duck factor) to mpv."""
self._log.debug("unduck() called")
effective = self._user_volume * self._duck_factor effective = self._user_volume * self._duck_factor
self._mpv.volume = max(0.0, min(100.0, effective)) self._mpv.volume = max(0.0, min(100.0, effective))
@@ -133,8 +157,27 @@ class LibMpvPlayer(AudioPlayer):
callback: Optional[Callable[[], None]] = None callback: Optional[Callable[[], None]] = None
with self._state_lock: with self._state_lock:
if self._suppress_end_event: # mpv events: event.data is a MpvEventEndFile object with a 'reason' attribute
self._suppress_end_event = False # The reason is an integer constant (see mpv.END_FILE_REASON_*)
end_file_data = event.data
reason = getattr(end_file_data, "reason", -1) if end_file_data else -1
# mpv END_FILE_REASON constants:
# 0 = eof (end of file), 1 = stop, 2 = abort, 3 = quit, 4 = error
is_eof = reason == 0
self._log.debug(
"_on_end_file: reason=%s (is_eof=%s), state=%s, has_callback=%s",
reason,
is_eof,
self._state,
self._done_callback is not None,
)
# Only process "eof" (reason=0) events as actual track completion.
# Other reasons are from track changes, stops, or errors.
if not is_eof:
self._log.debug("_on_end_file: ignoring non-eof event (reason=%s)", reason)
return return
self._set_state(PlayerState.IDLE) self._set_state(PlayerState.IDLE)
@@ -142,12 +185,20 @@ class LibMpvPlayer(AudioPlayer):
self._done_callback = None self._done_callback = None
if callback is not None: if callback is not None:
self._log.debug("_on_end_file: invoking callback")
try: try:
callback() callback()
except RuntimeError: except RuntimeError:
# Callback errors must never break the player # Callback errors must never break the player
pass pass
def _on_start_file(self, event) -> None:
"""Called when mpv starts playing a file."""
self._log.debug("unduck() called")
with self._state_lock:
self._log.debug("_on_start_file: state=%s", self._state)
self._set_state(PlayerState.PLAYING)
def _on_mpv_log(self, level: str, prefix: str, text: str) -> None: def _on_mpv_log(self, level: str, prefix: str, text: str) -> None:
""" """
Handle mpv log messages. Handle mpv log messages.
+377 -98
View File
@@ -4,11 +4,11 @@ import asyncio
import hashlib import hashlib
import logging import logging
import posixpath import posixpath
import re
import shutil import shutil
import threading
import time import time
from collections.abc import Iterable from collections.abc import Iterable
from typing import Dict, Optional, Set, Union from typing import Dict, List, Optional, Set, Union
from urllib.parse import urlparse, urlunparse from urllib.parse import urlparse, urlunparse
from urllib.request import urlopen from urllib.request import urlopen
@@ -20,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,
@@ -45,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
@@ -63,25 +73,28 @@ class VoiceSatelliteProtocol(APIServer):
self.state.satellite = self self.state.satellite = self
self.state.connected = False self.state.connected = False
existing_media_players = [ # Report capabilities appropriately
entity if state.output_only:
for entity in self.state.entities _LOGGER.debug("Output only features")
if isinstance(entity, MediaPlayerEntity) self.supported_features = VoiceAssistantFeature.API_AUDIO | VoiceAssistantFeature.ANNOUNCE
] else:
_LOGGER.debug("Voice assistant features")
self.supported_features = (
VoiceAssistantFeature.VOICE_ASSISTANT | VoiceAssistantFeature.API_AUDIO | VoiceAssistantFeature.ANNOUNCE | VoiceAssistantFeature.START_CONVERSATION | VoiceAssistantFeature.TIMERS
)
existing_media_players = [entity for entity in self.state.entities if isinstance(entity, MediaPlayerEntity)]
if existing_media_players: 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]
for extra in existing_media_players[1:]: for extra in existing_media_players[1:]:
self.state.entities.remove(extra) self.state.entities.remove(extra)
existing_mute_switches = [ existing_mute_switches = [entity for entity in self.state.entities if isinstance(entity, MuteSwitchEntity)]
entity
for entity in self.state.entities
if isinstance(entity, MuteSwitchEntity)
]
if existing_mute_switches: if existing_mute_switches:
self.state.mute_switch_entity = existing_mute_switches[0] self.state.mute_switch_entity = existing_mute_switches[0] # type: ignore
for extra in existing_mute_switches[1:]: for extra in existing_mute_switches[1:]: # type: ignore
self.state.entities.remove(extra) self.state.entities.remove(extra)
if self.state.media_player_entity is None: if self.state.media_player_entity is None:
@@ -92,12 +105,15 @@ class VoiceSatelliteProtocol(APIServer):
object_id="linux_voice_assistant_media_player", object_id="linux_voice_assistant_media_player",
music_player=state.music_player, music_player=state.music_player,
announce_player=state.tts_player, announce_player=state.tts_player,
initial_volume=state.volume,
) )
self.state.entities.append(self.state.media_player_entity) self.state.entities.append(self.state.media_player_entity)
elif self.state.media_player_entity not in self.state.entities: elif self.state.media_player_entity not in self.state.entities:
self.state.entities.append(self.state.media_player_entity) self.state.entities.append(self.state.media_player_entity)
self.state.media_player_entity.server = self self.state.media_player_entity.server = self
self.state.media_player_entity.volume = state.volume
self.state.media_player_entity.previous_volume = state.volume
# Add/update mute switch entity (like ESPHome Voice PE) # Add/update mute switch entity (like ESPHome Voice PE)
mute_switch = self.state.mute_switch_entity mute_switch = self.state.mute_switch_entity
@@ -120,14 +136,10 @@ class VoiceSatelliteProtocol(APIServer):
mute_switch.update_set_muted(self._set_muted) mute_switch.update_set_muted(self._set_muted)
mute_switch.sync_with_state() mute_switch.sync_with_state()
existing_thinking_sound_switches = [ existing_thinking_sound_switches = [entity for entity in self.state.entities if isinstance(entity, ThinkingSoundEntity)]
entity
for entity in self.state.entities
if isinstance(entity, ThinkingSoundEntity)
]
if existing_thinking_sound_switches: if existing_thinking_sound_switches:
self.state.thinking_sound_entity = existing_thinking_sound_switches[0] self.state.thinking_sound_entity = existing_thinking_sound_switches[0] # type: ignore
for extra in existing_thinking_sound_switches[1:]: for extra in existing_thinking_sound_switches[1:]: # type: ignore
self.state.entities.remove(extra) self.state.entities.remove(extra)
# Add/update thinking sound entity # Add/update thinking sound entity
@@ -147,29 +159,167 @@ class VoiceSatelliteProtocol(APIServer):
self.state.entities.append(thinking_sound_switch) self.state.entities.append(thinking_sound_switch)
# Load thinking sound enabled state from preferences (default to False if not set or unknown) # Load thinking sound enabled state from preferences (default to False if not set or unknown)
if hasattr( if hasattr(self.state.preferences, "thinking_sound") and self.state.preferences.thinking_sound in (0, 1):
self.state.preferences, "thinking_sound" self.state.thinking_sound_enabled = bool(self.state.preferences.thinking_sound)
) and self.state.preferences.thinking_sound in (0, 1):
self.state.thinking_sound_enabled = bool(
self.state.preferences.thinking_sound
)
else: else:
self.state.thinking_sound_enabled = False self.state.thinking_sound_enabled = False
thinking_sound_switch.server = self thinking_sound_switch.server = self
thinking_sound_switch.update_get_thinking_sound_enabled( thinking_sound_switch.update_get_thinking_sound_enabled(lambda: self.state.thinking_sound_enabled)
lambda: self.state.thinking_sound_enabled thinking_sound_switch.update_set_thinking_sound_enabled(self._set_thinking_sound_enabled)
)
thinking_sound_switch.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] = {}
@@ -177,9 +327,7 @@ class VoiceSatelliteProtocol(APIServer):
def _set_thinking_sound_enabled(self, new_state: bool) -> None: def _set_thinking_sound_enabled(self, new_state: bool) -> None:
self.state.thinking_sound_enabled = bool(new_state) self.state.thinking_sound_enabled = bool(new_state)
self.state.preferences.thinking_sound = ( self.state.preferences.thinking_sound = 1 if self.state.thinking_sound_enabled else 0
1 if self.state.thinking_sound_enabled else 0
)
if self.state.thinking_sound_enabled: if self.state.thinking_sound_enabled:
_LOGGER.debug("Thinking sound enabled") _LOGGER.debug("Thinking sound enabled")
@@ -188,6 +336,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)
@@ -197,7 +372,7 @@ class VoiceSatelliteProtocol(APIServer):
self._is_streaming_audio = False self._is_streaming_audio = False
self.state.tts_player.stop() self.state.tts_player.stop()
# Stop any ongoing voice processing # Stop any ongoing voice processing
self.state.stop_word.is_active = False self.state.stop_word.is_active = False # type: ignore
self.state.tts_player.play(self.state.mute_sound) self.state.tts_player.play(self.state.mute_sound)
else: else:
# voice_assistant.start_continuous behavior # voice_assistant.start_continuous behavior
@@ -206,24 +381,20 @@ class VoiceSatelliteProtocol(APIServer):
# Resume normal operation - wake word detection will be active again # Resume normal operation - wake word detection will be active again
pass pass
def handle_voice_event( def handle_voice_event(self, event_type: VoiceAssistantEventType, data: Dict[str, str]) -> None:
self, event_type: VoiceAssistantEventType, data: Dict[str, str]
) -> None:
_LOGGER.debug("Voice event: type=%s, data=%s", event_type.name, data) _LOGGER.debug("Voice event: type=%s, data=%s", event_type.name, data)
if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START: if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START:
self._tts_url = data.get("url") self._tts_url = data.get("url")
self._tts_played = False self._tts_played = False
self._continue_conversation = False self._continue_conversation = False
elif ( self._pipeline_active = True
event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_START elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_START and self.state.thinking_sound_enabled:
and self.state.thinking_sound_enabled
):
# Play short "thinking/processing" sound if configured # Play short "thinking/processing" sound if configured
processing = getattr(self.state, "processing_sound", None) processing = getattr(self.state, "processing_sound", None)
if processing: if processing:
_LOGGER.debug("Playing processing sound: %s", processing) _LOGGER.debug("Playing processing sound: %s", processing)
self.state.stop_word.is_active = True self.state.stop_word.is_active = True # type: ignore
self._processing = True self._processing = True
self.duck() self.duck()
self.state.tts_player.play(self.state.processing_sound) self.state.tts_player.play(self.state.processing_sound)
@@ -245,7 +416,11 @@ class VoiceSatelliteProtocol(APIServer):
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
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
@@ -261,6 +436,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()
@@ -272,6 +448,7 @@ class VoiceSatelliteProtocol(APIServer):
data[arg.name] = arg.value data[arg.name] = arg.value
self.handle_voice_event(VoiceAssistantEventType(msg.event_type), data) self.handle_voice_event(VoiceAssistantEventType(msg.event_type), data)
# assist_satellite.announce HERE
elif isinstance(msg, VoiceAssistantAnnounceRequest): elif isinstance(msg, VoiceAssistantAnnounceRequest):
_LOGGER.debug("Announcing: %s", msg.text) _LOGGER.debug("Announcing: %s", msg.text)
@@ -291,34 +468,23 @@ class VoiceSatelliteProtocol(APIServer):
elif isinstance(msg, VoiceAssistantTimerEventResponse): elif isinstance(msg, VoiceAssistantTimerEventResponse):
self.handle_timer_event(VoiceAssistantTimerEventType(msg.event_type), msg) self.handle_timer_event(VoiceAssistantTimerEventType(msg.event_type), msg)
elif isinstance(msg, DeviceInfoRequest): elif isinstance(msg, DeviceInfoRequest):
# Compute dynamic device name _LOGGER.debug("Device info request")
base_name = re.sub(r"[\s-]+", "-", self.state.name.lower()).strip("-")
mac_no_colon = self.state.mac_address.replace(":", "").lower()
mac_last6 = mac_no_colon[-6:]
device_name = f"{base_name}-{mac_last6}"
yield DeviceInfoResponse( yield DeviceInfoResponse(
uses_password=False, uses_password=False,
name=device_name, name=self.state.name,
friendly_name=self.state.friendly_name,
project_name="Open Home Foundation.Linux Voice Assistant",
project_version=self.state.version,
esphome_version=self.state.esphome_version,
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)
@@ -326,6 +492,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,
@@ -335,10 +504,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,
@@ -348,27 +527,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=[ active_wake_words=active_ww_ids,
ww.id
for ww in self.state.wake_words.values()
if ww.id in self.state.active_wake_words
],
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)
@@ -387,12 +575,35 @@ 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
@@ -405,35 +616,72 @@ class VoiceSatelliteProtocol(APIServer):
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.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:
# Don't respond to wake words when muted (voice_assistant.stop behavior) # Don't respond to wake words when muted (voice_assistant.stop behavior)
return return
wake_word_phrase = wake_word.wake_word if self._pipeline_active:
_LOGGER.debug("Ignoring wake word - pipeline already active")
return
wake_word_phrase = wake_word.wake_word # type: ignore
_LOGGER.debug("Detected wake word: %s", wake_word_phrase) _LOGGER.debug("Detected wake word: %s", wake_word_phrase)
self.send_messages( self._pipeline_active = True
[VoiceAssistantRequest(start=True, wake_word_phrase=wake_word_phrase)]
)
self.duck() self.duck()
self.state.tts_player.play(
self.state.wakeup_sound,
done_callback=lambda: self._on_wakeup_sound_finished(wake_word_phrase),
)
def _on_wakeup_sound_finished(self, wake_word_phrase: str) -> None:
"""Callback invoked when the wakeup sound finishes playing."""
_LOGGER.debug("Wakeup sound finished, starting audio streaming with wake word: %s", wake_word_phrase)
self.send_messages(
[VoiceAssistantRequest(start=True, wake_word_phrase=wake_word_phrase)],
)
self._is_streaming_audio = True self._is_streaming_audio = True
self.state.tts_player.play(self.state.wakeup_sound)
def stop(self) -> None: def stop(self) -> None:
self.state.active_wake_words.discard(self.state.stop_word.id) self.state.active_wake_words.discard(self.state.stop_word.id)
self.state.tts_player.stop() self._pipeline_active = False
if self._timer_finished: if self._timer_finished:
self._timer_finished = False self._timer_finished = False
self._timer_ring_start = None
self.unduck()
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()
_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:
@@ -454,12 +702,14 @@ class VoiceSatelliteProtocol(APIServer):
self.state.music_player.unduck() self.state.music_player.unduck()
def _tts_finished(self) -> None: def _tts_finished(self) -> None:
self._pipeline_active = False
self.state.active_wake_words.discard(self.state.stop_word.id) self.state.active_wake_words.discard(self.state.stop_word.id)
self.send_messages([VoiceAssistantAnnounceFinished()]) self.send_messages([VoiceAssistantAnnounceFinished()])
if self._continue_conversation: if self._continue_conversation:
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("Continuing conversation") _LOGGER.debug("Continuing conversation")
else: else:
self.unduck() self.unduck()
@@ -468,13 +718,31 @@ class VoiceSatelliteProtocol(APIServer):
def _play_timer_finished(self) -> None: def _play_timer_finished(self) -> None:
if not self._timer_finished: if not self._timer_finished:
_LOGGER.debug("Timer finished sound stopped")
self.unduck()
self._timer_ring_start = None
return
# Auto-stop after timer_max_ring_seconds
if self._timer_ring_start is not None:
elapsed = time.monotonic() - self._timer_ring_start
if elapsed >= self.state.timer_max_ring_seconds:
_LOGGER.info(
"Timer auto-stopped after %.0f seconds (max=%.0f)",
elapsed,
self.state.timer_max_ring_seconds,
)
self._timer_finished = False
self._timer_ring_start = None
self.state.active_wake_words.discard(self.state.stop_word.id)
self.unduck() self.unduck()
return return
self.state.tts_player.play( self.state.tts_player.play(
self.state.timer_finished_sound, self.state.timer_finished_sound,
done_callback=lambda: call_all( done_callback=lambda: call_all(
lambda: time.sleep(1.0), self._play_timer_finished lambda: time.sleep(1.0),
self._play_timer_finished,
), ),
) )
@@ -487,6 +755,7 @@ class VoiceSatelliteProtocol(APIServer):
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._pipeline_active = False
# Stop any ongoing audio playback and wake/stop word processing. # Stop any ongoing audio playback and wake/stop word processing.
try: try:
@@ -507,6 +776,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:
@@ -514,18 +792,21 @@ class VoiceSatelliteProtocol(APIServer):
if msg_type == PROTO_TO_MESSAGE_TYPE[AuthenticationRequest]: if msg_type == PROTO_TO_MESSAGE_TYPE[AuthenticationRequest]:
self.state.connected = True self.state.connected = True
_LOGGER.debug("Authentication successful, connected to Home Assistant")
# Send states after connect # Send states after connect
states = [] states: List[message.Message] = []
for entity in self.state.entities: _LOGGER.debug("Found %d entities in state", len(self.state.entities))
states.extend( for i, entity in enumerate(self.state.entities):
entity.handle_message(SubscribeHomeAssistantStatesRequest()) entity_states = list(entity.handle_message(SubscribeHomeAssistantStatesRequest()))
) states.extend(entity_states)
_LOGGER.debug("Entity %d (%s) returned %d state messages", i, type(entity).__name__, len(entity_states))
_LOGGER.debug("Total state messages to send: %d", len(states))
self.send_messages(states) self.send_messages(states)
_LOGGER.debug("Sent entity states after connect") for i, msg in enumerate(states):
_LOGGER.debug("Sent state message %d: %s", i, type(msg).__name__)
_LOGGER.debug("All entity states sent after connect")
def _download_external_wake_word( def _download_external_wake_word(self, external_wake_word: VoiceAssistantExternalWakeWord) -> Optional[AvailableWakeWord]:
self, external_wake_word: VoiceAssistantExternalWakeWord
) -> Optional[AvailableWakeWord]:
eww_dir = self.state.download_dir / "external_wake_words" eww_dir = self.state.download_dir / "external_wake_words"
eww_dir.mkdir(parents=True, exist_ok=True) eww_dir.mkdir(parents=True, exist_ok=True)
@@ -567,16 +848,14 @@ class VoiceSatelliteProtocol(APIServer):
# Download model file # Download model file
parsed_url = urlparse(external_wake_word.url) parsed_url = urlparse(external_wake_word.url)
parsed_url = parsed_url._replace( parsed_url = parsed_url._replace(
path=posixpath.join(posixpath.dirname(parsed_url.path), model_path.name) path=posixpath.join(posixpath.dirname(parsed_url.path), model_path.name),
) )
model_url = urlunparse(parsed_url) model_url = urlunparse(parsed_url)
_LOGGER.debug("Downloading %s to %s", model_url, model_path) _LOGGER.debug("Downloading %s to %s", model_url, model_path)
with urlopen(model_url) as request: with urlopen(model_url) as request:
if request.status != 200: if request.status != 200:
_LOGGER.warning( _LOGGER.warning("Failed to download: %s, status=%s", model_url, request.status)
"Failed to download: %s, status=%s", model_url, request.status
)
return None return None
with open(model_path, "wb") as model_file: with open(model_path, "wb") as model_file:
+64 -4
View File
@@ -1,10 +1,70 @@
"""Utility methods.""" """Utility methods."""
import uuid from collections.abc import Callable
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
from typing import Optional
# netifaces lib is from netifaces2 # netifaces lib is from netifaces2
import netifaces import netifaces
from collections.abc import Callable
from typing import Optional # Cache for version to avoid repeated file reading
_version_cache: Optional[str] = None
_esphome_version_cache: Optional[str] = None
def get_version() -> str:
"""
Read the version from version.txt file.
This function reads the content safely without risk of code injection,
as it only reads raw text and performs no evaluation.
Returns:
str: The version from version.txt or 'unknown' if the file
does not exist or cannot be read.
"""
global _version_cache
if _version_cache is not None:
return _version_cache
version_file = Path(__file__).parent.parent / "version.txt"
try:
# Sicher lesen: nur Rohtext, keine Evaluierung
file_version = version_file.read_text(encoding="utf-8").strip()
_version_cache = file_version if file_version else "unknown"
except (FileNotFoundError, PermissionError, OSError):
_version_cache = "unknown"
return _version_cache
def get_esphome_version() -> str:
"""
Read the version of the installed aioesphomeapi package.
This function uses importlib.metadata to safely retrieve the version
of an installed Python package without executing any code from the
package itself.
Returns:
str: The version of aioesphomeapi (e.g., '42.7.0'), or 'unknown'
if the package is not installed or the version cannot be read.
"""
global _esphome_version_cache
if _esphome_version_cache is not None:
return _esphome_version_cache
try:
_esphome_version_cache = version("aioesphomeapi")
except PackageNotFoundError:
_esphome_version_cache = "unknown"
return _esphome_version_cache
def call_all(*callables: Optional[Callable[[], None]]) -> None: def call_all(*callables: Optional[Callable[[], None]]) -> None:
for item in filter(None, callables): for item in filter(None, callables):
@@ -36,7 +96,7 @@ def get_default_ipv4(interface: str):
return None return None
addresses = netifaces.ifaddresses(interface) addresses = netifaces.ifaddresses(interface)
ipv4_info = addresses.get(netifaces.AF_INET) ipv4_info = addresses.get(netifaces.AF_INET) # type: ignore
if not ipv4_info: if not ipv4_info:
return None return 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 -3
View File
@@ -2,7 +2,6 @@
import logging import logging
import socket import socket
import uuid
from typing import Optional from typing import Optional
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -18,7 +17,11 @@ MDNS_TARGET_IP = "224.0.0.251"
class HomeAssistantZeroconf: class HomeAssistantZeroconf:
def __init__( def __init__(
self, port: int, mac_address: str, host_ip_address: str, name: Optional[str] = None, host: Optional[str] = None self,
port: int,
mac_address: str,
host_ip_address: str,
name: Optional[str] = None,
) -> None: ) -> None:
self.port = port self.port = port
self.mac_address = mac_address self.mac_address = mac_address
@@ -45,4 +48,3 @@ class HomeAssistantZeroconf:
) )
await self._aiozc.async_register_service(service_info) await self._aiozc.async_register_service(service_info)
_LOGGER.debug("Zeroconf discovery enabled: %s", service_info) _LOGGER.debug("Zeroconf discovery enabled: %s", service_info)
+8 -18
View File
@@ -1,26 +1,16 @@
[mypy] [mypy]
python_version = 3.11
warn_return_any = True
warn_unused_configs = True
[mypy-setuptools.*] # Ignore missing stubs for modules without py.typed marker
ignore_missing_imports = True [mypy-mpv.*]
[mypy-aioesphomeapi.api_pb2.*]
ignore_missing_imports = True
[mypy-pymicro_wakeword.*]
ignore_missing_imports = True
[mypy-pyopen_wakeword.*]
ignore_missing_imports = True ignore_missing_imports = True
[mypy-soundcard.*] [mypy-soundcard.*]
ignore_missing_imports = True ignore_missing_imports = True
[mypy-mpv.*] [mypy-aioesphomeapi.api_pb2]
ignore_missing_imports = True
[mypy-google.protobuf.*]
ignore_missing_imports = True
[mypy-aioesphomeapi.*]
ignore_missing_imports = True ignore_missing_imports = True
allow_subclassing_any = True
disable_error_code = attr-defined
-37
View File
@@ -1,37 +0,0 @@
[MESSAGES CONTROL]
disable=
format,
abstract-method,
cyclic-import,
duplicate-code,
global-statement,
import-outside-toplevel,
inconsistent-return-statements,
locally-disabled,
not-context-manager,
too-few-public-methods,
too-many-arguments,
too-many-branches,
too-many-instance-attributes,
too-many-lines,
too-many-locals,
too-many-public-methods,
too-many-return-statements,
too-many-statements,
too-many-boolean-expressions,
unnecessary-pass,
unused-argument,
broad-except,
too-many-nested-blocks,
invalid-name,
unused-import,
fixme,
useless-super-delegation,
missing-module-docstring,
missing-class-docstring,
missing-function-docstring,
import-error,
consider-using-with
[FORMAT]
expected-line-ending-format=LF
+81 -6
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"
@@ -17,13 +16,11 @@ classifiers = [
"Intended Audience :: Developers", "Intended Audience :: Developers",
"Topic :: Text Processing :: Linguistic", "Topic :: Text Processing :: Linguistic",
"License :: OSI Approved :: Apache Software License", "License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.13",
] ]
requires-python = ">=3.9.0" requires-python = ">=3.11.0"
dependencies = [ dependencies = [
"aioesphomeapi==42.7.0", "aioesphomeapi==42.7.0",
"netifaces2==0.0.22", "netifaces2==0.0.22",
@@ -34,7 +31,10 @@ dependencies = [
"python-mpv>=1,<2", "python-mpv>=1,<2",
"zeroconf<1", "zeroconf<1",
"getmac<1", "getmac<1",
"types-protobuf",
"webrtc_noise_gain",
] ]
dynamic = ["version"]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
@@ -43,8 +43,13 @@ dev = [
"mypy", "mypy",
"pylint", "pylint",
"pytest", "pytest",
"isort",
"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"
@@ -54,5 +59,75 @@ 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]
line-length = 200
target-version = ['py312', 'py313']
include = '\.pyi?$'
exclude = '''
/(
\.eggs
| \.git
| \.hg
| \.mypy_cache
| \.tox
| \.venv
| _build
| buck-out
| build
| dist
)/
'''
[tool.isort]
profile = "black"
line_length = 200
multi_line_output = 3
include_trailing_comma = true
force_grid_wrap = 0
use_parentheses = true
[tool.pylint."MESSAGES CONTROL"]
disable = [
"abstract-method",
"cyclic-import",
"duplicate-code",
"global-statement",
"import-outside-toplevel",
"inconsistent-return-statements",
"locally-disabled",
"not-context-manager",
"too-few-public-methods",
"too-many-arguments",
"too-many-branches",
"too-many-instance-attributes",
"too-many-lines",
"too-many-locals",
"too-many-public-methods",
"too-many-return-statements",
"too-many-statements",
"too-many-boolean-expressions",
"unnecessary-pass",
"unused-argument",
"broad-except",
"too-many-nested-blocks",
"invalid-name",
"unused-import",
"fixme",
"useless-super-delegation",
"missing-module-docstring",
"missing-class-docstring",
"missing-function-docstring",
"import-error",
"consider-using-with",
"R0917",
"C0301",
]
[tool.pylint."DESIGN"]
max-args = 50
[tool.pylint.format]
expected-line-ending-format = "LF"
+32 -19
View File
@@ -1,23 +1,36 @@
#!/usr/bin/env python3 #!/usr/bin/env bash
import subprocess
import venv
from pathlib import Path
_DIR = Path(__file__).parent SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_PROGRAM_DIR = _DIR.parent PROJECT_DIR="$SCRIPT_DIR/.."
_VENV_DIR = _PROGRAM_DIR / ".venv"
_MODULE_DIR = _PROGRAM_DIR / "linux_voice_assistant"
_LINT_DIRS = [_MODULE_DIR] echo "Running all linting checks..."
if _VENV_DIR.exists(): # Run each lint script
context = venv.EnvBuilder().ensure_directories(_VENV_DIR) echo "=== Running Black ==="
python_exe = context.env_exe "$SCRIPT_DIR/lint_black"
else: BLACK_EXIT=$?
python_exe = "python3"
subprocess.check_call([python_exe, "-m", "black"] + _LINT_DIRS + ["--check"]) echo -e "\n=== Running isort ==="
subprocess.check_call([python_exe, "-m", "isort"] + _LINT_DIRS + ["--check"]) "$SCRIPT_DIR/lint_isort"
subprocess.check_call([python_exe, "-m", "flake8"] + _LINT_DIRS) ISORT_EXIT=$?
subprocess.check_call([python_exe, "-m", "pylint"] + _LINT_DIRS)
subprocess.check_call([python_exe, "-m", "mypy"] + _LINT_DIRS) echo -e "\n=== Running flake8 ==="
"$SCRIPT_DIR/lint_flake8"
FLAKE8_EXIT=$?
echo -e "\n=== Running pylint ==="
"$SCRIPT_DIR/lint_pylint"
PYLINT_EXIT=$?
echo -e "\n=== Running mypy ==="
"$SCRIPT_DIR/lint_mypy"
MYPY_EXIT=$?
# Check if any linting failed
if [ $BLACK_EXIT -ne 0 ] || [ $ISORT_EXIT -ne 0 ] || [ $FLAKE8_EXIT -ne 0 ] || [ $PYLINT_EXIT -ne 0 ] || [ $MYPY_EXIT -ne 0 ]; then
echo -e "\n❌ Linting failed!"
exit 1
else
echo -e "\n✅ All linting checks passed!"
exit 0
fi
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
import subprocess
import venv
from pathlib import Path
import sys
_DIR = Path(__file__).parent
_PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv"
_MODULE_DIR = _PROGRAM_DIR / "linux_voice_assistant"
_LINT_DIRS = [_MODULE_DIR]
if _VENV_DIR.exists():
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
python_exe = context.env_exe
else:
python_exe = "python3"
# Überprüfen, ob --auto angegeben wurde
auto_fix = "--auto" in sys.argv
if auto_fix:
# Automatisch formatieren
subprocess.check_call([python_exe, "-m", "black"] + _LINT_DIRS)
else:
# Nur prüfen, ob Formatierung korrekt ist
subprocess.check_call([python_exe, "-m", "black"] + _LINT_DIRS + ["--check", "--color", "--diff"])
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env python3
import subprocess
import venv
from pathlib import Path
import sys
_DIR = Path(__file__).parent
_PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv"
_MODULE_DIR = _PROGRAM_DIR / "linux_voice_assistant"
_LINT_DIRS = [_MODULE_DIR]
if _VENV_DIR.exists():
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
python_exe = context.env_exe
else:
python_exe = "python3"
# Flake8 führt nur Prüfungen durch, keine automatischen Korrekturen
subprocess.check_call([python_exe, "-m", "flake8"] + _LINT_DIRS)
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
import subprocess
import venv
from pathlib import Path
import sys
_DIR = Path(__file__).parent
_PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv"
_MODULE_DIR = _PROGRAM_DIR / "linux_voice_assistant"
_LINT_DIRS = [_MODULE_DIR]
if _VENV_DIR.exists():
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
python_exe = context.env_exe
else:
python_exe = "python3"
# Überprüfen, ob --auto angegeben wurde
auto_fix = "--auto" in sys.argv
if auto_fix:
# Automatisch formatieren
subprocess.check_call([python_exe, "-m", "isort"] + _LINT_DIRS)
else:
# Nur prüfen, ob Formatierung korrekt ist
subprocess.check_call([python_exe, "-m", "isort"] + _LINT_DIRS + ["--check", "--diff"])
+4 -3
View File
@@ -2,13 +2,14 @@
import subprocess import subprocess
import venv import venv
from pathlib import Path from pathlib import Path
import sys
_DIR = Path(__file__).parent _DIR = Path(__file__).parent
_PROGRAM_DIR = _DIR.parent _PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv" _VENV_DIR = _PROGRAM_DIR / ".venv"
_MODULE_DIR = _PROGRAM_DIR / "linux_voice_assistant" _MODULE_DIR = _PROGRAM_DIR / "linux_voice_assistant"
_FORMAT_DIRS = [_MODULE_DIR] _LINT_DIRS = [_MODULE_DIR]
if _VENV_DIR.exists(): if _VENV_DIR.exists():
context = venv.EnvBuilder().ensure_directories(_VENV_DIR) context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
@@ -16,5 +17,5 @@ if _VENV_DIR.exists():
else: else:
python_exe = "python3" python_exe = "python3"
subprocess.check_call([python_exe, "-m", "black"] + _FORMAT_DIRS) # Mypy Ausführung
subprocess.check_call([python_exe, "-m", "isort"] + _FORMAT_DIRS) subprocess.check_call([python_exe, "-m", "mypy"] + _LINT_DIRS)
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
import subprocess
import venv
from pathlib import Path
import sys
_DIR = Path(__file__).parent
_PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv"
_MODULE_DIR = _PROGRAM_DIR / "linux_voice_assistant"
_LINT_DIRS = [_MODULE_DIR]
if _VENV_DIR.exists():
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
python_exe = context.env_exe
else:
python_exe = "python3"
# Überprüfen, ob --auto angegeben wurde
auto_fix = "--auto" in sys.argv
if auto_fix:
# Automatisch Korrekturen durchführen
subprocess.check_call([python_exe, "-m", "pylint"] + _LINT_DIRS + ["--fix"])
else:
# Nur prüfen, ob Code den Pylint-Standards entspricht
subprocess.check_call([python_exe, "-m", "pylint"] + _LINT_DIRS)
+2 -1
View File
@@ -14,4 +14,5 @@ if _VENV_DIR.exists():
else: else:
python_exe = "python3" python_exe = "python3"
subprocess.check_call([python_exe, "-m", "linux_voice_assistant"] + sys.argv[1:]) result = subprocess.run([python_exe, "-m", "linux_voice_assistant"] + sys.argv[1:])
sys.exit(result.returncode)
+14 -2
View File
@@ -2,6 +2,7 @@
import argparse import argparse
import subprocess import subprocess
import venv import venv
import os
from pathlib import Path from pathlib import Path
_DIR = Path(__file__).parent _DIR = Path(__file__).parent
@@ -10,6 +11,8 @@ _VENV_DIR = _PROGRAM_DIR / ".venv"
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--dev", action="store_true", help="Install dev requirements") parser.add_argument("--dev", action="store_true", help="Install dev requirements")
parser.add_argument("--cxxflags", type=str, help="CXXFLAGS for compilation (default: -O1 -g0)")
parser.add_argument("--makeflags", type=str, help="MAKEFLAGS for compilation (default: -j1)")
args = parser.parse_args() args = parser.parse_args()
# Create virtual environment # Create virtual environment
@@ -17,6 +20,13 @@ builder = venv.EnvBuilder(with_pip=True)
context = builder.ensure_directories(_VENV_DIR) context = builder.ensure_directories(_VENV_DIR)
builder.create(_VENV_DIR) builder.create(_VENV_DIR)
# Set environment variables
env = {}
if args.cxxflags:
env["CXXFLAGS"] = args.cxxflags
if args.makeflags:
env["MAKEFLAGS"] = args.makeflags
# Upgrade dependencies # Upgrade dependencies
pip = [context.env_exe, "-m", "pip"] pip = [context.env_exe, "-m", "pip"]
subprocess.check_call(pip + ["install", "--upgrade", "pip"]) subprocess.check_call(pip + ["install", "--upgrade", "pip"])
@@ -25,8 +35,10 @@ subprocess.check_call(pip + ["install", "--upgrade", "setuptools", "wheel"])
# Install requirements # Install requirements
subprocess.check_call(pip + ["install", "-e", str(_PROGRAM_DIR)]) subprocess.check_call(pip + ["install", "-e", str(_PROGRAM_DIR)])
if args.dev:
# Install dev requirements # Install dev requirements
if args.dev:
subprocess.check_call( subprocess.check_call(
pip + ["install", "-e", f"{_PROGRAM_DIR}[dev]"] pip + ["install", "-e", f"{_PROGRAM_DIR}[dev]"],
env={**dict(os.environ), **env}
) )
-8
View File
@@ -12,11 +12,3 @@ ignore =
E203, E203,
D202, D202,
W504 W504
[isort]
multi_line_output = 3
include_trailing_comma=True
force_grid_wrap=0
use_parentheses=True
line_length=88
indent = " "
+32
View File
@@ -0,0 +1,32 @@
# Automated testing
tbd
# Manual testing
## Functions from HA side:
### Announcement:
Function in HA is: assist_satellite.announce
### TTS:
Function in HA is: tts.speak
## Play music from MA:
### Start and stop
Start playing a playlist. Check if it is playing. Stop playing. Check if it is stopped.
### Next track
Start playing a playlist. Check if it is playing. Next track. Check if it is playing.
### Pause
## Timer
Play music and then start a timer. Test if duck and unduck works. Test if unduck work when using wake word or stop word to stop the timer ringing.
+1
View File
@@ -0,0 +1 @@
placeholder
+1
View File
@@ -0,0 +1 @@
placeholder
+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
}
} }